Skip to content

Commit f7afe0b

Browse files
fix(EnumControl, DateTimeControl): remove manual AntD statemanagement (#143)
* Remove form.setFieldValue call from EnumControl * Remove form.setFieldValue call from DateTimeControl * Backfill tests * Add stories * Move Col use outside of Form.Item * Add stories * Remove nested Col * Formatting * Revert Col changes in NumericControl * Remove unneeded type narrowing * Minor readability improvement
1 parent b37e5d6 commit f7afe0b

8 files changed

Lines changed: 333 additions & 66 deletions

File tree

src/controls/DateTimeControl.tsx

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { memo, useEffect } from "react"
1+
import { memo } from "react"
22
import type { ControlProps as JSFControlProps } from "@jsonforms/core"
33
import { withJsonFormsControlProps } from "@jsonforms/react"
4-
import { DatePicker, Form } from "antd"
4+
import { Col, DatePicker, Form } from "antd"
55
import type { Rule } from "antd/es/form"
66
import dayjs from "dayjs"
77

@@ -10,6 +10,7 @@ import {
1010
DateTimeControlOptions,
1111
isDateTimeControlOptions,
1212
} from "../ui-schema"
13+
import { useNestedAntDFormContext } from "../hooks/useNestedAntDFormContext"
1314

1415
type ControlProps = Omit<JSFControlProps, "uischema"> & {
1516
uischema: ControlUISchema<unknown> | JSFControlProps["uischema"]
@@ -22,19 +23,6 @@ function getOverrides(options: unknown): DateTimeControlOptions {
2223
return {}
2324
}
2425

25-
function getInitialValue(
26-
data: unknown,
27-
schemaDefault: unknown,
28-
): string | undefined {
29-
if (typeof data === "string" && data !== "") {
30-
return data
31-
}
32-
if (typeof schemaDefault === "string" && schemaDefault !== "") {
33-
return schemaDefault
34-
}
35-
return undefined
36-
}
37-
3826
export function DateTimeControl({
3927
handleChange,
4028
path,
@@ -46,14 +34,13 @@ export function DateTimeControl({
4634
visible,
4735
data,
4836
}: ControlProps) {
49-
const setInitialValue = createDateTimeInitialValueSetter(handleChange, path)
50-
const form = Form.useFormInstance()
51-
useEffect(() => {
52-
form.setFieldValue(
53-
path,
54-
setInitialValue(getInitialValue(data, schema.default)),
55-
)
56-
}, [data, form, path, schema.default, setInitialValue])
37+
const nestedAntdData = useNestedAntDFormContext()
38+
const name = nestedAntdData
39+
? nestedAntdData.index !== undefined
40+
? [nestedAntdData.path, nestedAntdData.index]
41+
: nestedAntdData.path
42+
: path
43+
5744
if (!visible) return null
5845

5946
const rules: Rule[] = [{ required, message: `${label} is required` }]
@@ -64,36 +51,38 @@ export function DateTimeControl({
6451
const overrides = getOverrides(uischema.options)
6552

6653
return (
67-
<Form.Item
68-
label={label}
69-
id={id}
70-
name={path}
71-
required={required}
72-
validateTrigger={["onBlur"]}
73-
rules={rules}
74-
{...formItemProps}
75-
>
76-
<DatePicker
77-
format={"YYYY-MM-DDTHH:mm:ssZ"}
78-
onChange={(_, dateString) => handleChange(path, dateString)}
79-
{...overrides}
80-
/>
81-
</Form.Item>
54+
<Col>
55+
<Form.Item
56+
label={label}
57+
id={id}
58+
name={name}
59+
required={required}
60+
validateTrigger={["onBlur"]}
61+
rules={rules}
62+
initialValue={getInitialValue(data, schema.default)}
63+
{...formItemProps}
64+
>
65+
<DatePicker
66+
format={"YYYY-MM-DDTHH:mm:ssZ"}
67+
onChange={(_, dateString) => handleChange(path, dateString)}
68+
{...overrides}
69+
/>
70+
</Form.Item>
71+
</Col>
8272
)
8373
}
8474

85-
/**
86-
* Creates an initial value setter for DateTimeControl that coerces string values to dayjs objects
87-
*/
88-
function createDateTimeInitialValueSetter(
89-
handleChange: (path: string, value: string | undefined) => void,
90-
path: string,
91-
) {
92-
return (value: string | undefined) => {
93-
const coercedValue = value ? dayjs(value) : value
94-
handleChange(path, value)
95-
return coercedValue
75+
function getInitialValue(
76+
data: unknown,
77+
schemaDefault: unknown,
78+
): dayjs.Dayjs | undefined {
79+
if (typeof data === "string" && data !== "") {
80+
return dayjs(data)
81+
}
82+
if (typeof schemaDefault === "string" && schemaDefault !== "") {
83+
return dayjs(schemaDefault)
9684
}
85+
return undefined
9786
}
9887

9988
export const DateTimeRenderer = withJsonFormsControlProps(memo(DateTimeControl))

src/controls/EnumControl.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useEffect } from "react"
21
import type { ControlProps as JSFControlProps } from "@jsonforms/core"
32
import { Form, Select, Segmented, Radio, Col } from "antd"
43
import type { Rule } from "antd/es/form"
54
import { EnumControlOptions, ControlUISchema } from "../ui-schema"
65
import { withJsonFormsControlProps } from "@jsonforms/react"
6+
import { useNestedAntDFormContext } from "../hooks/useNestedAntDFormContext"
77

88
type ControlProps = Omit<JSFControlProps, "uischema"> & {
99
uischema: ControlUISchema<unknown> | JSFControlProps["uischema"]
@@ -16,11 +16,7 @@ const isStringOrNumberArray = (arr: unknown[]): boolean => {
1616
}
1717

1818
export const EnumControl = (props: ControlProps) => {
19-
const form = Form.useFormInstance()
20-
21-
useEffect(() => {
22-
form.setFieldValue(props.path, props.data ?? props.schema.default)
23-
}, [props.data, form, props.path, props.schema.default])
19+
const nestedAntdData = useNestedAntDFormContext()
2420

2521
if (!props.visible) return null
2622

@@ -34,6 +30,11 @@ export const EnumControl = (props: ControlProps) => {
3430
const defaultValue =
3531
(props.data as unknown) ?? (props.schema.default as unknown)
3632

33+
const name =
34+
nestedAntdData?.index !== undefined
35+
? [nestedAntdData.path, nestedAntdData.index]
36+
: (nestedAntdData?.path ?? props.path)
37+
3738
const appliedUiSchemaOptions = props.uischema.options as EnumControlOptions
3839

3940
const enumValue = props.schema.enum
@@ -94,17 +95,19 @@ export const EnumControl = (props: ControlProps) => {
9495
}
9596

9697
return (
97-
<Form.Item
98-
label={props.label}
99-
id={props.id}
100-
name={props.path}
101-
required={props.required}
102-
initialValue={defaultValue}
103-
rules={rules}
104-
{...formItemProps}
105-
>
106-
<Col>{selector}</Col>
107-
</Form.Item>
98+
<Col>
99+
<Form.Item
100+
label={props.label}
101+
id={props.id}
102+
name={name}
103+
required={props.required}
104+
initialValue={defaultValue}
105+
rules={rules}
106+
{...formItemProps}
107+
>
108+
{selector}
109+
</Form.Item>
110+
</Col>
108111
)
109112
}
110113

src/controls/ObjectArrayControl.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
arrayControlSortableWithIconsUISchema,
1616
objectArrayWithNumericFieldControlJsonSchema,
1717
peopleArrayControlUISchema,
18+
objectArrayWithEnumFieldControlJsonSchema,
19+
itemsArrayControlUISchema,
1820
} from "../testSchemas/arraySchema"
1921
import { UISchema } from "../ui-schema"
2022
import { JSONFormData } from "../common/schema-derived-types"
@@ -167,6 +169,70 @@ describe("ObjectArrayControl", () => {
167169
screen.getByDisplayValue("35")
168170
})
169171

172+
test("correctly removes from the object list with enum field and remove button", async () => {
173+
let data: JSONFormData<typeof objectArrayWithEnumFieldControlJsonSchema> = {
174+
items: [],
175+
}
176+
const user = userEvent.setup()
177+
render({
178+
schema: objectArrayWithEnumFieldControlJsonSchema,
179+
uischema: itemsArrayControlUISchema,
180+
data: data,
181+
onChange: (result) => {
182+
data = result.data as JSONFormData<
183+
typeof objectArrayWithEnumFieldControlJsonSchema
184+
>
185+
},
186+
})
187+
188+
const addButton = await screen.findByRole("button", { name: "Add Items" })
189+
await user.click(addButton)
190+
await user.click(addButton)
191+
192+
const statusFields = await screen.findAllByLabelText("Status")
193+
expect(statusFields).toHaveLength(3)
194+
195+
await user.click(statusFields[0])
196+
await user.click(screen.getByTitle("foo"))
197+
198+
await user.click(statusFields[1])
199+
await user.click(screen.getAllByTitle("bar")[1])
200+
201+
await user.click(statusFields[2])
202+
await user.click(screen.getAllByTitle("baz")[2])
203+
204+
await waitFor(() => {
205+
expect(data.items).toEqual([
206+
{ status: "foo" },
207+
{ status: "bar" },
208+
{ status: "baz" },
209+
])
210+
})
211+
212+
const removeButtons = await screen.findAllByRole("button", {
213+
name: "Delete",
214+
})
215+
expect(removeButtons).toHaveLength(3)
216+
217+
// Remove the second item (index 1) which is "bar"
218+
await user.click(removeButtons[1])
219+
220+
await waitFor(() => {
221+
expect(data).toEqual({
222+
items: [{ status: "foo" }, { status: "baz" }],
223+
})
224+
})
225+
226+
expect(
227+
screen.getAllByRole("button", {
228+
name: "Delete",
229+
}),
230+
).toHaveLength(2)
231+
232+
const remainingStatusFields = screen.getAllByLabelText("Status")
233+
expect(remainingStatusFields).toHaveLength(2)
234+
})
235+
170236
test("renders with overwritten icons and does not allow overwriting onClick", async () => {
171237
const user = userEvent.setup()
172238
let data: JSONFormData<typeof objectArrayControlJsonSchema> = {

src/controls/PrimitiveArrayControl.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
stringArrayControlJsonSchemaWithRequired,
1010
stringArrayControlJsonSchemaWithTitle,
1111
numberArrayControlJsonSchema,
12+
enumArrayControlJsonSchema,
1213
arrayInsideCombinatorSchema,
1314
arrayControlSortableUISchema,
1415
arrayControlSortableWithIconsUISchema,
@@ -145,6 +146,60 @@ describe("PrimitiveArrayControl", () => {
145146
screen.getByDisplayValue("3")
146147
})
147148

149+
test("correctly removes from the enum list with remove button", async () => {
150+
let data: JSONFormData<typeof enumArrayControlJsonSchema> = {
151+
assets: [],
152+
}
153+
const user = userEvent.setup()
154+
render({
155+
schema: enumArrayControlJsonSchema,
156+
uischema: arrayControlUISchema,
157+
data: data,
158+
onChange: (result) => {
159+
data = result.data as JSONFormData<typeof enumArrayControlJsonSchema>
160+
},
161+
})
162+
const addButton = await screen.findByRole("button", { name: "Add Assets" })
163+
// Add 3 items
164+
await user.click(addButton)
165+
await user.click(addButton)
166+
await user.click(addButton)
167+
168+
const comboboxes = await screen.findAllByRole("combobox")
169+
expect(comboboxes).toHaveLength(3)
170+
171+
// Helper to select an option from a combobox
172+
const selectOption = async (index: number, title: string) => {
173+
await user.click(comboboxes[index])
174+
const options = await screen.findAllByTitle(title)
175+
await user.click(options[options.length - 1])
176+
}
177+
178+
await selectOption(0, "foo")
179+
await selectOption(1, "bar")
180+
await selectOption(2, "baz")
181+
182+
await waitFor(() => {
183+
expect(data.assets).toEqual(["foo", "bar", "baz"])
184+
})
185+
186+
const removeButtons = await screen.findAllByRole("button", {
187+
name: "Delete",
188+
})
189+
expect(removeButtons).toHaveLength(3)
190+
191+
// Remove the second item ("bar")
192+
await user.click(removeButtons[1])
193+
194+
// Verify the correct items remain in the data
195+
await waitFor(() => {
196+
expect(data.assets).toEqual(["foo", "baz"])
197+
})
198+
199+
const selectedItems = screen.getAllByRole("combobox")
200+
expect(selectedItems).toHaveLength(2)
201+
})
202+
148203
test("renders with title", async () => {
149204
const data = { assets: ["apple"] }
150205
render({

src/controls/combinators/AnyOfControl.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
AnyOfWithDefaultsUISchemaRegistryEntries,
1010
SplitterUISchemaRegistryEntry,
1111
splitterAnyOfJsonSchema,
12+
anyOfEnumSchema,
1213
} from "../../testSchemas/anyOfSchema"
1314
import { UISchema } from "../../ui-schema"
1415
import { JSONFormData } from "../../common/schema-derived-types"
@@ -106,6 +107,38 @@ describe("AnyOf control", () => {
106107
column = screen.getByLabelText("Column Name")
107108
expect(column).toHaveValue("abc")
108109
})
110+
test("AnyOf Control persists state with enums", async () => {
111+
render({ schema: anyOfEnumSchema })
112+
113+
// Start with Option A (default or first option)
114+
await screen.findByText("Option A")
115+
116+
// Select 'active' status
117+
const statusSelect = screen.getByRole("combobox")
118+
await userEvent.click(statusSelect)
119+
await userEvent.click(screen.getByTitle("active"))
120+
121+
// Switch to Option B
122+
await userEvent.click(screen.getByLabelText("Option B"))
123+
124+
// Verify Option B is active
125+
const optionBTab = screen.getByLabelText("Option B")
126+
// eslint-disable-next-line testing-library/no-node-access
127+
expect(optionBTab.closest("label")).toHaveClass("ant-radio-wrapper-checked")
128+
129+
// Select 'pending' status
130+
const statusSelectB = screen.getByRole("combobox")
131+
await userEvent.click(statusSelectB)
132+
await userEvent.click(screen.getByTitle("pending"))
133+
134+
// Switch back to Option A
135+
await userEvent.click(screen.getByLabelText("Option A"))
136+
137+
// Verify Option A is active
138+
const optionATab = screen.getByLabelText("Option A")
139+
// eslint-disable-next-line testing-library/no-node-access
140+
expect(optionATab.closest("label")).toHaveClass("ant-radio-wrapper-checked")
141+
})
109142
test("provides a default value for a required combinator", async () => {
110143
let data: JSONFormData<typeof AnyOfWithDefaultsSchema> = {}
111144
const onChange = (result: {

0 commit comments

Comments
 (0)