-
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.
Merge branch 'main' into play-with-controls
- Loading branch information
Showing
10 changed files
with
552 additions
and
14 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
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,102 @@ | ||
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 { | ||
numberBasisPointsSchema, | ||
numberBasisPointsUISchema, | ||
numberMagnitudeSchema, | ||
numberMagnitudeUISchema, | ||
numberTheNumberSchema, | ||
numberTheNumberUISchema, | ||
numberUISchemaWithRule, | ||
} from "../testSchemas/numberSchema" | ||
|
||
describe("NumberControl", () => { | ||
test("renders a number input with no UISchema provided", () => { | ||
render({ | ||
schema: numberMagnitudeSchema, | ||
}) | ||
|
||
expect(screen.getByText("Magnitude")).not.toBeNull() | ||
}) | ||
|
||
it("Follows the hide rule", () => { | ||
const data = { magnitude: 1000 } | ||
render({ | ||
data: data, | ||
schema: numberMagnitudeSchema, | ||
uischema: numberUISchemaWithRule, | ||
}) | ||
expect(screen.queryByText("Magnitude")).toBeNull() | ||
}) | ||
|
||
it.each([[0], [100]])("renders when data of %s is included", (dataVal: number) => { | ||
const data = { theNumber: dataVal} | ||
render({ | ||
data: data, | ||
schema: numberTheNumberSchema, // this has a default of 42.42 | ||
uischema: numberTheNumberUISchema, | ||
}) | ||
expect(screen.getByText("The Number")).not.toBeNull() | ||
expect(screen.getByRole("spinbutton")).toHaveValue(`${dataVal}`) | ||
}) | ||
|
||
it.each([[0], [100]])("renders default value of %s when no data is provided", (defaultValue: number) => { | ||
const { properties, ...rest } = numberMagnitudeSchema | ||
properties.magnitude = { ...properties.magnitude, ...{ default: defaultValue }} | ||
render({ | ||
schema: { ...rest, properties }, | ||
uischema: numberMagnitudeUISchema, | ||
}) | ||
|
||
expect(screen.getByText("Magnitude")).not.toBeNull() | ||
expect(screen.getByRole("spinbutton")).toHaveValue(`${defaultValue}`) | ||
}) | ||
it("renders default value when no data is provided", () => { | ||
render({ | ||
schema: numberTheNumberSchema, | ||
uischema: numberTheNumberUISchema, | ||
}) | ||
expect(screen.getByRole("spinbutton")).toHaveValue("42.42") | ||
}) | ||
|
||
it("changes its value when users type", async () => { | ||
let data: JSONSchema | ||
render({ | ||
schema: numberMagnitudeSchema, | ||
uischema: numberMagnitudeUISchema, | ||
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({ magnitude: 123 }) | ||
}) | ||
}) | ||
|
||
it("renders slider when min max values are present", () => { | ||
const data = { basisPoints: 1 } | ||
render({ | ||
data: data, | ||
schema: numberBasisPointsSchema, | ||
uischema: numberBasisPointsUISchema, | ||
}) | ||
expect(screen.getByText("Basis Points")).not.toBeNull() | ||
expect(screen.getByRole("spinbutton")).toHaveValue("1") | ||
expect(screen.getByRole("slider")).not.toBeNull() | ||
expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "1") | ||
}) | ||
it("hides slider when min max values are not present", () => { | ||
render({ | ||
schema: numberMagnitudeSchema, | ||
uischema: numberMagnitudeUISchema, | ||
}) | ||
expect(screen.queryByRole("slider")).toBeNull() | ||
}) | ||
}) |
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,5 @@ | ||
import { createNumericControl } from "./NumericControl" | ||
|
||
export const NumberControl = createNumericControl({ | ||
coerceNumber: (value) => Number(value), | ||
}) |
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,55 @@ | ||
import { describe, expect, it } from "vitest" | ||
import { screen, waitFor } from "@testing-library/react" | ||
import { userEvent } from "@testing-library/user-event" | ||
import { render } from "../common/test-render" | ||
import { numberTheNumberSchema, numberWeightSchema } from "../testSchemas/numberSchema" | ||
|
||
describe("NumericControl", () => { | ||
it("does not fall back to default if value is empty", () => { | ||
render({ | ||
schema: { ...numberTheNumberSchema }, | ||
data: {}, | ||
}) | ||
|
||
expect(screen.getByRole("spinbutton")).toHaveValue("") | ||
}) | ||
|
||
it("calls onChange with number values", async () => { | ||
let data = { theNumber: 42.00 } | ||
render({ | ||
schema: numberTheNumberSchema, | ||
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: numberWeightSchema, | ||
data: weight, | ||
onChange: (newState) => { | ||
state = newState | ||
}, | ||
}) | ||
|
||
await userEvent.clear(screen.getByRole("spinbutton")) | ||
|
||
await waitFor(() => { | ||
expect(state.data).toBe(weight) | ||
const errors = state.errors | ||
console.log({errors}) | ||
expect(state.errors).toHaveLength(0) | ||
}) | ||
}) | ||
}) |
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,122 @@ | ||
import { ControlProps, RendererProps } from "@jsonforms/core" | ||
import { Col, Form, InputNumber, Row, Slider } from "antd" | ||
import { decimalToPercentage } from "./utils" | ||
|
||
|
||
|
||
export const createNumericControl = (args: { coerceNumber: (value: number) => number; pattern?: string }) => { | ||
return function NumericControl({ | ||
data, | ||
handleChange, | ||
path, | ||
required, | ||
label, | ||
visible, | ||
id, | ||
schema, | ||
uischema, | ||
}: ControlProps & RendererProps) { | ||
const arialLabelWithFallback = label || schema.description || "Value" | ||
const isRequired = required || uischema.options?.required as boolean | ||
|
||
const maxStepsWithoutTextInput = 100 | ||
const { maximum, minimum, multipleOf } = schema | ||
const isRangeDefined = typeof maximum === "number" && typeof minimum === "number" | ||
let step: number | undefined = undefined | ||
let stepCount: number | undefined = undefined | ||
if (isRangeDefined) { | ||
const range = Math.abs(maximum - minimum) | ||
step = multipleOf || (range / maxStepsWithoutTextInput) | ||
stepCount = range / step | ||
} | ||
const isLargeStepCount = stepCount && stepCount > maxStepsWithoutTextInput | ||
|
||
const initialValue: number | undefined = typeof schema?.default === "number" ? schema.default : minimum | ||
const isEmptyObj = typeof data === "object" && data !== undefined && data !== null ? Object.keys(data as object).length === 0 : false | ||
const value = data === undefined || isEmptyObj ? initialValue : data as number | null | ||
|
||
const addonAfter = uischema.options?.addonAfter as string | undefined | ||
const addonBefore = uischema.options?.addonBefore as string | undefined | ||
const isPercentage = addonAfter?.trim() === "%" | ||
|
||
const onChange = (value: number | null) => { | ||
if ((typeof value === "number" && (!isRangeDefined || (isRangeDefined && value >= minimum && value <= maximum))) || value === null) { | ||
handleChange(path, value !== null ? args.coerceNumber(value) : value) | ||
} | ||
} | ||
|
||
const marginLeft = isRangeDefined ? 16 : 0 | ||
const style = { marginLeft: marginLeft, width: "100%" } | ||
const formatter = ((value?: number) => { | ||
if (typeof value !== "undefined") { | ||
if (isPercentage) { | ||
return decimalToPercentage(value) | ||
} else { | ||
return value.toString() | ||
} | ||
} | ||
return "" | ||
}) | ||
|
||
const numberInput = ( | ||
<InputNumber | ||
aria-label={arialLabelWithFallback} | ||
value={value} | ||
defaultValue={initialValue} | ||
pattern={args.pattern} | ||
onChange={onChange} | ||
style={style} | ||
max={maximum} | ||
min={minimum} | ||
formatter={formatter} | ||
controls={false} | ||
addonAfter={addonAfter} | ||
addonBefore={addonBefore} | ||
/> | ||
) | ||
|
||
if (!visible) return null | ||
|
||
const tooltip = { | ||
formatter: (value?: number) => { | ||
if (isPercentage) { | ||
return `${decimalToPercentage(value || initialValue)}%` | ||
} else { | ||
return `${addonBefore ? addonBefore : ""}${value || initialValue}${addonAfter ? addonAfter : ""}` | ||
} | ||
} | ||
} | ||
|
||
const slider = <Slider | ||
value={value === null ? initialValue : value} | ||
defaultValue={initialValue} | ||
min={minimum} | ||
max={maximum} | ||
disabled={initialValue === null} | ||
onChange={onChange} | ||
step={step} | ||
tooltip={tooltip} | ||
/> | ||
|
||
return ( | ||
<Form.Item | ||
label={label} | ||
id={id} | ||
name={path} | ||
required={isRequired} | ||
initialValue={initialValue} | ||
rules={[{ required, message: required ? `${label} is required` : "" }]} | ||
validateTrigger={["onBlur"]} | ||
> | ||
{isRangeDefined ? ( | ||
<Row> | ||
<Col span={8}>{slider}</Col> | ||
{isLargeStepCount ? <Col span={7}>{numberInput}</Col> : null} | ||
</Row> | ||
) : ( | ||
<Col span={18}>{numberInput}</Col> | ||
)} | ||
</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 |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { describe, expect, test } from "vitest" | ||
import { decimalToPercentage, percentageStringToDecimal } from "./utils" | ||
|
||
describe("percentageStringToDecimal", () => { | ||
test.each([ | ||
{ value: "100", expected: 1 }, | ||
{ value: "0", expected: 0 }, | ||
{ value: "50", expected: 0.5 }, | ||
{ value: "0.5", expected: 0.005 }, | ||
{ value: "0.005", expected: 0.00005 }, | ||
{ value: "0.066", expected: 0.00066 }, | ||
{ value: "85.9999999999", expected: 0.859999999999 }, | ||
])("when value is $value it returns $expected", ({ value, expected }: { value: string; expected: number }) => { | ||
expect(percentageStringToDecimal(value)).toEqual(expected) | ||
}) | ||
}) | ||
|
||
describe("decimalToPercentage", () => { | ||
test.each([ | ||
{ value: 1, expected: "100" }, | ||
{ value: 0, expected: "0" }, | ||
{ value: 0.5, expected: "50" }, | ||
{ value: 0.005, expected: "0.5" }, | ||
{ value: 0.00005, expected: "0.005" }, | ||
{ value: 0.859999999999, expected: "85.9999999999" }, | ||
])( | ||
"when value is $value it returns $expected. If symbol is provided, it is appended to the value", | ||
({ value, expected }: { value: number; expected: string }) => { | ||
expect(decimalToPercentage(value)).toEqual(expected) | ||
}, | ||
) | ||
}) |
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,9 @@ | ||
export function decimalToPercentage(value?: number) { | ||
if (value === undefined) return "" | ||
const percentage = parseFloat((value * 100).toFixed(10)) // accounting for 10 digits after the decimal point | ||
return `${percentage}` | ||
} | ||
|
||
export function percentageStringToDecimal(value: string | undefined) { | ||
return Number(value) / 100 | ||
} |
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
Oops, something went wrong.