Skip to content

Commit

Permalink
feat: Add NumericControl (#27)
Browse files Browse the repository at this point in the history
* Add NumericControl, InputNumber, tests, and stories

* Add type InputNumberOptions

* Add NumericControl to registry

* Add new test for integer rounding

* Move parser cast to higher level
  • Loading branch information
NathanFarmer authored Mar 13, 2024
1 parent bda0b36 commit badccf0
Show file tree
Hide file tree
Showing 9 changed files with 500 additions and 1 deletion.
83 changes: 83 additions & 0 deletions src/antd/InputNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ReactElement } from "react"
import { ControlProps, RendererProps } from "@jsonforms/core"
import { InputNumber as AntdInputNumber } from "antd"
import { InputNumberOptions } from "../ui-schema"
import { coerceToInteger, coerceToNumber, decimalToPercentage, percentageStringToDecimal } from "../controls/utils"

type InputNumber = ReactElement<typeof AntdInputNumber>
type AntdInputNumberProps = React.ComponentProps<typeof AntdInputNumber>
type InputNumberProps = AntdInputNumberProps & RendererProps & ControlProps

export const InputNumber = (props: InputNumberProps): InputNumber => {
const schema = props.schema
const ariaLabel = props.label || schema.description || "Value"

const defaultValue = schema.default as number | undefined
const isDataNonNullObject = typeof props.data === "object" && props.data !== undefined && props.data !== null
const isDataEmptyObj = isDataNonNullObject ? Object.keys(props.data as object).length === 0 : false
const value = props.data === undefined || isDataEmptyObj ? defaultValue : props.data as number

const numberType = schema.type
const isInteger = (typeof numberType === "string" && numberType === "integer") || (Array.isArray(numberType) && numberType.length === 1 && numberType.includes("integer"))
const handleChange = (value: number | string | null) => {
if (typeof value === "number") {
if (isInteger) {
props.handleChange(props.path, coerceToInteger(value))
} else {
props.handleChange(props.path, coerceToNumber(value))
}
} else {
props.handleChange(props.path, value)
}
}

const options = props.uischema.options as InputNumberOptions
const addonAfter = options?.addonAfter
const addonBefore = options?.addonBefore
const isPercentage = addonAfter && typeof addonAfter === "string" ? addonAfter?.trim() === "%" : false

const min = schema.minimum
const max = schema.maximum
const marginLeft = min === undefined || max === undefined ? 0 : 16
const style = { marginLeft: marginLeft, width: "100%" }

const formatter = ((value?: string | number): string => {
if (value !== "" && value !== undefined) {
if (isPercentage) {
const valueFloat = typeof value === "string" ? parseFloat(value) : value
return decimalToPercentage(valueFloat)
} else {
return value.toString()
}
}
return ""
})
const parser = ((value?: string): number | undefined => {
const isNumeric = value ? !isNaN(Number(value)) : false
if (isNumeric && value !== undefined) {
if (isPercentage) {
return percentageStringToDecimal(value)
} else if (numberType === "integer") {
return Math.round(parseFloat(value))
} else {
return parseFloat(value)
}
}
return undefined
})

return <AntdInputNumber
aria-label={ariaLabel}
defaultValue={defaultValue}
value={value}
onChange={(value) => handleChange(value)}
min={min}
max={max}
addonBefore={addonBefore}
addonAfter={addonAfter}
style={style}
formatter={formatter}
parser={parser as AntdInputNumberProps["parser"]}
controls={false}
/>
}
156 changes: 156 additions & 0 deletions src/controls/NumericControls/NumericControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { JSONSchema } from "json-schema-to-ts"
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 {
numericMagnitudeSchema,
numericTheNumberSchema,
numericWeightSchema,
numericSheepSchema,
numericBeansSchema,
numericUISchema,
numericUISchemaWithRule,
numericPriceSchema,
numericUSDUISchema,
} from "../../testSchemas/numericSchema/numericSchema"


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

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

it("calls onChange with number values", async () => {
let data = { numericValue: 42.00 }
render({
schema: numericTheNumberSchema,
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: numericWeightSchema,
data: weight,
onChange: (newState) => {
state = newState
},
})

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

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

await waitFor(() => {
expect(state.errors).toHaveLength(0)
})
})

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

screen.getByText("Magnitude")
})

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

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

it("renders default value when no data is provided", () => {
render({
schema: numericTheNumberSchema,
uischema: numericUISchema,
})
expect(screen.getByRole("spinbutton")).toHaveValue("42.42")
})

it("changes its value when users type", async () => {
let data: JSONSchema
render({
schema: numericMagnitudeSchema,
uischema: numericUISchema,
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({ numericValue: 123 })
})
})

it ("shows error message onBlur when field is required and empty", async () => {
render({
schema: numericTheNumberSchema,
uischema: numericUISchema,
})
const input = screen.getByRole("spinbutton")
await userEvent.clear(input)
await userEvent.tab()
await screen.findByText("The Number is required")
})

