From 20fd07d61740154fe112e1d21c1fbdb14fd9c487 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Fri, 10 Nov 2023 12:06:56 -0500 Subject: [PATCH] [ui] Allow searching jobs by Run ID (#15684) ## Summary & Motivation This is a small PR that makes it possible to filter runs by Run ID (Fixes #15260). We mostly already supported this, but there wasn't dropdown UI for the filter. This filter only allows you to enter 36-character Run IDs in the UUID format, which is unfortunate because the UI shows the 8 character version. It seems like it'd actually take a significant amount of work to allow "prefix-" matching against the 8-character display IDs because this filter goes all the way down to the RunStorage interface and there are multiple implementations. To make this limitation more clear, I hacked in an "empty state" which explains what you need to provide. (Screenshot 2), and changed the `freeformSearchResult` callback so that it can return null to offer no completion if the user's input is not a valid value. While I was in here, I also created local vars in the RunsFilterInput for some of the fillers that were inlined so the code is a bit more consistent. image image image ## How I Tested These Changes I tested this by filtering for run IDs, pasting / typing various text and reloading the pages to make sure the filter persists. --------- Co-authored-by: bengotow --- .../ui-components/src/components/Icon.tsx | 2 + .../ui-components/src/icon-svgs/id.svg | 9 + .../src/pipelines/PipelineRunsRoot.tsx | 1 + .../ui-core/src/runs/RunsFilterInput.tsx | 370 ++++++++++-------- .../ui-core/src/ui/Filters/FilterDropdown.tsx | 6 +- .../ui-core/src/ui/Filters/useFilter.tsx | 1 + .../src/ui/Filters/useStaticSetFilter.tsx | 4 +- .../src/ui/Filters/useSuggestionFilter.tsx | 37 +- .../src/ui/Filters/useTimeRangeFilter.tsx | 2 +- 9 files changed, 250 insertions(+), 182 deletions(-) create mode 100644 js_modules/dagster-ui/packages/ui-components/src/icon-svgs/id.svg diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/Icon.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/Icon.tsx index 321593eb75492..984534dae7589 100644 --- a/js_modules/dagster-ui/packages/ui-components/src/components/Icon.tsx +++ b/js_modules/dagster-ui/packages/ui-components/src/components/Icon.tsx @@ -78,6 +78,7 @@ import graph_upstream from '../icon-svgs/graph_upstream.svg'; import history from '../icon-svgs/history.svg'; import history_toggle_off from '../icon-svgs/history_toggle_off.svg'; import hourglass_bottom from '../icon-svgs/hourglass_bottom.svg'; +import id from '../icon-svgs/id.svg'; import infinity from '../icon-svgs/infinity.svg'; import info from '../icon-svgs/info.svg'; import job from '../icon-svgs/job.svg'; @@ -219,6 +220,7 @@ export const Icons = { youtube, arrow_indent, editor_role, + id, graph, graph_downstream, diff --git a/js_modules/dagster-ui/packages/ui-components/src/icon-svgs/id.svg b/js_modules/dagster-ui/packages/ui-components/src/icon-svgs/id.svg new file mode 100644 index 0000000000000..14eac7a606080 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-components/src/icon-svgs/id.svg @@ -0,0 +1,9 @@ + + + id + + + ID + + + \ No newline at end of file diff --git a/js_modules/dagster-ui/packages/ui-core/src/pipelines/PipelineRunsRoot.tsx b/js_modules/dagster-ui/packages/ui-core/src/pipelines/PipelineRunsRoot.tsx index 775172f74fc7f..a202775a26ad6 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/pipelines/PipelineRunsRoot.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/pipelines/PipelineRunsRoot.tsx @@ -50,6 +50,7 @@ const PAGE_SIZE = 25; const ENABLED_FILTERS: RunFilterTokenType[] = [ 'status', 'tag', + 'id', 'created_date_before', 'created_date_after', ]; diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/RunsFilterInput.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/RunsFilterInput.tsx index 4b81d2218153f..12ab6025ab24f 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/runs/RunsFilterInput.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/runs/RunsFilterInput.tsx @@ -218,8 +218,11 @@ export const useRunsFilterInput = ({tokens, onChange, enabledFilters}: RunsFilte const [fetchBackfillValues, backfillValues] = useTagDataFilterValues(DagsterTag.Backfill); const [fetchPartitionValues, partitionValues] = useTagDataFilterValues(DagsterTag.Partition); + const isIDFilterEnabled = !enabledFilters || enabledFilters?.includes('id'); + const isStatusFilterEnabled = !enabledFilters || enabledFilters?.includes('status'); const isBackfillsFilterEnabled = !enabledFilters || enabledFilters?.includes('backfill'); const isPartitionsFilterEnabled = !enabledFilters || enabledFilters?.includes('partition'); + const isJobFilterEnabled = !enabledFilters || enabledFilters?.includes('job'); const onFocus = React.useCallback(() => { fetchTagKeys(); @@ -250,8 +253,6 @@ export const useRunsFilterInput = ({tokens, onChange, enabledFilters}: RunsFilte [sensorValues, scheduleValues, userValues], ); - const isJobFilterEnabled = !enabledFilters || enabledFilters?.includes('job'); - const {pipelines, jobs} = React.useMemo(() => { const pipelineNames = []; const jobNames = []; @@ -286,9 +287,6 @@ export const useRunsFilterInput = ({tokens, onChange, enabledFilters}: RunsFilte }; }, [isJobFilterEnabled, options]); - const isPipelineFilterEnabled = - !enabledFilters || (enabledFilters?.includes('job') && pipelines.length); - const jobFilter = useStaticSetFilter({ name: 'Job', icon: 'job', @@ -438,173 +436,221 @@ export const useRunsFilterInput = ({tokens, onChange, enabledFilters}: RunsFilte }, }); - const {button, activeFiltersJsx} = useFilters({ - filters: [ - !enabledFilters || enabledFilters?.includes('status') ? statusFilter : null, - useStaticSetFilter({ - name: 'Launched by', - allowMultipleSelections: false, - icon: 'add_circle', - allValues: createdByValues, - renderLabel: ({value}) => { - let icon; - let labelValue = value.value; - if (value.type === DagsterTag.SensorName) { - icon = ; - } else if (value.type === DagsterTag.ScheduleName) { - icon = ; - } else if (value.type === DagsterTag.User) { - return ; - } else if (value.type === DagsterTag.Automaterialize) { - icon = ; - labelValue = 'Auto-materialize policy'; + const launchedByFilter = useStaticSetFilter({ + name: 'Launched by', + allowMultipleSelections: false, + icon: 'add_circle', + allValues: createdByValues, + renderLabel: ({value}) => { + let icon; + let labelValue = value.value; + if (value.type === DagsterTag.SensorName) { + icon = ; + } else if (value.type === DagsterTag.ScheduleName) { + icon = ; + } else if (value.type === DagsterTag.User) { + return ; + } else if (value.type === DagsterTag.Automaterialize) { + icon = ; + labelValue = 'Auto-materialize policy'; + } + return ( + + {icon} + + + ); + }, + getStringValue: (x) => { + if (x.type === DagsterTag.Automaterialize) { + return 'Auto-materialize policy'; + } + return x.value!; + }, + initialState: React.useMemo(() => { + return new Set( + tokens + .filter( + ({token, value}) => + token === 'tag' && CREATED_BY_TAGS.includes(value.split('=')[0] as DagsterTag), + ) + .map(({value}) => tagValueToFilterObject(value)), + ); + }, [tokens]), + onStateChanged: (values) => { + onChange([ + ...tokens.filter((token) => { + if (token.token !== 'tag') { + return true; } - return ( - - {icon} - - - ); - }, - getStringValue: (x) => { - if (x.type === DagsterTag.Automaterialize) { - return 'Auto-materialize policy'; + return !CREATED_BY_TAGS.includes(token.value.split('=')[0] as DagsterTag); + }), + ...Array.from(values).map((value) => ({ + token: 'tag' as const, + value: `${value.type}=${value.value}`, + })), + ]); + }, + }); + + const createdDateFilter = useTimeRangeFilter({ + name: 'Created date', + icon: 'date', + initialState: React.useMemo(() => { + const before = tokens.find((token) => token.token === 'created_date_before'); + const after = tokens.find((token) => token.token === 'created_date_after'); + return [ + after ? parseInt(after.value) * 1000 : null, + before ? parseInt(before.value) * 1000 : null, + ] as TimeRangeState; + }, [tokens]), + onStateChanged: (values) => { + onChange([ + ...tokens.filter( + (token) => !['created_date_before', 'created_date_after'].includes(token.token ?? ''), + ), + ...([ + values[0] != null ? {token: 'created_date_after', value: `${values[0] / 1000}`} : null, + values[1] != null ? {token: 'created_date_before', value: `${values[1] / 1000}`} : null, + ].filter((x) => x) as RunFilterToken[]), + ]); + }, + }); + + const tagFilter = useSuggestionFilter({ + name: 'Tag', + icon: 'tag', + initialSuggestions: tagSuggestions, + + freeformSearchResult: React.useCallback( + ( + query: string, + path: { + value: string; + key?: string | undefined; + }[], + ) => { + return { + ...tagSuggestionValueObject(path[0] ? path[0].value : '', query), + final: !!path.length, + }; + }, + [], + ), + + state: React.useMemo(() => { + return tokens + .filter(({token, value}) => { + if (token !== 'tag') { + return false; } - return x.value!; - }, - initialState: React.useMemo(() => { - return new Set( - tokens - .filter( - ({token, value}) => - token === 'tag' && CREATED_BY_TAGS.includes(value.split('=')[0] as DagsterTag), - ) - .map(({value}) => tagValueToFilterObject(value)), - ); - }, [tokens]), - onStateChanged: (values) => { - onChange([ - ...tokens.filter((token) => { - if (token.token !== 'tag') { - return true; - } - return !CREATED_BY_TAGS.includes(token.value.split('=')[0] as DagsterTag); - }), - ...Array.from(values).map((value) => ({ - token: 'tag' as const, - value: `${value.type}=${value.value}`, - })), - ]); - }, - }), - useTimeRangeFilter({ - name: 'Created date', - icon: 'date', - initialState: React.useMemo(() => { - const before = tokens.find((token) => token.token === 'created_date_before'); - const after = tokens.find((token) => token.token === 'created_date_after'); - return [ - after ? parseInt(after.value) * 1000 : null, - before ? parseInt(before.value) * 1000 : null, - ] as TimeRangeState; - }, [tokens]), - onStateChanged: (values) => { - onChange([ - ...tokens.filter( - (token) => !['created_date_before', 'created_date_after'].includes(token.token ?? ''), - ), - ...([ - values[0] != null - ? {token: 'created_date_after', value: `${values[0] / 1000}`} - : null, - values[1] != null - ? {token: 'created_date_before', value: `${values[1] / 1000}`} - : null, - ].filter((x) => x) as RunFilterToken[]), - ]); - }, - }), + return !tagsToExclude.includes(value.split('=')[0] as DagsterTag); + }) + .map((token) => { + const [key, value] = token.value.split('='); + return tagSuggestionValueObject(key!, value!).value; + }); + }, [tokens]), + + setState: (nextState) => { + onChange([ + ...tokens.filter(({token, value}) => { + if (token !== 'tag') { + return true; + } + return tagsToExclude.includes(value.split('=')[0] as DagsterTag); + }), + ...nextState.map(({key, value}) => { + return { + token: 'tag' as const, + value: `${key}=${value}`, + }; + }), + ]); + }, + onSuggestionClicked: async ({value}) => { + return await fetchTagValues(value); + }, + getStringValue: ({key, value}) => `${key}=${value}`, + getKey: ({key, value}) => `${key}: ${value}`, + renderLabel: ({value}) => ( + + + + + ), + renderActiveStateLabel: ({value}) => ( + + + + {value.key}={value.value} + + ), + isMatch: ({value}, query) => value.toLowerCase().includes(query.toLowerCase()), + matchType: 'all-of', + }); + + const ID_EMPTY = 'Type or paste 36-character ID'; + const ID_TOO_SHORT = 'Invalid Run ID'; + + const idFilter = useSuggestionFilter({ + name: 'Run ID', + icon: 'id', + initialSuggestions: [], + getNoSuggestionsPlaceholder: (query) => (!query ? ID_EMPTY : ID_TOO_SHORT), + state: React.useMemo(() => { + return tokens.filter(({token}) => token === 'id').map((token) => token.value); + }, [tokens]), + freeformSearchResult: (query) => { + return /^([a-f0-9-]{36})$/.test(query.trim()) ? {value: query.trim(), final: true} : null; + }, + setState: (nextState) => { + onChange([ + ...tokens.filter(({token}) => token !== 'id'), + ...nextState.map((value) => { + return {token: 'id' as const, value}; + }), + ]); + }, + getStringValue: (value) => value, + getKey: (value) => value, + renderLabel: ({value}) => ( + + + + + ), + onSuggestionClicked: async (value) => { + return [{value}]; + }, + renderActiveStateLabel: ({value}) => ( + + + + {value} + + ), + isMatch: (value, query) => value.toLowerCase().includes(query.toLowerCase()), + matchType: 'any-of', + }); + + const {button, activeFiltersJsx} = useFilters({ + filters: [ + isStatusFilterEnabled ? statusFilter : null, + launchedByFilter, + createdDateFilter, isJobFilterEnabled ? jobFilter : null, - isPipelineFilterEnabled ? pipelinesFilter : null, + isJobFilterEnabled && pipelines.length > 0 ? pipelinesFilter : null, + isIDFilterEnabled ? idFilter : null, isBackfillsFilterEnabled ? backfillsFilter : null, isPartitionsFilterEnabled ? partitionsFilter : null, - useSuggestionFilter({ - name: 'Tag', - icon: 'tag', - initialSuggestions: tagSuggestions, - - freeformSearchResult: React.useCallback( - ( - query: string, - path: { - value: string; - key?: string | undefined; - }[], - ) => { - return { - ...tagSuggestionValueObject(path[0] ? path[0].value : '', query), - final: !!path.length, - }; - }, - [], - ), - - state: React.useMemo(() => { - return tokens - .filter(({token, value}) => { - if (token !== 'tag') { - return false; - } - return !tagsToExclude.includes(value.split('=')[0] as DagsterTag); - }) - .map((token) => { - const [key, value] = token.value.split('='); - return tagSuggestionValueObject(key!, value!).value; - }); - }, [tokens]), - - setState: (nextState) => { - onChange([ - ...tokens.filter(({token, value}) => { - if (token !== 'tag') { - return true; - } - return tagsToExclude.includes(value.split('=')[0] as DagsterTag); - }), - ...nextState.map(({key, value}) => { - return { - token: 'tag' as const, - value: `${key}=${value}`, - }; - }), - ]); - }, - onSuggestionClicked: async ({value}) => { - return await fetchTagValues(value); - }, - getStringValue: ({key, value}) => `${key}=${value}`, - getKey: ({key, value}) => `${key}: ${value}`, - renderLabel: ({value}) => ( - - - - - ), - renderActiveStateLabel: ({value}) => ( - - - - {value.key}={value.value} - - ), - isMatch: ({value}, query) => value.toLowerCase().includes(query.toLowerCase()), - matchType: 'all-of', - }), + tagFilter, ].filter((x) => x) as FilterObject[], }); return {button: {button}, activeFiltersJsx}; }; + export function useTagDataFilterValues(tagKey?: DagsterTag) { const [fetch, {data}] = useLazyQuery( RUN_TAG_VALUES_QUERY, diff --git a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/FilterDropdown.tsx b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/FilterDropdown.tsx index 99b9fd75bebce..de0bf60d475d8 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/FilterDropdown.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/FilterDropdown.tsx @@ -126,7 +126,7 @@ export const FilterDropdown = ({filters, setIsOpen, setPortaledElements}: Filter setFocusedItemIndex(-1); }} text={ - + {filter.name} @@ -266,7 +266,9 @@ export const FilterDropdown = ({filters, setIsOpen, setPortaledElements}: Filter ) : ( - No results + + {selectedFilter?.getNoResultsPlaceholder?.(search) || 'No results'} + )} diff --git a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useFilter.tsx b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useFilter.tsx index da513f738a14b..838df3ad43b0c 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useFilter.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useFilter.tsx @@ -10,6 +10,7 @@ export type FilterObject = { icon: IconName; name: string; getResults: (query: string) => {label: JSX.Element; key: string; value: any}[]; + getNoResultsPlaceholder?: (query: string) => string; onSelect: (selectArg: { value: T; close: () => void; diff --git a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useStaticSetFilter.tsx b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useStaticSetFilter.tsx index 03a9fc4144a0f..5b044bc8cd3cc 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useStaticSetFilter.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useStaticSetFilter.tsx @@ -258,12 +258,12 @@ export function SetFilterLabel(props: SetFilterLabelProps) { const labelRef = React.useRef(null); return ( - // 4 px of margin to compensate for weird Checkbox CSS whose bounding box is smaller than the actual + // 2px of margin to compensate for weird Checkbox CSS whose bounding box is smaller than the actual // SVG it contains with size="small" {allowMultipleSelections ? : null} diff --git a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useSuggestionFilter.tsx b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useSuggestionFilter.tsx index 5299479b07095..82e50042dfe77 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useSuggestionFilter.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useSuggestionFilter.tsx @@ -16,13 +16,15 @@ type Args = { freeformSearchResult?: ( query: string, suggestionPath: TValue[], - ) => SuggestionFilterSuggestion; + ) => SuggestionFilterSuggestion | null; state: TValue[]; // Active suggestions setState: (state: TValue[]) => void; - initialSuggestions: SuggestionFilterSuggestion[]; + initialSuggestions: SuggestionFilterSuggestion[]; + getNoSuggestionsPlaceholder?: (query: string) => string; onSuggestionClicked: (value: TValue) => Promise[]> | void; + getStringValue: (value: TValue) => string; getKey: (value: TValue) => string; renderLabel: ({value, isActive}: {value: TValue; isActive: boolean}) => JSX.Element; @@ -44,6 +46,7 @@ export function useSuggestionFilter({ setState, initialSuggestions, onSuggestionClicked, + getNoSuggestionsPlaceholder, getStringValue, getKey, renderLabel, @@ -71,6 +74,7 @@ export function useSuggestionFilter({ setSuggestionPath([]); }, isLoadingFilters: nextSuggestionsLoading, + getNoResultsPlaceholder: getNoSuggestionsPlaceholder, getResults: (query: string) => { let results; let hasExactMatch = false; @@ -116,17 +120,19 @@ export function useSuggestionFilter({ } if (!hasExactMatch && freeformSearchResult && query.length) { const suggestion = freeformSearchResult(query, suggestionPath); - results.unshift({ - label: ( - - ), - key: getKey?.(suggestion.value) || 'freeform', - value: suggestion, - }); + if (suggestion) { + results.unshift({ + label: ( + + ), + key: getKey?.(suggestion.value) || 'freeform', + value: suggestion, + }); + } } return results; }, @@ -173,6 +179,7 @@ export function useSuggestionFilter({ state, nextSuggestionsLoading, getStringValue, + getNoSuggestionsPlaceholder, renderActiveStateLabel, renderLabel, matchType, @@ -207,12 +214,12 @@ function SuggestionFilterLabel(props: SuggestionFilterLabelProps) { const labelRef = React.useRef(null); return ( - // 4 px of margin to compensate for weird Checkbox CSS whose bounding box is smaller than the actual + // 2px of margin to compensate for weird Checkbox CSS whose bounding box is smaller than the actual // SVG it contains with size="small"
{renderLabel({value, isActive})}
diff --git a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useTimeRangeFilter.tsx b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useTimeRangeFilter.tsx index ccfdc2ad411a0..1aec7cda4c4dd 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useTimeRangeFilter.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/ui/Filters/useTimeRangeFilter.tsx @@ -171,7 +171,7 @@ export function useTimeRangeFilter({ function TimeRangeResult({range}: {range: string}) { return ( - + {range}