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"]