Skip to content

Commit

Permalink
feat: add password option to Text Control, update regex validation to…
Browse files Browse the repository at this point in the history
… use antd rule interface (#35)
  • Loading branch information
DrewHoo authored Mar 14, 2024
1 parent a1509fd commit 2d1a5f9
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 28 deletions.
5 changes: 3 additions & 2 deletions src/antd/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CellProps, WithClassname, Helpers } from "@jsonforms/core"
import type { CellProps, WithClassname } from "@jsonforms/core"
import { Helpers } from "@jsonforms/core"
import { Checkbox as AntDCheckbox } from "antd"
import { CheckboxChangeEvent } from "antd/es/checkbox"
import type { CheckboxChangeEvent } from "antd/es/checkbox"

interface CheckboxProps extends CellProps, WithClassname {
label?: string
Expand Down
4 changes: 3 additions & 1 deletion src/antd/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export const Slider = (props: SliderProps): ReactElement<typeof AntdSlider> => {
const formattedTooltipValue = isPercentage
? decimalToPercentage(tooltipValue)
: tooltipValue
return `${addonBefore ? addonBefore : ""}${formattedTooltipValue}${addonAfter ? addonAfter : ""}`
return `${addonBefore ? addonBefore : ""}${formattedTooltipValue}${
addonAfter ? addonAfter : ""
}`
},
}

Expand Down
8 changes: 8 additions & 0 deletions src/common/schema-derived-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { FromSchema, JSONSchema } from "json-schema-to-ts"

export type JSONFormData<T extends JSONSchema> = RecursivePartial<FromSchema<T>>
export type JSONData<T extends JSONSchema> = FromSchema<T>

type RecursivePartial<T> = T extends object
? { [K in keyof T]?: RecursivePartial<T[K]> }
: T
4 changes: 2 additions & 2 deletions src/controls/NumericControls/NumericControl.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ControlProps, RendererProps } from "@jsonforms/core"
import type { ControlProps, RendererProps } from "@jsonforms/core"
import { Col, Form } from "antd"
import { Rule } from "antd/lib/form"
import type { Rule } from "antd/es/form"
import { InputNumber } from "../../antd/InputNumber"

export const NumericControl = (props: ControlProps & RendererProps) => {
Expand Down
4 changes: 2 additions & 2 deletions src/controls/NumericControls/NumericSliderControl.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ControlProps, RendererProps } from "@jsonforms/core"
import type { ControlProps, RendererProps } from "@jsonforms/core"
import { Col, Form, Row } from "antd"
import { Rule } from "antd/lib/form"
import type { Rule } from "antd/es/form"
import { InputNumber } from "../../antd/InputNumber"
import { Slider } from "../../antd/Slider"

Expand Down
113 changes: 111 additions & 2 deletions src/controls/TextControl.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import { test, expect } from "vitest"
import { screen } from "@testing-library/react"
import { screen, waitFor } from "@testing-library/react"
import { userEvent } from "@testing-library/user-event"
import type { JSONSchema } from "json-schema-to-ts"

import { render } from "../common/test-render"
import type { UISchema } from "../ui-schema"
import type { JSONFormData } from "../common/schema-derived-types"

const textInputSchema = {
type: "object",
properties: { foo: { type: "string", title: "Foo" } },
additionalProperties: false,
} satisfies JSONSchema

