diff --git a/src/lib/components/schedule/schedules-list/schedules-table-row.svelte b/src/lib/components/schedule/schedules-list/schedules-table-row.svelte index 3629418f07..9926e5cb71 100644 --- a/src/lib/components/schedule/schedules-list/schedules-table-row.svelte +++ b/src/lib/components/schedule/schedules-list/schedules-table-row.svelte @@ -4,17 +4,19 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; + import SearchAttributeValue from '$lib/components/table/search-attribute-value.svelte'; import Timestamp from '$lib/components/timestamp.svelte'; import WorkflowStatusBadge from '$lib/components/workflow/workflow-status-badge.svelte'; import Link from '$lib/holocene/link.svelte'; import TableCellWithFilterOrCopyButtons from '$lib/holocene/table/table-cell-with-filter-or-copy-buttons.svelte'; import { translate } from '$lib/i18n/translate'; import type { ConfigurableTableHeader } from '$lib/stores/configurable-table-columns'; + import { customSearchAttributes } from '$lib/stores/search-attributes'; import { COLUMN_WIDTH_CLAMP_CLASSES, columnWidthStyle, } from '$lib/utilities/column-width'; - import { parsePayloadAttributes } from '$lib/utilities/decode-payload'; + import { parseSearchAttributes } from '$lib/utilities/decode-payload'; import { routeForSchedule, routeForWorkflow, @@ -36,9 +38,8 @@ const status = $derived(schedule?.info?.paused ? 'Paused' : 'Running'); const spec = $derived(schedule?.info?.spec); - const searchAttributes = $derived(schedule?.searchAttributes ?? {}); - const decodedAttributes = $derived( - parsePayloadAttributes({ searchAttributes }), + const decodedSearchAttributes = $derived( + parseSearchAttributes(schedule?.searchAttributes ?? {}).indexedFields ?? {}, ); const sortRecentActions = (recentActions: ScheduleActionResult[]) => { @@ -142,7 +143,12 @@ {:else} - {decodedAttributes?.searchAttributes?.indexedFields?.[label] ?? ''} + {#if decodedSearchAttributes[label] !== undefined} + + {/if} {/if} {/each} diff --git a/src/lib/components/standalone-activities/activities-summary-configurable-table.svelte b/src/lib/components/standalone-activities/activities-summary-configurable-table.svelte index 784ba1f61f..3ac7433812 100644 --- a/src/lib/components/standalone-activities/activities-summary-configurable-table.svelte +++ b/src/lib/components/standalone-activities/activities-summary-configurable-table.svelte @@ -23,7 +23,7 @@ resizeColumn, TABLE_TYPE, } from '$lib/stores/configurable-table-columns'; - import type { ActivityExecutionInfo } from '$lib/types/activity-execution'; + import type { ActivityExecutionListInfo } from '$lib/types/activity-execution'; import { getBatchSelectionTargets, getPageSelectionStatus, @@ -58,7 +58,7 @@ standaloneActivityBulkActionsEnabled(page) && $supportsAdvancedVisibility, ); - let visiblePaginatedItems: ActivityExecutionInfo[] = $state([]); + let visiblePaginatedItems: ActivityExecutionListInfo[] = $state([]); const pageSelectionStatus: PageSelectionStatus = $derived( getPageSelectionStatus( @@ -72,7 +72,7 @@ const handleSelectPage = ( isSelected: boolean, - activities: ActivityExecutionInfo[], + activities: ActivityExecutionListInfo[], ) => { selectActivities(isSelected, activities); prevClickedIndex = null; diff --git a/src/lib/components/standalone-activities/activities-summary-configurable-table/batch-actions.svelte b/src/lib/components/standalone-activities/activities-summary-configurable-table/batch-actions.svelte index bf7db83c3a..8653749a07 100644 --- a/src/lib/components/standalone-activities/activities-summary-configurable-table/batch-actions.svelte +++ b/src/lib/components/standalone-activities/activities-summary-configurable-table/batch-actions.svelte @@ -11,11 +11,11 @@ type ActivityBatchOperationContext, } from '$lib/pages/standalone-activities.svelte'; import { activitiesQuery, activityCount } from '$lib/stores/activities'; - import type { ActivityExecutionInfo } from '$lib/types/activity-execution'; + import type { ActivityExecutionListInfo } from '$lib/types/activity-execution'; import { standaloneActivityBulkActionsEnabled } from '$lib/utilities/standalone-activities-commands-disabled'; type Props = { - activities: ActivityExecutionInfo[]; + activities: ActivityExecutionListInfo[]; }; let { activities }: Props = $props(); 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 f535a9111f..2a2d366a1c 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 @@ -5,9 +5,14 @@ import { page } from '$app/state'; import ActivityStatusBadge from '$lib/components/standalone-activities/activity-status-badge.svelte'; + import SearchAttributeValue from '$lib/components/table/search-attribute-value.svelte'; import Timestamp from '$lib/components/timestamp.svelte'; import type { ConfigurableTableHeader } from '$lib/stores/configurable-table-columns'; - import type { ActivityExecutionInfo } from '$lib/types/activity-execution'; + import { + customSearchAttributes, + isFilterableCustomSearchAttribute, + } from '$lib/stores/search-attributes'; + import type { ActivityExecutionListInfo } from '$lib/types/activity-execution'; import { COLUMN_WIDTH_CLAMP_CLASSES, columnWidthStyle, @@ -21,7 +26,7 @@ type Props = { column: ConfigurableTableHeader; - activity: ActivityExecutionInfo; + activity: ActivityExecutionListInfo; }; let { column, activity }: Props = $props(); @@ -30,6 +35,17 @@ const filterableLabels = ['Activity ID', 'Run ID', 'Type', 'Task Queue']; + const customSearchAttributeValue = $derived( + activity.searchAttributes?.indexedFields?.[label], + ); + const isCustomKeywordOrTextAttribute = $derived( + isFilterableCustomSearchAttribute( + $customSearchAttributes, + label, + customSearchAttributeValue, + ), + ); + const className = $derived( twMerge( 'h-8 whitespace-nowrap', @@ -43,7 +59,7 @@ {#snippet renderFilterableTableCell( filterableCellProps: Pick< ComponentProps, - 'attribute' | 'value' | 'href' + 'attribute' | 'value' | 'href' | 'type' >, )} {/snippet} -{#if filterableLabels.includes(label)} +{#if filterableLabels.includes(label) || isCustomKeywordOrTextAttribute} {#if label === 'Activity ID'} {@render renderFilterableTableCell({ attribute: 'ActivityId', @@ -85,6 +101,12 @@ attribute: 'TaskQueue', value: activity.taskQueue ?? '', })} + {:else if isCustomKeywordOrTextAttribute} + {@render renderFilterableTableCell({ + attribute: label, + value: customSearchAttributeValue ?? '', + type: $customSearchAttributes[label], + })} {/if} {:else} @@ -105,6 +127,11 @@ {activity.stateTransitionCount ?? ''} {:else if label === 'Execution Time'} + {:else if label in $customSearchAttributes && customSearchAttributeValue !== undefined} + {/if} {/if} diff --git a/src/lib/components/standalone-activities/activities-summary-configurable-table/table-header-row.svelte b/src/lib/components/standalone-activities/activities-summary-configurable-table/table-header-row.svelte index 62eed514c3..9d3b50d34a 100644 --- a/src/lib/components/standalone-activities/activities-summary-configurable-table/table-header-row.svelte +++ b/src/lib/components/standalone-activities/activities-summary-configurable-table/table-header-row.svelte @@ -7,20 +7,20 @@ ACTIVITY_BATCH_OPERATION_CONTEXT, type ActivityBatchOperationContext, } from '$lib/pages/standalone-activities.svelte'; - import type { ActivityExecutionInfo } from '$lib/types/activity-execution'; + import type { ActivityExecutionListInfo } from '$lib/types/activity-execution'; import type { PageSelectionStatus } from '$lib/utilities/batch-selection'; import BatchActions from './batch-actions.svelte'; type Props = { - activities?: ActivityExecutionInfo[]; + activities?: ActivityExecutionListInfo[]; empty?: boolean; columnsCount?: number; showBatchActions?: boolean; pageSelectionStatus?: PageSelectionStatus; onSelectPage?: ( selected: boolean, - activities: ActivityExecutionInfo[], + activities: ActivityExecutionListInfo[], ) => void; children?: Snippet; }; diff --git a/src/lib/components/standalone-activities/activities-summary-configurable-table/table-row.svelte b/src/lib/components/standalone-activities/activities-summary-configurable-table/table-row.svelte index 3ce3d83e3c..5cc0e8aec8 100644 --- a/src/lib/components/standalone-activities/activities-summary-configurable-table/table-row.svelte +++ b/src/lib/components/standalone-activities/activities-summary-configurable-table/table-row.svelte @@ -11,11 +11,11 @@ type ActivityBatchOperationContext, activityKey, } from '$lib/pages/standalone-activities.svelte'; - import type { ActivityExecutionInfo } from '$lib/types/activity-execution'; + import type { ActivityExecutionListInfo } from '$lib/types/activity-execution'; import { standaloneActivityWriteActionsDisabled } from '$lib/utilities/standalone-activities-commands-disabled'; interface Props { - activity?: ActivityExecutionInfo; + activity?: ActivityExecutionListInfo; empty?: boolean; showBatchActions?: boolean; onClickBatchSelect?: (e: MouseEvent) => void; 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 c031b3e4c7..7dba143ede 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,8 +3,13 @@ import { page } from '$app/state'; + import SearchAttributeValue from '$lib/components/table/search-attribute-value.svelte'; import Timestamp from '$lib/components/timestamp.svelte'; import type { ConfigurableTableHeader } from '$lib/stores/configurable-table-columns'; + import { + customSearchAttributes, + isFilterableCustomSearchAttribute, + } from '$lib/stores/search-attributes'; import type { NexusOperationExecutionListInfo } from '$lib/types/nexus-operation-execution'; import { COLUMN_WIDTH_CLAMP_CLASSES, @@ -51,9 +56,20 @@ 'Service', 'Operation', ]; + + const customSearchAttributeValue = $derived( + operation.searchAttributes?.indexedFields?.[label], + ); + const isCustomKeywordOrTextAttribute = $derived( + isFilterableCustomSearchAttribute( + $customSearchAttributes, + label, + customSearchAttributeValue, + ), + ); -{#if filterableLabels.includes(label)} +{#if filterableLabels.includes(label) || isCustomKeywordOrTextAttribute} + {:else if isCustomKeywordOrTextAttribute} + {/if} {:else} @@ -129,6 +152,11 @@ {/if} {:else if label === 'State Transitions'} {operation.stateTransitionCount ?? ''} + {:else if label in $customSearchAttributes && customSearchAttributeValue !== undefined} + {/if} {/if} diff --git a/src/lib/components/table/search-attribute-value.svelte b/src/lib/components/table/search-attribute-value.svelte new file mode 100644 index 0000000000..dfb9fdc8c7 --- /dev/null +++ b/src/lib/components/table/search-attribute-value.svelte @@ -0,0 +1,46 @@ + + +{#if value != null} + {#if type === SEARCH_ATTRIBUTE_TYPE.DATETIME && typeof value === 'string'} + + {:else if type === SEARCH_ATTRIBUTE_TYPE.BOOL} + + {: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..6ba266bb16 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 @@ -4,20 +4,17 @@ import { page } from '$app/state'; + import SearchAttributeValue from '$lib/components/table/search-attribute-value.svelte'; import Timestamp from '$lib/components/timestamp.svelte'; import WorkflowStatusBadge from '$lib/components/workflow/workflow-status-badge.svelte'; import Tooltip from '$lib/holocene/tooltip.svelte'; - import { Badge } from '$lib/io/badge'; import type { ConfigurableTableHeader } from '$lib/stores/configurable-table-columns'; import { customSearchAttributes, - isCustomSearchAttribute, + isFilterableCustomSearchAttribute, workflowIncludesSearchAttribute, } from '$lib/stores/search-attributes'; - import { - SEARCH_ATTRIBUTE_TYPE, - type WorkflowExecution, - } from '$lib/types/workflows'; + import type { WorkflowExecution } from '$lib/types/workflows'; import { COLUMN_WIDTH_CLAMP_CLASSES, columnWidthStyle, @@ -54,10 +51,11 @@ const { label, width } = $derived(column); const namespace = $derived(page.params.namespace); const isCustomKeywordOrTextAttribute = $derived( - isCustomSearchAttribute(label) && - ($customSearchAttributes[label] === SEARCH_ATTRIBUTE_TYPE.KEYWORD || - $customSearchAttributes[label] === SEARCH_ATTRIBUTE_TYPE.TEXT) && - typeof workflow.searchAttributes?.indexedFields?.[label] === 'string', + isFilterableCustomSearchAttribute( + $customSearchAttributes, + label, + workflow.searchAttributes?.indexedFields?.[label], + ), ); const filterableLabels = [ @@ -245,23 +243,12 @@ {/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} + {:else if label in $customSearchAttributes && workflowIncludesSearchAttribute(workflow, label)} + {/if} {/if} diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts index c8453ae4f6..8545b92b65 100644 --- a/src/lib/models/workflow-execution.ts +++ b/src/lib/models/workflow-execution.ts @@ -1,5 +1,3 @@ -import { mapValues } from 'es-toolkit'; - import type { Callbacks, PendingActivity, @@ -10,14 +8,12 @@ import type { } from '$lib/types/events'; import type { Callback } from '$lib/types/nexus'; import type { - DecodedWorkflowSearchAttributes, ListWorkflowExecutionsResponse, MostRecentWorkflowVersionStamp, WorkflowExecution, WorkflowExecutionAPIResponse, - WorkflowSearchAttributes, } from '$lib/types/workflows'; -import { parseRawPayloadToJSON } from '$lib/utilities/decode-payload'; +import { parseSearchAttributes } from '$lib/utilities/decode-payload'; import { toCallbackStateReadable, toPendingActivityStateReadable, @@ -67,19 +63,6 @@ const toCallbacks = (callbacks?: Callbacks): Callback[] => { }); }; -const toSearchAttributes = ( - apiSearchAttributes: WorkflowSearchAttributes, -): DecodedWorkflowSearchAttributes => { - if (!apiSearchAttributes || !apiSearchAttributes.indexedFields) return {}; - const decoded = mapValues(apiSearchAttributes.indexedFields, (payload) => - parseRawPayloadToJSON(payload), - ) as Record; - - return { - indexedFields: decoded, - }; -}; - const getStartDelay = ({ executionTime, startTime, @@ -100,7 +83,7 @@ export const toWorkflowExecution = ( response?: WorkflowExecutionAPIResponse, ): WorkflowExecution => { const info = response?.workflowExecutionInfo; - const searchAttributes = toSearchAttributes(info?.searchAttributes ?? {}); + const searchAttributes = parseSearchAttributes(info?.searchAttributes ?? {}); const memo = info?.memo ?? {}; const name = info?.type?.name ?? ''; const id = info?.execution?.workflowId ?? ''; diff --git a/src/lib/pages/standalone-activities.svelte b/src/lib/pages/standalone-activities.svelte index 42dddbc91e..660098ec73 100644 --- a/src/lib/pages/standalone-activities.svelte +++ b/src/lib/pages/standalone-activities.svelte @@ -1,27 +1,27 @@ @@ -120,9 +120,9 @@ selectItems: selectActivities, handleSelectAll, reset: resetSelection, - } = createBatchSelection(activityKey); + } = createBatchSelection(activityKey); - const isRunning = (activity: ActivityExecutionInfo) => + const isRunning = (activity: ActivityExecutionListInfo) => activity.status === 'ACTIVITY_EXECUTION_STATUS_RUNNING'; const cancelableActivities = derivedStore(selectedActivities, (activities) => diff --git a/src/lib/pages/standalone-activity-workers.svelte b/src/lib/pages/standalone-activity-workers.svelte index 61ccb11d1a..a1bd3ea80e 100644 --- a/src/lib/pages/standalone-activity-workers.svelte +++ b/src/lib/pages/standalone-activity-workers.svelte @@ -1,12 +1,9 @@ ({ +}: ActivityExecutionListInfo): Execution => ({ type: ExecutionType.EXECUTION_TYPE_ACTIVITY, businessId: activityId ?? '', runId: runId ?? '', diff --git a/src/lib/services/standalone-activities.test.ts b/src/lib/services/standalone-activities.test.ts deleted file mode 100644 index 9c3110394b..0000000000 --- a/src/lib/services/standalone-activities.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { StandaloneActivityFormData } from '$lib/components/standalone-activities/start-standalone-activity-form/types'; - -import { - toActivityCallbacks, - toStartActivityExecutionRequest, -} from './standalone-activities'; - -const baseFormData: StandaloneActivityFormData = { - identity: 'test', - namespace: 'default', - activityId: 'activity-1', - taskQueue: 'task-queue', - activityType: 'MyActivity', - startToCloseTimeout: '', - scheduleToCloseTimeout: '', - scheduleToStartTimeout: '', - input: '', - encoding: 'json/plain', - messageType: '', - searchAttributes: [], - summary: '', - details: '', - initialInterval: '', - backoffCoefficient: '', - maximumInterval: '', - maximumAttempts: '', -}; - -describe('toStartActivityExecutionRequest', () => { - it('omits retry policy numbers left empty', async () => { - const request = await toStartActivityExecutionRequest(baseFormData); - - expect(request.retryPolicy).toEqual({}); - }); - - it('coerces populated retry policy numbers to numbers', async () => { - const request = await toStartActivityExecutionRequest({ - ...baseFormData, - backoffCoefficient: '2', - maximumAttempts: '5', - }); - - expect(request.retryPolicy?.backoffCoefficient).toBe(2); - expect(request.retryPolicy?.maximumAttempts).toBe(5); - }); - - it('omits timeouts left empty and forwards provided ones', async () => { - const empty = await toStartActivityExecutionRequest(baseFormData); - - expect(empty.startToCloseTimeout).toBeUndefined(); - expect(empty.scheduleToCloseTimeout).toBeUndefined(); - - const provided = await toStartActivityExecutionRequest({ - ...baseFormData, - startToCloseTimeout: '30s', - }); - - expect(provided.startToCloseTimeout).toBe('30s'); - }); -}); - -describe('toActivityCallbacks', () => { - const activityCallbacks = [ - { - trigger: {}, - info: { - callback: { - nexus: { url: 'http://callback' }, - links: [ - { - workflowEvent: { - namespace: 'caller-ns', - workflowId: 'caller-wf', - runId: 'caller-run', - eventRef: { eventId: '7' }, - }, - }, - ], - }, - state: 'CALLBACK_STATE_SUCCEEDED', - attempt: 2, - }, - }, - ] as unknown as Parameters[0]; - - it('extracts nested info and makes state readable', () => { - const [callback] = toActivityCallbacks(activityCallbacks); - - expect(callback.state).toBe('Succeeded'); - expect(callback.attempt).toBe(2); - expect(callback.callback?.links).toHaveLength(1); - expect(callback.callback?.nexus?.url).toBe('http://callback'); - }); - - it('skips callbacks without info and handles empty input', () => { - expect(toActivityCallbacks(undefined)).toEqual([]); - expect( - toActivityCallbacks([{}] as unknown as Parameters< - typeof toActivityCallbacks - >[0]), - ).toEqual([]); - }); -}); diff --git a/src/lib/services/standalone-activities.ts b/src/lib/services/standalone-activities.ts index a51fa9b34b..030a07ee20 100644 --- a/src/lib/services/standalone-activities.ts +++ b/src/lib/services/standalone-activities.ts @@ -20,11 +20,15 @@ import type { ActivityOptions, Payload, SearchAttribute } from '$lib/types'; import type { ActivityExecution, ActivityExecutionInfo, + ActivityExecutionListInfo, StartActivityExecutionRequest, } from '$lib/types/activity-execution'; import type { Callback } from '$lib/types/nexus'; import { activityOptionsUpdateMask } from '$lib/utilities/activity-options-update-mask'; -import { decodePayloadAndParseDataToJSON } from '$lib/utilities/decode-payload'; +import { + decodePayloadAndParseDataToJSON, + parseSearchAttributes, +} from '$lib/utilities/decode-payload'; import { encodePayloads, setSearchAttributes, @@ -101,10 +105,17 @@ export interface StartStandaloneActivityResponse { started: boolean; } +const toActivityExecutionListInfo = ( + activity: ActivityExecutionInfo, +): ActivityExecutionListInfo => ({ + ...activity, + searchAttributes: parseSearchAttributes(activity.searchAttributes), +}); + export type PaginatedActivitiesPromise = ( pageSize?: number, token?: string, -) => Promise<{ items: ActivityExecutionInfo[]; nextPageToken: string }>; +) => Promise<{ items: ActivityExecutionListInfo[]; nextPageToken: string }>; export const fetchPaginatedActivities = async ( namespace: string, @@ -134,7 +145,7 @@ export const fetchPaginatedActivities = async ( }).then((response) => { const { executions = [], nextPageToken = '' } = response || {}; return { - items: executions, + items: executions.map(toActivityExecutionListInfo), nextPageToken: nextPageToken ? String(nextPageToken) : '', }; }); diff --git a/src/lib/services/standalone-nexus-operations.ts b/src/lib/services/standalone-nexus-operations.ts index b0d7fa9efe..e924ad08cc 100644 --- a/src/lib/services/standalone-nexus-operations.ts +++ b/src/lib/services/standalone-nexus-operations.ts @@ -15,9 +15,13 @@ import type { NexusOperationExecution, NexusOperationExecutionInfo, NexusOperationExecutionListInfo, + NexusOperationExecutionListInfoAPIResponse, StartNexusOperationRequest, } from '$lib/types/nexus-operation-execution'; -import { decodePayloadAndParseDataToJSON } from '$lib/utilities/decode-payload'; +import { + decodePayloadAndParseDataToJSON, + parseSearchAttributes, +} from '$lib/utilities/decode-payload'; import { encodePayloads, setSearchAttributes, @@ -30,7 +34,7 @@ import { import { routeForApi } from '$lib/utilities/route-for-api'; export type ListNexusOperationsResponse = { - operations: NexusOperationExecutionListInfo[]; + operations: NexusOperationExecutionListInfoAPIResponse[]; nextPageToken: string; }; @@ -53,6 +57,13 @@ const emptyNexusOperationExecution: NexusOperationExecution = { info: emptyNexusOperationExecutionInfo, }; +const toNexusOperationExecutionListInfo = ( + operation: NexusOperationExecutionListInfoAPIResponse, +): NexusOperationExecutionListInfo => ({ + ...operation, + searchAttributes: parseSearchAttributes(operation.searchAttributes ?? {}), +}); + export type PaginatedNexusOperationsPromise = ( pageSize?: number, token?: string, @@ -90,7 +101,7 @@ export const fetchPaginatedNexusOperations = async ( }).then((response) => { const { operations = [], nextPageToken = '' } = response || {}; return { - items: operations, + items: operations.map(toNexusOperationExecutionListInfo), nextPageToken: nextPageToken ? String(nextPageToken) : '', }; }); diff --git a/src/lib/stores/search-attributes.test.ts b/src/lib/stores/search-attributes.test.ts index 998318d52f..819676c762 100644 --- a/src/lib/stores/search-attributes.test.ts +++ b/src/lib/stores/search-attributes.test.ts @@ -1,30 +1,11 @@ import { describe, expect, test } from 'vitest'; import { - allSearchAttributes, - isCustomSearchAttribute, + isFilterableCustomSearchAttribute, workflowIncludesSearchAttribute, } from './search-attributes'; describe('search attributes store', () => { - describe('isCustomSearchAttribute', () => { - test('returns false when the key is not a custom search attribute', () => { - allSearchAttributes.set({ - systemAttributes: { WorkflowType: 'Keyword' }, - customAttributes: {}, - }); - expect(isCustomSearchAttribute('CustomBool')).toBe(false); - }); - - test('returns true when the key is a custom search attribute', () => { - allSearchAttributes.set({ - systemAttributes: { WorkflowType: 'Keyword' }, - customAttributes: { CustomBool: 'Bool' }, - }); - expect(isCustomSearchAttribute('CustomBool')).toBe(true); - }); - }); - describe('workflowIncludesSearchAttribute', () => { test('returns true when the search attribute is defined on the workflow', () => { const mockWorkflow = { @@ -79,4 +60,50 @@ describe('search attributes store', () => { ); }); }); + + describe('isFilterableCustomSearchAttribute', () => { + const customAttributes = { + CustomKeyword: 'Keyword', + CustomText: 'Text', + CustomInt: 'Int', + } as const; + + test('returns true for a string Keyword value', () => { + expect( + isFilterableCustomSearchAttribute( + customAttributes, + 'CustomKeyword', + 'value', + ), + ).toBe(true); + }); + + test('returns true for a string Text value', () => { + expect( + isFilterableCustomSearchAttribute(customAttributes, 'CustomText', 'a'), + ).toBe(true); + }); + + test('returns false for a non-string value', () => { + expect( + isFilterableCustomSearchAttribute( + customAttributes, + 'CustomKeyword', + undefined, + ), + ).toBe(false); + }); + + test('returns false for other types', () => { + expect( + isFilterableCustomSearchAttribute(customAttributes, 'CustomInt', '1'), + ).toBe(false); + }); + + test('returns false for unknown attributes', () => { + expect( + isFilterableCustomSearchAttribute(customAttributes, 'Unknown', 'a'), + ).toBe(false); + }); + }); }); diff --git a/src/lib/stores/search-attributes.ts b/src/lib/stores/search-attributes.ts index 59531f5834..d8eded04af 100644 --- a/src/lib/stores/search-attributes.ts +++ b/src/lib/stores/search-attributes.ts @@ -1,4 +1,4 @@ -import { derived, get, readable, type Readable, writable } from 'svelte/store'; +import { derived, readable, type Readable, writable } from 'svelte/store'; import { z } from 'zod/v3'; @@ -85,10 +85,14 @@ export const customSearchAttributeOptions: Readable< })); }); -export const isCustomSearchAttribute = (key: string) => { - const customSearchAttrs = get(customSearchAttributes); - return key in customSearchAttrs; -}; +export const isFilterableCustomSearchAttribute = ( + customAttributes: SearchAttributes, + attribute: string, + value: unknown, +): value is string => + typeof value === 'string' && + (customAttributes[attribute] === SEARCH_ATTRIBUTE_TYPE.KEYWORD || + customAttributes[attribute] === SEARCH_ATTRIBUTE_TYPE.TEXT); export const workflowIncludesSearchAttribute = ( workflow: WorkflowExecution, diff --git a/src/lib/types/activity-execution.ts b/src/lib/types/activity-execution.ts index 8a3a008bc1..7652716475 100644 --- a/src/lib/types/activity-execution.ts +++ b/src/lib/types/activity-execution.ts @@ -3,7 +3,10 @@ import type { temporal } from '@temporalio/proto'; import type { Callback } from '$lib/types/nexus'; import type { Failure, Payloads } from '.'; -import type { WorkflowSearchAttributes } from './workflows'; +import type { + DecodedWorkflowSearchAttributes, + WorkflowSearchAttributes, +} from './workflows'; // Enum values arrive over REST/JSON as their SCREAMING_SNAKE names, so these are // string unions of the proto enum keys (not the numeric proto enum). Keying off @@ -99,6 +102,13 @@ export interface ActivityExecutionInfo extends Omit< | null; } +export type ActivityExecutionListInfo = Omit< + ActivityExecutionInfo, + 'searchAttributes' +> & { + searchAttributes: DecodedWorkflowSearchAttributes; +}; + export interface ActivityExecution { runId: string; info: ActivityExecutionInfo; diff --git a/src/lib/types/nexus-operation-execution.ts b/src/lib/types/nexus-operation-execution.ts index 2b174fb0e8..72a561c57a 100644 --- a/src/lib/types/nexus-operation-execution.ts +++ b/src/lib/types/nexus-operation-execution.ts @@ -6,7 +6,10 @@ import type { NexusOperationIdReusePolicy, StartNexusOperationExecutionRequest, } from '.'; -import type { WorkflowSearchAttributes } from './workflows'; +import type { + DecodedWorkflowSearchAttributes, + WorkflowSearchAttributes, +} from './workflows'; export type NexusOperationExecutionStatus = keyof typeof temporal.api.enums.v1.NexusOperationExecutionStatus; @@ -82,9 +85,16 @@ export interface NexusOperationExecutionListInfo extends Omit< closeTime: string; stateTransitionCount: string; executionDuration: string; - searchAttributes: WorkflowSearchAttributes; + searchAttributes: DecodedWorkflowSearchAttributes; } +export type NexusOperationExecutionListInfoAPIResponse = Omit< + NexusOperationExecutionListInfo, + 'searchAttributes' +> & { + searchAttributes?: WorkflowSearchAttributes; +}; + export interface NexusOperationExecution extends Omit< DescribeNexusOperationResponse, 'info' | 'longPollToken' diff --git a/src/lib/utilities/decode-payload.test.ts b/src/lib/utilities/decode-payload.test.ts index ddbd60e52d..67f9f0c9a4 100644 --- a/src/lib/utilities/decode-payload.test.ts +++ b/src/lib/utilities/decode-payload.test.ts @@ -7,6 +7,7 @@ import { decodeEventAttributes, parsePayloadAttributes, parseRawPayloadToJSON, + parseSearchAttributes, } from './decode-payload'; import { dataConvertedWorkflowStartedEvent, @@ -245,6 +246,38 @@ describe('parseRawPayloadToJSON with returnDataOnly = false', () => { }); }); +describe('parseSearchAttributes', () => { + it('parses indexed field payloads', () => { + expect( + parseSearchAttributes({ + indexedFields: { + CustomKeyword: { + metadata: { encoding: btoa('json/plain') }, + data: btoa('"keyword-value"'), + }, + CustomKeywordList: { + metadata: { encoding: btoa('json/plain') }, + data: btoa('["a","b"]'), + }, + }, + }), + ).toEqual({ + indexedFields: { + CustomKeyword: 'keyword-value', + CustomKeywordList: ['a', 'b'], + }, + }); + }); + + it('returns an empty object when there are no indexed fields', () => { + expect(parseSearchAttributes({})).toEqual({}); + }); + + it('returns an empty object when indexed fields are null', () => { + expect(parseSearchAttributes({ indexedFields: null })).toEqual({}); + }); +}); + describe('parsePayloadAttributes', () => { it('Should parsePayloadAttributes searchAttributes with indexedFields', () => { const payload = { diff --git a/src/lib/utilities/decode-payload.ts b/src/lib/utilities/decode-payload.ts index c7c00cc2a6..4799664a34 100644 --- a/src/lib/utilities/decode-payload.ts +++ b/src/lib/utilities/decode-payload.ts @@ -1,9 +1,15 @@ +import { mapValues } from 'es-toolkit'; + import { decodePayloadsWithCodec as callCodecEndpoint } from '$lib/services/data-encoder'; import type { DownloadEventHistorySetting } from '$lib/stores/events'; import { schemaForMessageType } from '$lib/system-nexus-endpoints'; -import type { Memo, Payload, Payloads } from '$lib/types'; +import type { Memo, Payload, Payloads, SearchAttribute } from '$lib/types'; import type { EventAttribute, WorkflowEvent } from '$lib/types/events'; import type { Optional, Replace } from '$lib/types/global'; +import type { + DecodedWorkflowSearchAttributes, + WorkflowSearchAttributes, +} from '$lib/types/workflows'; import { atob } from './atob'; import { @@ -162,6 +168,19 @@ export function parseRawPayloadToJSON( return payload; } +export const parseSearchAttributes = ( + apiSearchAttributes: WorkflowSearchAttributes | SearchAttribute, +): DecodedWorkflowSearchAttributes => { + if (!apiSearchAttributes || !apiSearchAttributes.indexedFields) return {}; + const decoded = mapValues(apiSearchAttributes.indexedFields, (payload) => + parseRawPayloadToJSON(payload), + ) as Record; + + return { + indexedFields: decoded, + }; +}; + /** * Phase 1 — synchronous, no network. * diff --git a/src/lib/utilities/delayed-activities.ts b/src/lib/utilities/delayed-activities.ts index 8e2bba98f3..dd62c538c0 100644 --- a/src/lib/utilities/delayed-activities.ts +++ b/src/lib/utilities/delayed-activities.ts @@ -1,6 +1,8 @@ import type { ActivityExecutionInfo } from '$lib/types/activity-execution'; import { isFuture } from '$lib/utilities/format-date'; -export const isActivityDelayed = (activity: ActivityExecutionInfo): boolean => { +export const isActivityDelayed = ( + activity: Pick, +): boolean => { return !!activity.executionTime && isFuture(activity.executionTime); };