From 1fcc79c9ae5992c51a0cbd373c8aacbe24c1d96c Mon Sep 17 00:00:00 2001 From: Ross Edfort Date: Wed, 16 Sep 2026 14:46:55 -0600 Subject: [PATCH 01/19] refactor(filters): extract quick filter helpers into a shared utility Move getDefaultConditional out of search-attribute-menu.svelte and add the value formatting, toggle and active-state helpers that table cells need to build a filter for any search attribute type (FE-614). --- .../search-attribute-menu.svelte | 27 +- src/lib/utilities/query/quick-filter.test.ts | 354 ++++++++++++++++++ src/lib/utilities/query/quick-filter.ts | 153 ++++++++ 3 files changed, 509 insertions(+), 25 deletions(-) create mode 100644 src/lib/utilities/query/quick-filter.test.ts create mode 100644 src/lib/utilities/query/quick-filter.ts diff --git a/src/lib/components/search-attribute-filter/search-attribute-menu.svelte b/src/lib/components/search-attribute-filter/search-attribute-menu.svelte index 2e9eeda5ea..c127bea3fe 100644 --- a/src/lib/components/search-attribute-filter/search-attribute-menu.svelte +++ b/src/lib/components/search-attribute-filter/search-attribute-menu.svelte @@ -17,10 +17,8 @@ import { IconFilter, IconSearch } from '$lib/io/icon'; import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters'; import type { SearchAttributeOption } from '$lib/stores/search-attributes'; - import { - SEARCH_ATTRIBUTE_TYPE, - type SearchAttributeType, - } from '$lib/types/workflows'; + import type { SearchAttributeType } from '$lib/types/workflows'; + import { getDefaultConditional } from '$lib/utilities/query/quick-filter'; import { createFilter, updateQueryParamsFromFilter, @@ -54,27 +52,6 @@ document.getElementById(searchId)?.focus(); }; - const getDefaultConditional = (type: SearchAttributeType) => { - switch (type) { - case SEARCH_ATTRIBUTE_TYPE.BOOL: - return '='; - case SEARCH_ATTRIBUTE_TYPE.DATETIME: - return '>='; - case SEARCH_ATTRIBUTE_TYPE.INT: - return '='; - case SEARCH_ATTRIBUTE_TYPE.DOUBLE: - return '='; - case SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST: - return 'in'; - case SEARCH_ATTRIBUTE_TYPE.KEYWORD: - return '='; - case SEARCH_ATTRIBUTE_TYPE.TEXT: - return '='; - default: - return '='; - } - }; - function handleNewQuery(value: string, type: SearchAttributeType) { searchAttributeValue = ''; filter.set( diff --git a/src/lib/utilities/query/quick-filter.test.ts b/src/lib/utilities/query/quick-filter.test.ts new file mode 100644 index 0000000000..a6be106d9e --- /dev/null +++ b/src/lib/utilities/query/quick-filter.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; + +import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters'; +import { + SEARCH_ATTRIBUTE_TYPE, + type SearchAttributes, +} from '$lib/types/workflows'; + +import { toListWorkflowQueryFromFilters } from './filter-workflow-query'; +import { + createQuickFilter, + formatQuickFilterValue, + getDefaultConditional, + isQuickFilterActive, + toggleQuickFilter, +} from './quick-filter'; +import { + createFilter, + toListWorkflowFilters, +} from './to-list-workflow-filters'; + +const attributes: SearchAttributes = { + ExecutionStatus: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + StartTime: SEARCH_ATTRIBUTE_TYPE.DATETIME, + CustomBoolField: SEARCH_ATTRIBUTE_TYPE.BOOL, + CustomIntField: SEARCH_ATTRIBUTE_TYPE.INT, + CustomDoubleField: SEARCH_ATTRIBUTE_TYPE.DOUBLE, + CustomKeywordField: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + CustomTextField: SEARCH_ATTRIBUTE_TYPE.TEXT, + CustomKeywordListField: SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST, +}; + +describe('getDefaultConditional', () => { + it('uses >= for Datetime so a quick filter reads as "at or after this value"', () => { + expect(getDefaultConditional(SEARCH_ATTRIBUTE_TYPE.DATETIME)).toBe('>='); + }); + + it('uses in for KeywordList', () => { + expect(getDefaultConditional(SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST)).toBe('in'); + }); + + it.each([ + SEARCH_ATTRIBUTE_TYPE.BOOL, + SEARCH_ATTRIBUTE_TYPE.INT, + SEARCH_ATTRIBUTE_TYPE.DOUBLE, + SEARCH_ATTRIBUTE_TYPE.KEYWORD, + SEARCH_ATTRIBUTE_TYPE.TEXT, + SEARCH_ATTRIBUTE_TYPE.UNSPECIFIED, + ])('uses = for %s', (type) => { + expect(getDefaultConditional(type)).toBe('='); + }); +}); + +describe('formatQuickFilterValue', () => { + const format = (type, value) => + formatQuickFilterValue({ + type, + value, + conditional: getDefaultConditional(type), + }); + + it('formats a Bool from a real boolean and from a string', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.BOOL, true)).toBe('true'); + expect(format(SEARCH_ATTRIBUTE_TYPE.BOOL, false)).toBe('false'); + expect(format(SEARCH_ATTRIBUTE_TYPE.BOOL, 'TRUE')).toBe('true'); + }); + + it('rejects a Bool that is neither a boolean nor true/false', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.BOOL, 'yes')).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.BOOL, 1)).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.BOOL, undefined)).toBeNull(); + }); + + it('keeps numbers unreformatted, including zero and negatives', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.INT, 0)).toBe('0'); + expect(format(SEARCH_ATTRIBUTE_TYPE.INT, -5)).toBe('-5'); + expect(format(SEARCH_ATTRIBUTE_TYPE.INT, '1024')).toBe('1024'); + expect(format(SEARCH_ATTRIBUTE_TYPE.DOUBLE, 1.5)).toBe('1.5'); + }); + + it('rejects values that are not numbers', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.INT, 'abc')).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.INT, '')).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.INT, NaN)).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.DOUBLE, Infinity)).toBeNull(); + }); + + it('passes a Datetime through as the raw ISO string', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.DATETIME, '2024-01-02T03:04:05Z')).toBe( + '2024-01-02T03:04:05Z', + ); + }); + + it('rejects a Datetime that is not parseable, such as a formatted cell value', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.DATETIME, 'not a date')).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.DATETIME, '')).toBeNull(); + }); + + it('wraps a KeywordList in the parenthesized literal the in conditional expects', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST, ['a', 'b'])).toBe( + '("a", "b")', + ); + }); + + it('wraps a single-element KeywordList the same way', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST, ['a'])).toBe('("a")'); + }); + + it('rejects an empty KeywordList', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST, [])).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST, ['', ''])).toBeNull(); + }); + + it('does not parenthesize a KeywordList for a non-in conditional', () => { + expect( + formatQuickFilterValue({ + type: SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST, + value: ['a'], + conditional: '=', + }), + ).toBe('a'); + }); + + it('passes Keyword and Text through, rejecting empty strings', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.KEYWORD, 'my-task-queue')).toBe( + 'my-task-queue', + ); + expect(format(SEARCH_ATTRIBUTE_TYPE.TEXT, 'some text')).toBe('some text'); + expect(format(SEARCH_ATTRIBUTE_TYPE.KEYWORD, '')).toBeNull(); + expect(format(SEARCH_ATTRIBUTE_TYPE.KEYWORD, 42)).toBeNull(); + }); + + it('rejects an unspecified type', () => { + expect(format(SEARCH_ATTRIBUTE_TYPE.UNSPECIFIED, 'anything')).toBeNull(); + }); +}); + +describe('createQuickFilter', () => { + it('sets the conditional from the type and leaves the join fields empty', () => { + const filter = createQuickFilter({ + attribute: 'StartTime', + type: SEARCH_ATTRIBUTE_TYPE.DATETIME, + value: '2024-01-02T03:04:05Z', + }); + + expect(filter).toMatchObject({ + attribute: 'StartTime', + type: SEARCH_ATTRIBUTE_TYPE.DATETIME, + value: '2024-01-02T03:04:05Z', + conditional: '>=', + operator: '', + parenthesis: '', + }); + }); + + it('returns null when the attribute has no known type', () => { + expect( + createQuickFilter({ + attribute: 'ParentNamespace', + type: undefined, + value: 'default', + }), + ).toBeNull(); + }); + + it('returns null when the value cannot be filtered on', () => { + expect( + createQuickFilter({ + attribute: 'CustomIntField', + type: SEARCH_ATTRIBUTE_TYPE.INT, + value: '', + }), + ).toBeNull(); + }); +}); + +describe('quick filter round trip', () => { + const cases: [string, string, unknown, string][] = [ + [ + 'CustomBoolField', + SEARCH_ATTRIBUTE_TYPE.BOOL, + true, + '`CustomBoolField`=true', + ], + ['CustomIntField', SEARCH_ATTRIBUTE_TYPE.INT, 42, '`CustomIntField`=42'], + [ + 'CustomDoubleField', + SEARCH_ATTRIBUTE_TYPE.DOUBLE, + 1.5, + '`CustomDoubleField`=1.5', + ], + [ + 'StartTime', + SEARCH_ATTRIBUTE_TYPE.DATETIME, + '2024-01-02T03:04:05Z', + '`StartTime`>="2024-01-02T03:04:05Z"', + ], + [ + 'CustomKeywordListField', + SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST, + ['Hello', 'World'], + '`CustomKeywordListField`in("Hello", "World")', + ], + [ + 'CustomKeywordField', + SEARCH_ATTRIBUTE_TYPE.KEYWORD, + 'my-value', + '`CustomKeywordField`="my-value"', + ], + [ + 'CustomTextField', + SEARCH_ATTRIBUTE_TYPE.TEXT, + 'some text', + '`CustomTextField`="some text"', + ], + ]; + + it.each(cases)( + 'emits and reparses a %s quick filter', + (attribute, type, value, expectedQuery) => { + const filter = createQuickFilter({ attribute, type, value }); + expect(filter).not.toBeNull(); + + const query = toListWorkflowQueryFromFilters([filter]); + expect(query).toBe(expectedQuery); + + const [parsed] = toListWorkflowFilters(query, attributes); + expect(parsed).toMatchObject({ + attribute: filter.attribute, + type: filter.type, + value: filter.value, + conditional: filter.conditional, + }); + }, + ); +}); + +describe('isQuickFilterActive', () => { + const quickFilter = createQuickFilter({ + attribute: 'CustomKeywordField', + type: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + value: 'my-value', + }); + + it('is active when the only filter for the attribute matches', () => { + expect(isQuickFilterActive([quickFilter], quickFilter)).toBe(true); + }); + + it('is inactive when no filters are set', () => { + expect(isQuickFilterActive([], quickFilter)).toBe(false); + }); + + it('is inactive for a different value on the same attribute', () => { + const other = createFilter({ ...quickFilter, value: 'other-value' }); + expect(isQuickFilterActive([other], quickFilter)).toBe(false); + }); + + it('is inactive when the conditional differs', () => { + const before = createFilter({ + attribute: 'StartTime', + type: SEARCH_ATTRIBUTE_TYPE.DATETIME, + value: '2024-01-02T03:04:05Z', + conditional: '<=', + }); + const after = createQuickFilter({ + attribute: 'StartTime', + type: SEARCH_ATTRIBUTE_TYPE.DATETIME, + value: '2024-01-02T03:04:05Z', + }); + + expect(isQuickFilterActive([before], after)).toBe(false); + }); + + it('is inactive for a custom date range even if the value matches', () => { + const between = createFilter({ + ...quickFilter, + customDate: true, + }); + expect(isQuickFilterActive([between], quickFilter)).toBe(false); + }); + + it('is inactive while several statuses are OR-ed together', () => { + const statuses = ['Running', 'Completed', 'Failed'].map((value) => + createFilter({ + attribute: 'ExecutionStatus', + type: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + value, + conditional: '=', + }), + ); + const completed = createQuickFilter({ + attribute: 'ExecutionStatus', + type: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + value: 'Completed', + }); + + expect(isQuickFilterActive(statuses, completed)).toBe(false); + }); +}); + +describe('toggleQuickFilter', () => { + const keywordFilter = createQuickFilter({ + attribute: 'CustomKeywordField', + type: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + value: 'my-value', + }); + + it('adds the filter when nothing is set', () => { + expect(toggleQuickFilter([], keywordFilter)).toEqual([keywordFilter]); + }); + + it('removes the filter when it is already active', () => { + expect(toggleQuickFilter([keywordFilter], keywordFilter)).toEqual([]); + }); + + it('replaces an existing filter on the same attribute', () => { + const existing = createFilter({ ...keywordFilter, value: 'other-value' }); + expect(toggleQuickFilter([existing], keywordFilter)).toEqual([ + keywordFilter, + ]); + }); + + it('leaves filters on other attributes alone', () => { + const other: SearchAttributeFilter = createFilter({ + attribute: 'CustomIntField', + type: SEARCH_ATTRIBUTE_TYPE.INT, + value: '42', + conditional: '=', + }); + + expect(toggleQuickFilter([other], keywordFilter)).toEqual([ + other, + keywordFilter, + ]); + }); + + it('collapses a multi-status OR group down to the clicked status', () => { + const statuses = ['Running', 'Completed', 'Failed'].map((value) => + createFilter({ + attribute: 'ExecutionStatus', + type: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + value, + conditional: '=', + }), + ); + const completed = createQuickFilter({ + attribute: 'ExecutionStatus', + type: SEARCH_ATTRIBUTE_TYPE.KEYWORD, + value: 'Completed', + }); + + expect(toggleQuickFilter(statuses, completed)).toEqual([completed]); + }); +}); diff --git a/src/lib/utilities/query/quick-filter.ts b/src/lib/utilities/query/quick-filter.ts new file mode 100644 index 0000000000..54bc534add --- /dev/null +++ b/src/lib/utilities/query/quick-filter.ts @@ -0,0 +1,153 @@ +import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters'; +import { + SEARCH_ATTRIBUTE_TYPE, + type SearchAttributeType, +} from '$lib/types/workflows'; + +import { isValidDate } from '../format-date'; +import { isInConditional } from '../is'; +import { createFilter } from './to-list-workflow-filters'; + +export type QuickFilterValue = + | string + | number + | boolean + | string[] + | null + | undefined; + +export const getDefaultConditional = (type: SearchAttributeType) => { + switch (type) { + case SEARCH_ATTRIBUTE_TYPE.BOOL: + return '='; + case SEARCH_ATTRIBUTE_TYPE.DATETIME: + return '>='; + case SEARCH_ATTRIBUTE_TYPE.INT: + return '='; + case SEARCH_ATTRIBUTE_TYPE.DOUBLE: + return '='; + case SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST: + return 'in'; + case SEARCH_ATTRIBUTE_TYPE.KEYWORD: + return '='; + case SEARCH_ATTRIBUTE_TYPE.TEXT: + return '='; + default: + return '='; + } +}; + +const formatBoolValue = (value: QuickFilterValue): string | null => { + if (typeof value === 'boolean') return String(value); + if (typeof value !== 'string') return null; + + const normalized = value.toLowerCase(); + if (normalized === 'true' || normalized === 'false') return normalized; + return null; +}; + +const formatNumberValue = (value: QuickFilterValue): string | null => { + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : null; + } + if (typeof value !== 'string' || !value.trim()) return null; + + return Number.isFinite(Number(value)) ? value.trim() : null; +}; + +const formatDatetimeValue = (value: QuickFilterValue): string | null => { + if (typeof value !== 'string' || !value) return null; + return isValidDate(value) ? value : null; +}; + +const formatKeywordListValue = ( + value: QuickFilterValue, + conditional: string, +): string | null => { + const values = Array.isArray(value) + ? value.filter((item) => typeof item === 'string' && item !== '') + : typeof value === 'string' && value + ? [value] + : []; + + if (!values.length) return null; + if (!isInConditional(conditional)) return values.join(', '); + + return `(${values.map((item) => `"${item}"`).join(', ')})`; +}; + +export const formatQuickFilterValue = ({ + type, + value, + conditional, +}: { + type: SearchAttributeType; + value: QuickFilterValue; + conditional: string; +}): string | null => { + switch (type) { + case SEARCH_ATTRIBUTE_TYPE.BOOL: + return formatBoolValue(value); + case SEARCH_ATTRIBUTE_TYPE.INT: + case SEARCH_ATTRIBUTE_TYPE.DOUBLE: + return formatNumberValue(value); + case SEARCH_ATTRIBUTE_TYPE.DATETIME: + return formatDatetimeValue(value); + case SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST: + return formatKeywordListValue(value, conditional); + case SEARCH_ATTRIBUTE_TYPE.KEYWORD: + case SEARCH_ATTRIBUTE_TYPE.TEXT: + return typeof value === 'string' && value ? value : null; + default: + return null; + } +}; + +export const createQuickFilter = ({ + attribute, + type, + value, +}: { + attribute: string; + type: SearchAttributeType | undefined; + value: QuickFilterValue; +}): SearchAttributeFilter | null => { + if (!attribute || !type) return null; + + const conditional = getDefaultConditional(type); + const formattedValue = formatQuickFilterValue({ type, value, conditional }); + if (formattedValue === null) return null; + + return createFilter({ attribute, type, value: formattedValue, conditional }); +}; + +export const isQuickFilterActive = ( + filters: SearchAttributeFilter[], + quickFilter: SearchAttributeFilter | null, +): boolean => { + if (!quickFilter) return false; + + const filtersForAttribute = filters.filter( + (filter) => filter.attribute === quickFilter.attribute, + ); + + return ( + filtersForAttribute.length === 1 && + filtersForAttribute[0].value === quickFilter.value && + filtersForAttribute[0].conditional === quickFilter.conditional && + !filtersForAttribute[0].customDate + ); +}; + +export const toggleQuickFilter = ( + filters: SearchAttributeFilter[], + quickFilter: SearchAttributeFilter, +): SearchAttributeFilter[] => { + const otherFilters = filters.filter( + (filter) => filter.attribute !== quickFilter.attribute, + ); + + return isQuickFilterActive(filters, quickFilter) + ? otherFilters + : [...otherFilters, quickFilter]; +}; From 0fc0fedd5db35ce66b5c90ce011ab3f42d6d8bfa Mon Sep 17 00:00:00 2001 From: Ross Edfort Date: Wed, 16 Sep 2026 14:53:35 -0600 Subject: [PATCH 02/19] feat(workflows): quick filter every search attribute value in the table Filterability is now derived from the column's search attribute and its type rather than a hardcoded label list, so Datetime, Bool, Int, Double and KeywordList values get a filter button with an operator that suits the type. Archival keeps the columns it supported before (FE-614). --- .../column-search-attributes.test.ts | 88 ++++++ .../column-search-attributes.ts | 84 ++++++ .../filterable-table-cell.svelte | 87 +++--- .../table-body-cell.svelte | 278 ++++++------------ .../holocene/filter-or-copy-buttons.svelte | 3 +- src/lib/utilities/query/quick-filter.test.ts | 30 ++ src/lib/utilities/query/quick-filter.ts | 46 ++- 7 files changed, 380 insertions(+), 236 deletions(-) create mode 100644 src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.test.ts create mode 100644 src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.ts diff --git a/src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.test.ts b/src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.test.ts new file mode 100644 index 0000000000..170e678393 --- /dev/null +++ b/src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import type { WorkflowExecution } from '$lib/types/workflows'; + +import { + getWorkflowColumnAttribute, + getWorkflowColumnValue, + UNFILTERABLE_WORKFLOW_COLUMNS, + WORKFLOW_COLUMN_ATTRIBUTE, +} from './column-search-attributes'; + +const workflow = { + name: 'MyWorkflowType', + id: 'my-workflow-id', + runId: 'my-run-id', + status: 'Completed', + startTime: '2024-01-02T03:04:05Z', + endTime: '2024-01-02T04:04:05Z', + executionTime: '2024-01-02T03:04:05Z', + taskQueue: 'my-task-queue', + historySizeBytes: '2048', + historyEvents: '17', + stateTransitionCount: '0', + parentNamespaceId: 'parent-namespace', + searchAttributes: { + indexedFields: { + TemporalWorkerDeploymentVersion: 'my-deployment.abc123', + TemporalChangeVersion: ['v1', 'v2'], + CustomIntField: 42, + CustomBoolField: true, + }, + }, +} as unknown as WorkflowExecution; + +describe('getWorkflowColumnAttribute', () => { + it('maps a column label to its search attribute', () => { + expect(getWorkflowColumnAttribute('Status')).toBe('ExecutionStatus'); + expect(getWorkflowColumnAttribute('Task Queue')).toBe('TaskQueue'); + }); + + it('treats an unmapped label as a custom search attribute name', () => { + expect(getWorkflowColumnAttribute('CustomIntField')).toBe('CustomIntField'); + }); +}); + +describe('getWorkflowColumnValue', () => { + it('returns raw values rather than the formatted cell text', () => { + expect(getWorkflowColumnValue('Start', workflow)).toBe( + '2024-01-02T03:04:05Z', + ); + expect(getWorkflowColumnValue('End', workflow)).toBe( + '2024-01-02T04:04:05Z', + ); + expect(getWorkflowColumnValue('History Size', workflow)).toBe('2048'); + expect(getWorkflowColumnValue('History Length', workflow)).toBe('17'); + }); + + it('returns the readable status used by the status filter chip', () => { + expect(getWorkflowColumnValue('Status', workflow)).toBe('Completed'); + }); + + it('omits a count the cell renders as blank', () => { + expect( + getWorkflowColumnValue('State Transitions', workflow), + ).toBeUndefined(); + }); + + it('falls back to the deployment version for Build ID', () => { + expect(getWorkflowColumnValue('Build ID', workflow)).toBe('abc123'); + }); + + it('reads custom search attributes with their runtime types intact', () => { + expect(getWorkflowColumnValue('CustomIntField', workflow)).toBe(42); + expect(getWorkflowColumnValue('CustomBoolField', workflow)).toBe(true); + expect(getWorkflowColumnValue('Change Version', workflow)).toEqual([ + 'v1', + 'v2', + ]); + }); + + it.each(UNFILTERABLE_WORKFLOW_COLUMNS)( + 'has no value to filter on for %s', + (label) => { + expect(getWorkflowColumnValue(label, workflow)).toBeUndefined(); + expect(WORKFLOW_COLUMN_ATTRIBUTE[label]).toBeUndefined(); + }, + ); +}); diff --git a/src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.ts b/src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.ts new file mode 100644 index 0000000000..a82ca25e10 --- /dev/null +++ b/src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.ts @@ -0,0 +1,84 @@ +import type { WorkflowExecution } from '$lib/types/workflows'; +import { getBuildIdFromVersion } from '$lib/utilities/get-deployment-build-id'; +import type { QuickFilterValue } from '$lib/utilities/query/quick-filter'; + +export const WORKFLOW_COLUMN_ATTRIBUTE: Record = { + Status: 'ExecutionStatus', + 'Workflow ID': 'WorkflowId', + 'Run ID': 'RunId', + Type: 'WorkflowType', + Start: 'StartTime', + End: 'CloseTime', + 'Execution Time': 'ExecutionTime', + 'Task Queue': 'TaskQueue', + 'History Size': 'HistorySizeBytes', + 'History Length': 'HistoryLength', + 'State Transitions': 'StateTransitionCount', + Deployment: 'TemporalWorkerDeployment', + 'Deployment Version': 'TemporalWorkerDeploymentVersion', + 'Build ID': 'TemporalWorkerBuildId', + 'Versioning Behavior': 'TemporalWorkflowVersioningBehavior', + 'Scheduled By ID': 'TemporalScheduledById', + 'Scheduled Start Time': 'TemporalScheduledStartTime', + 'Change Version': 'TemporalChangeVersion', +}; + +// Columns without a search attribute to filter on. Parent Namespace is not +// indexed, and Execution Duration is derived from start and end times rather +// than read from the workflow, so there is no value to filter by. +export const UNFILTERABLE_WORKFLOW_COLUMNS = [ + 'Parent Namespace', + 'Execution Duration', +]; + +// Only columns the archival query surface supports. Archival visibility accepts +// a narrower set of attributes and operators than standard visibility. +export const ARCHIVAL_FILTERABLE_COLUMNS = ['Type', 'Workflow ID', 'Run ID']; + +const positiveCount = (count: string): string | undefined => + parseInt(count, 10) > 0 ? count : undefined; + +export const getWorkflowColumnAttribute = (label: string): string => + WORKFLOW_COLUMN_ATTRIBUTE[label] ?? label; + +export const getWorkflowColumnValue = ( + label: string, + workflow: WorkflowExecution, +): QuickFilterValue => { + const indexedFields = workflow?.searchAttributes?.indexedFields; + + switch (label) { + case 'Status': + return workflow.status; + case 'Workflow ID': + return workflow.id; + case 'Run ID': + return workflow.runId; + case 'Type': + return workflow.name; + case 'Start': + return workflow.startTime; + case 'End': + return workflow.endTime; + case 'Execution Time': + return workflow.executionTime; + case 'Task Queue': + return workflow.taskQueue; + case 'History Size': + return workflow.historySizeBytes; + case 'History Length': + return positiveCount(workflow.historyEvents); + case 'State Transitions': + return positiveCount(workflow.stateTransitionCount); + case 'Build ID': + return ( + indexedFields?.TemporalWorkerBuildId || + getBuildIdFromVersion(indexedFields?.TemporalWorkerDeploymentVersion) + ); + case 'Parent Namespace': + case 'Execution Duration': + return undefined; + default: + return indexedFields?.[getWorkflowColumnAttribute(label)]; + } +}; diff --git a/src/lib/components/workflow/workflows-summary-configurable-table/filterable-table-cell.svelte b/src/lib/components/workflow/workflows-summary-configurable-table/filterable-table-cell.svelte index a75ffde178..d47fe857ae 100644 --- a/src/lib/components/workflow/workflows-summary-configurable-table/filterable-table-cell.svelte +++ b/src/lib/components/workflow/workflows-summary-configurable-table/filterable-table-cell.svelte @@ -1,5 +1,5 @@ @@ -76,17 +87,27 @@ {...cellProps} density={truncate ? 'dense' : 'comfortable'} filterIconTitle={translate('common.filter-workflows')} - copyValue={value} - onFilter={onRowFilterClick} - isFiltered={$workflowFilters.some( - (filter) => filter.attribute === attribute && filter.value === value, - )} + copyValue={displayValue} + onFilter={filterValue === null ? undefined : onRowFilterClick} + {isFiltered} > - - {#if href} - {truncate ? truncateValue(value) : value} - {:else} - {truncate ? truncateValue(value) : value} - {/if} - + {#if children} + {@render children()} + {:else} + + {#if href} + {truncate ? truncateValue(displayValue) : displayValue} + {:else} + {truncate ? truncateValue(displayValue) : displayValue} + {/if} + + {/if} diff --git a/src/lib/components/workflow/workflows-summary-configurable-table/table-body-cell.svelte b/src/lib/components/workflow/workflows-summary-configurable-table/table-body-cell.svelte index 4b8a4b773d..d667cc3a36 100644 --- a/src/lib/components/workflow/workflows-summary-configurable-table/table-body-cell.svelte +++ b/src/lib/components/workflow/workflows-summary-configurable-table/table-body-cell.svelte @@ -1,19 +1,15 @@ -{#snippet renderFilterableTableCell( - filterableCellProps: Pick< - ComponentProps, - 'attribute' | 'value' | 'href' | 'type' - >, -)} +{#snippet text(content: string)} + + {#if href} + {truncate ? truncateValue(content) : content} + {:else} + {truncate ? truncateValue(content) : content} + {/if} + +{/snippet} + +{#snippet cellContent()} + {#if label === 'Status'} + + {:else if label === 'Start' || label === 'End' || label === 'Execution Time' || label === 'Scheduled Start Time'} + + {:else if label === 'History Size'} + {formatBytes(parseInt(workflow.historySizeBytes, 10))} + {:else if label === 'State Transitions' || label === 'History Length'} + {displayValue} + {:else if label === 'Execution Duration'} + {formatDistanceAbbreviated({ + start: workflow?.startTime, + end: workflow?.endTime, + includeMilliseconds: true, + })} + {:else if label === 'Parent Namespace'} + {@render text(workflow?.parentNamespaceId ?? '')} + {:else if type === SEARCH_ATTRIBUTE_TYPE.DATETIME} + + {:else if type === SEARCH_ATTRIBUTE_TYPE.BOOL} + + {:else} + {@render text(displayValue)} + {/if} +{/snippet} + +{#if filterable} -{/snippet} - -{#if filterableLabels.includes(label) || isCustomKeywordOrTextAttribute} - {#if label === 'Type'} - {@render renderFilterableTableCell({ - attribute: 'WorkflowType', - value: workflow.name, - href: routeForWorkflow({ - namespace, - workflow: workflow.id, - run: workflow.runId, - archival, - }), - })} - {:else if label === 'Workflow ID'} - {@render renderFilterableTableCell({ - attribute: 'WorkflowId', - value: workflow.id, - href: routeForWorkflow({ - namespace, - workflow: workflow.id, - run: workflow.runId, - archival, - }), - })} - {:else if label === 'Run ID'} - {@render renderFilterableTableCell({ - attribute: 'RunId', - value: workflow.runId, - href: routeForWorkflow({ - namespace, - workflow: workflow.id, - run: workflow.runId, - archival, - }), - })} - {:else if label === 'Deployment'} - {@const deployment = - workflow.searchAttributes?.indexedFields?.TemporalWorkerDeployment} - {@render renderFilterableTableCell({ - attribute: 'TemporalWorkerDeployment', - value: deployment && typeof deployment === 'string' ? deployment : '', - href: deployment - ? routeForWorkerDeployment({ namespace, deployment }) - : undefined, - })} - {:else if label === 'Deployment Version'} - {@const version = - workflow.searchAttributes?.indexedFields?.TemporalWorkerDeploymentVersion} - {@render renderFilterableTableCell({ - attribute: 'TemporalWorkerDeploymentVersion', - value: version && typeof version === 'string' ? version : '', - })} - {:else if label === 'Build ID'} - {@const buildId = - workflow?.searchAttributes?.indexedFields?.['TemporalWorkerBuildId'] || - getBuildIdFromVersion( - workflow.searchAttributes?.indexedFields - ?.TemporalWorkerDeploymentVersion, - )} - {@render renderFilterableTableCell({ - attribute: 'TemporalWorkerBuildId', - value: buildId && typeof buildId === 'string' ? buildId : '', - })} - {:else if label === 'Versioning Behavior'} - {@const behavior = - workflow.searchAttributes?.indexedFields - ?.TemporalWorkflowVersioningBehavior} - {@render renderFilterableTableCell({ - attribute: 'TemporalWorkflowVersioningBehavior', - value: behavior && typeof behavior === 'string' ? behavior : '', - })} - {:else if isCustomKeywordOrTextAttribute} - {@const content = workflow.searchAttributes?.indexedFields?.[label]} - {@render renderFilterableTableCell({ - attribute: label, - value: typeof content === 'string' ? content : '', - type: $customSearchAttributes[label], - })} - {:else if label === 'Scheduled By ID'} - {@const scheduleId = - workflow.searchAttributes?.indexedFields?.TemporalScheduledById} - {@render renderFilterableTableCell({ - attribute: 'TemporalScheduledById', - value: scheduleId && typeof scheduleId === 'string' ? scheduleId : '', - })} - {/if} + > + {@render cellContent()} + {:else} - {#if label === 'Status'} - - {:else if label === 'End'} - - {:else if label === 'Start'} - - {:else if label === 'Task Queue'} - - {truncate ? truncateValue(workflow.taskQueue) : workflow.taskQueue} - - {:else if label === 'Parent Namespace'} - - {truncate - ? truncateValue(workflow?.parentNamespaceId ?? '') - : (workflow?.parentNamespaceId ?? '')} - - {:else if label === 'History Size'} - {formatBytes(parseInt(workflow.historySizeBytes, 10))} - {:else if label === 'State Transitions'} - {parseInt(workflow.stateTransitionCount, 10) > 0 - ? workflow.stateTransitionCount - : ''} - {:else if label === 'Execution Time'} - - {:else if label === 'Execution Duration'} - {formatDistanceAbbreviated({ - start: workflow?.startTime, - end: workflow?.endTime, - includeMilliseconds: true, - })} - {:else if label === 'History Length'} - {parseInt(workflow.historyEvents, 10) > 0 ? workflow.historyEvents : ''} - {:else if label === 'Scheduled Start Time'} - {@const content = - workflow.searchAttributes?.indexedFields?.TemporalScheduledStartTime} - {#if content && typeof content === 'string'} - - {/if} - {:else if label === 'Change Version'} - {workflow.searchAttributes?.indexedFields?.TemporalChangeVersion} - {:else if isCustomSearchAttribute(label) && workflowIncludesSearchAttribute(workflow, label)} - {@const content = workflow.searchAttributes?.indexedFields?.[label]} - {#if $customSearchAttributes[label] === SEARCH_ATTRIBUTE_TYPE.DATETIME && typeof content === 'string'} - - {:else if $customSearchAttributes[label] === SEARCH_ATTRIBUTE_TYPE.BOOL} - - {:else} - - {truncate ? truncateValue(content) : content} - - {/if} - {/if} + {@render cellContent()} {/if} diff --git a/src/lib/holocene/filter-or-copy-buttons.svelte b/src/lib/holocene/filter-or-copy-buttons.svelte index 9e2b9e5bf6..3e6a0b7e31 100644 --- a/src/lib/holocene/filter-or-copy-buttons.svelte +++ b/src/lib/holocene/filter-or-copy-buttons.svelte @@ -46,6 +46,7 @@ }} class="copy-or-filter-button" class:filtered + data-testid="quick-filter-button" > {#key filtered} @@ -72,7 +73,7 @@