Skip to content

Commit

Permalink
Add NumericSliderControl, SingleSlider, tests, and stories
Browse files Browse the repository at this point in the history
  • Loading branch information
NathanFarmer committed Mar 8, 2024
1 parent 14d70f5 commit 97aa738
Show file tree
Hide file tree
Showing 10 changed files with 418 additions and 11 deletions.
52 changes: 52 additions & 0 deletions src/antd/SliderSingle.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AntdSlider>
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 <AntdSlider
defaultValue={defaultValue}
value={value}
onChange={(value) => handleChange(value)}
min={min}
max={max}
step={step}
tooltip={tooltip}
/>
}
98 changes: 98 additions & 0 deletions src/controls/NumericControls/NumericSliderControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
32 changes: 32 additions & 0 deletions src/controls/NumericControls/NumericSliderControl.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Form.Item
label={props.label}
id={props.id}
name={props.path}
required={props.required}
initialValue={initialValue}
rules={rules}
validateTrigger={["onBlur"]}
>
<Row>
<Col span={8}>{SliderSingle({...props})}</Col><Col span={7}>{InputNumber({...props})}</Col>
</Row>
</Form.Item>
)
}
9 changes: 8 additions & 1 deletion src/controls/NumericControls/testers.ts
Original file line number Diff line number Diff line change
@@ -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
}),
)
7 changes: 5 additions & 2 deletions src/renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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[] = [
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof StorybookAntDJsonForm> = {
title: "Control/Numeric Slider",
component: StorybookAntDJsonForm,
tags: ["autodocs"],
args: {
uiSchema: numericSliderUISchema,
},
argTypes: {
uiSchema: {
control: "object",
},
}
}

export default meta
type Story = StoryObj<typeof StorybookAntDJsonForm>

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,
},
}
Loading

0 comments on commit 97aa738

Please sign in to comment.