diff --git a/package.json b/package.json
index 59b0a0e..d73e807 100644
--- a/package.json
+++ b/package.json
@@ -51,9 +51,7 @@
"@ant-design/icons": "^5.3.0",
"@jsonforms/core": "^3.2.1",
"@jsonforms/react": "^3.2.1",
- "@types/lodash.isempty": "^4.4.9",
"antd": "^5.14.0",
- "lodash.isempty": "^4.4.0",
"react": "^17 || ^18"
},
"devDependencies": {
@@ -72,6 +70,7 @@
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/lodash.isempty": "^4.4.9",
+ "@types/lodash.range": "^3.2.9",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^6.14.0",
@@ -86,7 +85,6 @@
"eslint-plugin-testing-library": "^6.2.0",
"jsdom": "^24.0.0",
"json-schema-to-ts": "^3.0.0",
- "lodash.isempty": "^4.4.0",
"prettier": "3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -95,5 +93,9 @@
"typescript": "^5.2.2",
"vite": "^5.0.12",
"vitest": "^1.2.2"
+ },
+ "dependencies": {
+ "lodash.isempty": "^4.4.0",
+ "lodash.range": "^3.2.0"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 98aecdd..d7b08e4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,14 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+dependencies:
+ lodash.isempty:
+ specifier: ^4.4.0
+ version: 4.4.0
+ lodash.range:
+ specifier: ^3.2.0
+ version: 3.2.0
+
devDependencies:
'@ant-design/icons':
specifier: ^5.3.0
@@ -50,6 +58,9 @@ devDependencies:
'@types/lodash.isempty':
specifier: ^4.4.9
version: 4.4.9
+ '@types/lodash.range':
+ specifier: ^3.2.9
+ version: 3.2.9
'@types/react':
specifier: ^18.2.55
version: 18.2.55
@@ -92,9 +103,6 @@ devDependencies:
json-schema-to-ts:
specifier: ^3.0.0
version: 3.0.0
- lodash.isempty:
- specifier: ^4.4.0
- version: 4.4.0
prettier:
specifier: 3.2.5
version: 3.2.5
@@ -4308,6 +4316,12 @@ packages:
'@types/lodash': 4.14.202
dev: true
+ /@types/lodash.range@3.2.9:
+ resolution: {integrity: sha512-JNStPShiaR3ROKIAgtzChBhouPBCJxMI/Fj7Xa1cG51A2EMRyosvtL4fiGXOFiQddaxjZmNVYkZGsDRt8fFKPg==}
+ dependencies:
+ '@types/lodash': 4.14.202
+ dev: true
+
/@types/lodash@4.14.202:
resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==}
dev: true
@@ -8049,7 +8063,7 @@ packages:
/lodash.isempty@4.4.0:
resolution: {integrity: sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==}
- dev: true
+ dev: false
/lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -8063,6 +8077,10 @@ packages:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
+ /lodash.range@3.2.0:
+ resolution: {integrity: sha512-Fgkb7SinmuzqgIhNhAElo0BL/R1rHCnhwSZf78omqSwvWqD0kD2ssOAutQonDKH/ldS8BxA72ORYI09qAY9CYg==}
+ dev: false
+
/lodash.uniqby@4.7.0:
resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==}
dev: true
diff --git a/src/controls/BooleanControl.test.tsx b/src/controls/BooleanControl.test.tsx
index d5e5795..325179e 100644
--- a/src/controls/BooleanControl.test.tsx
+++ b/src/controls/BooleanControl.test.tsx
@@ -1,5 +1,5 @@
import { test, expect, vi } from "vitest"
-import { screen } from "@testing-library/react"
+import { screen, waitFor } from "@testing-library/react"
import { userEvent } from "@testing-library/user-event"
import { render } from "../common/test-render"
@@ -39,19 +39,19 @@ test("handles onChange event correctly", async () => {
await userEvent.click(checkbox)
expect(checkbox).toBeChecked()
- // FYI the calls to updateData lag behind the actual checkbox state. Not sure why.
- // It could be the difference between json-forms handleChange(path, value) and the onChange event.
- expect(updateData).toHaveBeenLastCalledWith({
- data: { name: false },
- errors: [],
- })
+ await waitFor(() =>
+ expect(updateData).toHaveBeenLastCalledWith({
+ data: { name: true },
+ errors: [],
+ }),
+ )
await userEvent.click(checkbox)
expect(checkbox).not.toBeChecked()
- expect(updateData).toHaveBeenLastCalledWith({
- data: { name: true },
- errors: [],
- })
-
- expect(updateData).toBeCalledTimes(2)
+ await waitFor(() =>
+ expect(updateData).toHaveBeenLastCalledWith({
+ data: { name: false },
+ errors: [],
+ }),
+ )
})
diff --git a/src/controls/ObjectArrayControl.test.tsx b/src/controls/ObjectArrayControl.test.tsx
new file mode 100644
index 0000000..7934ed9
--- /dev/null
+++ b/src/controls/ObjectArrayControl.test.tsx
@@ -0,0 +1,160 @@
+import { test, expect } from "vitest"
+import { screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { render } from "../common/test-render"
+
+import {
+ objectArrayControlUISchema,
+ objectArrayControlJsonSchema,
+ objectArrayControlUISchemaWithIcons,
+ objectArrayControlJsonSchemaWithRequired,
+} from "../testSchemas/objectArraySchema"
+
+test("ObjectArrayControl renders without any data", async () => {
+ render({
+ schema: objectArrayControlJsonSchema,
+ uischema: objectArrayControlUISchema,
+ })
+ await screen.findByRole("button")
+ await screen.findByText("Add Assets")
+ await screen.findByText("No data")
+})
+
+test.each([
+ [objectArrayControlJsonSchema, false],
+ [objectArrayControlJsonSchemaWithRequired, true],
+])(
+ "ObjectArrayControl renders disabled remove button with one element if required",
+ async (schema, should_be_disabled) => {
+ render({
+ schema: schema,
+ uischema: objectArrayControlUISchema,
+ data: { assets: [{ asset: "my asset" }] },
+ })
+ await screen.findByText("Add Assets")
+ await screen.findByDisplayValue("my asset")
+ //note: the text is within a span in the ,
+ ]}
+ >
+
+
+
+
+ )
+ }
+
+ const addButton = (
+
+
+
+ )
+
+ return (
+ <>
+ {label}
+
+ >
+ )
+}
+
+export const ObjectArrayRenderer =
+ withJsonFormsArrayLayoutProps(ObjectArrayControl)
diff --git a/src/renderer-registry-entries.ts b/src/renderer-registry-entries.ts
index 0c1c95e..cf87ad8 100644
--- a/src/renderer-registry-entries.ts
+++ b/src/renderer-registry-entries.ts
@@ -10,6 +10,9 @@ import {
isLayout,
isObjectControl,
isNumberControl,
+ isObjectArrayControl,
+ isObjectArray,
+ isObjectArrayWithNesting,
schemaMatches,
not,
and,
@@ -27,6 +30,7 @@ import { ObjectRenderer } from "./controls/ObjectControl"
import { GroupLayoutRenderer } from "./layouts/GroupLayout"
import { NumericRenderer } from "./controls/NumericControl"
import { NumericSliderRenderer } from "./controls/NumericSliderControl"
+import { ObjectArrayRenderer } from "./controls/ObjectArrayControl"
// Ordered from lowest rank to highest rank. Higher rank renderers will be preferred over lower rank renderers.
export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [
@@ -74,6 +78,13 @@ export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [
),
renderer: NumericSliderRenderer,
},
+ {
+ tester: rankWith(
+ 3,
+ or(isObjectArrayControl, isObjectArray, isObjectArrayWithNesting),
+ ),
+ renderer: ObjectArrayRenderer,
+ },
{
tester: rankWith(10, and(isObjectControl, not(isLayout))),
renderer: ObjectRenderer,
diff --git a/src/stories/controls/ObjectArrayControl.stories.tsx b/src/stories/controls/ObjectArrayControl.stories.tsx
new file mode 100644
index 0000000..d3991a2
--- /dev/null
+++ b/src/stories/controls/ObjectArrayControl.stories.tsx
@@ -0,0 +1,146 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { rendererRegistryEntries } from "../../renderer-registry-entries"
+import { JSONSchema } from "json-schema-to-ts"
+import { UISchema } from "../../ui-schema"
+import { StorybookAntDJsonForm } from "../../common/StorybookAntDJsonForm"
+import {
+ objectArrayControlJsonSchema,
+ objectArrayControlUISchema,
+ objectArrayControlUISchemaWithIcons,
+} from "../../testSchemas/objectArraySchema"
+
+const meta: Meta = {
+ title: "Control/Object Array",
+ component: StorybookAntDJsonForm,
+ tags: ["autodocs"],
+ args: {
+ jsonSchema: objectArrayControlJsonSchema,
+ rendererRegistryEntries: [...rendererRegistryEntries],
+ },
+ // More on argTypes: https://storybook.js.org/docs/api/argtypes
+ argTypes: {
+ rendererRegistryEntries: { table: { disable: true } },
+ jsonSchema: {
+ control: "object",
+ },
+ uiSchemaRegistryEntries: { table: { disable: true } },
+ data: { table: { disable: true } },
+ config: { control: "object" },
+ onChange: { table: { disable: true, action: "on-change" } },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const ObjectArrayOfStrings: Story = {
+ tags: ["autodocs"],
+ args: {
+ jsonSchema: objectArrayControlJsonSchema,
+ uiSchema: objectArrayControlUISchema,
+ },
+}
+
+export const ObjectArrayWithUiOptionAddButtonTop: Story = {
+ tags: ["autodocs"],
+ args: {
+ jsonSchema: objectArrayControlJsonSchema,
+ uiSchema: {
+ type: "VerticalLayout",
+ elements: [
+ {
+ scope: "#/properties/assets",
+ type: "Control",
+ options: {
+ addButtonLocation: "top",
+ },
+ },
+ ],
+ } satisfies UISchema,
+ },
+}
+
+export const ObjectArrayWithUiOptionForButtons: Story = {
+ tags: ["autodocs"],
+ args: {
+ jsonSchema: objectArrayControlJsonSchema,
+ uiSchema: {
+ type: "VerticalLayout",
+ elements: [
+ {
+ scope: "#/properties/assets",
+ type: "Control",
+ options: {
+ addButtonProps: {
+ children: "Add more items",
+ type: "primary",
+ },
+ removeButtonProps: {
+ children: "Destory of my life!",
+ danger: true,
+ },
+ },
+ },
+ ],
+ } satisfies UISchema,
+ },
+}
+
+export const ObjectArrayWithUiOptionWithIcons: Story = {
+ tags: ["autodocs"],
+ args: {
+ jsonSchema: objectArrayControlJsonSchema,
+ uiSchema: objectArrayControlUISchemaWithIcons,
+ },
+}
+
+export const ObjectArrayWithMultipleProperties: Story = {
+ tags: ["autodocs"],
+ args: {
+ jsonSchema: {
+ type: "object",
+ properties: {
+ guest_list: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ name: {
+ title: "name",
+ type: "string",
+ },
+ gluten_free: {
+ title: "gluten-free",
+ type: "boolean",
+ },
+ vegan: {
+ title: "vegan",
+ type: "boolean",
+ },
+ },
+ },
+ },
+ },
+ } satisfies JSONSchema,
+ uiSchema: {
+ type: "VerticalLayout",
+ elements: [
+ {
+ scope: "#/properties/guest_list",
+ type: "Control",
+ options: {
+ addButtonProps: {
+ children: "Add Guest",
+ type: "primary",
+ },
+ removeButtonProps: {
+ children: "Remove",
+ danger: true,
+ },
+ },
+ },
+ ],
+ } satisfies UISchema,
+ },
+}
diff --git a/src/stories/controls/ObjectControl.stories.tsx b/src/stories/controls/ObjectControl.stories.tsx
index 44b93b8..16f3f6b 100644
--- a/src/stories/controls/ObjectControl.stories.tsx
+++ b/src/stories/controls/ObjectControl.stories.tsx
@@ -1,6 +1,7 @@
import { Meta, StoryObj } from "@storybook/react"
import { rendererRegistryEntries } from "../../renderer-registry-entries"
import { StorybookAntDJsonForm } from "../../common/StorybookAntDJsonForm"
+
import {
objectSchema,
objectUISchemaWithName,
@@ -16,11 +17,16 @@ const meta: Meta = {
jsonSchema: objectSchema,
rendererRegistryEntries: [...rendererRegistryEntries],
},
+ // More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
- rendererRegistryEntries: {},
+ rendererRegistryEntries: { table: { disable: true } },
jsonSchema: {
control: "object",
},
+ uiSchemaRegistryEntries: { table: { disable: true } },
+ data: { table: { disable: true } },
+ config: { control: "object" },
+ onChange: { table: { disable: true, action: "on-change" } },
},
}
diff --git a/src/testSchemas/objectArraySchema.tsx b/src/testSchemas/objectArraySchema.tsx
new file mode 100644
index 0000000..a033af1
--- /dev/null
+++ b/src/testSchemas/objectArraySchema.tsx
@@ -0,0 +1,75 @@
+import { JSONSchema } from "json-schema-to-ts"
+import { UISchema } from "../ui-schema"
+import { PlusCircleTwoTone, DeleteOutlined } from "@ant-design/icons"
+
+export const objectArrayControlUISchema = {
+ type: "VerticalLayout",
+ elements: [
+ {
+ scope: "#/properties/assets",
+ type: "Control",
+ },
+ ],
+} satisfies UISchema
+
+export const objectArrayControlJsonSchema = {
+ title: "Assets",
+ type: "object",
+ properties: {
+ assets: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ asset: {
+ title: "Asset",
+ type: "string",
+ },
+ },
+ },
+ },
+ },
+} satisfies JSONSchema
+
+export const objectArrayControlJsonSchemaWithRequired = {
+ title: "Assets",
+ type: "object",
+ properties: {
+ assets: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ asset: {
+ title: "Asset",
+ type: "string",
+ },
+ },
+ },
+ },
+ },
+ required: ["assets"],
+} satisfies JSONSchema
+
+export const objectArrayControlUISchemaWithIcons = {
+ type: "VerticalLayout",
+ elements: [
+ {
+ scope: "#/properties/assets",
+ type: "Control",
+ options: {
+ addButtonProps: {
+ children: "Add more items",
+ icon: ,
+ type: "primary",
+ },
+ removeButtonProps: {
+ children: "Destroy me!",
+ icon: ,
+ danger: true,
+ onClick: () => {}, // User should be unable to override the onClick event
+ },
+ },
+ },
+ ],
+} satisfies UISchema
diff --git a/src/ui-schema.ts b/src/ui-schema.ts
index b1ed2f3..bd94454 100644
--- a/src/ui-schema.ts
+++ b/src/ui-schema.ts
@@ -1,5 +1,5 @@
import type { JsonSchema } from "@jsonforms/core"
-import type { AlertProps, InputNumberProps } from "antd"
+import type { ButtonProps, InputNumberProps, AlertProps } from "antd"
import type { RuleObject as AntDRule } from "antd/es/form"
// jsonforms has composed their types in such a way that recursive types only specify the "base" type
@@ -173,6 +173,7 @@ type ControlOptions =
| OneOfControlOptions
| TextControlOptions
| AnyOfControlOptions
+ | ArrayControlOptions
/**
* A control element. The scope property of the control determines
@@ -294,6 +295,14 @@ type AndCondition = ComposableCondition & {
type: "AND"
}
+export type AddButtonLocation = "top" | "bottom"
+
+export interface ArrayControlOptions {
+ addButtonProps?: ButtonProps
+ removeButtonProps?: ButtonProps
+ addButtonLocation?: AddButtonLocation
+}
+
export type NumericControlOptions = {
addonBefore?: InputNumberProps["addonBefore"]
addonAfter?: InputNumberProps["addonAfter"]