-
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.
feat: Add
NumericSliderControl
(#25)
* Add NumericSliderControl, SingleSlider, tests, and stories
- Loading branch information
1 parent
a3e6ab6
commit 34ac5cf
Showing
7 changed files
with
421 additions
and
2 deletions.
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,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
98
src/controls/NumericControls/NumericSliderControl.test.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,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%") | ||
}) | ||
}) |
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,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> | ||
) | ||
} |
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 |
---|---|---|
@@ -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 | ||
}), | ||
) |
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
63 changes: 63 additions & 0 deletions
63
src/stories/controls/NumericControls/NumericSliderControl.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,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, | ||
}, | ||
} |
Oops, something went wrong.