test("falls back to default value if value is undefined", async () => {
const defaultValueTextInputSchema = {
type: "object",
properties: {
foo: { type: "string", title: "Foo", default: "i love pizza" },
},
additionalProperties: false,
} satisfies JSONSchema

test("renders data that the user enters", async () => {
render({
schema: {
type: "object",
Expand All @@ -16,3 +34,94 @@ test("falls back to default value if value is undefined", async () => {
await screen.findByDisplayValue("abc")
expect(input).toHaveValue("abc")
})

test("renders default value when present", async () => {
render({
schema: defaultValueTextInputSchema,
})
await waitFor(() => {
expect(
screen.getByPlaceholderText(
"Enter " + defaultValueTextInputSchema.properties.foo.title,
{ exact: false },
),
).toHaveValue(defaultValueTextInputSchema.properties.foo.default)
})
})

test("updates jsonforms data as expected", async () => {
let data: JSONFormData<typeof textInputSchema> = {}
render({
schema: textInputSchema,
data,
onChange: (result) => {
data = result.data
},
})

const input = screen.getByPlaceholderText(
"Enter " + textInputSchema.properties.foo.title,
{ exact: false },
)

await userEvent.clear(input)
await userEvent.type(input, "a")
await waitFor(() => {
expect(data).toEqual({ foo: "a" })
})
})

test("renders a password when present", async () => {
const passwordUISchema: UISchema = {
type: "Control",
scope: "#/properties/secret",
options: { type: "password" },
}
const passwordSchema = {
properties: { secret: { type: "string", title: "Secret" } },
} satisfies JSONSchema

render({ schema: passwordSchema, uischema: passwordUISchema })
await screen.findByPlaceholderText(
"Enter " + passwordSchema.properties.secret.title,
{ exact: false },
)
expect(
(screen.getByLabelText("Secret") satisfies HTMLInputElement).type,
).toEqual("password")
})

test("renders error messages from rule validation", async () => {
const patternUISchema: UISchema = {
type: "Control",
scope: "#/properties/name",
options: {
rules: [
{
pattern: "^[a-zA-Z ]*$",
message: "Only letters are allowed",
},
],
},
}

const patternSchema = {
type: "object",
properties: { name: { type: "string", title: "Name" } },
} satisfies JSONSchema

render({
schema: patternSchema,
uischema: patternUISchema,
})

const inputElement = await screen.findByPlaceholderText(
"Enter " + patternSchema.properties.name.title,
{ exact: false },
)

await userEvent.type(inputElement, "123")
await userEvent.tab() // to trigger onBlur validation

await screen.findByText("Only letters are allowed")
})
38 changes: 23 additions & 15 deletions src/controls/TextControl.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { ControlProps } from "@jsonforms/core"
import { ChangeEvent, useCallback, useEffect } from "react"
import { Input, Form, InputProps } from "antd"
import type { ChangeEvent } from "react"
import { useCallback, useEffect } from "react"
import type { InputProps } from "antd"
import { Input, Form } from "antd"
import { QuestionCircleOutlined } from "@ant-design/icons"
import { TextAreaProps } from "antd/lib/input"
import { TextControlOptions, TextControlType } from "../ui-schema"
import { assertNever } from "../common/assert-never"
import type { Rule } from "antd/es/form"
import type { TextAreaProps } from "antd/es/input"
import type { ControlProps } from "@jsonforms/core"

import type { TextControlOptions, TextControlType } from "../ui-schema"
import { assertNever } from "../common/assert-never"
interface TextControlProps extends ControlProps {
data: string // TODO: ensure this is true via tester OR change to unknown
data: string
handleChange(path: string, value: string): void
path: string
}
Expand Down Expand Up @@ -39,6 +42,14 @@ export function TextControl({
const tooltip = options.tooltip
const placeholderText = options.placeholderText
const form = Form.useFormInstance()
const rules: Rule[] = [
{
required: required || options.required,
whitespace: required,
message: required ? `${label} is required` : "",
},
...(options?.rules ? options.rules : []),
]
useEffect(() => {
form.setFieldValue(path, setInitialValue(data ?? schema.default))
}, [data, form, path, schema.default, setInitialValue])
Expand All @@ -48,14 +59,7 @@ export function TextControl({
label={label}
id={id}
name={path}
rules={[
{
required: required || options.required,
whitespace: required,
message: required ? `${label} is required` : "",
},
...(schema.pattern ? [{ pattern: new RegExp(schema.pattern) }] : []),
]}
rules={rules}
validateTrigger={["onBlur"]}
{...(tooltip
? {
Expand All @@ -70,6 +74,7 @@ export function TextControl({
type={textControlType}
aria-label={label || schema.description}
disabled={!enabled}
autoComplete="off"
onChange={(e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
handleChange(path, e.target.value)
}
Expand All @@ -84,6 +89,7 @@ export function TextControl({
type TextControlInputProps =
| (InputProps & { type: "singleline" })
| (TextAreaProps & { type: "multiline" })
| (InputProps & { type: "password" })

function TextControlInput({ type, ...rest }: TextControlInputProps) {
switch (type) {
Expand All @@ -93,6 +99,8 @@ function TextControlInput({ type, ...rest }: TextControlInputProps) {
case "singleline":
// idk why type isn't getting narrowed properly here, but cast seems safe
return <Input {...(rest as InputProps)} />
case "password":
return <Input.Password {...(rest as InputProps)} />
default:
try {
assertNever(type)
Expand Down
43 changes: 42 additions & 1 deletion src/stories/controls/TextControl.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Meta, StoryObj } from "@storybook/react"
import { rendererRegistryEntries } from "../../renderers"
import { UISchema } from "../../ui-schema"
import { TextControlOptions, UISchema } from "../../ui-schema"
import { StorybookAntDJsonForm } from "../../common/StorybookAntDJsonForm"

const schema = {
Expand Down Expand Up @@ -78,3 +78,44 @@ export const MultiLine: Story = {
} satisfies UISchema,
},
}

export const Password: Story = {
args: {
jsonSchema: schema,
uiSchema: {
type: "VerticalLayout",
elements: [
{
type: "Control",
scope: "#/properties/name",
label: "Name",
options: { type: "password" },
},
],
} satisfies UISchema,
},
}

export const RuleDefinedInUISchema: Story = {
args: {
jsonSchema: schema,
uiSchema: {
type: "VerticalLayout",
elements: [
{
type: "Control",
scope: "#/properties/name",
label: "Name",
options: {
rules: [
{
pattern: new RegExp("^(?! ).*(?<! )$"), // no leading or trailing spaces
message: "Name cannot start or end with a space",
},
],
} satisfies TextControlOptions,
},
],
} satisfies UISchema,
},
}
8 changes: 5 additions & 3 deletions src/ui-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { JsonSchema } from "@jsonforms/core"
import { AlertProps, InputNumberProps } from "antd"
import type { JsonSchema } from "@jsonforms/core"
import type { AlertProps, InputNumberProps } from "antd"
import type { RuleObject as AntDRule } from "antd/es/form"

// jsonforms has composed their types in such a way that recursive types only specify the "base" type
// this type is intended to fix that problem in the short term so that we can have strong type checking
Expand Down Expand Up @@ -145,13 +146,14 @@ export type OneOfControlOptions = {
toggleLabel?: string
}

export type TextControlType = "multiline" | "singleline"
export type TextControlType = "multiline" | "password" | "singleline"

export type TextControlOptions = {
type?: TextControlType
tooltip?: string
placeholderText?: string
required?: boolean
rules: AntDRule[]
}

export type AnyOfControlOptions = {
Expand Down

0 comments on commit 2d1a5f9

Please sign in to comment.