Skip to content

Commit

Permalink
Merge branch 'main' into play-with-controls
Browse files Browse the repository at this point in the history
  • Loading branch information
TrangPham authored Mar 7, 2024
2 parents 9c9c43b + 70ea4d5 commit 6d3dde1
Show file tree
Hide file tree
Showing 10 changed files with 552 additions and 14 deletions.
8 changes: 0 additions & 8 deletions src/controls/LabelRendererRegistryEntry.ts

This file was deleted.

102 changes: 102 additions & 0 deletions src/controls/NumberControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, test, it } from "vitest"
import { screen, waitFor } from "@testing-library/react"
import { userEvent } from "@testing-library/user-event"
import { render } from "../common/test-render"
import { JSONSchema } from "json-schema-to-ts"
import {
numberBasisPointsSchema,
numberBasisPointsUISchema,
numberMagnitudeSchema,
numberMagnitudeUISchema,
numberTheNumberSchema,
numberTheNumberUISchema,
numberUISchemaWithRule,
} from "../testSchemas/numberSchema"

describe("NumberControl", () => {
test("renders a number input with no UISchema provided", () => {
render({
schema: numberMagnitudeSchema,
})

expect(screen.getByText("Magnitude")).not.toBeNull()
})

it("Follows the hide rule", () => {
const data = { magnitude: 1000 }
render({
data: data,
schema: numberMagnitudeSchema,
uischema: numberUISchemaWithRule,
})
expect(screen.queryByText("Magnitude")).toBeNull()
})

it.each([[0], [100]])("renders when data of %s is included", (dataVal: number) => {
const data = { theNumber: dataVal}
render({
data: data,
schema: numberTheNumberSchema, // this has a default of 42.42
uischema: numberTheNumberUISchema,
})
expect(screen.getByText("The Number")).not.toBeNull()
expect(screen.getByRole("spinbutton")).toHaveValue(`${dataVal}`)
})

it.each([[0], [100]])("renders default value of %s when no data is provided", (defaultValue: number) => {
const { properties, ...rest } = numberMagnitudeSchema
properties.magnitude = { ...properties.magnitude, ...{ default: defaultValue }}
render({
schema: { ...rest, properties },
uischema: numberMagnitudeUISchema,
})

expect(screen.getByText("Magnitude")).not.toBeNull()
expect(screen.getByRole("spinbutton")).toHaveValue(`${defaultValue}`)
})
it("renders default value when no data is provided", () => {
render({
schema: numberTheNumberSchema,
uischema: numberTheNumberUISchema,
})
expect(screen.getByRole("spinbutton")).toHaveValue("42.42")
})

it("changes its value when users type", async () => {
let data: JSONSchema
render({
schema: numberMagnitudeSchema,
uischema: numberMagnitudeUISchema,
onChange: (state: { data: JSONSchema }) => {
data = state.data
},
})

await userEvent.clear(screen.getByRole("spinbutton"))
await userEvent.type(screen.getByRole("spinbutton"), "123")

await waitFor(() => {
expect(data).toEqual({ magnitude: 123 })
})
})

it("renders slider when min max values are present", () => {
const data = { basisPoints: 1 }
render({
data: data,
schema: numberBasisPointsSchema,
uischema: numberBasisPointsUISchema,
})
expect(screen.getByText("Basis Points")).not.toBeNull()
expect(screen.getByRole("spinbutton")).toHaveValue("1")
expect(screen.getByRole("slider")).not.toBeNull()
expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "1")
})
it("hides slider when min max values are not present", () => {
render({
schema: numberMagnitudeSchema,
uischema: numberMagnitudeUISchema,
})
expect(screen.queryByRole("slider")).toBeNull()
})
})
5 changes: 5 additions & 0 deletions src/controls/NumberControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createNumericControl } from "./NumericControl"

export const NumberControl = createNumericControl({
coerceNumber: (value) => Number(value),
})
55 changes: 55 additions & 0 deletions src/controls/NumericControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest"
import { screen, waitFor } from "@testing-library/react"
import { userEvent } from "@testing-library/user-event"
import { render } from "../common/test-render"
import { numberTheNumberSchema, numberWeightSchema } from "../testSchemas/numberSchema"

