diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsOverview.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsOverview.tsx index 8287cb5f84fa8..2e2134eec3484 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsOverview.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsOverview.tsx @@ -19,7 +19,7 @@ import {displayNameForAssetKey} from '../asset-graph/Utils'; import {TagIcon} from '../graph/OpTags'; import {useDocumentTitle} from '../hooks/useDocumentTitle'; import {useLaunchPadHooks} from '../launchpad/LaunchpadHooksContext'; -import {SearchDialog} from '../search/SearchDialog'; +import {AssetSearch} from '../search/AssetSearch'; import {ReloadAllButton} from '../workspace/ReloadAllButton'; import {buildRepoPathForHuman} from '../workspace/buildRepoAddress'; import {repoAddressAsHumanString, repoAddressAsURLString} from '../workspace/repoAddressAsString'; @@ -252,7 +252,7 @@ export const AssetsOverview = ({viewerName}: {viewerName?: string}) => { - + diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/AssetSearch.tsx b/js_modules/dagster-ui/packages/ui-core/src/search/AssetSearch.tsx new file mode 100644 index 0000000000000..97a34eefdc701 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/search/AssetSearch.tsx @@ -0,0 +1,204 @@ +import {Colors, Icon, Spinner} from '@dagster-io/ui-components'; +import Fuse from 'fuse.js'; +import debounce from 'lodash/debounce'; +import * as React from 'react'; +import {useHistory} from 'react-router-dom'; +import styled from 'styled-components'; + +import {SearchBox, SearchInput} from './SearchDialog'; +import {SearchResults} from './SearchResults'; +import {SearchResult, SearchResultType, isAssetFilterSearchResultType} from './types'; +import {useGlobalSearch} from './useGlobalSearch'; +import {Trace, createTrace} from '../performance'; + +const MAX_ASSET_RESULTS = 50; +const MAX_FILTER_RESULTS = 25; + +type State = { + queryString: string; + searching: boolean; + secondaryResults: Fuse.FuseResult[]; + highlight: number; + loaded: boolean; +}; + +type Action = + | {type: 'highlight'; highlight: number} + | {type: 'change-query'; queryString: string} + | {type: 'complete-secondary'; queryString: string; results: Fuse.FuseResult[]}; + +const reducer = (state: State, action: Action) => { + switch (action.type) { + case 'highlight': + return {...state, highlight: action.highlight}; + case 'change-query': + return {...state, queryString: action.queryString, searching: true, highlight: 0}; + case 'complete-secondary': + // If the received results match the current querystring, use them. Otherwise discard. + const secondaryResults = + action.queryString === state.queryString ? action.results : state.secondaryResults; + return {...state, secondaryResults, searching: false}; + default: + return state; + } +}; + +const initialState: State = { + queryString: '', + searching: false, + secondaryResults: [], + highlight: 0, + loaded: false, +}; + +const DEBOUNCE_MSEC = 100; + +type SearchResultGroups = { + assetResults: Fuse.FuseResult[]; + assetFilterResults: Fuse.FuseResult[]; +}; + +function groupSearchResults(secondaryResults: Fuse.FuseResult[]): SearchResultGroups { + return { + assetResults: secondaryResults.filter((result) => result.item.type === SearchResultType.Asset), + assetFilterResults: secondaryResults.filter((result) => + isAssetFilterSearchResultType(result.item.type), + ), + }; +} + +export const AssetSearch = () => { + const history = useHistory(); + const {loading, searchSecondary} = useGlobalSearch({ + includeAssetFilters: true, + }); + + const [state, dispatch] = React.useReducer(reducer, initialState); + const {queryString, secondaryResults, highlight} = state; + + const {assetResults, assetFilterResults} = groupSearchResults(secondaryResults); + + const renderedAssetResults = assetResults.slice(0, MAX_ASSET_RESULTS); + const renderedFilterResults = assetFilterResults.slice(0, MAX_FILTER_RESULTS); + + const renderedResults = [...renderedAssetResults, ...renderedFilterResults]; + const numRenderedResults = renderedResults.length; + + const isFirstSearch = React.useRef(true); + const firstSearchTrace = React.useRef(null); + + React.useEffect(() => { + if (!loading && secondaryResults) { + firstSearchTrace.current?.endTrace(); + } + }, [loading, secondaryResults]); + + const searchAndHandleSecondary = React.useCallback( + async (queryString: string) => { + const {queryString: queryStringForResults, results} = await searchSecondary(queryString); + dispatch({type: 'complete-secondary', queryString: queryStringForResults, results}); + }, + [searchSecondary], + ); + + const debouncedSearch = React.useMemo(() => { + const debouncedSearch = debounce(async (queryString: string) => { + searchAndHandleSecondary(queryString); + }, DEBOUNCE_MSEC); + return (queryString: string) => { + if (!firstSearchTrace.current && isFirstSearch.current) { + isFirstSearch.current = false; + const trace = createTrace('SearchDialog:FirstSearch'); + firstSearchTrace.current = trace; + trace.startTrace(); + } + return debouncedSearch(queryString); + }; + }, [searchAndHandleSecondary]); + + const onChange = async (e: React.ChangeEvent) => { + const newValue = e.target.value; + dispatch({type: 'change-query', queryString: newValue}); + debouncedSearch(newValue); + }; + + const onClickResult = React.useCallback( + (result: Fuse.FuseResult) => { + history.push(result.item.href); + }, + [history], + ); + + const highlightedResult = renderedResults[highlight] || null; + + const onKeyDown = (e: React.KeyboardEvent) => { + const {key} = e; + + if (!numRenderedResults) { + return; + } + + const lastResult = numRenderedResults - 1; + + switch (key) { + case 'ArrowUp': + e.preventDefault(); + dispatch({ + type: 'highlight', + highlight: highlight === 0 ? lastResult : highlight - 1, + }); + break; + case 'ArrowDown': + e.preventDefault(); + dispatch({ + type: 'highlight', + highlight: highlight === lastResult ? 0 : highlight + 1, + }); + break; + case 'Enter': + e.preventDefault(); + if (highlightedResult) { + history.push(highlightedResult.item.href); + } + } + }; + + return ( + + + + + {loading ? : null} + + + + + + ); +}; + +const SearchInputWrapper = styled.div` + position: relative; +`; + +const SearchResultsWrapper = styled.div` + top: 60px; + position: absolute; + z-index: 1; + width: 100%; +`; diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/SearchDialog.tsx b/js_modules/dagster-ui/packages/ui-core/src/search/SearchDialog.tsx index 18d42f77785e7..96447ada98516 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/search/SearchDialog.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/search/SearchDialog.tsx @@ -8,7 +8,7 @@ import {useHistory} from 'react-router-dom'; import styled from 'styled-components'; import {SearchResults} from './SearchResults'; -import {SearchResult, SearchResultType, isAssetFilterSearchResultType} from './types'; +import {SearchResult} from './types'; import {useGlobalSearch} from './useGlobalSearch'; import {__updateSearchVisibility} from './useSearchVisibility'; import {ShortcutHandler} from '../app/ShortcutHandler'; @@ -72,47 +72,7 @@ const initialState: State = { const DEBOUNCE_MSEC = 100; -type SearchResultGroups = { - primaryResults: Fuse.FuseResult[]; - assetResults: Fuse.FuseResult[]; - assetFilterResults: Fuse.FuseResult[]; -}; - -function groupSearchResults( - primaryResults: Fuse.FuseResult[], - secondaryResults: Fuse.FuseResult[], - isAssetSearch: boolean, -): SearchResultGroups { - if (isAssetSearch) { - return { - primaryResults: [], - assetResults: secondaryResults.filter( - (result) => result.item.type === SearchResultType.Asset, - ), - assetFilterResults: secondaryResults.filter((result) => - isAssetFilterSearchResultType(result.item.type), - ), - }; - } else { - return { - primaryResults, - assetResults: secondaryResults.filter( - (result) => result.item.type === SearchResultType.Asset, - ), - assetFilterResults: [], - }; - } -} - -export const SearchDialog = ({ - searchPlaceholder, - isAssetSearch, - displayAsOverlay = true, -}: { - searchPlaceholder?: string; - isAssetSearch: boolean; - displayAsOverlay?: boolean; -}) => { +export const SearchDialog = ({searchPlaceholder}: {searchPlaceholder: string}) => { const history = useHistory(); const {initialize, loading, searchPrimary, searchSecondary} = useGlobalSearch({ includeAssetFilters: false, @@ -122,13 +82,7 @@ export const SearchDialog = ({ const [state, dispatch] = React.useReducer(reducer, initialState); const {shown, queryString, primaryResults, secondaryResults, highlight} = state; - const { - primaryResults: primaryResultsGroup, - assetResults, - assetFilterResults, - } = groupSearchResults(primaryResults, secondaryResults, isAssetSearch); - const results = [...primaryResultsGroup, ...assetResults]; - + const results = [...primaryResults, ...secondaryResults]; const renderedResults = results.slice(0, MAX_DISPLAYED_RESULTS); const numRenderedResults = renderedResults.length; @@ -254,78 +208,61 @@ export const SearchDialog = ({ } }; - const searchResults = ( - - ); - - const searchInput = ( - - - - {loading ? : null} - - ); - - if (displayAsOverlay) { - return ( - <> - - - - -
- -
-
{searchPlaceholder}
-
- / + return ( + <> + + + + +
+ +
+
{searchPlaceholder}
-
-
- dispatch({type: 'hide-dialog'})} - transitionDuration={100} - > - - {searchInput} - {searchResults} - - - - ); - } else { - return ( - - {searchInput} - {searchResults} - - ); - } + / +
+
+
+ dispatch({type: 'hide-dialog'})} + transitionDuration={100} + > + + + + + {loading ? : null} + + + + + + ); }; const SearchTrigger = styled.button` @@ -366,22 +303,11 @@ const Container = styled.div` } `; -const SearchInputWrapper = styled.div` - position: relative; -`; - -const SearchResultsWrapper = styled.div` - top: 60px; - position: absolute; - z-index: 1; - width: 100%; -`; - interface SearchBoxProps { readonly hasQueryString: boolean; } -const SearchBox = styled.div` +export const SearchBox = styled.div` border-radius: 12px; box-shadow: 2px 2px 8px ${Colors.shadowDefault()}; @@ -392,7 +318,7 @@ const SearchBox = styled.div` padding: 12px 20px 12px 12px; `; -const SearchInput = styled.input` +export const SearchInput = styled.input` border: none; color: ${Colors.textLight()}; font-family: ${FontFamily.default};