it ("shows units next to text input if set in UI schema", async () => {
render({
schema: numericPriceSchema,
uischema: numericUSDUISchema,
})
await screen.findByText("$")
})

it.each([
numericSheepSchema,
numericBeansSchema,
])("is treated as an integer if the schema type is integer or the type is an array with only integer", async (schema: JSONSchema) => {
render({
schema: schema,
uischema: numericUISchema,
})
const input = screen.getByRole("spinbutton")
await userEvent.type(input, "123.45") // try to input a float
await userEvent.tab()
expect(input).toHaveValue("123") // it should be rounded to an integer
})
})
29 changes: 29 additions & 0 deletions src/controls/NumericControls/NumericControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ControlProps, RendererProps } from "@jsonforms/core"
import { Col, Form } from "antd"
import { Rule } from "antd/lib/form"
import { InputNumber } from "../../antd/InputNumber"


export const NumericControl = (props: ControlProps & RendererProps) => {
if (!props.visible) return null

const initialValue = typeof props.schema.default === "number" ? props.schema.default : undefined

const rules: Rule[] = [
{ required: props.required, message: `${props.label} is required` },
]

return (
<Form.Item
label={props.label}
id={props.id}
name={props.path}
required={props.required}
initialValue={initialValue}
rules={rules}
validateTrigger={["onBlur"]}
>
<Col span={18}>{InputNumber({...props})}</Col>
</Form.Item>
)
}
3 changes: 3 additions & 0 deletions src/controls/NumericControls/testers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Tester, isNumberControl, isIntegerControl, or } from "@jsonforms/core"

export const isNumericControl: Tester = or(isNumberControl, isIntegerControl)
4 changes: 4 additions & 0 deletions src/controls/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ export function decimalToPercentage(value?: number) {
export function percentageStringToDecimal(value: string | undefined) {
return Number(value) / 100
}

export const coerceToInteger = (value: number) => Math.round(value)

export const coerceToNumber = (value: number) => Number(value)
7 changes: 7 additions & 0 deletions src/renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ import { UnknownControl } from "./controls/UnknownControl";
import { VerticalLayoutRenderer } from "./layouts/VerticalLayoutRenderer";
import { ObjectControl } from "./controls/ObjectControl";
import { GroupLayoutRenderer } from "./layouts/GroupLayoutRenderer";
import { NumericControl } from "./controls/NumericControls/NumericControl";
import React from "react";

import { isNumericControl } from "./controls/NumericControls/testers";

// Ordered from lowest rank to highest rank. Higher rank renderers will be preferred over lower rank renderers.
export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [
{
Expand All @@ -53,6 +56,10 @@ export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [
tester: rankWith(2, uiTypeIs("Label")),
renderer: withJsonFormsLabelProps(AlertControl),
},
{
tester: rankWith(2, isNumericControl),
renderer: withJsonFormsControlProps(NumericControl),
},
{
tester: rankWith(10, and(isObjectControl, not(isLayout))),
renderer: withJsonFormsDetailProps(ObjectControl),
Expand Down
80 changes: 80 additions & 0 deletions src/stories/controls/NumericControls/NumericControl.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react"
import { StorybookAntDJsonForm } from "../../../common/StorybookAntDJsonForm"

import {
numericMagnitudeSchema,
numericTheNumberSchema,
numericWeightSchema,
numericSheepSchema,
numericUISchema,
numericPriceSchema,
numericUSDUISchema,
numericROISchema,
numericPercentageUISchema,
} from "../../../testSchemas/numericSchema/numericSchema"


const meta: Meta<typeof StorybookAntDJsonForm> = {
title: "Control/Numeric Input",
component: StorybookAntDJsonForm,
tags: ["autodocs"],
args: {
uiSchema: numericUISchema,
},
argTypes: {
uiSchema: {
control: "object",
},
}
}

export default meta
type Story = StoryObj<typeof StorybookAntDJsonForm>

export const RequiredFloatingPoint: Story = {
tags: ["autodocs"],
args: {
jsonSchema: numericMagnitudeSchema,
uiSchema: numericUISchema,
},
}

export const RequiredFloatingPointWithDefault: Story = {
tags: ["autodocs"],
args: {
jsonSchema: numericTheNumberSchema,
uiSchema: numericUISchema,
},
}

export const OptionalFloatingPoint: Story = {
tags: ["autodocs"],
args: {
jsonSchema: numericWeightSchema,
uiSchema: numericUISchema,
},
}

export const RequiredInteger: Story = {
tags: ["autodocs"],
args: {
jsonSchema: numericSheepSchema,
uiSchema: numericUISchema,
},
}

export const OptionalUSD: Story = {
tags: ["autodocs"],
args: {
jsonSchema: numericPriceSchema,
uiSchema: numericUSDUISchema,
},
}

export const RequiredPercentage: Story = {
tags: ["autodocs"],
args: {
jsonSchema: numericROISchema,
uiSchema: numericPercentageUISchema,
},
}
Loading

0 comments on commit badccf0

Please sign in to comment.