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 password option to Text Control, update regex validation to use antd rule interface #35

Merged
merged 7 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Loading