describe("NumericControl", () => {
it("does not fall back to default if value is empty", () => {
render({
schema: { ...numberTheNumberSchema },
data: {},
})

expect(screen.getByRole("spinbutton")).toHaveValue("")
})

it("calls onChange with number values", async () => {
let data = { theNumber: 42.00 }
render({
schema: numberTheNumberSchema,
data,
onChange: (state) => {
data = state.data
},
})

await userEvent.clear(screen.getByRole("spinbutton"))
await userEvent.type(screen.getByRole("spinbutton"), "42.00")

await waitFor(() => {
expect(data).toBe(42.00)
})
})

it("calls onChange with empty object and no errors when the input gets cleared out and optional", async () => {
const weight = {}
let state: Record<string, unknown> = {}
render({
schema: numberWeightSchema,
data: weight,
onChange: (newState) => {
state = newState
},
})

await userEvent.clear(screen.getByRole("spinbutton"))

await waitFor(() => {
expect(state.data).toBe(weight)
const errors = state.errors
console.log({errors})
expect(state.errors).toHaveLength(0)
})
})
})
122 changes: 122 additions & 0 deletions src/controls/NumericControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { ControlProps, RendererProps } from "@jsonforms/core"
import { Col, Form, InputNumber, Row, Slider } from "antd"
import { decimalToPercentage } from "./utils"



export const createNumericControl = (args: { coerceNumber: (value: number) => number; pattern?: string }) => {
return function NumericControl({
data,
handleChange,
path,
required,
label,
visible,
id,
schema,
uischema,
}: ControlProps & RendererProps) {
const arialLabelWithFallback = label || schema.description || "Value"
const isRequired = required || uischema.options?.required as boolean

const maxStepsWithoutTextInput = 100
const { maximum, minimum, multipleOf } = schema
const isRangeDefined = typeof maximum === "number" && typeof minimum === "number"
let step: number | undefined = undefined
let stepCount: number | undefined = undefined
if (isRangeDefined) {
const range = Math.abs(maximum - minimum)
step = multipleOf || (range / maxStepsWithoutTextInput)
stepCount = range / step
}
const isLargeStepCount = stepCount && stepCount > maxStepsWithoutTextInput

const initialValue: number | undefined = typeof schema?.default === "number" ? schema.default : minimum
const isEmptyObj = typeof data === "object" && data !== undefined && data !== null ? Object.keys(data as object).length === 0 : false
const value = data === undefined || isEmptyObj ? initialValue : data as number | null

const addonAfter = uischema.options?.addonAfter as string | undefined
const addonBefore = uischema.options?.addonBefore as string | undefined
const isPercentage = addonAfter?.trim() === "%"

const onChange = (value: number | null) => {
if ((typeof value === "number" && (!isRangeDefined || (isRangeDefined && value >= minimum && value <= maximum))) || value === null) {
handleChange(path, value !== null ? args.coerceNumber(value) : value)
}
}

const marginLeft = isRangeDefined ? 16 : 0
const style = { marginLeft: marginLeft, width: "100%" }
const formatter = ((value?: number) => {
if (typeof value !== "undefined") {
if (isPercentage) {
return decimalToPercentage(value)
} else {
return value.toString()
}
}
return ""
})

const numberInput = (
<InputNumber
aria-label={arialLabelWithFallback}
value={value}
defaultValue={initialValue}
pattern={args.pattern}
onChange={onChange}
style={style}
max={maximum}
min={minimum}
formatter={formatter}
controls={false}
addonAfter={addonAfter}
addonBefore={addonBefore}
/>
)

if (!visible) return null

const tooltip = {
formatter: (value?: number) => {
if (isPercentage) {
return `${decimalToPercentage(value || initialValue)}%`
} else {
return `${addonBefore ? addonBefore : ""}${value || initialValue}${addonAfter ? addonAfter : ""}`
}
}
}

const slider = <Slider
value={value === null ? initialValue : value}
defaultValue={initialValue}
min={minimum}
max={maximum}
disabled={initialValue === null}
onChange={onChange}
step={step}
tooltip={tooltip}
/>

return (
<Form.Item
label={label}
id={id}
name={path}
required={isRequired}
initialValue={initialValue}
rules={[{ required, message: required ? `${label} is required` : "" }]}
validateTrigger={["onBlur"]}
>
{isRangeDefined ? (
<Row>
<Col span={8}>{slider}</Col>
{isLargeStepCount ? <Col span={7}>{numberInput}</Col> : null}
</Row>
) : (
<Col span={18}>{numberInput}</Col>
)}
</Form.Item>
)
}
}
32 changes: 32 additions & 0 deletions src/controls/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest"
import { decimalToPercentage, percentageStringToDecimal } from "./utils"

