Skip to content

Commit

Permalink
[search ui] Display asset search on asset landing page (#20373)
Browse files Browse the repository at this point in the history
Walkthrough of the UI:
https://www.loom.com/share/182595e3ba40415694343c6d786720d7?sid=ecb71bf9-0a87-44e4-bb78-4673d6d06495

Changes included:
- Allows the `SearchDialog` to accept a prop indicating whether the
search displays as an overlay, or on the page
- Displays the fuzzy matches in the label in bold
- Displays filter results, alongside the # of assets

Next steps:
- Handle typeaheads (i.e. `asset is:`/`sensor is:`/etc)
  • Loading branch information
clairelin135 authored Mar 12, 2024
1 parent 2b3ed46 commit dd8fb52
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -234,7 +235,7 @@ export const AssetsOverview = ({viewerName}: {viewerName?: string}) => {
/>
<Box flex={{direction: 'column'}} style={{height: '100%', overflow: 'auto'}}>
<Box padding={64} flex={{justifyContent: 'center', alignItems: 'center'}}>
<Box style={{width: '60%'}} flex={{direction: 'column', gap: 16}}>
<Box style={{width: '60%', minWidth: '600px'}} flex={{direction: 'column', gap: 16}}>
<Box flex={{direction: 'row', alignItems: 'center', justifyContent: 'space-between'}}>
<Heading>
{getGreeting(timezone)}
Expand All @@ -245,7 +246,7 @@ export const AssetsOverview = ({viewerName}: {viewerName?: string}) => {
<AssetGlobalLineageButton />
</Box>
</Box>
<TextInput />
<AssetSearch />
</Box>
</Box>
<Box flex={{direction: 'column'}}>
Expand Down
203 changes: 203 additions & 0 deletions js_modules/dagster-ui/packages/ui-core/src/search/AssetSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<SearchResult>[];
highlight: number;
};

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,
};

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, 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 | Trace>(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<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%;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export const SearchDialog = ({searchPlaceholder}: {searchPlaceholder: string}) =
highlight={highlight}
queryString={queryString}
results={renderedResults}
filterResults={[]}
onClickResult={onClickResult}
/>
</Container>
Expand Down Expand Up @@ -306,15 +307,18 @@ interface SearchBoxProps {
readonly hasQueryString: boolean;
}

const SearchBox = styled.div<SearchBoxProps>`
export const SearchBox = styled.div<SearchBoxProps>`
border-radius: 12px;
box-shadow: 2px 2px 8px ${Colors.shadowDefault()};
align-items: center;
border-bottom: ${({hasQueryString}) =>
hasQueryString ? `1px solid ${Colors.keylineDefault()}` : 'none'};
display: flex;
padding: 12px 20px 12px 12px;
`;

const SearchInput = styled.input`
export const SearchInput = styled.input`
border: none;
color: ${Colors.textLight()};
font-family: ${FontFamily.default};
Expand Down
Loading

2 comments on commit dd8fb52

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for dagit-storybook ready!

✅ Preview
https://dagit-storybook-p425dvwxk-elementl.vercel.app

Built with commit dd8fb52.
This pull request is being automatically deployed with vercel-action

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for dagit-core-storybook ready!

✅ Preview
https://dagit-core-storybook-2ppmz9qgu-elementl.vercel.app

Built with commit dd8fb52.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.