Skip to content

Commit

Permalink
feat: Add NumericSliderControl (#25)
Browse files Browse the repository at this point in the history
* Add NumericSliderControl, SingleSlider, tests, and stories
  • Loading branch information
NathanFarmer authored Mar 13, 2024
1 parent a3e6ab6 commit 34ac5cf
Show file tree
Hide file tree
Showing 7 changed files with 421 additions and 2 deletions.
51 changes: 51 additions & 0 deletions src/antd/Slider.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AntdSlider> => {
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 <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,
})

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%")
})
})
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 { 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 (
<Form.Item
label={props.label}
id={props.id}
name={props.path}
required={props.required}
initialValue={initialValue}
rules={rules}
validateTrigger={["onBlur"]}
>
<Row>
<Col span={8}>{Slider({...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
}),
)
15 changes: 14 additions & 1 deletion src/renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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),
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 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,
},
}
Loading

0 comments on commit 34ac5cf

Please sign in to comment.