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
84 changes: 84 additions & 0 deletions src/antd/InputNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ReactElement } from "react"
import { ControlProps, RendererProps } from "@jsonforms/core"
import { InputNumber as AntdInputNumber } from "antd"
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 dataIsNonNullObject = typeof props.data === "object" && props.data !== undefined && props.data !== null
NathanFarmer marked this conversation as resolved.
Show resolved Hide resolved
const dataIsEmptyObj = dataIsNonNullObject ? Object.keys(props.data as object).length === 0 : false
const value = props.data === undefined || dataIsEmptyObj ? defaultValue : props.data as number

const numberType = schema.type
const isInteger = (typeof numberType === "string" && numberType === "integer") || (Array.isArray(numberType) && numberType.includes("integer"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a test case for the Array.isArray(numberType) bit?

Copy link
Contributor Author

@NathanFarmer NathanFarmer Mar 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just added a missing test that asserts that numbers get rounded on input for integer. I tested schemas with type: "integer" and type: ["integer"]. I decided that type arrays with more types than "integer" will cause this variable to get set to false.

const handleChange = (value: number | 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 addonAfter = props.uischema.options?.addonAfter as string | undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this cast still necessary?

Copy link
Contributor Author

@NathanFarmer NathanFarmer Mar 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's considered an any until I do this cast. It's essentially the same as what we decided to do here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a new type InputNumberOptions to ui-schema.ts to precisely follow the pattern established by AlertControl. There is still a cast here though, the cast is just on options now and it passes through the props types.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, it's a lot easier to read if you pull off an object and type it:

const options: Maybe<InputNumberOptions> = props.uischema.options as InputNumberOptions
const addonAfter = options?.addonAfter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, how is this different than what I did?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typos like props.uischema.options?.adonafter as string | undefined doesn't get caught but it would in my example

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I don't think you've seen the updated version. This is now outdated.

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

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 => {
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)
}
}
// this allows us to return undefined for cases where the value has been deleted
// when InputNumber is paired with a Slider, the Slider value selector will disappear
// for required fields an error message will show instead of jumping to some default value
return undefined as unknown as number
NathanFarmer marked this conversation as resolved.
Show resolved Hide resolved
})

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}
controls={false}
/>
}
137 changes: 137 additions & 0 deletions src/controls/NumericControls/NumericControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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,
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)
expect(state.errors).toHaveLength(0)
})
})

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

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

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,
})
expect(screen.getByText("The Number")).not.toBeNull()
expect(screen.getByRole("spinbutton")).toHaveValue(`${dataVal}`)
})

it.each([[numericTheNumberSchema]])("renders default value when no data is provided", (schema: JSONSchema) => {
render({
schema: schema,
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()
expect(await screen.findByText("The Number is required")).not.toBeNull()
})

it ("shows units next to text input if set in UI schema", async () => {
render({
schema: numericPriceSchema,
uischema: numericUSDUISchema,
})
expect(await screen.findByText("$")).not.toBeNull()
})
})
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
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