Skip to content

Commit c616a3b

Browse files
authored
fix: Inherit form layout from parent and preview layout changes in storybook (#160)
* Inherit form layout from parent and preview layout changes in storybook * Revert formatting change * fix: prettier formatting in VerticalLayout Made-with: Cursor * fix: inherit parent form layout via FormContext Keep the <Form> wrapper (required for Form.List in array controls) but read the parent form's layout from antd's FormContext and forward it, so consumers' <Form layout="vertical"> propagates through layout renderers. Made-with: Cursor * fix: inherit parent form layout in HorizontalLayout Apply the same FormContext layout inheritance pattern used in VerticalLayout, so both layout renderers consistently forward the parent form's layout. Made-with: Cursor * fix: detect parent form layout via DOM instead of FormContext import The previous approach imported FormContext from antd/es/form/context to read the parent form's layout. This broke across CJS/ESM module boundaries in consuming apps due to context identity mismatch. Replace with a DOM-based approach: a hidden probe element detects the parent <form> element's CSS class (ant-form-vertical, ant-form-inline) and passes the detected layout to the intermediate Form wrapper. Made-with: Cursor * refactor: simplify useParentFormLayout hook and improve docs Rename probeRef → ref, flatten guard clauses, and expand the JSDoc to explain *why* we use DOM-based detection (CJS/ESM module identity mismatch with antd's internal FormContext). Made-with: Cursor * fix: make Storybook layout arg reactive and reorder it below uiSchema - Remove empty dependency array from useParentFormLayout so it re-reads the parent form's CSS class after every render, picking up layout changes from Storybook args. - Add else branch to reset layout to undefined when switching back to the default "horizontal". - Move layout prop above uiSchemaRegistryEntries in the Props type so it appears just below uiSchema in Storybook controls. Made-with: Cursor * docs: note Form.List coupling in useParentFormLayout JSDoc Made-with: Cursor * docs: enumerate reasons in useParentFormLayout JSDoc Made-with: Cursor * docs: note FormContext is not public API Made-with: Cursor * fix: position layout arg after uiSchema in Storybook controls Add layout: {} to each story meta's argTypes to control ordering, placing it right after uiSchema (or jsonSchema when uiSchema is absent). Made-with: Cursor * fix: ensure layout arg appears after uiSchema in all Storybook stories Add uiSchema to meta argTypes in stories that were missing it so layout consistently appears directly below uiSchema in the controls. Made-with: Cursor * fix: hide noise props in minimal story argTypes so layout appears after uiSchema Add rendererRegistryEntries, uiSchemaRegistryEntries, data, and onChange disable entries to stories that were missing them. Without these, Storybook renders the component props in their type order, pushing layout below data and rendererRegistryEntries. Made-with: Cursor * fix: align horizontal layout items to bottom so checkboxes line up with inputs Change Row align from "middle" to "bottom" so controls without a top label (e.g. checkboxes) align with the input portion of adjacent controls when vertical labels are used. Made-with: Cursor
1 parent 2d4d17c commit c616a3b

21 files changed

Lines changed: 146 additions & 25 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ dist-ssr
2525
*.sln
2626
*.sw?
2727

28+
# Storybook
29+
storybook-static
30+
2831
# Built package
2932
great-expectations-jsonforms-antd-renderers-*.tgz
3033

.storybook/preview.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ const preview: Preview = {
99
},
1010
},
1111
},
12+
argTypes: {
13+
layout: {
14+
control: "select",
15+
options: ["horizontal", "vertical", "inline"],
16+
},
17+
},
1218
}
1319

1420
export default preview

src/common/StorybookAntDJsonForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ type Props<T> = {
1414
jsonSchema: T
1515
rendererRegistryEntries: JsonFormsRendererRegistryEntry[]
1616
uiSchema?: UISchema<T>
17+
layout?: FormProps["layout"]
1718
uiSchemaRegistryEntries?: JsonFormsUISchemaRegistryEntry[]
1819
config?: Record<string, unknown>
1920
onChange: JsonFormsReactProps["onChange"]
20-
layout?: FormProps["layout"]
2121
}
2222

2323
// this component exists to facilitate storybook rendering

src/hooks/useParentFormLayout.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useLayoutEffect, useRef, useState } from "react"
2+
import type { FormProps } from "antd"
3+
4+
/**
5+
* Returns a ref and the detected layout of the nearest ancestor Ant Design Form.
6+
*
7+
* Attach `ref` to any element rendered inside the parent Form. The hook reads
8+
* the CSS class on the closest `.ant-form` ancestor (`ant-form-vertical`,
9+
* `ant-form-inline`) to determine the layout. When no ancestor form exists or
10+
* the layout is the default "horizontal", `layout` is `undefined`.
11+
*
12+
* We take this approach for two reasons:
13+
*
14+
* 1. We keep the intermediate `<Form>` wrapper (with `component={false}` when
15+
* nested) rather than conditionally removing it because `Form.List` in the
16+
* array controls depends on it for correct store structure.
17+
* 2. We detect layout via the DOM rather than importing antd's internal
18+
* `FormContext` because it is not part of antd's public API and is a
19+
* different object in CJS vs ESM builds, which breaks context sharing
20+
* when this library is consumed as CJS.
21+
*/
22+
export function useParentFormLayout() {
23+
const ref = useRef<HTMLSpanElement>(null)
24+
const [layout, setLayout] = useState<FormProps["layout"]>()
25+
26+
// No dependency array — re-reads the DOM class after every render so the
27+
// layout stays in sync when the parent Form's `layout` prop changes.
28+
// Safe from infinite loops: setLayout is a no-op when the value is unchanged.
29+
// eslint-disable-next-line react-hooks/exhaustive-deps
30+
useLayoutEffect(() => {
31+
const formEl = ref.current?.closest(".ant-form")
32+
if (formEl?.classList.contains("ant-form-vertical")) setLayout("vertical")
33+
else if (formEl?.classList.contains("ant-form-inline")) setLayout("inline")
34+
else setLayout(undefined)
35+
})
36+
37+
return { ref, layout }
38+
}

