diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/Text.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/Text.tsx index bc6208ef2b6ea..7bf750b287a15 100644 --- a/js_modules/dagster-ui/packages/ui-components/src/components/Text.tsx +++ b/js_modules/dagster-ui/packages/ui-components/src/components/Text.tsx @@ -82,6 +82,12 @@ export const CaptionSubtitle = styled(Text)` line-height: 16px; `; +export const CaptionBolded = styled(Text)` + font-family: ${FontFamily.default}; + font-size: 12px; + font-weight: 900; +`; + export const Code = styled(Text)` background-color: ${Colors.backgroundBlue()}; border-radius: 2px; 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 0e1953b40d6bd..0ee81d2c9d089 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 @@ -1,5 +1,5 @@ import {useQuery} from '@apollo/client'; -import {Box, Colors, Heading, Icon, Page, Spinner, TextInput} from '@dagster-io/ui-components'; +import {Box, Colors, Heading, Icon, Page, Spinner} from '@dagster-io/ui-components'; import qs from 'qs'; import {useContext} from 'react'; import {Link, useParams} from 'react-router-dom'; @@ -20,6 +20,7 @@ import {displayNameForAssetKey} from '../asset-graph/Utils'; import {TagIcon} from '../graph/OpTags'; import {useDocumentTitle} from '../hooks/useDocumentTitle'; import {useLaunchPadHooks} from '../launchpad/LaunchpadHooksContext'; +import {AssetSearch} from '../search/AssetSearch'; import {ReloadAllButton} from '../workspace/ReloadAllButton'; import {buildRepoPathForHuman} from '../workspace/buildRepoAddress'; import {repoAddressAsHumanString, repoAddressAsURLString} from '../workspace/repoAddressAsString'; @@ -234,7 +235,7 @@ export const AssetsOverview = ({viewerName}: {viewerName?: string}) => { /> - + {getGreeting(timezone)} @@ -245,7 +246,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..6aebe37a5d507 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/search/AssetSearch.tsx @@ -0,0 +1,203 @@ +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; +}; + +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, +}; + +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, initialize} = 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(() => { + initialize(); + if (!loading && secondaryResults) { + firstSearchTrace.current?.endTrace(); + } + }, [loading, secondaryResults, initialize]); + + 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('AssetSearch: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 8d72f75e363e1..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 @@ -256,6 +256,7 @@ export const SearchDialog = ({searchPlaceholder}: {searchPlaceholder: string}) = highlight={highlight} queryString={queryString} results={renderedResults} + filterResults={[]} onClickResult={onClickResult} /> @@ -306,7 +307,10 @@ interface SearchBoxProps { readonly hasQueryString: boolean; } -const SearchBox = styled.div` +export const SearchBox = styled.div` + border-radius: 12px; + box-shadow: 2px 2px 8px ${Colors.shadowDefault()}; + align-items: center; border-bottom: ${({hasQueryString}) => hasQueryString ? `1px solid ${Colors.keylineDefault()}` : 'none'}; @@ -314,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}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/SearchResults.tsx b/js_modules/dagster-ui/packages/ui-core/src/search/SearchResults.tsx index 5d78c7a743b7e..eff3934695156 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/search/SearchResults.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/search/SearchResults.tsx @@ -1,10 +1,23 @@ -import {Colors, Icon, IconName} from '@dagster-io/ui-components'; +import { + Box, + Caption, + CaptionBolded, + Colors, + Icon, + IconName, + StyledTag, +} from '@dagster-io/ui-components'; import Fuse from 'fuse.js'; import * as React from 'react'; import {Link} from 'react-router-dom'; import styled from 'styled-components'; -import {AssetFilterSearchResultType, SearchResult, SearchResultType} from './types'; +import { + AssetFilterSearchResultType, + SearchResult, + SearchResultType, + isAssetFilterSearchResultType, +} from './types'; const iconForType = (type: SearchResultType | AssetFilterSearchResultType): IconName => { switch (type) { @@ -27,17 +40,84 @@ const iconForType = (type: SearchResultType | AssetFilterSearchResultType): Icon return 'op'; case SearchResultType.Resource: return 'resource'; + case AssetFilterSearchResultType.CodeLocation: + return 'folder'; + case AssetFilterSearchResultType.Owner: + return 'account_circle'; + case AssetFilterSearchResultType.AssetGroup: + return 'asset_group'; default: return 'source'; } }; +const assetFilterPrefixString = (type: AssetFilterSearchResultType): string => { + switch (type) { + case AssetFilterSearchResultType.CodeLocation: + return 'Code location'; + case AssetFilterSearchResultType.ComputeKind: + return 'Compute kind'; + case AssetFilterSearchResultType.Owner: + return 'Owner'; + case AssetFilterSearchResultType.AssetGroup: + return 'Group'; + default: + return ''; + } +}; + interface ItemProps { isHighlight: boolean; onClickResult: (result: Fuse.FuseResult) => void; result: Fuse.FuseResult; } +function buildSearchLabel(result: Fuse.FuseResult): JSX.Element[] { + // Fuse provides indices of the label that match the query string. + // Use these match indices to display the label with the matching parts bolded. + + // Match indices can overlap, i.e. [0, 4] and [1, 1] can both be matches + // So we merge them to be non-overlapping + const mergedIndices: Fuse.RangeTuple[] = []; + if (result.matches && result.matches.length > 0) { + const match = result.matches[0]!; // Only one match per row, since we only match by label + + // The indices should be returned in sorted order, but we sort just in case + const sortedIndices = Array.from(match.indices).sort((a, b) => (a[0] < b[0] ? -1 : 1)); + // Merge overlapping indices + if (sortedIndices.length > 0) { + mergedIndices.push(sortedIndices[0]!); + for (let i = 1; i < sortedIndices.length; i++) { + const last = mergedIndices[mergedIndices.length - 1]!; + const current = sortedIndices[i]!; + if (current[0] <= last[1]) { + last[1] = Math.max(last[1], current[1]); + } else { + mergedIndices.push(current); + } + } + } + } + + const labelComponents = []; + let parsedString = ''; + mergedIndices.forEach((indices) => { + const stringBeforeMatch = result.item.label.slice(parsedString.length, indices[0]); + labelComponents.push({stringBeforeMatch}); + parsedString += stringBeforeMatch; + + const match = result.item.label.slice(indices[0], indices[1] + 1); + labelComponents.push({match}); + parsedString += match; + }); + + const stringAfterMatch = result.item.label.substring(parsedString.length); + labelComponents.push({stringAfterMatch}); + parsedString += stringAfterMatch; + + return labelComponents; +} + const SearchResultItem = React.memo(({isHighlight, onClickResult, result}: ItemProps) => { const {item} = result; const element = React.useRef(null); @@ -58,17 +138,32 @@ const SearchResultItem = React.memo(({isHighlight, onClickResult, result}: ItemP [onClickResult, result], ); + const labelComponents = buildSearchLabel(result); + return ( - -
- - {item.description} -
+ + + + {isAssetFilterSearchResultType(item.type) && ( + {assetFilterPrefixString(item.type)}:  + )} + {labelComponents.map((component) => component)} + +
+ + {item.numResults ? `${item.numResults} assets` : item.description} + +
+
); @@ -79,17 +174,18 @@ interface Props { onClickResult: (result: Fuse.FuseResult) => void; queryString: string; results: Fuse.FuseResult[]; + filterResults: Fuse.FuseResult[]; } export const SearchResults = (props: Props) => { - const {highlight, onClickResult, queryString, results} = props; + const {highlight, onClickResult, queryString, results, filterResults} = props; - if (!results.length && queryString) { + if (!results.length && !filterResults.length && queryString) { return No results; } return ( - + {results.map((result, ii) => ( { onClickResult={onClickResult} /> ))} + {filterResults.length > 0 ? ( + <> + Matching filters + {filterResults.map((result, ii) => ( + + ))} + + ) : ( + <> + )} ); }; @@ -118,15 +229,27 @@ const List = styled.ul` padding: ${({hasResults}) => (hasResults ? '4px 0' : 'none')}; list-style: none; overflow-y: auto; + background-color: ${Colors.backgroundDefault()}; + box-shadow: 2px 2px 8px ${Colors.shadowDefault()}; + border-radius: 4px; `; interface HighlightableTextProps { readonly isHighlight: boolean; } +const MatchingFiltersHeader = styled.li` + background-color: ${Colors.backgroundDefault()}; + padding: 8px 12px; + border-bottom: 1px solid ${Colors.backgroundGray()}; + color: ${Colors.textLight()}; + font-weight: 500; +`; + const Item = styled.li` align-items: center; - background-color: ${({isHighlight}) => (isHighlight ? Colors.backgroundLight() : 'transparent')}; + background-color: ${({isHighlight}) => + isHighlight ? Colors.backgroundLight() : Colors.backgroundDefault()}; box-shadow: ${({isHighlight}) => (isHighlight ? Colors.accentLime() : 'transparent')} 4px 0 0 inset; color: ${Colors.textLight()}; @@ -155,11 +278,6 @@ const ResultLink = styled(Link)` } `; -const Label = styled.div` - color: ${({isHighlight}) => (isHighlight ? Colors.textDefault() : Colors.textLight())}; - font-weight: 500; -`; - const Description = styled.div` color: ${({isHighlight}) => (isHighlight ? Colors.textDefault() : Colors.textLight())}; font-size: 12px; diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/types.ts b/js_modules/dagster-ui/packages/ui-core/src/search/types.ts index 0d466b84570e3..7016114c04d5c 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/search/types.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/search/types.ts @@ -23,6 +23,17 @@ export enum AssetFilterSearchResultType { AssetGroup = 'AssetFilterSearchResultType.AssetGroup', } +export function isAssetFilterSearchResultType( + type: SearchResultType | AssetFilterSearchResultType, +): type is AssetFilterSearchResultType { + return ( + type === AssetFilterSearchResultType.AssetGroup || + type === AssetFilterSearchResultType.CodeLocation || + type === AssetFilterSearchResultType.ComputeKind || + type === AssetFilterSearchResultType.Owner + ); +} + export type SearchResult = { label: string; description: string; diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/useGlobalSearch.tsx b/js_modules/dagster-ui/packages/ui-core/src/search/useGlobalSearch.tsx index f92d0574a42e5..a63787f329966 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/search/useGlobalSearch.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/search/useGlobalSearch.tsx @@ -249,6 +249,7 @@ const fuseOptions = { keys: ['label', 'segments', 'tags', 'type'], threshold: 0.3, useExtendedSearch: true, + includeMatches: true, }; const EMPTY_RESPONSE = {queryString: '', results: []};