Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add NumericSliderControl #25

Merged
merged 29 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
14d70f5
Add NumericControl, InputNumber, tests, and stories
NathanFarmer Mar 8, 2024
97aa738
Add NumericSliderControl, SingleSlider, tests, and stories
NathanFarmer Mar 8, 2024
3126a3b
Remove deprecated NumberControl tests
NathanFarmer Mar 8, 2024
7245b92
Revert tsconfig changes
NathanFarmer Mar 8, 2024
469a3e6
Unused imports
NathanFarmer Mar 8, 2024
690707f
Improve variable name
NathanFarmer Mar 8, 2024
2a2cd8e
Improve variable name
NathanFarmer Mar 8, 2024
715aafe
Rename story
NathanFarmer Mar 8, 2024
bda4779
Merge branch 'main' into f/FE-59/add-numeric-control
NathanFarmer Mar 11, 2024
920726a
Merge branch 'f/FE-59/add-numeric-control' of github.com:great-expect…
NathanFarmer Mar 11, 2024
c357b0b
Delete src/controls/NumberControl.test.tsx
NathanFarmer Mar 11, 2024
8c5243d
Cleanup merge
NathanFarmer Mar 11, 2024
0f1a596
Merge branch 'f/FE-59/add-numeric-slider-control' of github.com:great…
NathanFarmer Mar 11, 2024
08aefa7
Add type InputNumberOptions
NathanFarmer Mar 13, 2024
98dcc50
Make linter happy
NathanFarmer Mar 13, 2024
2d95230
Merge branch 'main' into f/FE-59/add-numeric-control
NathanFarmer Mar 13, 2024
69f3dc9
Add NumericControl to registry
NathanFarmer Mar 13, 2024
c386cc5
Merge branch 'f/FE-59/add-numeric-control' of github.com:great-expect…
NathanFarmer Mar 13, 2024
8ed9805
One more linter error
NathanFarmer Mar 13, 2024
ac3dcd3
Add new test for integer rounding
NathanFarmer Mar 13, 2024
a3f12c9
Move parser cast to higher level
NathanFarmer Mar 13, 2024
f47f841
Import types
NathanFarmer Mar 13, 2024
d767581
Merge branch 'f/FE-59/add-numeric-control' into f/FE-59/add-numeric-s…
NathanFarmer Mar 13, 2024
6b35ea5
Make linter happy
NathanFarmer Mar 13, 2024
da644e2
Rename SliderSingle to Slider
NathanFarmer Mar 13, 2024
2fb0441
Merge branch 'main' into f/FE-59/add-numeric-slider-control
NathanFarmer Mar 13, 2024
63b3b3a
Rename SliderSingle to Slider
NathanFarmer Mar 13, 2024
07c0247
Merge branch 'f/FE-59/add-numeric-slider-control' of github.com:great…
NathanFarmer Mar 13, 2024
946ef26
Merge branch 'main' into f/FE-59/add-numeric-slider-control
NathanFarmer Mar 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/antd/InputNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ReactElement } from "react"
import { ControlProps, RendererProps } from "@jsonforms/core"
import { InputNumber as AntdInputNumber } from "antd"
import { coerceToInteger, coerceToNumber, decimalToPercentage, percentageStringToDecimal } from "../controls/utils"

type InputNumber = ReactElement<typeof AntdInputNumber>
type AntdInputNumberProps = React.ComponentProps<typeof AntdInputNumber>
type InputNumberProps = AntdInputNumberProps & RendererProps & ControlProps

