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 25 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
83 changes: 83 additions & 0 deletions src/antd/InputNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ReactElement } from "react"
import { ControlProps, RendererProps } from "@jsonforms/core"
import { InputNumber as AntdInputNumber } from "antd"
import { InputNumberOptions } from "../ui-schema"
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.length === 1 && numberType.includes("integer"))
const handleChange = (value: number | string | 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 options = props.uischema.options as InputNumberOptions
const addonAfter = options?.addonAfter
const addonBefore = options?.addonBefore
const isPercentage = addonAfter && typeof addonAfter === "string" ? addonAfter?.trim() === "%" : false

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 | undefined => {
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)
}
}
return undefined
})

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 as AntdInputNumberProps["parser"]}
controls={false}
/>
}
156 changes: 156 additions & 0 deletions src/controls/NumericControls/NumericControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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,
numericSheepSchema,
numericBeansSchema,
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)
})

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

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

screen.getByText("Magnitude")
})

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,
})
screen.getByText("The Number")
expect(screen.getByRole("spinbutton")).toHaveValue(`${dataVal}`)
})

it("renders default value when no data is provided", () => {
render({
schema: numericTheNumberSchema,
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()
await screen.findByText("The Number is required")
})

it ("shows units next to text input if set in UI schema", async () => {
render({
schema: numericPriceSchema,
uischema: numericUSDUISchema,
})
await screen.findByText("$")
})

it.each([
numericSheepSchema,
numericBeansSchema,
])("is treated as an integer if the schema type is integer or the type is an array with only integer", async (schema: JSONSchema) => {
render({
schema: schema,
uischema: numericUISchema,
})
const input = screen.getByRole("spinbutton")
await userEvent.type(input, "123.45") // try to input a float
await userEvent.tab()
expect(input).toHaveValue("123") // it should be rounded to an integer
})
})
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>
)
}
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%")
})
})
Loading