src/layouts/HorizontalLayout.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AntDLayout, AntDLayoutProps } from "./LayoutRenderer"
44
import { HorizontalLayoutUISchema } from "../ui-schema"
55
import { Form, Row } from "antd"
66
import { withJsonFormsLayoutProps } from "@jsonforms/react"
7+
import { useParentFormLayout } from "../hooks/useParentFormLayout"
78

89
export const HORIZONTAL_LAYOUT_FORM_TEST_ID = "horizontal-layout-form"
910

@@ -26,6 +27,8 @@ export function HorizontalLayout({
2627
visible,
2728
}
2829
const form = Form.useFormInstance()
30+
const { ref, layout } = useParentFormLayout()
31+
2932
if (visible === false) {
3033
return null
3134
}
@@ -36,7 +39,7 @@ export function HorizontalLayout({
3639
<Row
3740
justify="space-between"
3841
gutter={12}
39-
align="middle"
42+
align="bottom"
4043
style={{ maxWidth: "100%" }}
4144
>
4245
<AntDLayout
@@ -49,11 +52,19 @@ export function HorizontalLayout({
4952
</>
5053
)
5154

52-
if (form) {
53-
return content
54-
}
55-
56-
return <Form data-testid={HORIZONTAL_LAYOUT_FORM_TEST_ID}>{content}</Form>
55+
return (
56+
<>
57+
<span ref={ref} style={{ display: "none" }} />
58+
<Form
59+
data-testid={HORIZONTAL_LAYOUT_FORM_TEST_ID}
60+
component={form ? false : "form"}
61+
layout={layout}
62+
form={form}
63+
>
64+
{content}
65+
</Form>
66+
</>
67+
)
5768
}
5869

5970
export const HorizontalLayoutRenderer =

src/layouts/VerticalLayout.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AntDLayout, AntDLayoutProps } from "./LayoutRenderer"
33
import { Form } from "antd"
44
import { VerticalLayoutUISchema } from "../ui-schema"
55
import { withJsonFormsLayoutProps } from "@jsonforms/react"
6+
import { useParentFormLayout } from "../hooks/useParentFormLayout"
67

78
export const VERTICAL_LAYOUT_FORM_TEST_ID = "vertical-layout-form"
89

@@ -24,20 +25,25 @@ export function VerticalLayout({
2425
visible,
2526
}
2627
const form = Form.useFormInstance()
28+
const { ref, layout } = useParentFormLayout()
2729

2830
if (visible === false) {
2931
return null
3032
}
3133

3234
return (
33-
<Form
34-
data-testid={VERTICAL_LAYOUT_FORM_TEST_ID}
35-
component={form ? false : "form"}
36-
scrollToFirstError
37-
form={form}
38-
>
39-
<AntDLayout {...childProps} renderers={renderers} cells={cells} />
40-
</Form>
35+
<>
36+
<span ref={ref} style={{ display: "none" }} />
37+
<Form
38+
data-testid={VERTICAL_LAYOUT_FORM_TEST_ID}
39+
component={form ? false : "form"}
40+
layout={layout}
41+
scrollToFirstError
42+
form={form}
43+
>
44+
<AntDLayout {...childProps} renderers={renderers} cells={cells} />
45+
</Form>
46+
</>
4147
)
4248
}
4349

src/stories/controls/AnyOfControl.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const meta: Meta<typeof StorybookAntDJsonForm<typeof splitterAnyOfJsonSchema>> =
4141
control: "object",
4242
description: "this is a minimal anyOf combinator schema",
4343
},
44+
uiSchema: { control: "object" },
45+
layout: {},
4446
uiSchemaRegistryEntries: { table: { disable: true } },
4547
data: { table: { disable: true } },
4648
config: { control: "object" },

src/stories/controls/BooleanControl.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const meta: Meta<typeof StorybookAntDJsonForm> = {
4141
jsonSchema: {
4242
control: "object",
4343
},
44+
uiSchema: { control: "object" },
45+
layout: {},
4446
data: { table: { disable: true } },
4547
config: { control: "object" },
4648
onChange: { table: { disable: true, action: "on-change" } },

src/stories/controls/DateTimeControl.stories.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ const meta: Meta<typeof StorybookAntDJsonForm> = {
1717
jsonSchema: dateTimeSchema,
1818
uiSchema: dateTimeUISchema,
1919
},
20-
argTypes: {},
20+
argTypes: {
21+
rendererRegistryEntries: { table: { disable: true } },
22+
uiSchema: { control: "object" },
23+
layout: {},
24+
uiSchemaRegistryEntries: { table: { disable: true } },
25+
data: { table: { disable: true } },
26+
config: { control: "object" },
27+
onChange: { table: { disable: true, action: "on-change" } },
28+
},
2129
}
2230

2331
export default meta

src/stories/controls/EnumControl.stories.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@ const meta: Meta<typeof StorybookAntDJsonForm> = {
2020
uiSchema: enumProfessionUISchema,
2121
},
2222
argTypes: {
23-
uiSchema: {
24-
control: "object",
25-
},
23+
rendererRegistryEntries: { table: { disable: true } },
24+
uiSchema: { control: "object" },
25+
layout: {},
26+
uiSchemaRegistryEntries: { table: { disable: true } },
27+
data: { table: { disable: true } },
28+
config: { control: "object" },
29+
onChange: { table: { disable: true, action: "on-change" } },
2630
},
2731
}
2832

0 commit comments

Comments
 (0)