export const InputNumber = (props: InputNumberProps): InputNumber => {
const schema = props.schema
const ariaLabel = props.label || schema.description || "Value"

const defaultValue = schema.default as number | undefined
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 value = props.data === undefined || isDataEmptyObj ? defaultValue : props.data as number

const numberType = schema.type
const isInteger = (typeof numberType === "string" && numberType === "integer") || (Array.isArray(numberType) && numberType.includes("integer"))
const handleChange = (value: number | null) => {
if (typeof value === "number") {
if (isInteger) {
props.handleChange(props.path, coerceToInteger(value))
} else {
props.handleChange(props.path, coerceToNumber(value))
}
} else {
props.handleChange(props.path, value)
}
}

const addonAfter = props.uischema.options?.addonAfter as string | undefined
const addonBefore = props.uischema.options?.addonBefore as string | undefined
const isPercentage = addonAfter?.trim() === "%"

const min = schema.minimum
const max = schema.maximum
const marginLeft = min === undefined || max === undefined ? 0 : 16
const style = { marginLeft: marginLeft, width: "100%" }

const formatter = ((value?: string | number): string => {
if (value !== "" && value !== undefined) {
if (isPercentage) {
const valueFloat = typeof value === "string" ? parseFloat(value) : value
return decimalToPercentage(valueFloat)
} else {
return value.toString()
}
}
return ""
})
const parser = ((value?: string): number => {
const isNumeric = value ? !isNaN(Number(value)) : false
if (isNumeric && value !== undefined) {
if (isPercentage) {
return percentageStringToDecimal(value)
} else if (numberType === "integer") {
return Math.round(parseFloat(value))
} else {
return parseFloat(value)
}
}
// this allows us to return undefined for cases where the value has been deleted
// when InputNumber is paired with a Slider, the Slider value selector will disappear
// for required fields an error message will show instead of jumping to some default value
return undefined as unknown as number
})

return <AntdInputNumber
aria-label={ariaLabel}
defaultValue={defaultValue}
value={value}
onChange={(value) => handleChange(value)}
min={min}
max={max}
addonBefore={addonBefore}
addonAfter={addonAfter}
style={style}
formatter={formatter}
parser={parser}
controls={false}
/>
}
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 => {
NathanFarmer marked this conversation as resolved.
Show resolved Hide resolved
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}
/>
}
137 changes: 137 additions & 0 deletions src/controls/NumericControls/NumericControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { JSONSchema } from "json-schema-to-ts"
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 {
numericMagnitudeSchema,
numericTheNumberSchema,
numericWeightSchema,
numericUISchema,
numericUISchemaWithRule,
numericPriceSchema,
numericUSDUISchema,
} from "../../testSchemas/numericSchema/numericSchema"


describe("NumericControl", () => {
it("does not fall back to default if value is empty", () => {
render({
schema: numericTheNumberSchema,
data: {},
})

expect(screen.getByRole("spinbutton")).toHaveValue("")
})

it("calls onChange with number values", async () => {
let data = { numericValue: 42.00 }
render({
schema: numericTheNumberSchema,
data,
onChange: (state) => {
data = state.data
},
})

await userEvent.clear(screen.getByRole("spinbutton"))
await userEvent.type(screen.getByRole("spinbutton"), "42.00")

await waitFor(() => {
expect(data).toBe(42.00)
})
})

it("calls onChange with empty object and no errors when the input gets cleared out and optional", async () => {
const weight = {}
let state: Record<string, unknown> = {}
render({
schema: numericWeightSchema,
data: weight,
onChange: (newState) => {
state = newState
},
})

await userEvent.clear(screen.getByRole("spinbutton"))

await waitFor(() => {
expect(state.data).toBe(weight)
expect(state.errors).toHaveLength(0)
})
})

test("renders a number input with no UISchema provided", () => {
render({
schema: numericMagnitudeSchema,
})

expect(screen.getByText("Magnitude")).not.toBeNull()
})

it("Follows the hide rule", () => {
const data = { numericValue: 1000 }
render({
data: data,
schema: numericMagnitudeSchema,
uischema: numericUISchemaWithRule,
})
expect(screen.queryByText("Magnitude")).toBeNull()
})

it.each([[0], [100]])("renders when data of %s is included", (dataVal: number) => {
const data = { numericValue: dataVal}
render({
data: data,
schema: numericTheNumberSchema, // this has a default of 42.42
uischema: numericUISchema,
})
expect(screen.getByText("The Number")).not.toBeNull()
expect(screen.getByRole("spinbutton")).toHaveValue(`${dataVal}`)
})

it.each([[numericTheNumberSchema]])("renders default value when no data is provided", (schema: JSONSchema) => {
render({
schema: schema,
uischema: numericUISchema,
})
expect(screen.getByRole("spinbutton")).toHaveValue("42.42")
})

it("changes its value when users type", async () => {
let data: JSONSchema
render({
schema: numericMagnitudeSchema,
uischema: numericUISchema,
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({ numericValue: 123 })
})
})

it ("shows error message onBlur when field is required and empty", async () => {
render({
schema: numericTheNumberSchema,
uischema: numericUISchema,
})
const input = screen.getByRole("spinbutton")
await userEvent.clear(input)
await userEvent.tab()
expect(await screen.findByText("The Number is required")).not.toBeNull()
})

it ("shows units next to text input if set in UI schema", async () => {
render({
schema: numericPriceSchema,
uischema: numericUSDUISchema,
})
expect(await screen.findByText("$")).not.toBeNull()
})
})
29 changes: 29 additions & 0 deletions src/controls/NumericControls/NumericControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ControlProps, RendererProps } from "@jsonforms/core"
import { Col, Form } from "antd"
import { Rule } from "antd/lib/form"
import { InputNumber } from "../../antd/InputNumber"


export const NumericControl = (props: ControlProps & RendererProps) => {
if (!props.visible) return null

const initialValue = typeof props.schema.default === "number" ? props.schema.default : undefined

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"]}
>
<Col span={18}>{InputNumber({...props})}</Col>
</Form.Item>
)
}
Loading