diff --git a/src/__snapshots__/Storyshots.test.js.snap b/src/__snapshots__/Storyshots.test.js.snap index b3e796c609..8c7fe118d8 100644 --- a/src/__snapshots__/Storyshots.test.js.snap +++ b/src/__snapshots__/Storyshots.test.js.snap @@ -6564,6 +6564,7 @@ exports[`Storyshots Forms/Form builder Default 1`] = ` className="form-control" disabled={false} id="root_age" + inputMode="numeric" name="root_age" onBlur={[Function]} onChange={[Function]} @@ -6571,8 +6572,7 @@ exports[`Storyshots Forms/Form builder Default 1`] = ` placeholder="" readOnly={false} required={false} - step={1} - type="number" + type="text" value="" />
. + */ + +import CustomFormComponent from "@/bricks/renderers/CustomFormComponent"; +import { + normalizeOutgoingFormData, + normalizeIncomingFormData, +} from "@/bricks/renderers/customForm"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { type Schema } from "@/types/schemaTypes"; +import { type JsonObject } from "type-fest"; + +describe("CustomFormComponent", () => { + test("renders a text input with inputmode numeric in place of a number input", () => { + const schema: Schema = { + type: "object", + properties: { + rating: { type: "number", title: "Rating" }, + }, + }; + + const data = {}; + + // This is what we'd send to server + const outgoingData = normalizeOutgoingFormData(schema, data); + + // This is what we feed to the form + const normalizedData = normalizeIncomingFormData( + schema, + outgoingData, + ) as JsonObject; + + render( + , + ); + + expect( + // Hidden:true because Stylesheets component sets hidden unless all stylesheets are loaded + screen.getByRole("textbox", { name: "Rating", hidden: true }), + ).toHaveAttribute("inputmode", "numeric"); + + expect(screen.queryByRole("spinButton")).not.toBeInTheDocument(); + }); +}); diff --git a/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap b/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap index b2f937cdb8..d5a42d9f99 100644 --- a/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap +++ b/src/bricks/renderers/__snapshots__/customForm.test.tsx.snap @@ -57,10 +57,10 @@ exports[`form data normalization renders normalized data 1`] = ` aria-describedby="root_age__error root_age__description root_age__help" class="form-control" id="root_age" + inputmode="numeric" name="root_age" placeholder="" - step="1" - type="number" + type="text" value="" />
@@ -121,10 +121,10 @@ exports[`form data normalization renders normalized data 1`] = ` aria-describedby="root_rating__error root_rating__description root_rating__help" class="form-control" id="root_rating" + inputmode="numeric" name="root_rating" placeholder="" - step="any" - type="number" + type="text" value="" /> diff --git a/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx b/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx index 18c7f8e018..8b3fae364a 100644 --- a/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx +++ b/src/bricks/transformers/ephemeralForm/EphemeralForm.test.tsx @@ -104,4 +104,28 @@ describe("EphemeralForm", () => { ).toBeInTheDocument(); expect(screen.getByRole("strong")).toHaveTextContent("bold"); }); + + it("renders a text input with inputmode numeric in place of a number input", async () => { + getFormDefinitionMock.mockResolvedValue({ + schema: { + title: "Test Form", + type: "object", + properties: { + rating: { type: "number", title: "Rating" }, + }, + }, + uiSchema: {}, + cancelable: false, + submitCaption: "Submit", + location: "modal", + }); + + render(); + + await expect( + screen.findByRole("textbox", { name: "Rating", hidden: true }), + ).resolves.toHaveAttribute("inputmode", "numeric"); + + expect(screen.queryByRole("spinButton")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/formBuilder/BaseInputTemplate.test.tsx b/src/components/formBuilder/BaseInputTemplate.test.tsx new file mode 100644 index 0000000000..d408794fc9 --- /dev/null +++ b/src/components/formBuilder/BaseInputTemplate.test.tsx @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import BaseInputTemplate, { + type StrictBaseInputTemplateProps, +} from "@/components/formBuilder/BaseInputTemplate"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { type JSONSchema7 } from "json-schema"; +import React from "react"; + +describe("RJSF BaseInputTemplate Override", () => { + function getProps(id: string, schema: JSONSchema7, type?: string) { + return { + value: "", + id, + name: id, + schema, + options: {}, + label: "", + type, + onBlur: jest.fn(), + onChange: jest.fn(), + onFocus: jest.fn(), + // @ts-expect-error -- Required by type, not used by component + registry: undefined, + } satisfies StrictBaseInputTemplateProps; + } + + it("renders a standard text input when the type is text", () => { + const schema = { title: "Text", type: "string" } as JSONSchema7; + + render(); + + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("renders a text input with type email when the format is email", () => { + const schema = { + title: "Email", + type: "string", + format: "email", + } as JSONSchema7; + + render(); + + expect(screen.getByRole("textbox")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveAttribute("type", "email"); + }); + + it("renders a text input with type url when the format is url", () => { + const schema = { + title: "URL", + type: "string", + format: "url", + } as JSONSchema7; + + render(); + + expect(screen.getByRole("textbox")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveAttribute("type", "url"); + }); + + it("renders a file input when the format is data-url", () => { + const schema = { + title: "File", + type: "string", + format: "data-url", + } as JSONSchema7; + + const { container } = render( + , + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const input = container.querySelector("#url"); + + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("type", "file"); + }); + + it("renders a date picker when the format is date", () => { + const schema = { + title: "Date", + type: "string", + format: "date", + } as JSONSchema7; + + const { container } = render( + , + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const input = container.querySelector("#date"); + + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("type", "date"); + }); + + it("renders a date-time picker when the format is date-time", () => { + const schema = { + title: "Date-Time", + type: "string", + format: "date-time", + } as JSONSchema7; + + const { container } = render( + , + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const input = container.querySelector("#dateTime"); + + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("type", "datetime-local"); + }); + + it("renders a standard text input with inputMode numeric and a regex pattern when the type is number", () => { + const schema = { title: "Number", type: "number" } as JSONSchema7; + + render(); + + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveAttribute("type", "text"); + expect(screen.getByRole("textbox")).toHaveAttribute("inputMode", "numeric"); + }); + + it.each([ + [0, "0", "2", 2], + [1, "1", "2", 12], + [1.5, "1.5", "2", 1.52], + [1, "1", ".05", 1.05], + [-1, "-1", "2", -12], + [1.045e25, "1.045e+25", "2", 1.045e252], + [undefined, "", "2", 2], + [1.045, "1.045", "e25", 1.045e25], + ])( + "when number %d is passed as the value, it is converted to string %s; when the onChange is called with string %s, it is converted to number %d", + async (value, inputValue, typedValue, calledWith) => { + const schema = { title: "Number", type: "number" } as JSONSchema7; + const onChange = jest.fn(); + + render( + , + ); + + expect(screen.getByRole("textbox")).toHaveValue(inputValue); + + await userEvent.type(screen.getByRole("textbox"), typedValue); + + expect(screen.getByRole("textbox")).toHaveValue(inputValue + typedValue); + expect(onChange).toHaveBeenCalledWith(calledWith); + }, + ); + + it("numeric input ignores keystrokes that are not valid numbers", async () => { + const schema = { title: "Number", type: "number" } as JSONSchema7; + const onChange = jest.fn(); + + render( + , + ); + + expect(screen.getByRole("textbox")).toHaveValue(""); + + await userEvent.type(screen.getByRole("textbox"), "abc123"); + + expect(screen.getByRole("textbox")).toHaveValue("123"); + expect(onChange).toHaveBeenCalledWith(123); + }); + + it("numeric input does not lose decimal when the value is changed", async () => { + const schema = { title: "Number", type: "number" } as JSONSchema7; + const onChange = jest.fn(); + + render( + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("1"); + + await userEvent.type(screen.getByRole("textbox"), "."); + await userEvent.type(screen.getByRole("textbox"), "0"); + await userEvent.type(screen.getByRole("textbox"), "5"); + + expect(screen.getByRole("textbox")).toHaveValue("1.05"); + + expect(onChange).toHaveBeenCalledWith(1.05); + }); + + it("numeric input can be cleared", async () => { + const schema = { title: "Number", type: "number" } as JSONSchema7; + const onChange = jest.fn(); + + render( + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("1"); + + await userEvent.type(screen.getByRole("textbox"), "{backspace}"); + + expect(screen.getByRole("textbox")).toHaveValue(""); + + expect(onChange).toHaveBeenCalledWith(Number.NaN); + }); +}); diff --git a/src/components/formBuilder/BaseInputTemplate.tsx b/src/components/formBuilder/BaseInputTemplate.tsx new file mode 100644 index 0000000000..5f4468af34 --- /dev/null +++ b/src/components/formBuilder/BaseInputTemplate.tsx @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { type ChangeEvent, type FocusEvent, useState } from "react"; +import { + ariaDescribedByIds, + type BaseInputTemplateProps, + examplesId, + type FormContextType, + getInputProps, + type StrictRJSFSchema, + type InputPropsType, + type RJSFSchema, +} from "@rjsf/utils"; +import React from "react"; +import { FormControl, type FormControlProps } from "react-bootstrap"; +import { type UnknownObject } from "@/types/objectTypes"; + +// RJSF's BaseInputTemplateProps is overly permissive. Tightening it up here. +export interface StrictBaseInputTemplateProps< + T = HTMLInputElement, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = UnknownObject, +> extends BaseInputTemplateProps { + extraProps?: InputPropsType; + type?: string; + value: string | number; +} + +// eslint-disable-next-line security/detect-unsafe-regex +const DEFAULT_NUMBER_REGEX = /^-?\d*(?:\.\d*)?(?:[Ee][+-]?\d*)?$/; + +/* @since 1.8.7 + * Used for number inputs to store the value as a string + * to avoid losing decimals during the conversion to number + */ +function useNumericInput< + T = HTMLInputElement, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = UnknownObject, +>({ + value, + extraProps, + schema, + type, + options, +}: { + value: string | number; + extraProps?: InputPropsType; + schema: S; + type?: string; + options: StrictBaseInputTemplateProps["options"]; +}) { + const [storedValue, setStoredValue] = useState(value?.toString() ?? ""); + + const inputProps: FormControlProps & { + step?: number | "any"; + inputMode?: "numeric"; + } = { + ...extraProps, + ...getInputProps(schema, type, options), + }; + + // Converting number inputs to text inputs with numeric inputMode + // Removes the spinner to improve UX + // See https://github.com/pixiebrix/pixiebrix-extension/issues/7343 + if (inputProps.type === "number") { + inputProps.step = undefined; + inputProps.type = "text"; + inputProps.inputMode = "numeric"; + } + + return { storedValue, setStoredValue, inputProps }; +} + +function getValue( + value: string | number, + storedValue?: string | number, + type?: "numeric", +): string | number { + if (type === "numeric") { + return storedValue ?? ""; + } + + return value || value === 0 ? value : ""; +} + +export default function BaseInputTemplate< + T = HTMLInputElement, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = UnknownObject, +>({ + id, + placeholder, + required, + readonly, + disabled, + type, + value, + onChange, + onChangeOverride, + onBlur, + onFocus, + autofocus, + options, + schema, + rawErrors = [], + children, + extraProps, +}: StrictBaseInputTemplateProps) { + const { storedValue, setStoredValue, inputProps } = useNumericInput({ + value, + extraProps, + schema, + type, + options, + }); + + const _onChange = ({ target: { value } }: ChangeEvent) => { + let _value: string | number = value; + + if ( + inputProps.inputMode === "numeric" && + // Tried to pass regex as a pattern, but it didn't prevent invalid keystrokes + DEFAULT_NUMBER_REGEX.test(value) + ) { + setStoredValue(value); + _value = Number.parseFloat(value); + } + + onChange(_value === "" ? options.emptyValue : _value); + }; + + const _onBlur = ({ target: { value } }: FocusEvent) => { + onBlur(id, value); + }; + + const _onFocus = ({ target: { value } }: FocusEvent) => { + onFocus(id, value); + }; + + return ( + <> + 0 ? "is-invalid" : ""} + list={schema.examples ? examplesId(id) : undefined} + {...inputProps} + value={getValue(value, storedValue, inputProps.inputMode)} + onChange={onChangeOverride || _onChange} + onBlur={_onBlur} + onFocus={_onFocus} + aria-describedby={ariaDescribedByIds(id, Boolean(schema.examples))} + /> + {children} + {Array.isArray(schema.examples) ? ( + (id)}> + {[ + ...(schema.examples as string[]), + ...(schema.default && !schema.examples.includes(schema.default) + ? ([schema.default] as string[]) + : []), + ].map((example: string) => ( + + ) : null} + + ); +} diff --git a/src/components/formBuilder/RjsfTemplates.ts b/src/components/formBuilder/RjsfTemplates.ts index eaec5d272c..4a3b78898d 100644 --- a/src/components/formBuilder/RjsfTemplates.ts +++ b/src/components/formBuilder/RjsfTemplates.ts @@ -15,6 +15,7 @@ * along with this program. If not, see . */ +import BaseInputTemplate from "@/components/formBuilder/BaseInputTemplate"; import DescriptionFieldTemplate from "@/components/formBuilder/DescriptionFieldTemplate"; import FieldTemplate from "@/components/formBuilder/FieldTemplate"; import { type FormProps } from "@rjsf/core"; @@ -22,4 +23,5 @@ import { type FormProps } from "@rjsf/core"; export const templates = { FieldTemplate, DescriptionFieldTemplate, + BaseInputTemplate, } satisfies FormProps["templates"]; diff --git a/src/components/formBuilder/preview/FormPreview.test.tsx b/src/components/formBuilder/preview/FormPreview.test.tsx index 84559c913b..3a378dca94 100644 --- a/src/components/formBuilder/preview/FormPreview.test.tsx +++ b/src/components/formBuilder/preview/FormPreview.test.tsx @@ -127,4 +127,29 @@ describe("FormPreview", () => { expect(screen.getByText("A note")).toBeInTheDocument(); expect(screen.queryByText("notes")).not.toBeInTheDocument(); }); + + test("renders a text input with inputmode numeric in place of a number input", async () => { + const schema: Schema = { + title: "Form", + type: "object", + properties: { + rating: { type: "number", title: "Rating" }, + }, + }; + const uiSchema: UiSchema = {}; + + const props: FormPreviewProps = { + rjsfSchema: { schema, uiSchema }, + activeField: "notes", + setActiveField: defaultProps.setActiveField, + }; + + render(); + + await expect( + screen.findByRole("textbox", { name: "Rating", hidden: true }), + ).resolves.toHaveAttribute("inputmode", "numeric"); + + expect(screen.queryByRole("spinButton")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap b/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap index 9109a20a76..45550bd9d6 100644 --- a/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap +++ b/src/components/formBuilder/preview/__snapshots__/FormPreview.test.tsx.snap @@ -184,10 +184,10 @@ exports[`FormPreview it renders simple schema 1`] = ` aria-describedby="root_age__error root_age__description root_age__help" class="form-control" id="root_age" + inputmode="numeric" name="root_age" placeholder="" - step="any" - type="number" + type="text" value="" /> diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 2e8ac43805..3d5124b0d7 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -526,6 +526,7 @@ "./vendors/reactPerformanceTesting/utils/pushTask.ts", "./vendors/reactPerformanceTesting/utils/shouldTrack.ts", "./vendors/reactPerformanceTesting/utils/symbols.ts", - "./vendors/validateUuid.ts" + "./vendors/validateUuid.ts", + "./components/formBuilder/BaseInputTemplate.tsx" ] }