@douglasneuroinformatics/libui 2.5.3 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/dist/components/Form/NumberField/NumberFieldRadio.d.ts +1 -1
  3. package/dist/components/Form/NumberField/NumberFieldRadio.d.ts.map +1 -1
  4. package/dist/components/Form/NumberField/NumberFieldRadio.js +7 -4
  5. package/dist/components/Form/NumberField/NumberFieldSelect.d.ts +1 -1
  6. package/dist/components/Form/NumberField/NumberFieldSelect.d.ts.map +1 -1
  7. package/dist/components/Form/NumberField/NumberFieldSelect.js +6 -2
  8. package/dist/douglasneuroinformatics-libui-2.6.1.tgz +0 -0
  9. package/dist/i18n.js +1 -1
  10. package/package.json +16 -14
  11. package/src/components/Accordion/Accordion.spec.tsx +37 -0
  12. package/src/components/Accordion/Accordion.stories.tsx +35 -0
  13. package/src/components/ActionDropdown/ActionDropdown.stories.tsx +17 -0
  14. package/src/components/AlertDialog/AlertDialog.stories.tsx +35 -0
  15. package/src/components/ArrowToggle/ArrowToggle.spec.tsx +49 -0
  16. package/src/components/ArrowToggle/ArrowToggle.stories.tsx +27 -0
  17. package/src/components/Avatar/Avatar.spec.tsx +26 -0
  18. package/src/components/Avatar/Avatar.stories.tsx +20 -0
  19. package/src/components/Badge/Badge.spec.tsx +19 -0
  20. package/src/components/Badge/Badge.stories.tsx +13 -0
  21. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +44 -0
  22. package/src/components/Button/Button.spec.tsx +27 -0
  23. package/src/components/Button/Button.stories.tsx +63 -0
  24. package/src/components/Card/Card.spec.tsx +19 -0
  25. package/src/components/Card/Card.stories.tsx +56 -0
  26. package/src/components/Checkbox/Checkbox.spec.tsx +28 -0
  27. package/src/components/Checkbox/Checkbox.stories.tsx +34 -0
  28. package/src/components/ClientTable/ClientTable.stories.tsx +126 -0
  29. package/src/components/Collapsible/Collapsible.stories.tsx +45 -0
  30. package/src/components/Command/Command.stories.tsx +55 -0
  31. package/src/components/ContextMenu/ContextMenu.stories.tsx +61 -0
  32. package/src/components/DatePicker/DatePicker.stories.tsx +15 -0
  33. package/src/components/Dialog/Dialog.stories.tsx +44 -0
  34. package/src/components/Drawer/Drawer.stories.tsx +37 -0
  35. package/src/components/DropdownButton/DropdownButton.stories.tsx +13 -0
  36. package/src/components/DropdownMenu/DropdownMenu.stories.tsx +78 -0
  37. package/src/components/ErrorFallback/ErrorFallback.stories.tsx +9 -0
  38. package/src/components/Form/BooleanField/BooleanField.spec.tsx +35 -0
  39. package/src/components/Form/BooleanField/BooleanField.stories.tsx +49 -0
  40. package/src/components/Form/DateField/DateField.spec.tsx +99 -0
  41. package/src/components/Form/DateField/DateField.stories.tsx +28 -0
  42. package/src/components/Form/Form.stories.tsx +360 -0
  43. package/src/components/Form/Form.test.tsx +119 -0
  44. package/src/components/Form/NumberField/NumberField.stories.tsx +103 -0
  45. package/src/components/Form/NumberField/NumberFieldRadio.tsx +16 -8
  46. package/src/components/Form/NumberField/NumberFieldSelect.tsx +10 -6
  47. package/src/components/Form/SetField/SetField.stories.tsx +63 -0
  48. package/src/components/Form/StringField/StringField.stories.tsx +94 -0
  49. package/src/components/Heading/Heading.stories.tsx +37 -0
  50. package/src/components/HoverCard/HoverCard.stories.tsx +40 -0
  51. package/src/components/Input/Input.spec.tsx +23 -0
  52. package/src/components/Input/Input.stories.tsx +9 -0
  53. package/src/components/Label/Label.spec.tsx +17 -0
  54. package/src/components/Label/Label.stories.tsx +13 -0
  55. package/src/components/LanguageToggle/LanguageToggle.stories.tsx +17 -0
  56. package/src/components/LineGraph/LineGraph.stories.tsx +81 -0
  57. package/src/components/ListboxDropdown/ListboxDropdown.stories.tsx +44 -0
  58. package/src/components/MenuBar/MenuBar.stories.tsx +101 -0
  59. package/src/components/NotificationHub/NotificationHub.stories.tsx +41 -0
  60. package/src/components/Pagination/Pagination.stories.tsx +41 -0
  61. package/src/components/Popover/Popover.spec.tsx +24 -0
  62. package/src/components/Popover/Popover.stories.tsx +72 -0
  63. package/src/components/Progress/Progress.stories.tsx +24 -0
  64. package/src/components/RadioGroup/RadioGroup.spec.tsx +42 -0
  65. package/src/components/RadioGroup/RadioGroup.stories.tsx +35 -0
  66. package/src/components/Resizable/Resizable.stories.tsx +39 -0
  67. package/src/components/ScrollArea/ScrollArea.spec.tsx +19 -0
  68. package/src/components/ScrollArea/ScrollArea.stories.tsx +23 -0
  69. package/src/components/SearchBar/SearchBar.stories.tsx +11 -0
  70. package/src/components/Select/Select.stories.tsx +31 -0
  71. package/src/components/Separator/Separator.spec.tsx +19 -0
  72. package/src/components/Separator/Separator.stories.tsx +30 -0
  73. package/src/components/Sheet/Sheet.stories.tsx +49 -0
  74. package/src/components/Slider/Slider.stories.tsx +9 -0
  75. package/src/components/Spinner/Spinner.stories.tsx +14 -0
  76. package/src/components/SpinnerIcon/SpinnerIcon.stories.tsx +9 -0
  77. package/src/components/Switch/Switch.stories.tsx +21 -0
  78. package/src/components/Table/Table.stories.tsx +88 -0
  79. package/src/components/Tabs/Tabs.stories.tsx +70 -0
  80. package/src/components/TextArea/TextArea.spec.tsx +23 -0
  81. package/src/components/TextArea/TextArea.stories.tsx +13 -0
  82. package/src/components/ThemeToggle/ThemeToggle.stories.tsx +9 -0
  83. package/src/components/Tooltip/Tooltip.stories.tsx +44 -0
  84. package/src/hooks/useDownload.test.ts +66 -0
  85. package/src/hooks/useEventCallback.test.tsx +22 -0
  86. package/src/hooks/useEventListener.test.tsx +120 -0
  87. package/src/hooks/useInterval.test.ts +58 -0
  88. package/src/hooks/useIsomorphicLayoutEffect.test.ts +27 -0
  89. package/src/hooks/useMediaQuery.test.ts +33 -0
  90. package/src/hooks/useNotificationsStore.test.ts +30 -0
  91. package/src/hooks/useOnClickOutside.test.ts +59 -0
  92. package/src/hooks/useSessionStorage.test.ts +186 -0
  93. package/src/hooks/useTheme.test.ts +74 -0
  94. package/src/hooks/useWindowSize.test.ts +57 -0
  95. package/src/i18n.ts +1 -1
  96. package/tailwind.config.cjs +4 -1