describe("percentageStringToDecimal", () => {
test.each([
{ value: "100", expected: 1 },
{ value: "0", expected: 0 },
{ value: "50", expected: 0.5 },
{ value: "0.5", expected: 0.005 },
{ value: "0.005", expected: 0.00005 },
{ value: "0.066", expected: 0.00066 },
{ value: "85.9999999999", expected: 0.859999999999 },
])("when value is $value it returns $expected", ({ value, expected }: { value: string; expected: number }) => {
expect(percentageStringToDecimal(value)).toEqual(expected)
})
})

describe("decimalToPercentage", () => {
test.each([
{ value: 1, expected: "100" },
{ value: 0, expected: "0" },
{ value: 0.5, expected: "50" },
{ value: 0.005, expected: "0.5" },
{ value: 0.00005, expected: "0.005" },
{ value: 0.859999999999, expected: "85.9999999999" },
])(
"when value is $value it returns $expected. If symbol is provided, it is appended to the value",
({ value, expected }: { value: number; expected: string }) => {
expect(decimalToPercentage(value)).toEqual(expected)
},
)
})
9 changes: 9 additions & 0 deletions src/controls/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function decimalToPercentage(value?: number) {
if (value === undefined) return ""
const percentage = parseFloat((value * 100).toFixed(10)) // accounting for 10 digits after the decimal point
return `${percentage}`
}

export function percentageStringToDecimal(value: string | undefined) {
return Number(value) / 100
}
12 changes: 7 additions & 5 deletions src/renderers.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { JsonFormsRendererRegistryEntry,JsonFormsCellRendererRegistryEntry, isBooleanControl, isStringControl, rankWith, uiTypeIs } from "@jsonforms/core";
import { JsonFormsRendererRegistryEntry,JsonFormsCellRendererRegistryEntry, isBooleanControl, isNumberControl, isStringControl, rankWith, uiTypeIs } from "@jsonforms/core";
import { withJsonFormsControlProps, withJsonFormsLabelProps, withJsonFormsCellProps, withJsonFormsLayoutProps } from "@jsonforms/react";

import { BooleanControl } from "./controls/BooleanControl";
import { AlertControl } from "./controls/AlertControl";
import { TextControl } from "./controls/TextControl";
import { UnknownControl } from "./controls/UnknownControl";
import { VerticalLayoutRenderer } from "./layouts/VerticalLayout";
import { NumberControl } from "./controls/NumberControl";


// Ordered from lowest rank to highest rank. Higher rank renderers will be preferred over lower rank renderers.
export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [
{ tester: rankWith(1, () => true), renderer: withJsonFormsControlProps(UnknownControl)},
{ tester: rankWith(2, uiTypeIs("VerticalLayout")), renderer: withJsonFormsLayoutProps(VerticalLayoutRenderer)},
{ tester: rankWith(1, () => true), renderer: withJsonFormsControlProps(UnknownControl) },
{ tester: rankWith(2, uiTypeIs("VerticalLayout")), renderer: withJsonFormsLayoutProps(VerticalLayoutRenderer) },
{ tester: rankWith(2, isBooleanControl), renderer: withJsonFormsControlProps(BooleanControl) },
{ tester: rankWith(2, isStringControl), renderer: withJsonFormsControlProps(TextControl)},
{ tester: rankWith(100, uiTypeIs("Label")),renderer: withJsonFormsLabelProps(AlertControl)},
{ tester: rankWith(2, isStringControl), renderer: withJsonFormsControlProps(TextControl) },
{ tester: rankWith(2, uiTypeIs("Label")), renderer: withJsonFormsLabelProps(AlertControl) },
{ tester: rankWith(2, isNumberControl), renderer: withJsonFormsControlProps(NumberControl) },
];

export const cellRegistryEntries: JsonFormsCellRendererRegistryEntry[] = [
Expand Down
Loading

0 comments on commit 6d3dde1

Please sign in to comment.