diff --git a/src/common/schema-derived-types.ts b/src/common/schema-derived-types.ts index 0916bc9..00e7bc5 100644 --- a/src/common/schema-derived-types.ts +++ b/src/common/schema-derived-types.ts @@ -2,6 +2,7 @@ import { FromSchema, JSONSchema } from "json-schema-to-ts" import { AnyOfControlOptions, ArrayControlOptions, + EnumControlOptions, NumericControlOptions, OneOfControlOptions, TextControlOptions, @@ -18,25 +19,28 @@ type RecursivePartial = T extends object type JsonSchemaTypeToControlOptions< T, // K extends keyof T & string, -> = T extends { type: infer U } - ? U extends "object" // ObjectControlOptions goes here - ? unknown - : U extends "string" - ? TextControlOptions - : U extends "number" | "integer" - ? NumericControlOptions - : U extends "array" - ? ArrayControlOptions - : U extends "boolean" - ? unknown // BooleanControlOptions goes here - : unknown - : T extends { anyOf: unknown } - ? AnyOfControlOptions - : T extends { oneOf: unknown } - ? OneOfControlOptions - : unknown +> = T extends { enum: unknown } + ? EnumControlOptions + : T extends { type: infer U } + ? U extends "object" // ObjectControlOptions goes here + ? unknown + : U extends "string" + ? TextControlOptions + : U extends "number" | "integer" + ? NumericControlOptions + : U extends "array" + ? ArrayControlOptions + : U extends "boolean" + ? unknown // BooleanControlOptions goes here + : unknown + : T extends { anyOf: unknown } + ? AnyOfControlOptions + : T extends { oneOf: unknown } + ? OneOfControlOptions + : unknown type IsControlProperty = T extends // is this a property we can apply a control to? + | { enum: unknown } | { type: string } | { anyOf: unknown } | { oneOf: unknown } diff --git a/src/controls/EnumControl.test.tsx b/src/controls/EnumControl.test.tsx new file mode 100644 index 0000000..b254d1f --- /dev/null +++ b/src/controls/EnumControl.test.tsx @@ -0,0 +1,75 @@ +import { test, expect, vi } from "vitest" +import { screen, waitFor } from "@testing-library/react" +import { userEvent } from "@testing-library/user-event" +import { render } from "../common/test-render" +import { + enumPSISchema, + enumPSIUISchema, + enumProfessionSchema, + enumProfessionUISchema, + enumSnakeCaseSchema, + enumSnakeCaseUISchema, +} from "../testSchemas/enumSchema" + +test("renders the enum component as radio optionType", () => { + render({ + schema: enumPSISchema, + uischema: enumPSIUISchema, + }) + + const radioButtons = screen.getAllByRole("radio") + expect(radioButtons).toHaveLength(3) +}) + +test("renders the enum component as dropdown optionType", () => { + render({ + schema: enumProfessionSchema, + uischema: enumProfessionUISchema, + }) + + screen.getByRole("combobox") +}) + +test("renders the enum component with custom titles", async () => { + render({ + schema: enumSnakeCaseSchema, + uischema: enumSnakeCaseUISchema, + }) + + await userEvent.click(screen.getByRole("combobox")) + screen.getByTitle("Option 1") + screen.getByTitle("Option 2") + screen.getByTitle("Option 3") +}) + +test("handles onChange event correctly", async () => { + const updateData = vi.fn() + render({ + schema: enumProfessionSchema, + data: { profession: "Bob Ross Impersonator" }, + onChange: (result) => { + updateData(result) + }, + }) + + screen.getByTitle("Bob Ross Impersonator") + const combobox = screen.getByRole("combobox") + + await userEvent.click(combobox) + await userEvent.click(screen.getByTitle("Footballer")) + await waitFor(() => + expect(updateData).toHaveBeenLastCalledWith({ + data: { profession: "Footballer" }, + errors: [], + }), + ) + + await userEvent.click(combobox) + await userEvent.click(screen.getByTitle("Software Engineer")) + await waitFor(() => + expect(updateData).toHaveBeenLastCalledWith({ + data: { profession: "Software Engineer" }, + errors: [], + }), + ) +}) diff --git a/src/controls/EnumControl.tsx b/src/controls/EnumControl.tsx new file mode 100644 index 0000000..c98db46 --- /dev/null +++ b/src/controls/EnumControl.tsx @@ -0,0 +1,105 @@ +import type { ControlProps as JSFControlProps } from "@jsonforms/core" +import { Col, Form, Select, Segmented, Radio } from "antd" +import type { Rule } from "antd/es/form" +import { EnumControlOptions, ControlUISchema } from "../ui-schema" +import { withJsonFormsControlProps } from "@jsonforms/react" + +type ControlProps = Omit & { + uischema: ControlUISchema | JSFControlProps["uischema"] +} + +const isStringOrNumberArray = (arr: unknown[]): boolean => { + return arr.every( + (value) => typeof value === "string" || typeof value === "number", + ) +} + +export const EnumControl = (props: ControlProps) => { + if (!props.visible) return null + + const rules: Rule[] = [ + { required: props.required, message: `${props.label} is required` }, + ] + + const formItemProps = + "formItemProps" in props.uischema ? props.uischema.formItemProps : {} + + const defaultValue = + (props.data as unknown) ?? (props.schema.default as unknown) + + const appliedUiSchemaOptions = props.uischema.options as EnumControlOptions + + const enumValue = props.schema.enum + const enumValueToLabelMap = appliedUiSchemaOptions?.enumValueToLabelMap + const options = + enumValue && isStringOrNumberArray(enumValue) + ? enumValue.map((value: string | number) => ({ + label: enumValueToLabelMap ? enumValueToLabelMap[value] : value, + value: value, + })) + : [] + + let selector + switch (appliedUiSchemaOptions?.optionType) { + case "radio": + selector = ( + { + props.handleChange(props.path, e.target.value) + }} + optionType="button" + buttonStyle="solid" + /> + ) + break + case "segmented": + selector = ( + { + props.handleChange(props.path, value) + }} + /> + ) + break + case "dropdown": + default: + selector = ( +