Skip to content

Commit

Permalink
feat: EnumControl (#83)
Browse files Browse the repository at this point in the history
* Control, types, regsitry entry, stories

* Revert change to pnpm-lock

* Type error

* Reduce rank for due to conflict with oneOf/anyOf

* Add enumValueToLabelMap

* Format

* Add tests

* Add tests

* Comment

* Fix test

* Format

* Fix test

* Cast on cast crime

* Fix

* Format'

* Narrow any type

* Fix

* Format

* Fix

* Format
  • Loading branch information
NathanFarmer authored Jun 28, 2024
1 parent 0f47e6d commit 6da9a19
Show file tree
Hide file tree
Showing 8 changed files with 400 additions and 34 deletions.
38 changes: 21 additions & 17 deletions src/common/schema-derived-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FromSchema, JSONSchema } from "json-schema-to-ts"
import {
AnyOfControlOptions,
ArrayControlOptions,
EnumControlOptions,
NumericControlOptions,
OneOfControlOptions,
TextControlOptions,
Expand All @@ -18,25 +19,28 @@ type RecursivePartial<T> = 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> = T extends // is this a property we can apply a control to?
| { enum: unknown }
| { type: string }
| { anyOf: unknown }
| { oneOf: unknown }
Expand Down
75 changes: 75 additions & 0 deletions src/controls/EnumControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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: [],
}),
)
})
105 changes: 105 additions & 0 deletions src/controls/EnumControl.tsx
Original file line number Diff line number Diff line change
@@ -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<JSFControlProps, "uischema"> & {
uischema: ControlUISchema<unknown> | 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 = (
<Radio.Group
defaultValue={defaultValue}
options={options}
onChange={(e) => {
props.handleChange(props.path, e.target.value)
}}
optionType="button"
buttonStyle="solid"
/>
)
break
case "segmented":
selector = (
<Segmented
defaultValue={defaultValue}
options={options}
onChange={(value) => {
props.handleChange(props.path, value)
}}
/>
)
break
case "dropdown":
default:
selector = (
<Select
showSearch
defaultValue={defaultValue}
options={options}
onChange={(option) => {
props.handleChange(props.path, option)
}}
filterOption={(inputValue, option) => {
const optionValue = option?.value?.toString() || ""
return (
optionValue.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
)
}}
/>
)
break
}

return (
<Form.Item
label={props.label}
id={props.id}
name={props.path}
required={props.required}
initialValue={defaultValue}
rules={rules}
validateTrigger={["onBlur"]}
{...formItemProps}
>
<Col span={18}>{selector}</Col>
</Form.Item>
)
}

export const EnumRenderer = withJsonFormsControlProps(EnumControl)
28 changes: 14 additions & 14 deletions src/controls/combinators/AnyOfControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,16 @@ describe("AnyOf control", () => {
// Column Name is available in both subschemas
await screen.findByText("Splitter")
screen.getByLabelText("Column Name")
expect(screen.queryByLabelText("Method Name")).toHaveValue("split_on_year")
screen.getByTitle("split_on_year")
expect(
screen.queryByTitle("split_on_year_and_month"),
).not.toBeInTheDocument()

// UiSchema is not set here, so we should see the Method Name changing

await userEvent.click(screen.getByLabelText("SplitterYearAndMonth"))
screen.getByLabelText("Column Name")
await waitFor(() =>
expect(screen.queryByLabelText("Method Name")).toHaveValue(
"split_on_year_and_month",
),
)
screen.getByTitle("split_on_year_and_month")
})
test("AnyOf Control with button UISchema allows switching between subschemas and respects uiSchemaRegistryEntries", async () => {
render({
Expand Down Expand Up @@ -77,17 +76,18 @@ describe("AnyOf control", () => {
// Column Name is available in both subschemas
await screen.findByText("Splitter")
screen.getByLabelText("Column Name")
expect(screen.queryByLabelText("Method Name")).toHaveValue("split_on_year")
screen.getByTitle("split_on_year")
expect(
screen.queryByTitle("split_on_year_and_month"),
).not.toBeInTheDocument()

// Open the dropdown
await userEvent.click(screen.getByText("Year"))

// Select another option
await userEvent.click(screen.getByText("Year - Month"))
screen.getByLabelText("Column Name")
expect(screen.queryByLabelText("Method Name")).toHaveValue(
"split_on_year_and_month",
)
screen.getByTitle("split_on_year_and_month")
})
test("AnyOf Control persists state when switching between subschemas", async () => {
render({ schema: splitterAnyOfJsonSchema })
Expand All @@ -98,13 +98,13 @@ describe("AnyOf control", () => {
await userEvent.type(screen.getByLabelText("Column Name"), "abc")

await userEvent.click(screen.getByLabelText("SplitterYearAndMonth"))
screen.getByLabelText("Column Name")
expect(screen.queryByLabelText("Column Name")).not.toHaveValue("abc")
let column = screen.getByLabelText("Column Name")
expect(column).not.toHaveValue("abc")
await userEvent.type(screen.getByLabelText("Column Name"), "xyz")

await userEvent.click(screen.getByLabelText("SplitterYear"))
screen.getByLabelText("Column Name")
expect(screen.queryByLabelText("Column Name")).toHaveValue("abc")
column = screen.getByLabelText("Column Name")
expect(column).toHaveValue("abc")
})
test("provides a default value for a required combinator", async () => {
let data: JSONFormData<typeof AnyOfWithDefaultsSchema> = {}
Expand Down
12 changes: 9 additions & 3 deletions src/renderer-registry-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isPrimitiveArrayControl,
isOneOfControl,
isAnyOfControl,
isEnumControl,
} from "@jsonforms/core"
import { withJsonFormsCellProps } from "@jsonforms/react"

Expand All @@ -37,6 +38,7 @@ import { ObjectArrayRenderer } from "./controls/ObjectArrayControl"
import { PrimitiveArrayRenderer } from "./controls/PrimitiveArrayControl"
import { OneOfRenderer } from "./controls/combinators/OneOfControl"
import { AnyOfRenderer } from "./controls/combinators/AnyOfControl"
import { EnumRenderer } from "./controls/EnumControl"

// Ordered from lowest rank to highest rank. Higher rank renderers will be preferred over lower rank renderers.
export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [
Expand Down Expand Up @@ -85,16 +87,20 @@ export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [
renderer: NumericSliderRenderer,
},
{
tester: rankWith(3, isOneOfControl),
tester: rankWith(4, isEnumControl),
renderer: EnumRenderer,
},
{
tester: rankWith(5, isOneOfControl),
renderer: OneOfRenderer,
},
{
tester: rankWith(3, isAnyOfControl),
tester: rankWith(5, isAnyOfControl),
renderer: AnyOfRenderer,
},
{
tester: rankWith(
3,
5,
or(isObjectArrayControl, isObjectArray, isObjectArrayWithNesting),
),
renderer: ObjectArrayRenderer,
Expand Down
Loading

0 comments on commit 6da9a19

Please sign in to comment.