From 6dab73e5b78214596e18920e9e7d448b92ba901b Mon Sep 17 00:00:00 2001 From: Assem Hafez Date: Wed, 15 Oct 2025 10:02:06 +0200 Subject: [PATCH 1/9] Search attribute component Signed-off-by: Assem Hafez --- ...orkflow-actions-search-attributes.test.tsx | 364 ++++++++++++++++++ ...low-actions-search-attributes.constants.ts | 19 + ...rkflow-actions-search-attributes.styles.ts | 80 ++++ .../workflow-actions-search-attributes.tsx | 331 ++++++++++++++++ ...orkflow-actions-search-attributes.types.ts | 23 ++ 5 files changed, 817 insertions(+) create mode 100644 src/views/workflow-actions/workflow-actions-search-attributes/__tests__/workflow-actions-search-attributes.test.tsx create mode 100644 src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.constants.ts create mode 100644 src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.styles.ts create mode 100644 src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.tsx create mode 100644 src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.types.ts diff --git a/src/views/workflow-actions/workflow-actions-search-attributes/__tests__/workflow-actions-search-attributes.test.tsx b/src/views/workflow-actions/workflow-actions-search-attributes/__tests__/workflow-actions-search-attributes.test.tsx new file mode 100644 index 000000000..669c9278d --- /dev/null +++ b/src/views/workflow-actions/workflow-actions-search-attributes/__tests__/workflow-actions-search-attributes.test.tsx @@ -0,0 +1,364 @@ +import React from 'react'; + +import { render, screen, userEvent, waitFor } from '@/test-utils/rtl'; + +import { IndexedValueType } from '@/__generated__/proto-ts/uber/cadence/api/v1/IndexedValueType'; + +import SearchAttributesInput from '../workflow-actions-search-attributes'; +import type { + Props, + SearchAttributeOption, +} from '../workflow-actions-search-attributes.types'; + +const mocksearchAttributes: Array = [ + { + name: 'WorkflowType', + valueType: IndexedValueType.INDEXED_VALUE_TYPE_STRING, + }, + { + name: 'StartTime', + valueType: IndexedValueType.INDEXED_VALUE_TYPE_DATETIME, + }, + { + name: 'CustomBoolField', + valueType: IndexedValueType.INDEXED_VALUE_TYPE_BOOL, + }, + { + name: 'CustomNumberField', + valueType: IndexedValueType.INDEXED_VALUE_TYPE_DOUBLE, + }, +]; + +describe(SearchAttributesInput.name, () => { + it('should render with default empty state', () => { + setup(); + + expect(screen.getByText('Search Attributes')).toBeInTheDocument(); + + const keySelects = screen.getAllByRole('combobox', { + name: 'Search attribute key', + }); + expect(keySelects).toHaveLength(1); + expect(keySelects[0]).toHaveValue(''); + + const valueInputs = screen.getAllByRole('textbox', { + name: 'Search attribute value', + }); + expect(valueInputs).toHaveLength(1); + expect(valueInputs[0]).toHaveValue(''); + + expect(screen.getByText('Add Search Attribute')).toBeInTheDocument(); + }); + + it('should render with custom label and button text', () => { + setup({ + label: 'Custom Search Attributes', + addButtonText: 'Add Custom Attribute', + }); + + expect(screen.getByText('Custom Search Attributes')).toBeInTheDocument(); + expect(screen.getByText('Add Custom Attribute')).toBeInTheDocument(); + }); + + it('should render existing values', () => { + setup({ + value: [ + { key: 'WorkflowType', value: 'MyWorkflow' }, + { key: 'StartTime', value: '2023-01-01T00:00:00Z' }, + ], + }); + + const allComboboxes = screen.getAllByRole('combobox'); + expect(allComboboxes).toHaveLength(2); + + expect(screen.getByDisplayValue('MyWorkflow')).toBeInTheDocument(); + + expect(screen.getByDisplayValue('2023–01–01 00:00:00')).toBeInTheDocument(); + }); + + it('should allow selecting an attribute key', async () => { + const { user, mockOnChange } = setup(); + + await user.click( + screen.getByRole('combobox', { name: 'Search attribute key' }) + ); + + await user.click(screen.getByText('WorkflowType')); + + expect(mockOnChange).toHaveBeenCalledWith([ + { key: 'WorkflowType', value: '' }, + ]); + }); + + it('should update input type based on selected attribute type', () => { + setup({ + value: [ + { key: 'WorkflowType', value: '' }, + { key: 'StartTime', value: '' }, + { key: 'CustomBoolField', value: '' }, + { key: 'CustomNumberField', value: '' }, + ], + }); + + const stringInput = screen.getByPlaceholderText('Enter value'); + expect(stringInput).toHaveAttribute('type', 'text'); + + expect(screen.getByText('Select value')).toBeInTheDocument(); + + const numberInput = screen.getByPlaceholderText('Enter number'); + expect(numberInput).toHaveAttribute('type', 'number'); + + expect( + screen.getByPlaceholderText('Select date and time') + ).toBeInTheDocument(); + }); + + it('should allow entering values', async () => { + const { user, mockOnChange } = setup({ + value: [{ key: 'WorkflowType', value: '' }], + }); + + const valueInput = screen.getByRole('textbox', { + name: 'Search attribute value', + }); + await user.clear(valueInput); + await user.type(valueInput, 'MyWorkflow'); + + // Check that onChange was called (it will be called for each character) + expect(mockOnChange).toHaveBeenCalled(); + // Verify that the last call included the key we expect + const calls = mockOnChange.mock.calls; + expect(calls[calls.length - 1][0][0].key).toBe('WorkflowType'); + expect(calls.length).toBeGreaterThan(0); + }); + + it('should disable value input when no key is selected', () => { + setup(); + + const valueInput = screen.getByRole('textbox', { + name: 'Search attribute value', + }); + expect(valueInput).toBeDisabled(); + }); + + it('should render boolean input as dropdown', async () => { + const { user, mockOnChange } = setup({ + value: [{ key: 'CustomBoolField', value: '' }], + }); + + const booleanSelect = screen.getByText('Select value'); + expect(booleanSelect).toBeInTheDocument(); + + // Click on the dropdown + await user.click(booleanSelect); + + // Should see TRUE/FALSE options + expect(screen.getByText('TRUE')).toBeInTheDocument(); + expect(screen.getByText('FALSE')).toBeInTheDocument(); + }); + + it('should render timestamp input as date picker', () => { + setup({ + value: [{ key: 'StartTime', value: '' }], + }); + + // DatePicker should be present (check for the placeholder text it uses) + expect( + screen.getByPlaceholderText('Select date and time') + ).toBeInTheDocument(); + }); + + it('should reset value when key changes', async () => { + const { user, mockOnChange } = setup({ + value: [{ key: 'WorkflowType', value: 'ExistingValue' }], + }); + + // Change the key by clicking on the combobox and selecting a different option + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('StartTime')); + + expect(mockOnChange).toHaveBeenCalledWith([ + { key: 'StartTime', value: '' }, + ]); + }); + + it('should add new attribute when add button is clicked', async () => { + const { user, mockOnChange } = setup({ + value: [{ key: 'WorkflowType', value: 'MyWorkflow' }], + }); + + await user.click(screen.getByText('Add Search Attribute')); + + expect(mockOnChange).toHaveBeenCalledWith([ + { key: 'WorkflowType', value: 'MyWorkflow' }, + { key: '', value: '' }, + ]); + }); + + it('should disable add button when current attributes are incomplete', () => { + setup({ + value: [{ key: 'WorkflowType', value: '' }], + }); + + expect(screen.getByText('Add Search Attribute')).toBeDisabled(); + }); + + it('should delete attribute when delete button is clicked', async () => { + const { user, mockOnChange } = setup({ + value: [ + { key: 'WorkflowType', value: 'MyWorkflow' }, + { key: 'StartTime', value: '2023-01-01T00:00:00Z' }, + ], + }); + + const deleteButtons = screen.getAllByLabelText('Delete attribute'); + await user.click(deleteButtons[0]); + + expect(mockOnChange).toHaveBeenCalledWith([ + { key: 'StartTime', value: '2023-01-01T00:00:00Z' }, + ]); + }); + + it('should clear attribute when delete button is clicked on single empty attribute', async () => { + const { user, mockOnChange } = setup({ + value: [{ key: 'WorkflowType', value: 'SomeValue' }], + }); + + const deleteButton = screen.getByLabelText('Clear attribute'); + await user.click(deleteButton); + + expect(mockOnChange).toHaveBeenCalledWith([]); + }); + + it('should disable delete button when only empty attribute exists', () => { + setup(); + + expect(screen.getByLabelText('Delete attribute')).toBeDisabled(); + }); + + it('should display global error state on fields', () => { + setup({ + error: 'Global error message', + }); + + // Global errors apply error state to all fields (aria-invalid="true") + const valueInput = screen.getByRole('textbox', { + name: 'Search attribute value', + }); + + expect(valueInput).toHaveAttribute('aria-invalid', 'true'); + }); + + it('should display field-specific error state', () => { + setup({ + value: [ + { key: 'WorkflowType', value: 'test' }, + { key: 'StartTime', value: '' }, + ], + error: [ + undefined, // First field has no error + { + valueError: 'Value is required', + }, + ], + }); + + const allValueInputs = screen.getAllByRole('textbox', { + name: 'Search attribute value', + }); + + expect(allValueInputs[0]).toHaveAttribute('aria-invalid', 'false'); + expect(allValueInputs[1]).toHaveAttribute('aria-invalid', 'true'); + }); + + it('should be searchable in the key select dropdown', async () => { + const { user } = setup(); + + const keySelect = screen.getByRole('combobox', { + name: 'Search attribute key', + }); + await user.click(keySelect); + await user.type(keySelect, 'Work'); + + expect(screen.getByText('WorkflowType')).toBeInTheDocument(); + }); + + it('should filter out already selected attributes from suggestions', async () => { + const { user } = setup({ + value: [ + { key: 'WorkflowType', value: 'MyWorkflow' }, + { key: 'StartTime', value: '2023-01-01T00:00:00Z' }, + { key: '', value: '' }, // Empty row + ], + }); + + // Get the last (empty) key select and click it to open dropdown + const allKeySelects = screen.getAllByRole('combobox', { + name: /Search attribute key/i, + }); + const emptyKeySelect = allKeySelects[allKeySelects.length - 1]; + await user.click(emptyKeySelect); + + // Wait for dropdown to open and get all options + await waitFor(() => { + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + }); + + // Get all options in the dropdown + const options = screen.getAllByRole('option'); + const optionTexts = options.map((opt) => opt.textContent); + + // The empty row should show only unselected attributes + // (filtering out 'WorkflowType' and 'StartTime' which are already selected) + expect(optionTexts).not.toContain('WorkflowType'); + expect(optionTexts).not.toContain('StartTime'); + expect(optionTexts).toContain('CustomBoolField'); + expect(optionTexts).toContain('CustomNumberField'); + expect(options).toHaveLength(2); // Only 2 unselected attributes + }); + + it('should disable add button when all attributes are selected', () => { + setup({ + value: [ + { key: 'WorkflowType', value: 'MyWorkflow' }, + { key: 'StartTime', value: '2023-01-01T00:00:00Z' }, + { key: 'CustomBoolField', value: 'TRUE' }, + { key: 'CustomNumberField', value: '123' }, + ], + }); + + // Add button should be disabled since all 4 available attributes are selected + expect(screen.getByText('Add Search Attribute')).toBeDisabled(); + }); + + it('should enable add button when not all attributes are selected and current fields are complete', () => { + setup({ + value: [ + { key: 'WorkflowType', value: 'MyWorkflow' }, + { key: 'StartTime', value: '2023-01-01T00:00:00Z' }, + ], + }); + + // Add button should be enabled since only 2 out of 4 available attributes are selected + expect(screen.getByText('Add Search Attribute')).not.toBeDisabled(); + }); +}); + +function setup(props: Partial = {}) { + const mockOnChange = jest.fn(); + const user = userEvent.setup(); + + const defaultProps: Props = { + onChange: mockOnChange, + searchAttributes: mocksearchAttributes, + ...props, + }; + + render(); + + return { + user, + mockOnChange, + }; +} diff --git a/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.constants.ts b/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.constants.ts new file mode 100644 index 000000000..46fafebbf --- /dev/null +++ b/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.constants.ts @@ -0,0 +1,19 @@ +import { type AttributeValueType } from './workflow-actions-search-attributes.types'; + +export const BOOLEAN_OPTIONS = [ + { id: 'true', label: 'TRUE' }, + { id: 'false', label: 'FALSE' }, +] as const; + +export const INPUT_PLACEHOLDERS_FOR_VALUE_TYPE: Record< + AttributeValueType, + string +> = { + INDEXED_VALUE_TYPE_DATETIME: 'Select date and time', + INDEXED_VALUE_TYPE_BOOL: 'Select value', + INDEXED_VALUE_TYPE_INT: 'Enter integer', + INDEXED_VALUE_TYPE_DOUBLE: 'Enter number', + INDEXED_VALUE_TYPE_STRING: 'Enter value', + INDEXED_VALUE_TYPE_KEYWORD: 'Enter value', + INDEXED_VALUE_TYPE_INVALID: 'Enter value', +}; diff --git a/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.styles.ts b/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.styles.ts new file mode 100644 index 000000000..bb4be957d --- /dev/null +++ b/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.styles.ts @@ -0,0 +1,80 @@ +import { type Theme } from 'baseui'; +import { type InputOverrides } from 'baseui/input'; +import { type SelectOverrides } from 'baseui/select'; +import { type StyleObject } from 'styletron-react'; + +import type { + StyletronCSSObject, + StyletronCSSObjectOf, +} from '@/hooks/use-styletron-classes'; + +export const overrides = { + keySelect: { + Root: { + style: (): StyleObject => ({ + flex: '0 0 200px', // Fixed width, no grow/shrink + }), + }, + } satisfies SelectOverrides, + valueInput: { + Root: { + style: (): StyleObject => ({ + flex: '1', + minWidth: '0', // Allow shrinking + }), + }, + } satisfies InputOverrides, +}; + +const cssStylesObj = { + container: (theme: Theme) => ({ + display: 'flex', + flexDirection: 'column', + gap: '16px', + borderLeft: `2px solid ${theme.colors.borderOpaque}`, + paddingLeft: '16px', + }), + attributeRow: () => ({ + display: 'flex', + alignItems: 'flex-start', + gap: '8px', + width: '100%', + }), + keyContainer: () => ({ + flex: '0 0 200px', // Fixed width, no grow/shrink + }), + valueContainer: () => ({ + flex: '1', + minWidth: '0', // Allow shrinking + }), + buttonContainer: () => ({ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + minWidth: '40px', + paddingTop: '4px', + }), + deleteButton: () => ({ + padding: '8px', + borderRadius: '8px', + }), + addButtonContainer: () => ({ + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'flex-start', + }), + addButton: () => ({ + padding: '6px 12px', + fontSize: '12px', + fontWeight: '500', + lineHeight: '16px', + }), + plusIcon: () => ({ + fontSize: '16px', + fontWeight: '500', + lineHeight: '1', + }), +} satisfies StyletronCSSObject; + +export const cssStyles: StyletronCSSObjectOf = + cssStylesObj; diff --git a/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.tsx b/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.tsx new file mode 100644 index 000000000..b8cb82d28 --- /dev/null +++ b/src/views/workflow-actions/workflow-actions-search-attributes/workflow-actions-search-attributes.tsx @@ -0,0 +1,331 @@ +'use client'; +import React, { useCallback, useMemo } from 'react'; + +import { Button, SHAPE, SIZE } from 'baseui/button'; +import { DatePicker } from 'baseui/datepicker'; +import { FormControl } from 'baseui/form-control'; +import { Input } from 'baseui/input'; +import { Select } from 'baseui/select'; +import { MdDeleteOutline } from 'react-icons/md'; + +import useStyletronClasses from '@/hooks/use-styletron-classes'; + +import { + INPUT_PLACEHOLDERS_FOR_VALUE_TYPE, + BOOLEAN_OPTIONS, +} from './workflow-actions-search-attributes.constants'; +import { + cssStyles, + overrides, +} from './workflow-actions-search-attributes.styles'; +import type { + Props, + SearchAttributeItem, + SearchAttributeOption, +} from './workflow-actions-search-attributes.types'; + +export default function WorkflowActionsSearchAttributes({ + label = 'Search Attributes', + isLoading = false, + value = [], + onChange, + error, + searchAttributes, + addButtonText = 'Add Search Attribute', +}: Props) { + const { cls } = useStyletronClasses(cssStyles); + + const selectedAttributes = useMemo(() => { + // return attibutes sorted that exists in value in the same order + return value.reduce((acc, item) => { + const attribute = searchAttributes.find((attr) => attr.name === item.key); + if (attribute) { + acc.push(attribute); + } + return acc; + }, [] as SearchAttributeOption[]); + }, [searchAttributes, value]); + + const getFieldError = useCallback( + (index: number): boolean => { + if (typeof error === 'string') return true; + if (Array.isArray(error)) { + return Boolean(error[index]); + } + return false; + }, + [error] + ); + + // Ensure we always show at least one empty row + const displayValue = useMemo((): SearchAttributeItem[] => { + const items = value || []; + + // Always show at least one empty row if no entries exist + if (items.length === 0) { + return [{ key: '', value: '' }]; + } + + return items; + }, [value]); + + const getAttributeOptionsForRow = useCallback( + (currentKey: string) => { + const usedKeys = (value || []) + .map((item) => item.key) + .filter((key) => key && key !== currentKey); + + return searchAttributes + .filter((attr) => !usedKeys.includes(attr.name)) + .map((attr) => ({ + id: attr.name, + label: attr.name, + valueType: attr.valueType, + })); + }, + [searchAttributes, value] + ); + + const unusedSearchAttributes = useMemo(() => { + const usedKeys = (value || []).map((item) => item.key).filter((key) => key); + return searchAttributes.filter((attr) => !usedKeys.includes(attr.name)); + }, [searchAttributes, value]); + + const hasMoreSearchAttributes = unusedSearchAttributes.length > 0; + + const hasCompleteFields = useMemo(() => { + const items = value || []; + // If no items exist, we'll show one empty row - this is not incomplete + if (items.length === 0) { + return false; + } + + // If items exist, ALL must have both key and value + return items.every((item) => { + const hasKey = item.key?.trim(); + const hasValue = + item.value !== '' && item.value !== null && item.value !== undefined; + // Incomplete if either key or value is missing + return hasKey && hasValue; + }); + }, [value]); + + const handleKeyChange = useCallback( + (index: number, newKey: string) => { + const newArray = [...(value || [])]; + newArray[index] = { key: newKey, value: '' }; + onChange([...newArray]); + }, + [value, onChange] + ); + + const handleValueChange = useCallback( + (index: number, newValue: string, valueType?: string) => { + const newArray = [...(value || [])]; + const currentItem = newArray[index]; + + if (!currentItem?.key) return; // Can't set value without key + + // Allow empty string for clearing the field + if (newValue === '') { + newArray[index] = { ...currentItem, value: '' }; + onChange(newArray); + return; + } + + // Convert value based on type + let processedValue: string | number | boolean = newValue; + + if (valueType === 'INDEXED_VALUE_TYPE_INT') { + processedValue = parseInt(newValue, 10); + } else if (valueType === 'INDEXED_VALUE_TYPE_BOOL') { + processedValue = newValue === 'true'; + } + + newArray[index] = { ...currentItem, value: processedValue }; + onChange(newArray); + }, + [value, onChange] + ); + + const handleAddAttribute = useCallback(() => { + onChange([...(value || []), { key: '', value: '' }]); + }, [value, onChange]); + + const handleDeleteAttribute = useCallback( + (index: number) => { + const newArray = [...(value || [])]; + newArray.splice(index, 1); + onChange(newArray); + }, + [value, onChange] + ); + + const renderValueInput = useCallback( + (item: SearchAttributeItem, index: number) => { + const selectedAttribute = selectedAttributes[index]; + const inputError = getFieldError(index); + const inputPlaceholder = + INPUT_PLACEHOLDERS_FOR_VALUE_TYPE[selectedAttribute?.valueType] || + 'Enter value'; + + // Common input value props + const commonInputProps = { + 'aria-label': 'Search attribute value', + placeholder: inputPlaceholder, + size: SIZE.compact, + error: inputError, + overrides: overrides.valueInput, + }; + + switch (selectedAttribute?.valueType) { + case 'INDEXED_VALUE_TYPE_BOOL': + return ( + + handleValueChange( + index, + e.target.value, + selectedAttribute?.valueType + ) + } + /> + ); + + default: + return ( + handleValueChange(index, e.target.value)} + disabled={!selectedAttribute?.valueType} // Disable input if no attribute type is selected + /> + ); + } + }, + [selectedAttributes, getFieldError, handleValueChange] + ); + + return ( + +
+ {displayValue.map((item: SearchAttributeItem, index: number) => { + const isEmptyRow = !item.key && !item.value; + const isLastItem = displayValue.length === 1; + const showDeleteButton = !isEmptyRow || (value || []).length > 0; + const deleteButtonLabel = + isLastItem && !isEmptyRow ? 'Clear attribute' : 'Delete attribute'; + + return ( +
+
+