mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 20:38:21 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823259278d | ||
|
|
a5d615bc20 | ||
|
|
a153d1e23d | ||
|
|
b8f44078cd | ||
|
|
a1917c1aff | ||
|
|
45e717fa2c |
@@ -254,6 +254,8 @@ Use the built-in `validateForm` action to validate all registered fields at once
|
||||
|
||||
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
|
||||
|
||||
Controls registered through `useFieldValidation` participate in `validateForm` while they are mounted with a binding path and validation configuration. When one of these controls unmounts, changes its binding path, or removes its validation configuration, its stale errors are cleared while its stored state value is preserved. If multiple mounted controls share a path, that path remains registered until the last one unmounts. Direct calls to `useValidation().registerField()` create a durable registration that remains for the lifetime of the `ValidationProvider`.
|
||||
|
||||
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
|
||||
|
||||
## Next
|
||||
|
||||
@@ -255,6 +255,8 @@ const { errors, validate } = useFieldValidation("/form/email", {
|
||||
});
|
||||
```
|
||||
|
||||
Controls registered through `useFieldValidation` participate in form-wide validation while they are mounted with a binding path and validation configuration. Unmounting one of these controls, changing its binding, or removing its validation configuration clears that path's validation errors without deleting its stored value. Direct calls to `useValidation().registerField()` create a durable registration that remains for the lifetime of the `ValidationProvider`.
|
||||
|
||||
## Hooks
|
||||
|
||||
| Hook | Purpose |
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ValidationConfig } from "@json-render/core";
|
||||
import { StateProvider } from "./state";
|
||||
import {
|
||||
ValidationProvider,
|
||||
useFieldValidation,
|
||||
useValidation,
|
||||
} from "./validation";
|
||||
|
||||
const requiredConfig: ValidationConfig = {
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
};
|
||||
const emailConfig: ValidationConfig = {
|
||||
checks: [{ type: "email", message: "Invalid email" }],
|
||||
};
|
||||
|
||||
function Field({
|
||||
testId,
|
||||
config,
|
||||
}: {
|
||||
testId: string;
|
||||
config: ValidationConfig;
|
||||
}) {
|
||||
const { errors } = useFieldValidation("/form/name", config);
|
||||
return <span data-testid={testId}>{errors[0] ?? ""}</span>;
|
||||
}
|
||||
|
||||
function ValidatingField({
|
||||
testId,
|
||||
config,
|
||||
}: {
|
||||
testId: string;
|
||||
config: ValidationConfig;
|
||||
}) {
|
||||
const { errors, validate } = useFieldValidation("/form/name", config);
|
||||
return (
|
||||
<>
|
||||
<button onClick={validate}>Validate first</button>
|
||||
<span data-testid={testId}>{errors[0] ?? ""}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidateButton() {
|
||||
const { validateAll } = useValidation();
|
||||
return <button onClick={validateAll}>Validate</button>;
|
||||
}
|
||||
|
||||
function ImperativeRegistration({
|
||||
onRegister,
|
||||
}: {
|
||||
onRegister: (value: void) => void;
|
||||
}) {
|
||||
const { registerField } = useValidation();
|
||||
React.useEffect(() => {
|
||||
onRegister(registerField("/form/name", requiredConfig));
|
||||
}, [onRegister, registerField]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestForm({ showFirst }: { showFirst: boolean }) {
|
||||
return (
|
||||
<StateProvider initialState={{ form: { name: "not-an-email" } }}>
|
||||
<ValidationProvider>
|
||||
{showFirst && (
|
||||
<Field key="first" testId="first" config={requiredConfig} />
|
||||
)}
|
||||
<Field key="active" testId="active" config={emailConfig} />
|
||||
<ValidateButton />
|
||||
</ValidationProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LocallyValidatedSharedPathForm({
|
||||
showFirst,
|
||||
showActive = true,
|
||||
}: {
|
||||
showFirst: boolean;
|
||||
showActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<StateProvider initialState={{ form: { name: "" } }}>
|
||||
<ValidationProvider>
|
||||
{showFirst && (
|
||||
<ValidatingField testId="first" config={requiredConfig} />
|
||||
)}
|
||||
{showActive && <Field testId="active" config={emailConfig} />}
|
||||
</ValidationProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ValidationProvider registrations", () => {
|
||||
it("preserves the fire-and-forget registerField contract", () => {
|
||||
let registrationResult: unknown = "not registered";
|
||||
|
||||
render(
|
||||
<StateProvider initialState={{ form: { name: "" } }}>
|
||||
<ValidationProvider>
|
||||
<ImperativeRegistration
|
||||
onRegister={(value) => {
|
||||
registrationResult = value;
|
||||
}}
|
||||
/>
|
||||
</ValidationProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
expect(registrationResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves errors when a non-active shared-path registration unmounts", () => {
|
||||
const view = render(<TestForm showFirst />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Validate" }));
|
||||
expect(screen.getByTestId("first").textContent).toBe("Invalid email");
|
||||
expect(screen.getByTestId("active").textContent).toBe("Invalid email");
|
||||
|
||||
view.rerender(<TestForm showFirst={false} />);
|
||||
|
||||
expect(screen.getByTestId("active").textContent).toBe("Invalid email");
|
||||
});
|
||||
|
||||
it("clears errors produced by a released shared-path registration", () => {
|
||||
const view = render(<LocallyValidatedSharedPathForm showFirst />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Validate first" }));
|
||||
expect(screen.getByTestId("first").textContent).toBe("Name is required");
|
||||
expect(screen.getByTestId("active").textContent).toBe("Name is required");
|
||||
|
||||
view.rerender(<LocallyValidatedSharedPathForm showFirst={false} />);
|
||||
|
||||
expect(screen.getByTestId("active").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("preserves errors owned by the fallback active registration", () => {
|
||||
const view = render(<LocallyValidatedSharedPathForm showFirst />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Validate first" }));
|
||||
expect(screen.getByTestId("first").textContent).toBe("Name is required");
|
||||
expect(screen.getByTestId("active").textContent).toBe("Name is required");
|
||||
|
||||
view.rerender(
|
||||
<LocallyValidatedSharedPathForm showFirst showActive={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("first").textContent).toBe("Name is required");
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,9 @@ export interface ValidationContextValue {
|
||||
}
|
||||
|
||||
const ValidationContext = createContext<ValidationContextValue | null>(null);
|
||||
const MountedFieldRegistrationContext = createContext<
|
||||
((path: string, config: ValidationConfig) => () => void) | null
|
||||
>(null);
|
||||
const EMPTY_VALIDATION_FUNCTIONS: Record<string, ValidationFunction> = {};
|
||||
|
||||
/**
|
||||
@@ -61,37 +64,28 @@ export interface ValidationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two DynamicValue args records shallowly.
|
||||
* Values are primitives or { $state: string }, so shallow comparison suffices.
|
||||
*/
|
||||
function dynamicArgsEqual(
|
||||
a: Record<string, unknown> | undefined,
|
||||
b: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
/** Compare JSON-like validation config values structurally. */
|
||||
function validationValueEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a === null || b === null) return false;
|
||||
if (typeof a !== "object" || typeof b !== "object") return false;
|
||||
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (Array.isArray(a)) {
|
||||
if (!Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((value, index) => validationValueEqual(value, b[index]));
|
||||
}
|
||||
|
||||
if (Array.isArray(b)) return false;
|
||||
|
||||
const recordA = a as Record<string, unknown>;
|
||||
const recordB = b as Record<string, unknown>;
|
||||
const keysA = Object.keys(recordA);
|
||||
const keysB = Object.keys(recordB);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
const va = a[key];
|
||||
const vb = b[key];
|
||||
if (va === vb) continue;
|
||||
// Handle { $state: string } objects
|
||||
if (
|
||||
typeof va === "object" &&
|
||||
va !== null &&
|
||||
typeof vb === "object" &&
|
||||
vb !== null
|
||||
) {
|
||||
const sa = (va as Record<string, unknown>).$state;
|
||||
const sb = (vb as Record<string, unknown>).$state;
|
||||
if (typeof sa === "string" && sa === sb) continue;
|
||||
}
|
||||
return false;
|
||||
if (!(key in recordB)) return false;
|
||||
if (!validationValueEqual(recordA[key], recordB[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -103,25 +97,29 @@ function validationConfigEqual(
|
||||
a: ValidationConfig,
|
||||
b: ValidationConfig,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
return validationValueEqual(a, b);
|
||||
}
|
||||
|
||||
// Compare validateOn
|
||||
if (a.validateOn !== b.validateOn) return false;
|
||||
|
||||
// Compare checks arrays
|
||||
const ac = a.checks ?? [];
|
||||
const bc = b.checks ?? [];
|
||||
if (ac.length !== bc.length) return false;
|
||||
|
||||
for (let i = 0; i < ac.length; i++) {
|
||||
const ca = ac[i]!;
|
||||
const cb = bc[i]!;
|
||||
if (ca.type !== cb.type) return false;
|
||||
if (ca.message !== cb.message) return false;
|
||||
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
|
||||
/** Return the config that currently owns validation for a path. */
|
||||
function getActiveConfig(
|
||||
registrations: Map<symbol, ValidationConfig>,
|
||||
): ValidationConfig | undefined {
|
||||
let active: ValidationConfig | undefined;
|
||||
for (const config of registrations.values()) {
|
||||
active = config;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
return true;
|
||||
/** Whether any live registration uses the given validation config. */
|
||||
function hasRegisteredConfig(
|
||||
registrations: Map<symbol, ValidationConfig>,
|
||||
target: ValidationConfig,
|
||||
): boolean {
|
||||
for (const config of registrations.values()) {
|
||||
if (validationConfigEqual(config, target)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,23 +137,127 @@ export function ValidationProvider({
|
||||
// Mutable mirror of fieldStates for synchronous reads (e.g. reading errors
|
||||
// immediately after validateAll() before React flushes the batched setState).
|
||||
const fieldStatesRef = useRef<Record<string, FieldValidationState>>({});
|
||||
const [fieldConfigs, setFieldConfigs] = useState<
|
||||
Record<string, ValidationConfig>
|
||||
>({});
|
||||
// Tracks the config that produced each stored result so cleanup can tell
|
||||
// whether that result still belongs to any mounted registration.
|
||||
const fieldStateConfigsRef = useRef<Map<string, ValidationConfig>>(new Map());
|
||||
// Each mounted control gets its own registration. This lets one control
|
||||
// unregister without disabling another control bound to the same path.
|
||||
const fieldRegistrationsRef = useRef<
|
||||
Map<string, Map<symbol, ValidationConfig>>
|
||||
>(new Map());
|
||||
// Imperative registerField calls retain one durable registration per path.
|
||||
// Mounted controls use separate identities so they can unregister safely.
|
||||
const imperativeRegistrationIdsRef = useRef<Map<string, symbol>>(new Map());
|
||||
|
||||
const clear = useCallback((path: string) => {
|
||||
fieldStateConfigsRef.current.delete(path);
|
||||
if (!Object.prototype.hasOwnProperty.call(fieldStatesRef.current, path)) {
|
||||
return;
|
||||
}
|
||||
const next = { ...fieldStatesRef.current };
|
||||
delete next[path];
|
||||
fieldStatesRef.current = next;
|
||||
setFieldStates(next);
|
||||
}, []);
|
||||
|
||||
const registerField = useCallback(
|
||||
(path: string, config: ValidationConfig) => {
|
||||
setFieldConfigs((prev) => {
|
||||
const existing = prev[path];
|
||||
// Bail out (return same reference) if config is unchanged to avoid
|
||||
// infinite re-render loops when callers pass a fresh object each render.
|
||||
if (existing && validationConfigEqual(existing, config)) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [path]: config };
|
||||
});
|
||||
let registrations = fieldRegistrationsRef.current.get(path);
|
||||
if (!registrations) {
|
||||
registrations = new Map();
|
||||
fieldRegistrationsRef.current.set(path, registrations);
|
||||
}
|
||||
|
||||
const previousActiveConfig = getActiveConfig(registrations);
|
||||
let registrationId = imperativeRegistrationIdsRef.current.get(path);
|
||||
if (!registrationId) {
|
||||
registrationId = Symbol(path);
|
||||
imperativeRegistrationIdsRef.current.set(path, registrationId);
|
||||
}
|
||||
|
||||
const existingConfig = registrations.get(registrationId);
|
||||
if (
|
||||
existingConfig &&
|
||||
previousActiveConfig &&
|
||||
validationConfigEqual(existingConfig, config) &&
|
||||
validationConfigEqual(previousActiveConfig, config)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reinsert changed imperative registrations so the latest registration
|
||||
// keeps the same last-writer-wins behavior as the original path store.
|
||||
registrations.delete(registrationId);
|
||||
registrations.set(registrationId, config);
|
||||
if (
|
||||
previousActiveConfig &&
|
||||
!validationConfigEqual(previousActiveConfig, config)
|
||||
) {
|
||||
clear(path);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[clear],
|
||||
);
|
||||
|
||||
const registerMountedField = useCallback(
|
||||
(path: string, config: ValidationConfig) => {
|
||||
const registrationId = Symbol(path);
|
||||
let registrations = fieldRegistrationsRef.current.get(path);
|
||||
if (!registrations) {
|
||||
registrations = new Map();
|
||||
fieldRegistrationsRef.current.set(path, registrations);
|
||||
}
|
||||
|
||||
const previousActiveConfig = getActiveConfig(registrations);
|
||||
registrations.set(registrationId, config);
|
||||
if (
|
||||
previousActiveConfig &&
|
||||
!validationConfigEqual(previousActiveConfig, config)
|
||||
) {
|
||||
clear(path);
|
||||
}
|
||||
|
||||
let registered = true;
|
||||
return () => {
|
||||
if (!registered) return;
|
||||
registered = false;
|
||||
|
||||
const currentRegistrations = fieldRegistrationsRef.current.get(path);
|
||||
if (!currentRegistrations?.has(registrationId)) return;
|
||||
|
||||
const activeConfigBeforeRemoval = getActiveConfig(currentRegistrations);
|
||||
currentRegistrations.delete(registrationId);
|
||||
const activeConfigAfterRemoval = getActiveConfig(currentRegistrations);
|
||||
const fieldStateConfig = fieldStateConfigsRef.current.get(path);
|
||||
const fieldStateConfigWasReleased =
|
||||
fieldStateConfig !== undefined &&
|
||||
!hasRegisteredConfig(currentRegistrations, fieldStateConfig);
|
||||
const activeConfigChanged =
|
||||
activeConfigBeforeRemoval === undefined ||
|
||||
activeConfigAfterRemoval === undefined ||
|
||||
!validationConfigEqual(
|
||||
activeConfigBeforeRemoval,
|
||||
activeConfigAfterRemoval,
|
||||
);
|
||||
const fieldStateMatchesActiveConfig =
|
||||
fieldStateConfig !== undefined &&
|
||||
activeConfigAfterRemoval !== undefined &&
|
||||
validationConfigEqual(fieldStateConfig, activeConfigAfterRemoval);
|
||||
|
||||
if (currentRegistrations.size === 0) {
|
||||
fieldRegistrationsRef.current.delete(path);
|
||||
}
|
||||
|
||||
if (
|
||||
!activeConfigAfterRemoval ||
|
||||
fieldStateConfigWasReleased ||
|
||||
(activeConfigChanged && !fieldStateMatchesActiveConfig)
|
||||
) {
|
||||
clear(path);
|
||||
}
|
||||
};
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
const validate = useCallback(
|
||||
@@ -189,6 +291,7 @@ export function ValidationProvider({
|
||||
...fieldStatesRef.current,
|
||||
[path]: newFieldState,
|
||||
};
|
||||
fieldStateConfigsRef.current.set(path, config);
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
|
||||
return result;
|
||||
@@ -209,16 +312,32 @@ export function ValidationProvider({
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((path: string) => {
|
||||
const { [path]: _, ...rest } = fieldStatesRef.current;
|
||||
fieldStatesRef.current = rest;
|
||||
setFieldStates(rest);
|
||||
}, []);
|
||||
|
||||
const validateAll = useCallback(() => {
|
||||
let allValid = true;
|
||||
|
||||
for (const [path, config] of Object.entries(fieldConfigs)) {
|
||||
// Only active registrations belong in a form-wide validation result.
|
||||
// Pruning here is a final safeguard against errors from released fields.
|
||||
const activePaths = new Set(fieldRegistrationsRef.current.keys());
|
||||
const hasStaleStates = Object.keys(fieldStatesRef.current).some(
|
||||
(path) => !activePaths.has(path),
|
||||
);
|
||||
if (hasStaleStates) {
|
||||
fieldStatesRef.current = Object.fromEntries(
|
||||
Object.entries(fieldStatesRef.current).filter(([path]) =>
|
||||
activePaths.has(path),
|
||||
),
|
||||
);
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
}
|
||||
for (const path of fieldStateConfigsRef.current.keys()) {
|
||||
if (!activePaths.has(path)) {
|
||||
fieldStateConfigsRef.current.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [path, registrations] of fieldRegistrationsRef.current) {
|
||||
const config = getActiveConfig(registrations);
|
||||
if (!config) continue;
|
||||
const result = validate(path, config);
|
||||
if (!result.valid) {
|
||||
allValid = false;
|
||||
@@ -226,7 +345,7 @@ export function ValidationProvider({
|
||||
}
|
||||
|
||||
return allValid;
|
||||
}, [fieldConfigs, validate]);
|
||||
}, [validate]);
|
||||
|
||||
const value = useMemo<ValidationContextValue>(
|
||||
() => ({
|
||||
@@ -257,7 +376,9 @@ export function ValidationProvider({
|
||||
|
||||
return (
|
||||
<ValidationContext.Provider value={value}>
|
||||
{children}
|
||||
<MountedFieldRegistrationContext.Provider value={registerMountedField}>
|
||||
{children}
|
||||
</MountedFieldRegistrationContext.Provider>
|
||||
</ValidationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -300,15 +421,33 @@ export function useFieldValidation(
|
||||
validate: validateField,
|
||||
touch: touchField,
|
||||
clear: clearField,
|
||||
registerField,
|
||||
} = useValidation();
|
||||
const registerMountedField = useContext(MountedFieldRegistrationContext);
|
||||
if (!registerMountedField) {
|
||||
throw new Error(
|
||||
"useFieldValidation must be used within a ValidationProvider",
|
||||
);
|
||||
}
|
||||
|
||||
// Register field on mount
|
||||
// Stabilize structurally equal inline configs so unrelated re-renders do not
|
||||
// tear down and recreate a field registration.
|
||||
const stableConfigRef = useRef<ValidationConfig | undefined>(config);
|
||||
if (
|
||||
(config === undefined && stableConfigRef.current !== undefined) ||
|
||||
(config !== undefined &&
|
||||
(stableConfigRef.current === undefined ||
|
||||
!validationConfigEqual(stableConfigRef.current, config)))
|
||||
) {
|
||||
stableConfigRef.current = config;
|
||||
}
|
||||
const stableConfig = stableConfigRef.current;
|
||||
|
||||
// The returned cleanup releases this control's registration on unmount and
|
||||
// before a binding path or validation config changes.
|
||||
React.useEffect(() => {
|
||||
if (path && config) {
|
||||
registerField(path, config);
|
||||
}
|
||||
}, [path, config, registerField]);
|
||||
if (!path || !stableConfig) return;
|
||||
return registerMountedField(path, stableConfig);
|
||||
}, [path, stableConfig, registerMountedField]);
|
||||
|
||||
const state = fieldStates[path] ?? {
|
||||
touched: false,
|
||||
@@ -317,8 +456,8 @@ export function useFieldValidation(
|
||||
};
|
||||
|
||||
const validate = useCallback(
|
||||
() => validateField(path, config ?? { checks: [] }),
|
||||
[path, config, validateField],
|
||||
() => validateField(path, stableConfig ?? { checks: [] }),
|
||||
[path, stableConfig, validateField],
|
||||
);
|
||||
|
||||
const touch = useCallback(() => touchField(path), [path, touchField]);
|
||||
|
||||
@@ -656,6 +656,438 @@ describe("validateForm action", () => {
|
||||
const state = getState();
|
||||
expect(state.formValidation).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("unregisters a conditional field when it is hidden", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: {
|
||||
showGuest: false,
|
||||
form: { guestName: "" },
|
||||
result: null,
|
||||
},
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["showGuest", "hideGuest", "guestName", "submit"],
|
||||
},
|
||||
showGuest: {
|
||||
type: "Button",
|
||||
props: { label: "Show guest" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/showGuest", value: true },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
hideGuest: {
|
||||
type: "Button",
|
||||
props: { label: "Hide guest" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/showGuest", value: false },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
guestName: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Guest name",
|
||||
value: { $bindState: "/form/guestName" },
|
||||
checks: [{ type: "required", message: "Guest name is required" }],
|
||||
},
|
||||
visible: { $state: "/showGuest", eq: true },
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show guest" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/guestName": ["Guest name is required"] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide guest" }));
|
||||
});
|
||||
expect(screen.queryByText("Guest name")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState()).toMatchObject({
|
||||
form: { guestName: "" },
|
||||
result: { valid: true, errors: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates only the fields in the active tab", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: {
|
||||
activeTab: "guest",
|
||||
form: { guestName: "", accountName: "Ada" },
|
||||
result: null,
|
||||
},
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: [
|
||||
"guestTab",
|
||||
"accountTab",
|
||||
"guestName",
|
||||
"accountName",
|
||||
"submit",
|
||||
],
|
||||
},
|
||||
guestTab: {
|
||||
type: "Button",
|
||||
props: { label: "Guest tab" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/activeTab", value: "guest" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
accountTab: {
|
||||
type: "Button",
|
||||
props: { label: "Account tab" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/activeTab", value: "account" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
guestName: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Guest name",
|
||||
value: { $bindState: "/form/guestName" },
|
||||
checks: [{ type: "required", message: "Guest name is required" }],
|
||||
},
|
||||
visible: { $state: "/activeTab", eq: "guest" },
|
||||
children: [],
|
||||
},
|
||||
accountName: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Account name",
|
||||
value: { $bindState: "/form/accountName" },
|
||||
checks: [{ type: "required", message: "Account name is required" }],
|
||||
},
|
||||
visible: { $state: "/activeTab", eq: "account" },
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/guestName": ["Guest name is required"] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Account tab" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("unregisters a mounted field when its checks are removed", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
const createSpec = (withChecks: boolean): Spec => ({
|
||||
state: { form: { name: "" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["name", "submit"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
value: { $bindState: "/form/name" },
|
||||
...(withChecks
|
||||
? {
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const spec = createSpec(true);
|
||||
const view = render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(screen.getByTestId("input-error").textContent).toBe(
|
||||
"Name is required",
|
||||
);
|
||||
|
||||
const specWithoutChecks = createSpec(false);
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={specWithoutChecks} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
expect(screen.queryByTestId("input-error")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("keeps a shared path registered when one control unmounts", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
const required = [{ type: "required", message: "Name is required" }];
|
||||
|
||||
const spec: Spec = {
|
||||
state: { showDuplicate: true, form: { name: "" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["primary", "duplicate", "hideDuplicate", "submit"],
|
||||
},
|
||||
primary: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Primary name",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: required,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
duplicate: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Duplicate name",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: required,
|
||||
},
|
||||
visible: { $state: "/showDuplicate", eq: true },
|
||||
children: [],
|
||||
},
|
||||
hideDuplicate: {
|
||||
type: "Button",
|
||||
props: { label: "Hide duplicate" },
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/showDuplicate", value: false },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(screen.getAllByTestId("input-error")).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide duplicate" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/name": ["Name is required"] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId("input"), {
|
||||
target: { value: "Ada" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("moves validation when a control changes binding paths", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
const createSpec = (path: string): Spec => ({
|
||||
state: { form: { oldName: "", currentName: "" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["name", "submit"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
value: { $bindState: path },
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: {
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const oldSpec = createSpec("/form/oldName");
|
||||
const view = render(
|
||||
<JSONUIProvider registry={reg} initialState={oldSpec.state}>
|
||||
<Renderer spec={oldSpec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/oldName": ["Name is required"] },
|
||||
});
|
||||
|
||||
const currentSpec = createSpec("/form/currentName");
|
||||
view.rerender(
|
||||
<JSONUIProvider registry={reg} initialState={oldSpec.state}>
|
||||
<Renderer spec={currentSpec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(getState().result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/currentName": ["Name is required"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -121,7 +121,7 @@ Use `visible` on elements to show/hide based on state. New syntax: `{ "$state":
|
||||
| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. |
|
||||
| `ActionProvider` | Handle actions dispatched via the event system |
|
||||
| `VisibilityProvider` | Enable conditional rendering based on state |
|
||||
| `ValidationProvider` | Form field validation |
|
||||
| `ValidationProvider` | Form field validation; mounted controls unregister without deleting stored values |
|
||||
|
||||
### External Store (Controlled Mode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user