Skip to content

Commit

Permalink
Merge pull request #36 from capply/dynamic-schema
Browse files Browse the repository at this point in the history
Allow schema to be dynamic
  • Loading branch information
jnicklas authored Sep 9, 2024
2 parents f331b76 + 7cd3f65 commit 1d4ff82
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/yellow-lizards-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"signal-form": minor
---

React to schema changes dynamically
8 changes: 4 additions & 4 deletions lib/create-form-context.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FormEventHandler } from "react";
import type { Signal } from "@preact/signals-react";
import type { ReadonlySignal, Signal } from "@preact/signals-react";
import { signal } from "@preact/signals-react";
import type { AnyObjectSchema, InferType } from "yup";
import type { FormContext } from "~/context";
Expand All @@ -12,7 +12,7 @@ type CreateFormContextOptions<S extends AnyObjectSchema> = {
submittedData?: any;
defaultData?: DeepPartial<InferType<S>>;
data?: Signal<InferType<S>>;
schema?: S;
schema: ReadonlySignal<S | undefined>;
id: string;
onSubmit?: FormEventHandler<HTMLFormElement>;
};
Expand All @@ -33,8 +33,8 @@ export function createFormContext<S extends AnyObjectSchema>({
let didSubmit = signal(!!submittedData);

function validate(): ValidationResult<InferType<S>> {
if (schema) {
let validationResult = validateSync(schema, data.value);
if (schema.value) {
let validationResult = validateSync(schema.value, data.value);
if (validationResult.ok) {
errors.value = [];
result.value = validationResult.data;
Expand Down
14 changes: 10 additions & 4 deletions lib/form.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { FormHTMLAttributes, ForwardedRef, ReactNode } from "react";
import { forwardRef, useId, useMemo } from "react";
import type { Signal } from "@preact/signals-react";
import { forwardRef, useEffect, useId, useMemo } from "react";
import { useSignal, type Signal } from "@preact/signals-react";
import type { AnyObjectSchema, InferType } from "yup";
import { FieldsContext, FormContext } from "~/context";
import type { ValidationError } from "~/utils/validate";
import { type ValidationError } from "~/utils/validate";
import { createFormContext } from "./create-form-context";
import type { DeepPartial } from "./utils/deep-partial";

Expand Down Expand Up @@ -32,19 +32,25 @@ export const Form = forwardRef(
ref: ForwardedRef<HTMLFormElement>
): JSX.Element => {
let formId = useId();
let schemaSignal = useSignal(schema);

let formContext: FormContext<S> = useMemo(() => {
return createFormContext<S>({
submittedErrors,
submittedData,
defaultData,
data,
schema,
schema: schemaSignal,
id: id || formId,
onSubmit,
});
}, []);

useEffect(() => {
schemaSignal.value = schema;
formContext.validate();
}, [schema]);

return (
<form
id={id || formId}
Expand Down
10 changes: 8 additions & 2 deletions lib/remix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import type { FormProps as RemixFormProps } from "@remix-run/react";
import type { ForwardedRef, ReactNode } from "react";
import { forwardRef, useEffect, useId, useMemo } from "react";
import type { Signal } from "@preact/signals-react";
import { useSignal, type Signal } from "@preact/signals-react";
import type { AnyObjectSchema, InferType } from "yup";
import { FieldsContext, FormContext, useFormContext } from "~/context";
import type { ErrorActionData, ValidationErrorResult } from "~/utils/validate";
Expand Down Expand Up @@ -38,6 +38,7 @@ export const Form = forwardRef(
): JSX.Element => {
let actionData = useActionData<ErrorActionData | undefined>();
let formId = useId();
let schemaSignal = useSignal(schema);

useEffect(() => {
if (actionData?.errors) {
Expand All @@ -51,12 +52,17 @@ export const Form = forwardRef(
submittedData: actionData?.input,
defaultData,
data,
schema,
schema: schemaSignal,
id: id || formId,
onSubmit,
});
}, []);

useEffect(() => {
schemaSignal.value = schema;
formContext.validate();
}, [schema]);

return (
<RemixForm
id={id || formId}
Expand Down
72 changes: 72 additions & 0 deletions stories/DynamicSchema.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { StoryObj, Meta } from "@storybook/react";

import { Input, Form, schema } from "~/index";
import { createRemixStoryDecorator } from "./utils/decorators";
import { FieldErrors } from "~/controls/field-errors";
import { useState } from "react";
import { expect, userEvent, within } from "@storybook/test";

// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction
const meta = {
title: "SignalForm/DynamicSchema",
component: Form,
decorators: [createRemixStoryDecorator()],
} satisfies Meta<typeof Form>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
render() {
let [required, setRequired] = useState(false);

let Schema = schema.object().shape({
title: required ? schema.string().required() : schema.string(),
});

return (
<Form schema={Schema}>
<p>
<label>
<input
checked={required}
onChange={(e) => setRequired(e.target.checked)}
type="checkbox"
/>
Required title
</label>
</p>
<p>
<label>
Title
<br />
<Input name="title" />
</label>
</p>
<FieldErrors name="title" />
</Form>
);
},
};

export const Test: Story = {
...Default,
play: async ({ canvasElement }) => {
let canvas = within(canvasElement);

let requiredCheckbox = await canvas.findByLabelText("Required title");
let titleInput = await canvas.findByLabelText("Title");

await userEvent.type(titleInput, "Hello", { delay: 10 });
await userEvent.clear(titleInput);

await expect(canvas.queryByText("title is a required field")).toBeNull();

await userEvent.click(requiredCheckbox);

await canvas.findByText("title is a required field");
await userEvent.type(titleInput, "Hello", { delay: 10 });

await expect(canvas.queryByText("title is a required field")).toBeNull();
},
};

0 comments on commit 1d4ff82

Please sign in to comment.