diff --git a/src/antd/Slider.tsx b/src/antd/Slider.tsx new file mode 100644 index 0000000..190c43e --- /dev/null +++ b/src/antd/Slider.tsx @@ -0,0 +1,51 @@ +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 SliderProps = AntdSliderSingleProps & RendererProps & ControlProps + +export const Slider = (props: SliderProps): ReactElement => { + 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 defaultValue = typeof props.schema?.default === "number" ? props.schema.default : props.schema.minimum + const value = props.data === undefined || isDataEmptyObj ? 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..02c65f3 --- /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, + }) + + screen.getByRole("slider") + screen.getByText("Basis Points") + }) + + 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) + + screen.getByText("50%") + }) +}) diff --git a/src/controls/NumericControls/NumericSliderControl.tsx b/src/controls/NumericControls/NumericSliderControl.tsx new file mode 100644 index 0000000..d4e1117 --- /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 { Slider } from "../../antd/Slider" + + +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 ( + + + {Slider({...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 cd578a6..222a7b5 100644 --- a/src/renderers.ts +++ b/src/renderers.ts @@ -26,9 +26,10 @@ import { VerticalLayoutRenderer } from "./layouts/VerticalLayoutRenderer"; import { ObjectControl } from "./controls/ObjectControl"; import { GroupLayoutRenderer } from "./layouts/GroupLayoutRenderer"; import { NumericControl } from "./controls/NumericControls/NumericControl"; +import { NumericSliderControl } from "./controls/NumericControls/NumericSliderControl"; import React from "react"; -import { isNumericControl } from "./controls/NumericControls/testers"; +import { isNumericControl, isNumericSliderControl } 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[] = [ @@ -56,10 +57,22 @@ export const rendererRegistryEntries: JsonFormsRendererRegistryEntry[] = [ tester: rankWith(2, uiTypeIs("Label")), renderer: withJsonFormsLabelProps(AlertControl), }, + { + tester: rankWith(2, isNumericControl), + renderer: withJsonFormsControlProps(NumericControl) + }, + { + tester: rankWith(2, isNumericControl), + renderer: withJsonFormsControlProps(NumericControl), + }, { tester: rankWith(2, isNumericControl), renderer: withJsonFormsControlProps(NumericControl), }, + { + tester: rankWith(3, isNumericSliderControl), + renderer: withJsonFormsControlProps(NumericSliderControl) + }, { tester: rankWith(10, and(isObjectControl, not(isLayout))), renderer: withJsonFormsDetailProps(ObjectControl), diff --git a/src/stories/controls/NumericControls/NumericSliderControl.stories.tsx b/src/stories/controls/NumericControls/NumericSliderControl.stories.tsx new file mode 100644 index 0000000..8783f91 --- /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 RequiredFloatingPointWithUnits: 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