-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
bda0b36
commit badccf0
Showing
9 changed files
with
500 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
src/stories/controls/NumericControls/NumericControl.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
} |
Oops, something went wrong.