Skip to content

Commit a2df99b

Browse files
authored
feat: Search attribute component (#1045)
* Search attribute component Signed-off-by: Assem Hafez <[email protected]> * Fix type check Signed-off-by: Assem Hafez <[email protected]> * revert errors to have key and value Signed-off-by: Assem Hafez <[email protected]> * Address the nits * change button label * add parsing for double Signed-off-by: Assem Hafez <[email protected]> --------- Signed-off-by: Assem Hafez <[email protected]>
1 parent bee89b2 commit a2df99b

File tree

5 files changed

+806
-0
lines changed

5 files changed

+806
-0
lines changed
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
import React from 'react';
2+
3+
import { render, screen, userEvent, waitFor } from '@/test-utils/rtl';
4+
5+
import { IndexedValueType } from '@/__generated__/proto-ts/uber/cadence/api/v1/IndexedValueType';
6+
7+
import SearchAttributesInput from '../workflow-actions-search-attributes';
8+
import type {
9+
Props,
10+
SearchAttributeOption,
11+
} from '../workflow-actions-search-attributes.types';
12+
13+
const mockSearchAttributes: Array<SearchAttributeOption> = [
14+
{
15+
name: 'WorkflowType',
16+
valueType: IndexedValueType.INDEXED_VALUE_TYPE_STRING,
17+
},
18+
{
19+
name: 'StartTime',
20+
valueType: IndexedValueType.INDEXED_VALUE_TYPE_DATETIME,
21+
},
22+
{
23+
name: 'CustomBoolField',
24+
valueType: IndexedValueType.INDEXED_VALUE_TYPE_BOOL,
25+
},
26+
{
27+
name: 'CustomNumberField',
28+
valueType: IndexedValueType.INDEXED_VALUE_TYPE_DOUBLE,
29+
},
30+
];
31+
32+
describe(SearchAttributesInput.name, () => {
33+
it('should render with default empty state', () => {
34+
setup();
35+
36+
expect(screen.getByText('Search Attributes')).toBeInTheDocument();
37+
38+
const keySelects = screen.getAllByRole('combobox', {
39+
name: 'Search attribute key',
40+
});
41+
expect(keySelects).toHaveLength(1);
42+
expect(keySelects[0]).toHaveValue('');
43+
44+
const valueInputs = screen.getAllByRole('textbox', {
45+
name: 'Search attribute value',
46+
});
47+
expect(valueInputs).toHaveLength(1);
48+
expect(valueInputs[0]).toHaveValue('');
49+
50+
expect(screen.getByText('Add search attribute')).toBeInTheDocument();
51+
});
52+
53+
it('should render with custom label and button text', () => {
54+
setup({
55+
label: 'Custom Search Attributes',
56+
addButtonText: 'Add Custom Attribute',
57+
});
58+
59+
expect(screen.getByText('Custom Search Attributes')).toBeInTheDocument();
60+
expect(screen.getByText('Add Custom Attribute')).toBeInTheDocument();
61+
});
62+
63+
it('should render existing values', () => {
64+
setup({
65+
value: [
66+
{ key: 'WorkflowType', value: 'MyWorkflow' },
67+
{ key: 'StartTime', value: '2023-01-01T00:00:00Z' },
68+
],
69+
});
70+
71+
const allComboboxes = screen.getAllByRole('combobox');
72+
expect(allComboboxes).toHaveLength(2);
73+
74+
expect(screen.getByDisplayValue('MyWorkflow')).toBeInTheDocument();
75+
76+
expect(screen.getByDisplayValue('2023–01–01 00:00:00')).toBeInTheDocument();
77+
});
78+
79+
it('should allow selecting an attribute key', async () => {
80+
const { user, mockOnChange } = setup();
81+
82+
await user.click(
83+
screen.getByRole('combobox', { name: 'Search attribute key' })
84+
);
85+
86+
await user.click(screen.getByText('WorkflowType'));
87+
88+
expect(mockOnChange).toHaveBeenCalledWith([
89+
{ key: 'WorkflowType', value: '' },
90+
]);
91+
});
92+
93+
it('should update input type based on selected attribute type', () => {
94+
setup({
95+
value: [
96+
{ key: 'WorkflowType', value: '' },
97+
{ key: 'StartTime', value: '' },
98+
{ key: 'CustomBoolField', value: '' },
99+
{ key: 'CustomNumberField', value: '' },
100+
],
101+
});
102+
103+
const stringInput = screen.getByPlaceholderText('Enter value');
104+
expect(stringInput).toHaveAttribute('type', 'text');
105+
106+
expect(screen.getByText('Select value')).toBeInTheDocument();
107+
108+
const numberInput = screen.getByPlaceholderText('Enter number');
109+
expect(numberInput).toHaveAttribute('type', 'number');
110+
111+
expect(
112+
screen.getByPlaceholderText('Select date and time')
113+
).toBeInTheDocument();
114+
});
115+
116+
it('should allow entering values', async () => {
117+
const { user, mockOnChange } = setup({
118+
value: [{ key: 'WorkflowType', value: '' }],
119+
});
120+
121+
const valueInput = screen.getByRole('textbox', {
122+
name: 'Search attribute value',
123+
});
124+
await user.clear(valueInput);
125+
await user.type(valueInput, 'MyWorkflow');
126+
127+
// Check that onChange was called (it will be called for each character)
128+
expect(mockOnChange).toHaveBeenCalled();
129+
// Verify that the last call included the key we expect
130+
const calls = mockOnChange.mock.calls;
131+
expect(calls[calls.length - 1][0][0].key).toBe('WorkflowType');
132+
expect(calls.length).toBeGreaterThan(0);
133+
});
134+
135+
it('should disable value input when no key is selected', () => {
136+
setup();
137+
138+
const valueInput = screen.getByRole('textbox', {
139+
name: 'Search attribute value',
140+
});
141+
expect(valueInput).toBeDisabled();
142+
});
143+
144+
it('should render boolean input as dropdown', async () => {
145+
const { user } = setup({
146+
value: [{ key: 'CustomBoolField', value: '' }],
147+
});
148+
149+
const booleanSelect = screen.getByText('Select value');
150+
expect(booleanSelect).toBeInTheDocument();
151+
152+
// Click on the dropdown
153+
await user.click(booleanSelect);
154+
155+
// Should see TRUE/FALSE options
156+
expect(screen.getByText('TRUE')).toBeInTheDocument();
157+
expect(screen.getByText('FALSE')).toBeInTheDocument();
158+
});
159+
160+
it('should render timestamp input as date picker', () => {
161+
setup({
162+
value: [{ key: 'StartTime', value: '' }],
163+
});
164+
165+
// DatePicker should be present (check for the placeholder text it uses)
166+
expect(
167+
screen.getByPlaceholderText('Select date and time')
168+
).toBeInTheDocument();
169+
});
170+
171+
it('should reset value when key changes', async () => {
172+
const { user, mockOnChange } = setup({
173+
value: [{ key: 'WorkflowType', value: 'ExistingValue' }],
174+
});
175+
176+
// Change the key by clicking on the combobox and selecting a different option
177+
await user.click(screen.getByRole('combobox'));
178+
await user.click(screen.getByText('StartTime'));
179+
180+
expect(mockOnChange).toHaveBeenCalledWith([
181+
{ key: 'StartTime', value: '' },
182+
]);
183+
});
184+
185+
it('should add new attribute when add button is clicked', async () => {
186+
const { user, mockOnChange } = setup({
187+
value: [{ key: 'WorkflowType', value: 'MyWorkflow' }],
188+
});
189+
190+
await user.click(screen.getByText('Add search attribute'));
191+
192+
expect(mockOnChange).toHaveBeenCalledWith([
193+
{ key: 'WorkflowType', value: 'MyWorkflow' },
194+
{ key: '', value: '' },
195+
]);
196+
});
197+
198+
it('should disable add button when current attributes are incomplete', () => {
199+
setup({
200+
value: [{ key: 'WorkflowType', value: '' }],
201+
});
202+
203+
expect(screen.getByText('Add search attribute')).toBeDisabled();
204+
});
205+
206+
it('should delete attribute when delete button is clicked', async () => {
207+
const { user, mockOnChange } = setup({
208+
value: [
209+
{ key: 'WorkflowType', value: 'MyWorkflow' },
210+
{ key: 'StartTime', value: '2023-01-01T00:00:00Z' },
211+
],
212+
});
213+
214+
const deleteButtons = screen.getAllByLabelText('Delete attribute');
215+
await user.click(deleteButtons[0]);
216+
217+
expect(mockOnChange).toHaveBeenCalledWith([
218+
{ key: 'StartTime', value: '2023-01-01T00:00:00Z' },
219+
]);
220+
});
221+
222+
it('should clear attribute when delete button is clicked on single empty attribute', async () => {
223+
const { user, mockOnChange } = setup({
224+
value: [{ key: 'WorkflowType', value: 'SomeValue' }],
225+
});
226+
227+
const deleteButton = screen.getByLabelText('Clear attribute');
228+
await user.click(deleteButton);
229+
230+
expect(mockOnChange).toHaveBeenCalledWith([]);
231+
});
232+
233+
it('should disable delete button when only empty attribute exists', () => {
234+
setup();
235+
236+
expect(screen.getByLabelText('Delete attribute')).toBeDisabled();
237+
});
238+
239+
it('should display global error state on fields', () => {
240+
setup({
241+
error: 'Global error message',
242+
});
243+
244+
// Global errors apply error state to all fields (aria-invalid="true")
245+
const valueInput = screen.getByRole('textbox', {
246+
name: 'Search attribute value',
247+
});
248+
249+
expect(valueInput).toHaveAttribute('aria-invalid', 'true');
250+
});
251+
252+
it('should display field-specific error state', () => {
253+
setup({
254+
value: [
255+
{ key: 'WorkflowType', value: 'test' },
256+
{ key: 'StartTime', value: '' },
257+
],
258+
error: [
259+
undefined, // First field has no error
260+
{
261+
value: 'Value is required',
262+
},
263+
],
264+
});
265+
266+
const allValueInputs = screen.getAllByRole('textbox', {
267+
name: 'Search attribute value',
268+
});
269+
270+
expect(allValueInputs[0]).toHaveAttribute('aria-invalid', 'false');
271+
expect(allValueInputs[1]).toHaveAttribute('aria-invalid', 'true');
272+
});
273+
274+
it('should be searchable in the key select dropdown', async () => {
275+
const { user } = setup();
276+
277+
const keySelect = screen.getByRole('combobox', {
278+
name: 'Search attribute key',
279+
});
280+
await user.click(keySelect);
281+
await user.type(keySelect, 'Work');
282+
283+
expect(screen.getByText('WorkflowType')).toBeInTheDocument();
284+
});
285+
286+
it('should filter out already selected attributes from suggestions', async () => {
287+
const { user } = setup({
288+
value: [
289+
{ key: 'WorkflowType', value: 'MyWorkflow' },
290+
{ key: 'StartTime', value: '2023-01-01T00:00:00Z' },
291+
{ key: '', value: '' }, // Empty row
292+
],
293+
});
294+
295+
// Get the last (empty) key select and click it to open dropdown
296+
const allKeySelects = screen.getAllByRole('combobox', {
297+
name: /Search attribute key/i,
298+
});
299+
const emptyKeySelect = allKeySelects[allKeySelects.length - 1];
300+
await user.click(emptyKeySelect);
301+
302+
// Wait for dropdown to open and get all options
303+
await waitFor(() => {
304+
const listbox = screen.getByRole('listbox');
305+
expect(listbox).toBeInTheDocument();
306+
});
307+
308+
// Get all options in the dropdown
309+
const options = screen.getAllByRole('option');
310+
const optionTexts = options.map((opt) => opt.textContent);
311+
312+
// The empty row should show only unselected attributes
313+
// (filtering out 'WorkflowType' and 'StartTime' which are already selected)
314+
expect(optionTexts).not.toContain('WorkflowType');
315+
expect(optionTexts).not.toContain('StartTime');
316+
expect(optionTexts).toContain('CustomBoolField');
317+
expect(optionTexts).toContain('CustomNumberField');
318+
expect(options).toHaveLength(2); // Only 2 unselected attributes
319+
});
320+
321+
it('should disable add button when all attributes are selected', () => {
322+
setup({
323+
value: [
324+
{ key: 'WorkflowType', value: 'MyWorkflow' },
325+
{ key: 'StartTime', value: '2023-01-01T00:00:00Z' },
326+
{ key: 'CustomBoolField', value: 'TRUE' },
327+
{ key: 'CustomNumberField', value: '123' },
328+
],
329+
});
330+
331+
// Add button should be disabled since all 4 available attributes are selected
332+
expect(screen.getByText('Add search attribute')).toBeDisabled();
333+
});
334+
335+
it('should enable add button when not all attributes are selected and current fields are complete', () => {
336+
setup({
337+
value: [
338+
{ key: 'WorkflowType', value: 'MyWorkflow' },
339+
{ key: 'StartTime', value: '2023-01-01T00:00:00Z' },
340+
],
341+
});
342+
343+
// Add button should be enabled since only 2 out of 4 available attributes are selected
344+
expect(screen.getByText('Add search attribute')).not.toBeDisabled();
345+
});
346+
});
347+
348+
function setup(props: Partial<Props> = {}) {
349+
const mockOnChange = jest.fn();
350+
const user = userEvent.setup();
351+
352+
const defaultProps: Props = {
353+
onChange: mockOnChange,
354+
searchAttributes: mockSearchAttributes,
355+
...props,
356+
};
357+
358+
render(<SearchAttributesInput {...defaultProps} />);
359+
360+
return {
361+
user,
362+
mockOnChange,
363+
};
364+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { type AttributeValueType } from './workflow-actions-search-attributes.types';
2+
3+
export const BOOLEAN_OPTIONS = [
4+
{ id: 'true', label: 'TRUE' },
5+
{ id: 'false', label: 'FALSE' },
6+
] as const;
7+
8+
export const INPUT_PLACEHOLDERS_FOR_VALUE_TYPE: Record<
9+
AttributeValueType,
10+
string
11+
> = {
12+
INDEXED_VALUE_TYPE_DATETIME: 'Select date and time',
13+
INDEXED_VALUE_TYPE_BOOL: 'Select value',
14+
INDEXED_VALUE_TYPE_INT: 'Enter integer',
15+
INDEXED_VALUE_TYPE_DOUBLE: 'Enter number',
16+
INDEXED_VALUE_TYPE_STRING: 'Enter value',
17+
INDEXED_VALUE_TYPE_KEYWORD: 'Enter value',
18+
INDEXED_VALUE_TYPE_INVALID: 'Enter value',
19+
};
20+
21+
export const DATE_TIME_FORMAT = 'yyyy-MM-dd HH:mm:ss';

0 commit comments

Comments
 (0)