diff --git a/src/lib/components/search-attribute-filter/dropdown-filter-chip.svelte b/src/lib/components/search-attribute-filter/dropdown-filter-chip.svelte index 0bbcf057f5..4b1ae545e5 100644 --- a/src/lib/components/search-attribute-filter/dropdown-filter-chip.svelte +++ b/src/lib/components/search-attribute-filter/dropdown-filter-chip.svelte @@ -1,56 +1,18 @@ -{#snippet conditionalButtons(options: { value: string; label: string }[])} - - {#each options as option (option.value)} - { - if (isNullConditional(option.value)) { - localFilter.value = ''; - } else if (isNullFilter) { - localFilter.value = filter.value; - } - localFilter.conditional = option.value; - }} - size="xs">{option.label} - {/each} - -{/snippet} -
{getDisplayKeyWithConditional(localFilter)}{getDisplayKeyWithConditional(filter)} - {getDisplayValue(localFilter)} + {getDisplayValue(filter)}
@@ -356,241 +113,18 @@ usePortal class="max-h-fit w-min min-w-0 max-w-[calc(100dvw-1rem)] p-4" > -
-
-
-

Filter by {filter.attribute}

-
- {#if isTextFilter(localFilter)} -
- {@render conditionalButtons(conditionalOptions)} - -
- {:else if isDateTimeFilter(localFilter)} -
- {@render conditionalButtons(dateConditionalOptions)} - {#if isTimeRange} -
- - -
-
- - -
- {:else} -
- -
- - -
-
-
- -
- - -
-
- {/if} -

- - {translate('common.based-on-time-preface')} - {selectedTime} -

-
- {:else if isNumberFilter(localFilter)} -
- {@render conditionalButtons(numberConditionalOptions)} - -
- {:else if isListFilter(localFilter)} -
- {@render conditionalButtons(listConditionalOptions)} - {#if isInConditional(localFilter.conditional)} - - translate('workflows.remove-keyword-label', { - keyword: chip, - })} - placeholder="{translate( - 'common.enter', - )} {localFilter.attribute}" - external - /> - {:else} - - {/if} -
- {:else if isBooleanFilter(localFilter)} -
- - { - localFilter.conditional = '='; - localFilter.value = 'true'; - }} - active={localFilter.value === 'true'} - size="xs">True - { - localFilter.conditional = '='; - localFilter.value = 'false'; - }} - active={localFilter.value === 'false'} - size="xs">False - -
- {:else} -
- - -
- {/if} - -
- - -
-
-
+ + {#key $open} + { + onUpdate(updated); + $open = false; + }} + {onRemove} + /> + {/key}
diff --git a/src/lib/components/search-attribute-filter/filter-editor.svelte b/src/lib/components/search-attribute-filter/filter-editor.svelte new file mode 100644 index 0000000000..71a48c845a --- /dev/null +++ b/src/lib/components/search-attribute-filter/filter-editor.svelte @@ -0,0 +1,557 @@ + + +{#snippet conditionalButtons(options: { value: string; label: string }[])} + + {#each options as option (option.value)} + { + if (isNullConditional(option.value)) { + localFilter.value = ''; + } else if (isNullFilter) { + localFilter.value = filter.value; + } + localFilter.conditional = option.value; + }} + size="xs">{option.label} + {/each} + +{/snippet} + +
+
+
+

Filter by {filter.attribute}

+
+ {#if isTextFilter(localFilter)} +
+ {@render conditionalButtons(conditionalOptions)} + +
+ {:else if isDateTimeFilter(localFilter)} +
+ {@render conditionalButtons(dateConditionalOptions)} + {#if isTimeRange} +
+ + +
+
+ + +
+ {:else} +
+ +
+ + +
+
+
+ +
+ + +
+
+ {/if} +

+ + {translate('common.based-on-time-preface')} + {selectedTime} +

+
+ {:else if isNumberFilter(localFilter)} +
+ {@render conditionalButtons(numberConditionalOptions)} + +
+ {:else if isListFilter(localFilter)} +
+ {@render conditionalButtons(listConditionalOptions)} + {#if isInConditional(localFilter.conditional)} + + translate('workflows.remove-keyword-label', { + keyword: chip, + })} + placeholder="{translate('common.enter')} {localFilter.attribute}" + external + /> + {:else} + + {/if} +
+ {:else if isBooleanFilter(localFilter)} +
+ + { + localFilter.conditional = '='; + localFilter.value = 'true'; + }} + active={localFilter.value === 'true'} + size="xs">True + { + localFilter.conditional = '='; + localFilter.value = 'false'; + }} + active={localFilter.value === 'false'} + size="xs">False + +
+ {:else} +
+ + +
+ {/if} + +
+ {#if onRemove} + + {/if} + +
+
+
diff --git a/src/lib/components/search-attribute-filter/quick-filter-cell.svelte b/src/lib/components/search-attribute-filter/quick-filter-cell.svelte new file mode 100644 index 0000000000..030207bb22 --- /dev/null +++ b/src/lib/components/search-attribute-filter/quick-filter-cell.svelte @@ -0,0 +1,69 @@ + + +{#if resolved.filterable && !disabled} + + {@render children(resolved)} + +{:else} + + {@render children(resolved)} + +{/if} diff --git a/src/lib/components/search-attribute-filter/quick-filter-table-cell.svelte b/src/lib/components/search-attribute-filter/quick-filter-table-cell.svelte new file mode 100644 index 0000000000..f3eecf4733 --- /dev/null +++ b/src/lib/components/search-attribute-filter/quick-filter-table-cell.svelte @@ -0,0 +1,179 @@ + + + + {@render children()} + + {#if hasPopup && $open} + {@const seed = createQuickFilter({ attribute, type, value })} + {#if seed} + + { + if (!isFiltered && atQueryLimit) return; + replaceFilter(filter); + $open = false; + }} + /> + + {/if} + {/if} + 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/components/standalone-activities/activities-summary-configurable-table/column-search-attributes.test.ts b/src/lib/components/standalone-activities/activities-summary-configurable-table/column-search-attributes.test.ts new file mode 100644 index 0000000000..1e64cdf96b --- /dev/null +++ b/src/lib/components/standalone-activities/activities-summary-configurable-table/column-search-attributes.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import type { ActivityExecutionListInfo } from '$lib/types/activity-execution'; + +import { getActivityColumnValue } from './column-search-attributes'; + +const activity = { + activityId: 'my-activity', + runId: 'my-run', + activityType: { name: 'MyActivityType' }, + taskQueue: 'my-task-queue', + status: 'ACTIVITY_EXECUTION_STATUS_COMPLETED', + scheduleTime: '2024-01-02T03:04:05Z', + closeTime: '2024-01-02T03:09:15.5Z', + executionDuration: '310.5s', + stateTransitionCount: '4', + searchAttributes: { + indexedFields: { + CustomKeywordField: 'my-keyword', + CustomIntField: 42, + }, + }, +} as unknown as ActivityExecutionListInfo; + +describe('getActivityColumnValue', () => { + it('reads the activity fields a mapped column filters on', () => { + expect(getActivityColumnValue('Activity ID', activity)).toBe('my-activity'); + expect(getActivityColumnValue('Type', activity)).toBe('MyActivityType'); + expect(getActivityColumnValue('Start', activity)).toBe( + '2024-01-02T03:04:05Z', + ); + }); + + it('filters status on the readable form, not the enum key', () => { + expect(getActivityColumnValue('Status', activity)).toBe('Completed'); + }); + + it('builds a query duration from the proto seconds string', () => { + expect(getActivityColumnValue('Execution Duration', activity)).toBe( + '5m10s500ms', + ); + }); + + it('falls through to a custom search attribute of the same name', () => { + expect(getActivityColumnValue('CustomKeywordField', activity)).toBe( + 'my-keyword', + ); + expect(getActivityColumnValue('CustomIntField', activity)).toBe(42); + }); + + it('has no value for a column that is neither mapped nor indexed', () => { + expect(getActivityColumnValue('Not A Column', activity)).toBeUndefined(); + }); +}); diff --git a/src/lib/components/standalone-activities/activities-summary-configurable-table/column-search-attributes.ts b/src/lib/components/standalone-activities/activities-summary-configurable-table/column-search-attributes.ts new file mode 100644 index 0000000000..991223779c --- /dev/null +++ b/src/lib/components/standalone-activities/activities-summary-configurable-table/column-search-attributes.ts @@ -0,0 +1,69 @@ +import type { ActivityExecutionListInfo } from '$lib/types/activity-execution'; +import { + durationStringToMilliseconds, + toQueryDuration, +} from '$lib/utilities/format-time'; +import { toActivityStatus } from '$lib/utilities/get-activity-status-and-count'; +import type { QuickFilterValue } from '$lib/utilities/query/quick-filter'; +import { + getColumnAttribute, + type QuickFilterColumns, +} from '$lib/utilities/query/quick-filter-table'; + +export const ACTIVITY_COLUMN_ATTRIBUTE: Record = { + 'Activity ID': 'ActivityId', + 'Run ID': 'RunId', + Type: 'ActivityType', + 'Task Queue': 'TaskQueue', + Status: 'ExecutionStatus', + Start: 'StartTime', + End: 'CloseTime', + 'Execution Time': 'ExecutionTime', + 'State Transitions': 'StateTransitionCount', + 'Execution Duration': 'ExecutionDuration', +}; + +export const UNFILTERABLE_ACTIVITY_COLUMNS: string[] = []; + +export const getActivityColumnValue = ( + label: string, + activity: ActivityExecutionListInfo, +): QuickFilterValue => { + switch (label) { + case 'Activity ID': + return activity.activityId; + case 'Run ID': + return activity.runId; + case 'Type': + return activity.activityType?.name; + case 'Task Queue': + return activity.taskQueue; + case 'Status': + return toActivityStatus(activity.status); + case 'Start': + return activity.scheduleTime; + case 'End': + return activity.closeTime; + case 'Execution Time': + return activity.executionTime; + case 'State Transitions': + return activity.stateTransitionCount; + case 'Execution Duration': { + const milliseconds = durationStringToMilliseconds( + activity.executionDuration, + ); + return milliseconds ? toQueryDuration(milliseconds) : undefined; + } + default: + return activity.searchAttributes?.indexedFields?.[ + getColumnAttribute(ACTIVITY_COLUMN_ATTRIBUTE, label) + ]; + } +}; + +export const ACTIVITY_QUICK_FILTER_COLUMNS: QuickFilterColumns = + { + attributes: ACTIVITY_COLUMN_ATTRIBUTE, + getValue: getActivityColumnValue, + unfilterable: UNFILTERABLE_ACTIVITY_COLUMNS, + }; diff --git a/src/lib/components/standalone-activities/activities-summary-configurable-table/filterable-table-cell.svelte b/src/lib/components/standalone-activities/activities-summary-configurable-table/filterable-table-cell.svelte deleted file mode 100644 index 1a3a53dc47..0000000000 --- a/src/lib/components/standalone-activities/activities-summary-configurable-table/filterable-table-cell.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - filter.attribute === attribute && filter.value === value, - )} -> - {#if href} - {value} - {:else} - {value} - {/if} - diff --git a/src/lib/components/standalone-activities/activities-summary-configurable-table/table-body-cell.svelte b/src/lib/components/standalone-activities/activities-summary-configurable-table/table-body-cell.svelte index 2a2d366a1c..263183d450 100644 --- a/src/lib/components/standalone-activities/activities-summary-configurable-table/table-body-cell.svelte +++ b/src/lib/components/standalone-activities/activities-summary-configurable-table/table-body-cell.svelte @@ -1,18 +1,19 @@ -{#snippet renderFilterableTableCell( - filterableCellProps: Pick< - ComponentProps, - 'attribute' | 'value' | 'href' | 'type' - >, +{#snippet cellContent( + type: SearchAttributeType | undefined, + displayValue: string | undefined, )} - + {#if label === 'Status'} + + {:else if label === 'Start' || label === 'End' || label === 'Execution Time'} + + {:else if label === 'Execution Duration'} + {#if activity.executionDuration} + {formatDurationAbbreviated(activity.executionDuration)} + {/if} + {:else if href} + {displayValue} + {:else} + + {/if} {/snippet} -{#if filterableLabels.includes(label) || isCustomKeywordOrTextAttribute} - {#if label === 'Activity ID'} - {@render renderFilterableTableCell({ - attribute: 'ActivityId', - value: activity.activityId ?? '', - href: routeForStandaloneActivityDetails({ - namespace, - activityId: activity.activityId ?? '', - runId: activity.runId ?? '', - }), - })} - {:else if label === 'Run ID'} - {@render renderFilterableTableCell({ - attribute: 'RunId', - value: activity.runId ?? '', - href: routeForStandaloneActivityDetails({ - namespace, - activityId: activity.activityId ?? '', - runId: activity.runId ?? '', - }), - })} - {:else if label === 'Type'} - {@render renderFilterableTableCell({ - attribute: 'ActivityType', - value: activity.activityType?.name ?? '', - })} - {:else if label === 'Task Queue'} - {@render renderFilterableTableCell({ - attribute: 'TaskQueue', - value: activity.taskQueue ?? '', - })} - {:else if isCustomKeywordOrTextAttribute} - {@render renderFilterableTableCell({ - attribute: label, - value: customSearchAttributeValue ?? '', - type: $customSearchAttributes[label], - })} - {/if} -{:else} - - {#if label === 'Status'} - - {:else if label === 'Start'} - - {:else if label === 'End'} - - {:else if label === 'Execution Duration'} - {#if activity.executionDuration} - {formatDurationAbbreviated(activity.executionDuration)} - {/if} - {:else if label === 'State Transitions'} - {activity.stateTransitionCount ?? ''} - {:else if label === 'Execution Time'} - - {:else if label in $customSearchAttributes && customSearchAttributeValue !== undefined} - - {/if} - -{/if} + + {#snippet children({ type, displayValue })} + {@render cellContent(type, displayValue)} + {/snippet} + diff --git a/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/column-search-attributes.test.ts b/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/column-search-attributes.test.ts new file mode 100644 index 0000000000..3b197fe289 --- /dev/null +++ b/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/column-search-attributes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import type { NexusOperationExecutionListInfo } from '$lib/types/nexus-operation-execution'; + +import { getNexusOperationColumnValue } from './column-search-attributes'; + +const operation = { + operationId: 'my-operation', + runId: 'my-run', + endpoint: 'my-endpoint', + service: 'my-service', + operation: 'my-op', + status: 'NEXUS_OPERATION_EXECUTION_STATUS_COMPLETED', + scheduleTime: '2024-01-02T03:04:05Z', + closeTime: '2024-01-02T03:09:15.5Z', + executionDuration: '310.5s', + stateTransitionCount: '4', + searchAttributes: { + indexedFields: { + CustomKeywordField: 'my-keyword', + }, + }, +} as unknown as NexusOperationExecutionListInfo; + +describe('getNexusOperationColumnValue', () => { + it('reads the operation fields a mapped column filters on', () => { + expect(getNexusOperationColumnValue('Operation ID', operation)).toBe( + 'my-operation', + ); + expect(getNexusOperationColumnValue('Schedule Time', operation)).toBe( + '2024-01-02T03:04:05Z', + ); + }); + + it('filters status on the readable form, not the enum key', () => { + expect(getNexusOperationColumnValue('Status', operation)).toBe('Completed'); + }); + + it('builds a query duration from the proto seconds string', () => { + expect(getNexusOperationColumnValue('Execution Duration', operation)).toBe( + '5m10s500ms', + ); + }); + + it('falls through to a custom search attribute of the same name', () => { + expect(getNexusOperationColumnValue('CustomKeywordField', operation)).toBe( + 'my-keyword', + ); + }); +}); diff --git a/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/column-search-attributes.ts b/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/column-search-attributes.ts new file mode 100644 index 0000000000..9e77002097 --- /dev/null +++ b/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/column-search-attributes.ts @@ -0,0 +1,69 @@ +import type { NexusOperationExecutionListInfo } from '$lib/types/nexus-operation-execution'; +import { + durationStringToMilliseconds, + toQueryDuration, +} from '$lib/utilities/format-time'; +import { toNexusOperationStatus } from '$lib/utilities/get-nexus-operation-status-and-count'; +import type { QuickFilterValue } from '$lib/utilities/query/quick-filter'; +import { + getColumnAttribute, + type QuickFilterColumns, +} from '$lib/utilities/query/quick-filter-table'; + +export const NEXUS_OPERATION_COLUMN_ATTRIBUTE: Record = { + 'Operation ID': 'OperationId', + 'Run ID': 'RunId', + Endpoint: 'Endpoint', + Service: 'Service', + Operation: 'Operation', + Status: 'ExecutionStatus', + 'Schedule Time': 'ScheduleTime', + 'Close Time': 'CloseTime', + 'State Transitions': 'StateTransitionCount', + 'Execution Duration': 'ExecutionDuration', +}; + +export const UNFILTERABLE_NEXUS_OPERATION_COLUMNS: string[] = []; + +export const getNexusOperationColumnValue = ( + label: string, + operation: NexusOperationExecutionListInfo, +): QuickFilterValue => { + switch (label) { + case 'Operation ID': + return operation.operationId; + case 'Run ID': + return operation.runId; + case 'Endpoint': + return operation.endpoint; + case 'Service': + return operation.service; + case 'Operation': + return operation.operation; + case 'Status': + return toNexusOperationStatus(operation.status); + case 'Schedule Time': + return operation.scheduleTime; + case 'Close Time': + return operation.closeTime; + case 'State Transitions': + return operation.stateTransitionCount; + case 'Execution Duration': { + const milliseconds = durationStringToMilliseconds( + operation.executionDuration, + ); + return milliseconds ? toQueryDuration(milliseconds) : undefined; + } + default: + return operation.searchAttributes?.indexedFields?.[ + getColumnAttribute(NEXUS_OPERATION_COLUMN_ATTRIBUTE, label) + ]; + } +}; + +export const NEXUS_OPERATION_QUICK_FILTER_COLUMNS: QuickFilterColumns = + { + attributes: NEXUS_OPERATION_COLUMN_ATTRIBUTE, + getValue: getNexusOperationColumnValue, + unfilterable: UNFILTERABLE_NEXUS_OPERATION_COLUMNS, + }; diff --git a/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/filterable-table-cell.svelte b/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/filterable-table-cell.svelte deleted file mode 100644 index 2f8e9d3490..0000000000 --- a/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/filterable-table-cell.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -{#if href} - {value} -{:else} - {value} -{/if} - filter.attribute === attribute && filter.value === value, - )} -/> diff --git a/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/table-body-cell.svelte b/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/table-body-cell.svelte index 7dba143ede..d036b06cf4 100644 --- a/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/table-body-cell.svelte +++ b/src/lib/components/standalone-nexus-operations/nexus-operations-summary-configurable-table/table-body-cell.svelte @@ -3,14 +3,16 @@ import { page } from '$app/state'; + import QuickFilterCell from '$lib/components/search-attribute-filter/quick-filter-cell.svelte'; import SearchAttributeValue from '$lib/components/table/search-attribute-value.svelte'; import Timestamp from '$lib/components/timestamp.svelte'; + import Link from '$lib/holocene/link.svelte'; + import { translate } from '$lib/i18n/translate'; import type { ConfigurableTableHeader } from '$lib/stores/configurable-table-columns'; - import { - customSearchAttributes, - isFilterableCustomSearchAttribute, - } from '$lib/stores/search-attributes'; + import { nexusOperationFilters } from '$lib/stores/filters'; + import { nexusOperationSearchAttributes } from '$lib/stores/search-attributes'; import type { NexusOperationExecutionListInfo } from '$lib/types/nexus-operation-execution'; + import type { SearchAttributeType } from '$lib/types/workflows'; import { COLUMN_WIDTH_CLAMP_CLASSES, columnWidthStyle, @@ -19,8 +21,7 @@ import { routeForStandaloneNexusOperationDetails } from '$lib/utilities/route-for'; import NexusOperationStatusBadge from '../nexus-operation-status-badge.svelte'; - - import FilterableTableCell from './filterable-table-cell.svelte'; + import { NEXUS_OPERATION_QUICK_FILTER_COLUMNS } from './column-search-attributes'; type Props = { column: ConfigurableTableHeader; @@ -31,132 +32,61 @@ const { label, width } = $derived(column); const namespace = $derived(page.params.namespace); - const clampToWidth = $derived( - width !== undefined && COLUMN_WIDTH_CLAMP_CLASSES, - ); - const widthStyle = $derived(columnWidthStyle(width)); - - let filterOrCopyButtonsVisible = $state(false); - const showFilterOrCopy = () => (filterOrCopyButtonsVisible = true); - const hideFilterOrCopy = () => (filterOrCopyButtonsVisible = false); - const handleFocusOut = (e: FocusEvent) => { - const nextTarget = e.relatedTarget as HTMLElement; - if ( - nextTarget && - !['filter-button', 'copy-button'].includes(nextTarget.id) - ) { - hideFilterOrCopy(); - } - }; - - const filterableLabels = [ - 'Operation ID', - 'Run ID', - 'Endpoint', - 'Service', - 'Operation', - ]; - - const customSearchAttributeValue = $derived( - operation.searchAttributes?.indexedFields?.[label], - ); - const isCustomKeywordOrTextAttribute = $derived( - isFilterableCustomSearchAttribute( - $customSearchAttributes, - label, - customSearchAttributeValue, + const className = $derived( + twMerge( + 'h-8 whitespace-nowrap', + width !== undefined && COLUMN_WIDTH_CLAMP_CLASSES, ), ); - - -{#if filterableLabels.includes(label) || isCustomKeywordOrTextAttribute} - - {#if label === 'Operation ID'} - - {:else if label === 'Run ID'} - - {:else if label === 'Endpoint'} - - {:else if label === 'Service'} - - {:else if label === 'Operation'} - - {:else if isCustomKeywordOrTextAttribute} - - {/if} - -{:else} - - {#if label === 'Status'} - - {:else if label === 'Schedule Time'} - - {:else if label === 'Close Time'} - - {:else if label === 'Execution Duration'} - {#if operation.executionDuration} - {formatDistanceAbbreviated({ - start: operation.scheduleTime, - end: operation.closeTime, - includeMilliseconds: true, - })} - {/if} - {:else if label === 'State Transitions'} - {operation.stateTransitionCount ?? ''} - {:else if label in $customSearchAttributes && customSearchAttributeValue !== undefined} - + }) + : undefined, + ); + + const widthStyle = $derived(columnWidthStyle(width)); + const testId = 'nexus-operations-summary-table-body-cell'; + + +{#snippet cellContent( + type: SearchAttributeType | undefined, + displayValue: string | undefined, +)} + {#if label === 'Status'} + + {:else if label === 'Schedule Time' || label === 'Close Time'} + + {:else if label === 'Execution Duration'} + {#if operation.executionDuration} + {formatDistanceAbbreviated({ + start: operation.scheduleTime, + end: operation.closeTime, + includeMilliseconds: true, + })} {/if} - -{/if} + {:else if href} + {displayValue} + {:else} + + {/if} +{/snippet} + + + {#snippet children({ type, displayValue })} + {@render cellContent(type, displayValue)} + {/snippet} + diff --git a/src/lib/components/workers/workers-table/workers-table-cell.svelte b/src/lib/components/workers/workers-table/workers-table-cell.svelte index 0f80f011b4..9b5447edf0 100644 --- a/src/lib/components/workers/workers-table/workers-table-cell.svelte +++ b/src/lib/components/workers/workers-table/workers-table-cell.svelte @@ -9,16 +9,25 @@ import { translate } from '$lib/i18n/translate'; import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters'; import { workerFilters } from '$lib/stores/filters'; + import { workerSearchAttributes } from '$lib/stores/search-attributes'; import { - createFilter, - updateQueryParamsFromFilter, - } from '$lib/utilities/query/to-list-workflow-filters'; + createQuickFilter, + getQuickFilterConditional, + isQuickFilterActive, + type QuickFilterValue, + toggleQuickFilter, + toQuickFilterValue, + } from '$lib/utilities/query/quick-filter'; + import { updateQueryParamsFromFilter } from '$lib/utilities/query/to-list-workflow-filters'; import { truncateValue } from '$lib/utilities/truncate-value'; interface Props { attribute?: string; filters?: SearchAttributeFilter[]; value?: string | null; + // The value to filter on when it differs from what the cell displays, such + // as a timestamp rendered in the user's time format. + rawValue?: QuickFilterValue; filterable?: boolean; href?: string; children?: Snippet; @@ -28,31 +37,65 @@ attribute, filters, value, + rawValue, filterable = false, href, children, }: Props = $props(); + const type = $derived( + attribute ? $workerSearchAttributes[attribute] : undefined, + ); + const filterValue = $derived(rawValue ?? value); + const quickFilterValue = $derived( + toQuickFilterValue({ + attribute: attribute ?? '', + type, + value: filterValue, + }), + ); + const matchesFilter = (a: SearchAttributeFilter, b: SearchAttributeFilter) => - a.attribute === b.attribute && a.value === b.value; + a.attribute === b.attribute && + a.value === b.value && + a.conditional === b.conditional; - const toggleFilters = $derived( - filters && filters.length > 0 - ? filters - : [createFilter({ attribute, value: value ?? '', conditional: '=' })], - ); + const filterList = $derived(filters ?? []); + const hasMultipleFilters = $derived(filterList.length > 0); const isFiltered = $derived( - toggleFilters.some((f) => - $workerFilters.some((wf) => matchesFilter(wf, f)), - ), + hasMultipleFilters + ? filterList.some((f) => + $workerFilters.some((wf) => matchesFilter(wf, f)), + ) + : isQuickFilterActive($workerFilters, { + attribute: attribute ?? '', + value: quickFilterValue ?? '', + conditional: getQuickFilterConditional({ + attribute: attribute ?? '', + type, + }), + }), ); const onRowFilterClick = () => { - const toRemove = toggleFilters.filter((f) => + if (!hasMultipleFilters) { + const quickFilter = createQuickFilter({ + attribute: attribute ?? '', + type, + value: filterValue, + }); + if (!quickFilter) return; + + $workerFilters = toggleQuickFilter($workerFilters, quickFilter); + updateQueryParamsFromFilter(page.url, $workerFilters); + return; + } + + const toRemove = filterList.filter((f) => $workerFilters.some((wf) => matchesFilter(wf, f)), ); - const toAdd = toggleFilters.filter( + const toAdd = filterList.filter( (f) => !$workerFilters.some((wf) => matchesFilter(wf, f)), ); @@ -66,9 +109,7 @@ updateQueryParamsFromFilter(page.url, $workerFilters); }; - const hasContent = $derived( - Boolean(value) || Boolean(filters && filters.length > 0), - ); + const hasContent = $derived(hasMultipleFilters || quickFilterValue !== null); - + - + { + const attributeFor = (label: string) => + getColumnAttribute(WORKFLOW_COLUMN_ATTRIBUTE, label); + + it('maps a column label to its search attribute', () => { + expect(attributeFor('Status')).toBe('ExecutionStatus'); + expect(attributeFor('Task Queue')).toBe('TaskQueue'); + }); + + it('treats an unmapped label as a custom search attribute name', () => { + expect(attributeFor('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('builds a query duration from the start and end times', () => { + // The cell renders "1h ..." through a display formatter; the filter needs a + // value with no delimiter in it. + expect(getWorkflowColumnValue('Execution Duration', workflow)).toBe('1h'); + expect(WORKFLOW_COLUMN_ATTRIBUTE['Execution Duration']).toBe( + 'ExecutionDuration', + ); + }); + + it('has no duration to filter on while the workflow is still running', () => { + const running = { + ...workflow, + endTime: undefined, + } as unknown as WorkflowExecution; + expect( + getWorkflowColumnValue('Execution Duration', running), + ).toBeUndefined(); + }); + + 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..bd5027cc4e --- /dev/null +++ b/src/lib/components/workflow/workflows-summary-configurable-table/column-search-attributes.ts @@ -0,0 +1,104 @@ +import type { WorkflowExecution } from '$lib/types/workflows'; +import { + getMillisecondDuration, + toQueryDuration, +} from '$lib/utilities/format-time'; +import { getBuildIdFromVersion } from '$lib/utilities/get-deployment-build-id'; +import type { QuickFilterValue } from '$lib/utilities/query/quick-filter'; +import { + getColumnAttribute, + type QuickFilterColumns, +} from '$lib/utilities/query/quick-filter-table'; + +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', + 'Execution Duration': 'ExecutionDuration', + Deployment: 'TemporalWorkerDeployment', + 'Deployment Version': 'TemporalWorkerDeploymentVersion', + 'Build ID': 'TemporalWorkerBuildId', + 'Versioning Behavior': 'TemporalWorkflowVersioningBehavior', + 'Scheduled By ID': 'TemporalScheduledById', + 'Scheduled Start Time': 'TemporalScheduledStartTime', + 'Change Version': 'TemporalChangeVersion', +}; + +// Parent Namespace is not an indexed search attribute, so there is nothing to +// filter by. +export const UNFILTERABLE_WORKFLOW_COLUMNS = ['Parent Namespace']; + +// 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 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 'Execution Duration': { + // No duration field on the workflow, and a running one has no end time, so + // the cell renders blank and there is nothing to filter by. + const milliseconds = getMillisecondDuration({ + start: workflow.startTime, + end: workflow.endTime, + onlyUnderSecond: false, + }); + return milliseconds ? toQueryDuration(milliseconds) : undefined; + } + case 'Parent Namespace': + return undefined; + default: + return indexedFields?.[ + getColumnAttribute(WORKFLOW_COLUMN_ATTRIBUTE, label) + ]; + } +}; + +export const WORKFLOW_QUICK_FILTER_COLUMNS: QuickFilterColumns = + { + attributes: WORKFLOW_COLUMN_ATTRIBUTE, + getValue: getWorkflowColumnValue, + unfilterable: UNFILTERABLE_WORKFLOW_COLUMNS, + }; 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 deleted file mode 100644 index a75ffde178..0000000000 --- a/src/lib/components/workflow/workflows-summary-configurable-table/filterable-table-cell.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - - filter.attribute === attribute && filter.value === value, - )} -> - - {#if href} - {truncate ? truncateValue(value) : value} - {:else} - {truncate ? truncateValue(value) : value} - {/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 6ba266bb16..5277b0df7e 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,20 +1,23 @@ -{#snippet renderFilterableTableCell( - filterableCellProps: Pick< - ComponentProps, - 'attribute' | 'value' | 'href' | 'type' - >, -)} - +{#snippet text(content: string | undefined, href: string | undefined)} + + {#if href} + {truncate ? truncateValue(content) : content} + {:else} + {truncate ? truncateValue(content) : content} + {/if} + {/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 : '', +{#snippet cellContent( + type: SearchAttributeType | undefined, + value: QuickFilterValue, + displayValue: string | undefined, +)} + {@const href = hrefFor(value)} + {#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 ?? '', href)} + {:else if href} + {@render text(displayValue, href)} + {:else} + {/if} -{: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 label in $customSearchAttributes && workflowIncludesSearchAttribute(workflow, label)} - - {/if} - -{/if} +{/snippet} + + + {#snippet children({ type, value, displayValue })} + {@render cellContent(type, value, displayValue)} + {/snippet} + diff --git a/src/lib/holocene/filter-or-copy-buttons.svelte b/src/lib/holocene/filter-or-copy-buttons.svelte index 9e2b9e5bf6..357ee4c18e 100644 --- a/src/lib/holocene/filter-or-copy-buttons.svelte +++ b/src/lib/holocene/filter-or-copy-buttons.svelte @@ -14,6 +14,10 @@ copyIconTitle: string; copySuccessIconTitle: string; filterIconTitle: string; + // When the filter button opens a menu rather than filtering directly, the + // menu locates this button by its data-menu-anchor. + menuId?: string; + menuOpen?: boolean; class?: string; }; @@ -27,6 +31,8 @@ copyIconTitle, copySuccessIconTitle, filterIconTitle, + menuId, + menuOpen = false, class: className = '', }: Props = $props(); @@ -46,6 +52,11 @@ }} class="copy-or-filter-button" class:filtered + data-testid="quick-filter-button" + data-menu-anchor={menuId} + aria-haspopup={menuId ? 'true' : undefined} + aria-controls={menuId} + aria-expanded={menuId ? menuOpen : undefined} > {#key filtered} @@ -71,12 +82,14 @@ {/if}