Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add NumericControl #27

Merged
merged 13 commits into from
Mar 13, 2024
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)
NathanFarmer marked this conversation as resolved.
Show resolved Hide resolved
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
Loading