-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor asset search to be in separate component
- Loading branch information
1 parent
8523819
commit 4c23fcf
Showing
3 changed files
with
265 additions
and
135 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
204 changes: 204 additions & 0 deletions
204
js_modules/dagster-ui/packages/ui-core/src/search/AssetSearch.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SearchResult>[]; | ||
highlight: number; | ||
loaded: boolean; | ||
}; | ||
|
||
type Action = | ||
| {type: 'highlight'; highlight: number} | ||
| {type: 'change-query'; queryString: string} | ||
| {type: 'complete-secondary'; queryString: string; results: Fuse.FuseResult<SearchResult>[]}; | ||
|
||
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<SearchResult>[]; | ||
assetFilterResults: Fuse.FuseResult<SearchResult>[]; | ||
}; | ||
|
||
function groupSearchResults(secondaryResults: Fuse.FuseResult<SearchResult>[]): 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 | Trace>(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<HTMLInputElement>) => { | ||
const newValue = e.target.value; | ||
dispatch({type: 'change-query', queryString: newValue}); | ||
debouncedSearch(newValue); | ||
}; | ||
|
||
const onClickResult = React.useCallback( | ||
(result: Fuse.FuseResult<SearchResult>) => { | ||
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 ( | ||
<SearchInputWrapper> | ||
<SearchBox hasQueryString={!!queryString.length}> | ||
<Icon name="search" color={Colors.accentGray()} size={20} /> | ||
<SearchInput | ||
data-search-input="1" | ||
autoFocus | ||
spellCheck={false} | ||
onChange={onChange} | ||
onKeyDown={onKeyDown} | ||
placeholder="Search assets" | ||
type="text" | ||
value={queryString} | ||
/> | ||
{loading ? <Spinner purpose="body-text" /> : null} | ||
</SearchBox> | ||
<SearchResultsWrapper> | ||
<SearchResults | ||
highlight={highlight} | ||
queryString={queryString} | ||
results={renderedAssetResults} | ||
filterResults={renderedFilterResults} | ||
onClickResult={onClickResult} | ||
/> | ||
</SearchResultsWrapper> | ||
</SearchInputWrapper> | ||
); | ||
}; | ||
|
||
const SearchInputWrapper = styled.div` | ||
position: relative; | ||
`; | ||
|
||
const SearchResultsWrapper = styled.div` | ||
top: 60px; | ||
position: absolute; | ||
z-index: 1; | ||
width: 100%; | ||
`; |
Oops, something went wrong.