diff --git a/src/antd/SliderSingle.tsx b/src/antd/SliderSingle.tsx new file mode 100644 index 0000000..38b090e --- /dev/null +++ b/src/antd/SliderSingle.tsx @@ -0,0 +1,52 @@ +import { ReactElement } from "react" +import { ControlProps, RendererProps } from "@jsonforms/core" +import { Slider as AntdSlider, SliderSingleProps as AntdSliderSingleProps } from "antd" +import { coerceToInteger, coerceToNumber, decimalToPercentage } from "../controls/utils" + +type Slider = ReactElement +type SliderSingleProps = AntdSliderSingleProps & RendererProps & ControlProps + +export const SliderSingle = (props: SliderSingleProps): Slider => { + const dataIsNonNullObject = typeof props.data === "object" && props.data !== undefined && props.data !== null + const dataIsEmptyObj = dataIsNonNullObject ? Object.keys(props.data as object).length === 0 : false + const defaultValue = typeof props.schema?.default === "number" ? props.schema.default : props.schema.minimum + const value = props.data === undefined || dataIsEmptyObj ? defaultValue : props.data as number + + const addonAfter = props.uischema.options?.addonAfter as string | undefined + const addonBefore = props.uischema.options?.addonBefore as string | undefined + const isPercentage = addonAfter?.trim() === "%" + + const min = props.schema.minimum + const max = props.schema.maximum + const step = props.schema.multipleOf + + const numberType = props.schema.type + const isInteger = (typeof numberType === "string" && numberType === "integer") || (Array.isArray(numberType) && numberType.includes("integer")) + const handleChange = (value: number) => { + if ((min !== undefined && value >= min) && (max !== undefined && value <= max)) { + if (isInteger) { + props.handleChange(props.path, value !== null ? coerceToInteger(value) : value) + } else { + props.handleChange(props.path, value !== null ? coerceToNumber(value) : value) + } + } + } + + const tooltip = { + formatter: (value?: number) => { + const tooltipValue = value !== undefined ? value : defaultValue + const formattedTooltipValue = isPercentage ? decimalToPercentage(tooltipValue) : tooltipValue + return `${addonBefore ? addonBefore : ""}${formattedTooltipValue}${addonAfter ? addonAfter : ""}` + } + } + + return handleChange(value)} + min={min} + max={max} + step={step} + tooltip={tooltip} + /> +} diff --git a/src/controls/NumericControls/NumericSliderControl.test.tsx b/src/controls/NumericControls/NumericSliderControl.test.tsx new file mode 100644 index 0000000..50bc517 --- /dev/null +++ b/src/controls/NumericControls/NumericSliderControl.test.tsx @@ -0,0 +1,98 @@ +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 { + numericSliderBasisPointsSchema, + numericSliderUISchema, + numericSliderFinalGradeSchema, + numericSliderPercentageUISchema, + numericSliderUISchemaWithRule, +} from "../../testSchemas/numericSchema/numericSliderSchema" + +describe("NumericSliderControl", () => { + test("renders a slider and number input with no UISchema provided", () => { + render({ + schema: numericSliderBasisPointsSchema, + }) + + expect(screen.getByRole("slider")).not.toBeNull() + expect(screen.getByText("Basis Points")).not.toBeNull() + }) + + it("Follows the hide rule", () => { + const data = { numericRangeValue: 1000 } + render({ + data: data, + schema: numericSliderBasisPointsSchema, + uischema: numericSliderUISchemaWithRule, + }) + expect(screen.queryByText("Basis Points")).toBeNull() + }) + + it("renders default value when no data is provided", () => { + render({ + schema: numericSliderFinalGradeSchema, + uischema: numericSliderUISchema, + }) + expect(screen.getByRole("spinbutton")).toHaveValue("0.5") + }) + + it("changes its value when users type", async () => { + let data: JSONSchema + render({ + schema: numericSliderBasisPointsSchema, + uischema: numericSliderUISchema, + 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({ numericRangeValue: 123 }) + }) + }) + + it("does not fall back to default if value is empty", () => { + render({ + schema: numericSliderBasisPointsSchema, + data: {}, + }) + + expect(screen.getByRole("spinbutton")).toHaveValue("") + }) + + it("calls onChange with number values", async () => { + let data = { numericRangeValue: 42.00 } + render({ + schema: numericSliderBasisPointsSchema, + data, + onChange: (state) => { + data = state.data + }, + }) + + await userEvent.clear(screen.getByRole("spinbutton")) + await userEvent.type(screen.getByRole("spinbutton"), "42.00") + + await waitFor(() => { + expect(data).toEqual({ numericRangeValue: 42.00 }) + }) + }) + + it ("shows units in tooltip if set in UI schema", async () => { + render({ + schema: numericSliderFinalGradeSchema, + uischema: numericSliderPercentageUISchema, + }) + const slider = screen.getByRole("slider") + expect(screen.queryByText("50%")).toBeNull() + await userEvent.hover(slider) + + expect(screen.queryByText("50%")).not.toBeNull() + }) +}) diff --git a/src/controls/NumericControls/NumericSliderControl.tsx b/src/controls/NumericControls/NumericSliderControl.tsx new file mode 100644 index 0000000..9c279e4 --- /dev/null +++ b/src/controls/NumericControls/NumericSliderControl.tsx @@ -0,0 +1,32 @@ +import { ControlProps, RendererProps } from "@jsonforms/core" +import { Col, Form, Row } from "antd" +import { Rule } from "antd/lib/form" +import { InputNumber } from "../../antd/InputNumber" +import { SliderSingle } from "../../antd/SliderSingle" + + +export const NumericSliderControl = (props: ControlProps & RendererProps) => { + if (!props.visible) return null + + const initialValue = typeof props.schema.default === "number" ? props.schema.default : props.schema.minimum + + const rules: Rule[] = [ + { required: props.required, message: `${props.label} is required` }, + ] + + return ( + + + {SliderSingle({...props})}{InputNumber({...props})} + + + ) +} diff --git a/src/controls/NumericControls/testers.ts b/src/controls/NumericControls/testers.ts index dab3bf8..8efc99a 100644 --- a/src/controls/NumericControls/testers.ts +++ b/src/controls/NumericControls/testers.ts @@ -1,3 +1,10 @@ -import { Tester, isNumberControl, isIntegerControl, or } from "@jsonforms/core" +import { JsonSchema, Tester, isNumberControl, isIntegerControl, and, or, schemaMatches } from "@jsonforms/core" export const isNumericControl: Tester = or(isNumberControl, isIntegerControl) + +export const isNumericSliderControl: Tester = and( + isNumericControl, + schemaMatches((schema: JsonSchema) => { + return schema.minimum !== undefined && schema.maximum !== undefined + }), +) diff --git a/src/renderers.ts b/src/renderers.ts index b595170..1d1f16a 100644 --- a/src/renderers.ts +++ b/src/renderers.ts @@ -6,7 +6,9 @@ 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"; +import { NumericSliderControl } from "./controls/NumericControls/NumericSliderControl"; + +import { isNumericControl, isNumericSliderControl } from "./controls/NumericControls/testers"; // Ordered from lowest rank to highest rank. Higher rank renderers will be preferred over lower rank renderers. @@ -16,7 +18,8 @@ export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [ { tester: rankWith(2, isBooleanControl), renderer: withJsonFormsControlProps(BooleanControl) }, { tester: rankWith(2, isStringControl), renderer: withJsonFormsControlProps(TextControl) }, { tester: rankWith(2, uiTypeIs("Label")), renderer: withJsonFormsLabelProps(AlertControl) }, - { tester: rankWith(2, isNumberControl), renderer: withJsonFormsControlProps(NumberControl) }, + { tester: rankWith(2, isNumericControl), renderer: withJsonFormsControlProps(NumericControl)}, + { tester: rankWith(3, isNumericSliderControl), renderer: withJsonFormsControlProps(NumericSliderControl) }, ]; export const cellRegistryEntries: JsonFormsCellRendererRegistryEntry[] = [ diff --git a/src/stories/controls/NumericControls/NumericSliderControl.stories.tsx b/src/stories/controls/NumericControls/NumericSliderControl.stories.tsx new file mode 100644 index 0000000..3e5dd77 --- /dev/null +++ b/src/stories/controls/NumericControls/NumericSliderControl.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { StorybookAntDJsonForm } from "../../../common/StorybookAntDJsonForm"; + +import { + numericSliderBasisPointsSchema, + numericSliderUISchema, + numericSliderTemperatureSchema, + numericSliderTemperatureUISchema, + numericSliderFinalGradeSchema, + numericSliderPercentageUISchema, + numericSliderDonateNowSchema, + numericSliderUSDUISchema, +} from "../../../testSchemas/numericSchema/numericSliderSchema"; + + +const meta: Meta = { + title: "Control/Numeric Slider", + component: StorybookAntDJsonForm, + tags: ["autodocs"], + args: { + uiSchema: numericSliderUISchema, + }, + argTypes: { + uiSchema: { + control: "object", + }, + } +} + +export default meta +type Story = StoryObj + +export const RequiredInteger: Story = { + tags: ["autodocs"], + args: { + jsonSchema: numericSliderBasisPointsSchema, + uiSchema: numericSliderUISchema, + }, +} + +export const RequiredIntegerWithUnits: Story = { + tags: ["autodocs"], + args: { + jsonSchema: numericSliderTemperatureSchema, + uiSchema: numericSliderTemperatureUISchema, + }, +} + +export const RequiredPercentageWithDefault: Story = { + tags: ["autodocs"], + args: { + jsonSchema: numericSliderFinalGradeSchema, + uiSchema: numericSliderPercentageUISchema, + }, +} + +export const OptionalUSDWithDefault: Story = { + tags: ["autodocs"], + args: { + jsonSchema: numericSliderDonateNowSchema, + uiSchema: numericSliderUSDUISchema, + }, +} diff --git a/src/testSchemas/numericSchema/numericSliderSchema.ts b/src/testSchemas/numericSchema/numericSliderSchema.ts new file mode 100644 index 0000000..cdaf5c0 --- /dev/null +++ b/src/testSchemas/numericSchema/numericSliderSchema.ts @@ -0,0 +1,155 @@ +import { RuleEffect } from "@jsonforms/core" +import { JSONSchema } from "json-schema-to-ts" +import { UISchema } from "../../ui-schema" + +export const numericSliderBasisPointsSchema = { + type: "object", + properties: { + numericRangeValue: { + title: "Basis Points", + type: "number", + minimum: 0, + maximum: 10000, + multipleOf: 1, + }, + }, + required: ["numericRangeValue"], +} satisfies JSONSchema + +export const numericSliderTemperatureSchema = { + type: "object", + properties: { + numericRangeValue: { + title: "Today's Temperature", + type: "number", + minimum: -50, + maximum: 150, + multipleOf: 1, + default: 70, + }, + }, + required: ["numericRangeValue"], +} satisfies JSONSchema + +export const numericSliderHumiditySchema = { + type: "object", + properties: { + numericRangeValue: { + title: "Humidity", + type: "number", + minimum: 0.0, + maximum: 1.0, + multipleOf: 0.01, + }, + }, + required: ["numericRangeValue"], +} satisfies JSONSchema + +export const numericSliderRelativeChangeSchema = { + type: "object", + properties: { + numericRangeValue: { + title: "Relative Change", + type: "number", + minimum: -1.0, + maximum: 10.0, + multipleOf: 0.01, + }, + }, + required: ["numericRangeValue"], +} satisfies JSONSchema + + +export const numericSliderFinalGradeSchema = { + type: "object", + properties: { + numericRangeValue: { + title: "Final Grade", + type: "number", + minimum: 0.0, + maximum: 1.0, + multipleOf: 0.01, + default: 0.5, + }, + }, + required: ["numericRangeValue"], +} satisfies JSONSchema + + +export const numericSliderDonateNowSchema = { + type: "object", + properties: { + numericRangeValue: { + type: "number", + title: "Donate Now", + minimum: 5.00, + maximum: 1000.00, + multipleOf: 5.00, + default: 20.00, + } + }, +} satisfies JSONSchema + + +export const numericSliderUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/numericRangeValue", + }, + ], +} satisfies UISchema + +export const numericSliderUSDUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/numericRangeValue", + options: { + addonBefore: "$", + } + }, + ], +} satisfies UISchema + +export const numericSliderPercentageUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/numericRangeValue", + options: { + addonAfter: "%", + } + }, + ], +} satisfies UISchema + +export const numericSliderTemperatureUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/numericRangeValue", + options: { + addonAfter: "°F", + } + }, + ], +} satisfies UISchema + +export const numericSliderUISchemaWithRule = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/numericRangeValue", + rule: { + effect: RuleEffect.HIDE, + condition: {}, + }, + }, + ], +} satisfies UISchema diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index c08aab8..45bd57f 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -3,7 +3,9 @@ "compilerOptions": { "lib": [ "ES6", - "DOM" + "ES2020", + "DOM", + "DOM.Iterable" ], "target": "ES6", "module": "CommonJS", diff --git a/tsconfig.esm.json b/tsconfig.esm.json index ee1babe..3a7d9bf 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -3,7 +3,8 @@ "compilerOptions": { "lib": [ "ES2022", - "DOM" + "DOM", + "DOM.Iterable" ], "target": "ES2022", "module": "NodeNext", diff --git a/tsconfig.production.json b/tsconfig.production.json index 687039a..1eb9271 100644 --- a/tsconfig.production.json +++ b/tsconfig.production.json @@ -12,13 +12,7 @@ "declarationMap": true, "allowSyntheticDefaultImports": true, - "target": "ES2020", "useDefineForClassFields": true, - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], "module": "ESNext", "skipLibCheck": true, "esModuleInterop": true,