@@ -0,0 +1,49 @@
1
+ import React, { useState } from 'react';
2
+
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+
5
+ import { BooleanField } from './BooleanField.js';
6
+
7
+ type Story = StoryObj<typeof BooleanField>;
8
+
9
+ export default { component: BooleanField } as Meta<typeof BooleanField>;
10
+
11
+ export const Radio: Story = {
12
+ decorators: [
13
+ (Story) => {
14
+ const [value, setValue] = useState<boolean | undefined>();
15
+ return (
16
+ <Story
17
+ args={{
18
+ description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.',
19
+ label: 'Boolean Field',
20
+ name: 'boolean',
21
+ setValue,
22
+ value,
23
+ variant: 'radio'
24
+ }}
25
+ />
26
+ );
27
+ }
28
+ ]
29
+ };
30
+
31
+ export const Checkbox: Story = {
32
+ decorators: [
33
+ (Story) => {
34
+ const [value, setValue] = useState<boolean | undefined>(false);
35
+ return (
36
+ <Story
37
+ args={{
38
+ description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.',
39
+ label: 'Boolean Field',
40
+ name: 'boolean',
41
+ setValue,
42
+ value,
43
+ variant: 'checkbox'
44
+ }}
45
+ />
46
+ );
47
+ }
48
+ ]
49
+ };
@@ -0,0 +1,99 @@
1
+ import React from 'react';
2
+
3
+ import { toBasicISOString } from '@douglasneuroinformatics/libjs';
4
+ import { getByText, render, screen } from '@testing-library/react';
5
+ import { userEvent } from '@testing-library/user-event';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import { DateField } from './DateField.js';
9
+
10
+ describe('DateField', () => {
11
+ const setError = vi.fn();
12
+ const setValue = vi.fn();
13
+
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ it('should render an input with an empty string value ', () => {
19
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
20
+ const input: HTMLInputElement = screen.getByRole('textbox');
21
+ expect(input).toBeInTheDocument();
22
+ expect(input.value).toBe('');
23
+ });
24
+ it('should allow typing arbitrary text while focused', async () => {
25
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
26
+ const input: HTMLInputElement = screen.getByRole('textbox');
27
+ await userEvent.type(input, 'foo');
28
+ expect(input.value).toBe('foo');
29
+ });
30
+ it('should not initially show the datepicker', () => {
31
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
32
+ expect(() => screen.getByTestId('datepicker')).toThrow();
33
+ });
34
+ it('should show the datepicker when the input is clicked', async () => {
35
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
36
+ const input = screen.getByRole('textbox');
37
+ expect(() => screen.getByTestId('datepicker')).toThrow();
38
+ await userEvent.click(input);
39
+ expect(screen.getByTestId('datepicker')).toBeInTheDocument();
40
+ });
41
+ it('should discard invalid text when unfocused, once the date picker is closed', async () => {
42
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
43
+ const input: HTMLInputElement = screen.getByRole('textbox');
44
+ await userEvent.type(input, 'foo');
45
+ expect(input.value).toBe('foo');
46
+ const datepicker = screen.getByTestId('datepicker');
47
+ await userEvent.click(datepicker);
48
+ expect(datepicker).toBeInTheDocument();
49
+ await userEvent.type(input, 'foo');
50
+ await userEvent.click(screen.getByText('Date'));
51
+ expect(() => screen.getByTestId('datepicker')).toThrow();
52
+ expect(input.value).toBe('');
53
+ });
54
+ it('should not attempt to set the value if the date is invalid', async () => {
55
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
56
+ const input: HTMLInputElement = screen.getByRole('textbox');
57
+ await userEvent.type(input, 'foo');
58
+ expect(input.value).toBe('foo');
59
+ await userEvent.click(screen.getByText('Date'));
60
+ expect(input.value).toBe('');
61
+ expect(setValue).not.toHaveBeenCalled();
62
+ });
63
+ it('should attempt to set the value if the user enters a correct date', async () => {
64
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
65
+ const input: HTMLInputElement = screen.getByRole('textbox');
66
+ await userEvent.type(input, '2000-01-01');
67
+ await userEvent.click(screen.getByText('Date'));
68
+ expect(input.value).toBe('2000-01-01');
69
+ expect(setValue).toHaveBeenCalledOnce();
70
+ expect(setValue).toHaveBeenCalledWith(new Date('2000-01-01'));
71
+ });
72
+ it('should allow setting the date using the date picker', async () => {
73
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={undefined} />);
74
+ const input: HTMLInputElement = screen.getByRole('textbox');
75
+ let datepicker: HTMLElement;
76
+ let expectedDate: Date;
77
+ let expectedDateString: string;
78
+
79
+ await userEvent.click(input);
80
+ datepicker = screen.getByTestId('datepicker');
81
+ await userEvent.click(getByText(datepicker, '1'));
82
+ expectedDate = new Date(new Date().setDate(1));
83
+ expectedDateString = toBasicISOString(expectedDate);
84
+ expect(toBasicISOString(setValue.mock.lastCall[0])).toBe(expectedDateString);
85
+
86
+ await userEvent.click(input);
87
+ datepicker = screen.getByTestId('datepicker');
88
+ await userEvent.click(getByText(datepicker, '2'));
89
+ expectedDate = new Date(new Date().setDate(2));
90
+ expectedDateString = toBasicISOString(expectedDate);
91
+ expect(toBasicISOString(setValue.mock.lastCall[0])).toBe(expectedDateString);
92
+ });
93
+ it('should render the value provided as a prop', () => {
94
+ const today = new Date();
95
+ render(<DateField label="Date" name="date" setError={setError} setValue={setValue} value={today} />);
96
+ const input: HTMLInputElement = screen.getByRole('textbox');
97
+ expect(input.value).toBe(toBasicISOString(today));
98
+ });
99
+ });
@@ -0,0 +1,28 @@
1
+ import React, { useState } from 'react';
2
+
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+
5
+ import { DateField } from './DateField.js';
6
+
7
+ type Story = StoryObj<typeof DateField>;
8
+
9
+ export default { component: DateField } as Meta<typeof DateField>;
10
+
11
+ export const Default: Story = {
12
+ decorators: [
13
+ (Story) => {
14
+ const [value, setValue] = useState<Date | undefined>();
15
+ return (
16
+ <Story
17
+ args={{
18
+ description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.',
19
+ label: 'Date of Birth',
20
+ name: 'date-of-birth',
21
+ setValue,
22
+ value
23
+ }}
24
+ />
25
+ );
26
+ }
27
+ ]
28
+ };
@@ -0,0 +1,360 @@
1
+ /* eslint-disable perfectionist/sort-objects */
2
+
3
+ import React from 'react';
4
+
5
+ import type { FormFields } from '@douglasneuroinformatics/libui-form-types';
6
+ import type { Meta, StoryObj } from '@storybook/react';
7
+ import { z } from 'zod';
8
+
9
+ import { Heading } from '../Heading/Heading.js';
10
+ import { Form } from './Form.js';
11
+
12
+ const $ExampleFormData = z.object({
13
+ booleanCheck: z.boolean().optional(),
14
+ booleanRadio: z.boolean().optional(),
15
+ recordArray: z.array(
16
+ z.object({
17
+ recordArrayStringInput: z.string().optional(),
18
+ showRecordArrayDynamicField: z.boolean().optional(),
19
+ recordArrayDynamicField: z.string().optional()
20
+ })
21
+ ),
22
+ date: z.date().optional(),
23
+ numberInput: z.number().optional(),
24
+ numberSlider: z.number().optional(),
25
+ numberRecord: z.record(z.number()),
26
+ numberRadio: z.number().min(1).max(5).int().optional(),
27
+ numberSelect: z.number().min(1).max(5).int().optional(),
28
+ stringSelect: z.enum(['a', 'b', 'c']).optional(),
29
+ setListbox: z.set(z.enum(['a', 'b', 'c', 'd'])).optional(),
30
+ setSelect: z.set(z.enum(['a', 'b', 'c', 'd'])).optional(),
31
+ showDynamicField: z.boolean().optional(),
32
+ dynamicField: z.string().optional(),
33
+ stringTextArea: z.string().optional(),
34
+ stringPassword: z.string().optional(),
35
+ stringInput: z.string().optional(),
36
+ stringRadio: z.enum(['a', 'b', 'c']).optional()
37
+ });
38
+ type ExampleFormSchemaType = typeof $ExampleFormData;
39
+ type ExampleFormData = z.TypeOf<typeof $ExampleFormData>;
40
+
41
+ export default {
42
+ component: Form,
43
+ decorators: [
44
+ (Story) => (
45
+ <div className="container mx-auto max-w-5xl">
46
+ <Heading className="my-8 text-center" variant="h1">
47
+ Example Form
48
+ </Heading>
49
+ <Story />
50
+ </div>
51
+ )
52
+ ],
53
+ tags: ['autodocs']
54
+ } as Meta<typeof Form>;
55
+
56
+ const booleanFields: FormFields<Pick<ExampleFormData, 'booleanCheck' | 'booleanRadio'>> = {
57
+ booleanRadio: {
58
+ kind: 'boolean',
59
+ label: 'Radio',
60
+ variant: 'radio'
61
+ },
62
+ booleanCheck: {
63
+ kind: 'boolean',
64
+ label: 'Checkbox',
65
+ variant: 'checkbox'
66
+ }
67
+ };
68
+
69
+ const dateFields: FormFields<Pick<ExampleFormData, 'date'>> = {
70
+ date: {
71
+ kind: 'date',
72
+ label: 'Datepicker (Default)'
73
+ }
74
+ };
75
+
76
+ const numberFields: FormFields<Pick<ExampleFormData, 'numberInput' | 'numberRadio' | 'numberSelect' | 'numberSlider'>> =
77
+ {
78
+ numberInput: {
79
+ description: 'This is a number field',
80
+ kind: 'number',
81
+ label: 'Input',
82
+ max: 10,
83
+ min: 0,
84
+ variant: 'input'
85
+ },
86
+ numberRadio: {
87
+ description: 'This is a number field',
88
+ kind: 'number',
89
+ variant: 'radio',
90
+ label: 'Radio',
91
+ options: {
92
+ 1: 'Very Low',
93
+ 2: 'Low',
94
+ 3: 'Medium',
95
+ 4: 'High',
96
+ 5: 'Very High'
97
+ }
98
+ },
99
+ numberSlider: {
100
+ description: 'This is a number field',
101
+ kind: 'number',
102
+ label: 'Slider',
103
+ max: 10,
104
+ min: 0,
105
+ variant: 'slider'
106
+ },
107
+ numberSelect: {
108
+ description: 'This is a number field',
109
+ kind: 'number',
110
+ variant: 'select',
111
+ label: 'Select',
112
+ options: {
113
+ 1: 'Very Low',
114
+ 2: 'Low',
115
+ 3: 'Medium',
116
+ 4: 'High',
117
+ 5: 'Very High'
118
+ }
119
+ }
120
+ };
121
+
122
+ const setFields: FormFields<Pick<ExampleFormData, 'setListbox' | 'setSelect'>> = {
123
+ setListbox: {
124
+ kind: 'set',
125
+ label: 'Listbox',
126
+ options: {
127
+ a: 'Option A',
128
+ b: 'Option B',
129
+ c: 'Option C',
130
+ d: 'Option D'
131
+ },
132
+ variant: 'listbox'
133
+ },
134
+ setSelect: {
135
+ kind: 'set',
136
+ label: 'Select',
137
+ options: {
138
+ a: 'Option A',
139
+ b: 'Option B',
140
+ c: 'Option C',
141
+ d: 'Option D'
142
+ },
143
+ variant: 'select'
144
+ }
145
+ };
146
+
147
+ const stringFields: FormFields<
148
+ Pick<ExampleFormData, 'stringInput' | 'stringPassword' | 'stringRadio' | 'stringSelect' | 'stringTextArea'>
149
+ > = {
150
+ stringSelect: {
151
+ kind: 'string',
152
+ label: 'Select',
153
+ options: {
154
+ a: 'Option A',
155
+ b: 'Option B',
156
+ c: 'Option C'
157
+ },
158
+ variant: 'select'
159
+ },
160
+ stringTextArea: {
161
+ kind: 'string',
162
+ label: 'Text Area',
163
+ variant: 'textarea'
164
+ },
165
+ stringPassword: {
166
+ kind: 'string',
167
+ label: 'Password',
168
+ variant: 'password'
169
+ },
170
+ stringInput: {
171
+ description: 'This is a string field',
172
+ kind: 'string',
173
+ label: 'Input',
174
+ variant: 'input'
175
+ },
176
+ stringRadio: {
177
+ kind: 'string',
178
+ label: 'Radio',
179
+ options: {
180
+ a: 'Option A',
181
+ b: 'Option B',
182
+ c: 'Option C'
183
+ },
184
+ variant: 'radio'
185
+ }
186
+ };
187
+
188
+ const dynamicFields: FormFields<Pick<ExampleFormData, 'dynamicField' | 'showDynamicField'>> = {
189
+ showDynamicField: {
190
+ kind: 'boolean',
191
+ label: 'Show Dynamic Field?',
192
+ variant: 'checkbox'
193
+ },
194
+ dynamicField: {
195
+ kind: 'dynamic',
196
+ deps: ['showDynamicField'],
197
+ render(data) {
198
+ if (!data?.showDynamicField) {
199
+ return null;
200
+ }
201
+ return {
202
+ kind: 'string',
203
+ label: 'Dynamic Field',
204
+ variant: 'input'
205
+ };
206
+ }
207
+ }
208
+ };
209
+
210
+ const numberRecordFields: FormFields<Pick<ExampleFormData, 'numberRecord'>> = {
211
+ numberRecord: {
212
+ kind: 'number-record',
213
+ label: 'Number Record',
214
+ items: {
215
+ q1: {
216
+ label: 'Question 1'
217
+ },
218
+ q2: {
219
+ label: 'Question 2'
220
+ },
221
+ q3: {
222
+ label: 'Question 3'
223
+ }
224
+ },
225
+ options: {
226
+ 1: 'Very Low',
227
+ 2: 'Low',
228
+ 3: 'Medium',
229
+ 4: 'High',
230
+ 5: 'Very High'
231
+ },
232
+ variant: 'likert'
233
+ }
234
+ };
235
+
236
+ const recordArrayFields: FormFields<Pick<ExampleFormData, 'recordArray'>> = {
237
+ recordArray: {
238
+ description: 'This is a record-array field',
239
+ kind: 'record-array',
240
+ label: 'Record Array',
241
+ fieldset: {
242
+ recordArrayStringInput: {
243
+ kind: 'string',
244
+ label: 'String',
245
+ variant: 'input'
246
+ },
247
+ showRecordArrayDynamicField: {
248
+ kind: 'boolean',
249
+ label: 'Show Dynamic Field',
250
+ variant: 'radio'
251
+ },
252
+ recordArrayDynamicField: {
253
+ kind: 'dynamic',
254
+ render(fieldset) {
255
+ if (!fieldset.showRecordArrayDynamicField) {
256
+ return null;
257
+ }
258
+ return {
259
+ kind: 'string',
260
+ label: 'Dynamic Field',
261
+ variant: 'input'
262
+ };
263
+ }
264
+ }
265
+ }
266
+ }
267
+ };
268
+
269
+ const ungroupedContent = {
270
+ ...booleanFields,
271
+ ...dateFields,
272
+ ...numberFields,
273
+ ...setFields,
274
+ ...stringFields,
275
+ ...dynamicFields,
276
+ ...numberRecordFields,
277
+ ...recordArrayFields
278
+ } as const;
279
+
280
+ export const Grouped: StoryObj<typeof Form<ExampleFormSchemaType>> = {
281
+ args: {
282
+ content: [
283
+ {
284
+ title: 'Boolean',
285
+ description: "The following variants are available for 'boolean' fields",
286
+ fields: booleanFields
287
+ },
288
+ {
289
+ title: 'Date',
290
+ description: `The following variants are available for 'date' fields`,
291
+ fields: dateFields
292
+ },
293
+ {
294
+ title: 'Number',
295
+ description: `The following variants are available for 'number' fields`,
296
+ fields: numberFields
297
+ },
298
+ {
299
+ title: 'Set',
300
+ description: `The following variants are available for 'set' fields`,
301
+ fields: setFields
302
+ },
303
+ {
304
+ title: 'String',
305
+ description: `The following variants are available for 'string' fields`,
306
+ fields: stringFields
307
+ },
308
+ {
309
+ title: 'Dynamic',
310
+ description: `A 'dynamic' field may be used with any data type. For a given data type T, a dynamic field must define a render method that returns either a scalar field for type T, or null to indicate the field should not be shown to the user. The render function receives as its first and only argument the current values in the form, unless it is in the context of a record-array field, in which case it will receive the current value of the fieldset in which it is situated. To optimize performance, a dynamic field must specify an array of dependent fields, a change in which will trigger a rerender of the component.`,
311
+ fields: dynamicFields
312
+ },
313
+ {
314
+ title: 'Number Record',
315
+ fields: numberRecordFields
316
+ },
317
+ {
318
+ title: 'Record Array',
319
+ description: `A 'record-array' field is composed of an array of given set of fields (referred to as a fieldset). A fieldset may include any number of fields, which can be any scalar kind. However, only one fieldset may be defined for a single fieldset array field.`,
320
+ fields: recordArrayFields
321
+ }
322
+ ],
323
+ onSubmit: (data) => {
324
+ alert(JSON.stringify(data, (_key, value) => (value instanceof Set ? [...value] : (value as unknown)), 2));
325
+ },
326
+ validationSchema: $ExampleFormData
327
+ }
328
+ };
329
+
330
+ export const Ungrouped: StoryObj<typeof Form<ExampleFormSchemaType>> = {
331
+ args: {
332
+ content: ungroupedContent,
333
+ onSubmit: (data) => {
334
+ alert(JSON.stringify(data, (_key, value) => (value instanceof Set ? [...value] : (value as unknown)), 2));
335
+ },
336
+ validationSchema: $ExampleFormData
337
+ }
338
+ };
339
+
340
+ export const WithRequiredFields: StoryObj<typeof Form<ExampleFormSchemaType>> = {
341
+ args: {
342
+ content: ungroupedContent,
343
+ onSubmit: (data) => {
344
+ alert(JSON.stringify(data, (_key, value) => (value instanceof Set ? [...value] : (value as unknown)), 2));
345
+ },
346
+ revalidateOnBlur: true,
347
+ validationSchema: $ExampleFormData.required()
348
+ }
349
+ };
350
+
351
+ export const ReadOnly: StoryObj<typeof Form<ExampleFormSchemaType>> = {
352
+ args: {
353
+ content: ungroupedContent,
354
+ readOnly: true,
355
+ onSubmit: (data) => {
356
+ alert(JSON.stringify(data, (_key, value) => (value instanceof Set ? [...value] : (value as unknown)), 2));
357
+ },
358
+ validationSchema: $ExampleFormData
359
+ }
360
+ };
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { userEvent } from '@testing-library/user-event';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { z } from 'zod';
7
+
8
+ import { Form } from './Form.js';
9
+
10
+ describe('Form', () => {
11
+ const testid = 'test-form';
12
+ const onError = vi.fn();
13
+ const onSubmit = vi.fn();
14
+
15
+ describe('conditional rendering', () => {
16
+ beforeEach(() => {
17
+ render(
18
+ <Form
19
+ content={{
20
+ a: {
21
+ kind: 'boolean',
22
+ label: 'Field A',
23
+ variant: 'checkbox'
24
+ },
25
+ b: {
26
+ deps: ['a'],
27
+ kind: 'dynamic',
28
+ render: (data) => {
29
+ if (!data?.a) {
30
+ return null;
31
+ }
32
+ return {
33
+ kind: 'string',
34
+ label: 'Field B',
35
+ variant: 'input'
36
+ };
37
+ }
38
+ }
39
+ }}
40
+ data-testid={testid}
41
+ validationSchema={z
42
+ .object({
43
+ a: z.boolean().default(false),
44
+ b: z.string().optional()
45
+ })
46
+ .refine((data) => !data.a || data.b)}
47
+ onError={onError}
48
+ onSubmit={onSubmit}
49
+ />
50
+ );
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.clearAllMocks();
55
+ });
56
+
57
+ it('should render', () => {
58
+ expect(screen.getByTestId(testid)).toBeInTheDocument();
59
+ });
60
+
61
+ it('should initially display field a, but not field b', () => {
62
+ expect(screen.getByLabelText('Field A')).toBeInTheDocument();
63
+ expect(() => screen.getByLabelText('Field B')).toThrow();
64
+ });
65
+
66
+ it('should display field b after checking field a, then remove field b when a is unchecked', () => {
67
+ const a = screen.getByLabelText('Field A');
68
+ expect(a).toHaveAttribute('data-state', 'unchecked');
69
+ fireEvent.click(a);
70
+ expect(a).toHaveAttribute('data-state', 'checked');
71
+ expect(screen.getByLabelText('Field B')).toBeInTheDocument();
72
+ fireEvent.click(a);
73
+ expect(() => screen.getByLabelText('Field B')).toThrow();
74
+ });
75
+
76
+ it('should allow submitting the form if field A is not checked', async () => {
77
+ fireEvent.submit(screen.getByTestId(testid));
78
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
79
+ expect(onError).not.toBeCalled();
80
+ });
81
+
82
+ it('should not allow submitting the form if field A has been checked', async () => {
83
+ fireEvent.click(screen.getByLabelText('Field A'));
84
+ fireEvent.submit(screen.getByTestId(testid));
85
+ await waitFor(() => expect(onError).toHaveBeenCalledOnce());
86
+ expect(onSubmit).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('should allow submitting the form if field A has been checked and then unchecked', async () => {
90
+ const a = screen.getByLabelText('Field A');
91
+ expect(a).toHaveAttribute('data-state', 'unchecked');
92
+ fireEvent.click(a);
93
+ expect(a).toHaveAttribute('data-state', 'checked');
94
+ fireEvent.click(a);
95
+ expect(a).toHaveAttribute('data-state', 'unchecked');
96
+ fireEvent.submit(screen.getByTestId(testid));
97
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
98
+ expect(onError).not.toBeCalled();
99
+ });
100
+
101
+ it('should delete the data associated when field b when it is removed from the DOM', async () => {
102
+ const a = screen.getByLabelText('Field A');
103
+ fireEvent.click(a);
104
+ let b: HTMLInputElement = screen.getByLabelText('Field B');
105
+ await userEvent.type(b, 'TEST');
106
+ expect(b.value).toBe('TEST');
107
+ fireEvent.click(a);
108
+ expect(b).not.toBeInTheDocument();
109
+ fireEvent.click(a);
110
+ b = screen.getByLabelText('Field B');
111
+ expect(b).toBeInTheDocument();
112
+ expect(b.value).toBe('');
113
+ fireEvent.click(a);
114
+ fireEvent.submit(screen.getByTestId(testid));
115
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
116
+ expect(onSubmit.mock.lastCall[0].b).toBeUndefined();
117
+ });
118
+ });
119
+ });