dynamic-forms.test.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  1. import { describe, it, expect, vi } from "vitest";
  2. import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
  3. import { createSignal } from "solid-js";
  4. import { defineCatalog, type Spec } from "@json-render/core";
  5. import {
  6. JSONUIProvider,
  7. Renderer,
  8. defineRegistry,
  9. type ComponentRenderProps,
  10. } from "./renderer";
  11. import type { ComponentFn } from "./catalog-types";
  12. import { useStateStore } from "./contexts/state";
  13. import { useFieldValidation } from "./contexts/validation";
  14. import { useBoundProp } from "./hooks";
  15. import { schema as solidSchema } from "./schema";
  16. import { z } from "zod";
  17. const exampleCatalog = defineCatalog(solidSchema, {
  18. components: {
  19. Stack: {
  20. props: z.object({
  21. gap: z.number().optional(),
  22. padding: z.number().optional(),
  23. direction: z.enum(["vertical", "horizontal"]).optional(),
  24. align: z.enum(["start", "center", "end"]).optional(),
  25. }),
  26. slots: ["default"],
  27. description:
  28. "Layout container that stacks children vertically or horizontally",
  29. },
  30. Card: {
  31. props: z.object({
  32. title: z.string().optional(),
  33. subtitle: z.string().optional(),
  34. }),
  35. slots: ["default"],
  36. description: "A card container with optional title and subtitle",
  37. },
  38. Text: {
  39. props: z.object({
  40. content: z.string(),
  41. size: z.enum(["sm", "md", "lg", "xl"]).optional(),
  42. weight: z.enum(["normal", "medium", "bold"]).optional(),
  43. color: z.string().optional(),
  44. }),
  45. slots: [],
  46. description: "Displays a text string",
  47. },
  48. Button: {
  49. props: z.object({
  50. label: z.string(),
  51. variant: z.enum(["primary", "secondary", "danger"]).optional(),
  52. disabled: z.boolean().optional(),
  53. }),
  54. slots: [],
  55. description: "A clickable button that emits a 'press' event",
  56. },
  57. Badge: {
  58. props: z.object({
  59. label: z.string(),
  60. color: z.string().optional(),
  61. }),
  62. slots: [],
  63. description: "A small badge/tag label",
  64. },
  65. ListItem: {
  66. props: z.object({
  67. title: z.string(),
  68. description: z.string().optional(),
  69. completed: z.boolean().optional(),
  70. }),
  71. slots: [],
  72. description: "A single item in a list",
  73. },
  74. RendererTabs: {
  75. props: z.object({ renderer: z.string() }),
  76. slots: [],
  77. description:
  78. "Segmented tab control for switching between Vue, React, Svelte, and Solid renderers",
  79. },
  80. RendererBadge: {
  81. props: z.object({ renderer: z.string() }),
  82. slots: [],
  83. description: "Badge indicating which renderer is currently active",
  84. },
  85. },
  86. actions: {
  87. increment: {
  88. params: z.object({}),
  89. description: "Increment the counter by 1",
  90. },
  91. decrement: {
  92. params: z.object({}),
  93. description: "Decrement the counter by 1",
  94. },
  95. reset: {
  96. params: z.object({}),
  97. description: "Reset the counter to 0",
  98. },
  99. toggleItem: {
  100. params: z.object({ index: z.number() }),
  101. description: "Toggle the completed state of a todo item",
  102. },
  103. switchToVue: {
  104. params: z.object({}),
  105. description: "Switch to the Vue renderer",
  106. },
  107. switchToReact: {
  108. params: z.object({}),
  109. description: "Switch to the React renderer",
  110. },
  111. switchToSvelte: {
  112. params: z.object({}),
  113. description: "Switch to the Svelte renderer",
  114. },
  115. switchToSolid: {
  116. params: z.object({}),
  117. description: "Switch to the Solid renderer",
  118. },
  119. },
  120. });
  121. const definedStack: ComponentFn<typeof exampleCatalog, "Stack"> = (ctx) => (
  122. <div>{ctx.children}</div>
  123. );
  124. const definedButton: ComponentFn<typeof exampleCatalog, "Button"> = (ctx) => (
  125. <button data-testid="defined-btn" onClick={() => ctx.emit("press")}>
  126. {ctx.props.label}
  127. </button>
  128. );
  129. const definedText: ComponentFn<typeof exampleCatalog, "Text"> = (ctx) => (
  130. <span data-testid="defined-text">{ctx.props.content}</span>
  131. );
  132. const definedCard: ComponentFn<typeof exampleCatalog, "Card"> = (ctx) => (
  133. <div>{ctx.children}</div>
  134. );
  135. const definedBadge: ComponentFn<typeof exampleCatalog, "Badge"> = (ctx) => (
  136. <span>{ctx.props.label}</span>
  137. );
  138. const definedListItem: ComponentFn<typeof exampleCatalog, "ListItem"> = (
  139. ctx,
  140. ) => <div>{ctx.props.title}</div>;
  141. const definedRendererBadge: ComponentFn<
  142. typeof exampleCatalog,
  143. "RendererBadge"
  144. > = (ctx) => <span>{ctx.props.renderer}</span>;
  145. const definedRendererTabs: ComponentFn<
  146. typeof exampleCatalog,
  147. "RendererTabs"
  148. > = () => <div />;
  149. const exampleComponents = {
  150. Stack: definedStack,
  151. Button: definedButton,
  152. Text: definedText,
  153. Card: definedCard,
  154. Badge: definedBadge,
  155. ListItem: definedListItem,
  156. RendererBadge: definedRendererBadge,
  157. RendererTabs: definedRendererTabs,
  158. };
  159. function Button(props: ComponentRenderProps<{ label: string }>) {
  160. return (
  161. <button data-testid="btn" onClick={() => props.emit("press")}>
  162. {props.element.props.label}
  163. </button>
  164. );
  165. }
  166. function Text(props: ComponentRenderProps<{ text: unknown }>) {
  167. const display = () => {
  168. const v = props.element.props.text;
  169. if (v == null) return "";
  170. return typeof v === "string" ? v : JSON.stringify(v);
  171. };
  172. return <span data-testid="text">{display()}</span>;
  173. }
  174. function InputField(
  175. props: ComponentRenderProps<{
  176. label?: string;
  177. value?: string;
  178. checks?: Array<{
  179. type: string;
  180. message: string;
  181. args?: Record<string, unknown>;
  182. }>;
  183. }>,
  184. ) {
  185. const elementProps = () => props.element.props;
  186. const [boundValue, setBoundValue] = useBoundProp<string>(
  187. elementProps().value as string | undefined,
  188. props.bindings?.value,
  189. );
  190. const [localValue, setLocalValue] = createSignal("");
  191. const isBound = () => !!props.bindings?.value;
  192. const value = () => (isBound() ? (boundValue ?? "") : localValue());
  193. const setValue = (v: string) =>
  194. isBound() ? setBoundValue(v) : setLocalValue(v);
  195. const hasValidation = () =>
  196. !!(props.bindings?.value && elementProps().checks?.length);
  197. const config = () =>
  198. hasValidation() ? { checks: elementProps().checks ?? [] } : undefined;
  199. const { errors } = useFieldValidation(props.bindings?.value ?? "", config());
  200. return (
  201. <div>
  202. {elementProps().label && <label>{elementProps().label}</label>}
  203. <input
  204. data-testid="input"
  205. value={value()}
  206. onInput={(e) => setValue(e.currentTarget.value)}
  207. />
  208. {errors().length > 0 && (
  209. <span data-testid="input-error">{errors()[0]}</span>
  210. )}
  211. </div>
  212. );
  213. }
  214. function SelectField(
  215. props: ComponentRenderProps<{ label?: string; value?: string }>,
  216. ) {
  217. const [boundValue] = useBoundProp<string>(
  218. props.element.props.value as string | undefined,
  219. props.bindings?.value,
  220. );
  221. return <span data-testid="select-value">{boundValue ?? ""}</span>;
  222. }
  223. function ValidatedSelect(
  224. props: ComponentRenderProps<{
  225. label?: string;
  226. name?: string;
  227. options?: string[];
  228. placeholder?: string;
  229. value?: string;
  230. checks?: Array<{
  231. type: string;
  232. message: string;
  233. args?: Record<string, unknown>;
  234. }>;
  235. validateOn?: "change" | "blur" | "submit";
  236. }>,
  237. ) {
  238. const elementProps = () => props.element.props;
  239. const [boundValue, setBoundValue] = useBoundProp<string>(
  240. elementProps().value as string | undefined,
  241. props.bindings?.value,
  242. );
  243. const [localValue, setLocalValue] = createSignal("");
  244. const isBound = () => !!props.bindings?.value;
  245. const value = () => (isBound() ? (boundValue ?? "") : localValue());
  246. const setValue = (v: string) =>
  247. isBound() ? setBoundValue(v) : setLocalValue(v);
  248. const validateOn = () => elementProps().validateOn ?? "change";
  249. const hasValidation = () =>
  250. !!(props.bindings?.value && elementProps().checks?.length);
  251. const config = () =>
  252. hasValidation()
  253. ? { checks: elementProps().checks ?? [], validateOn: validateOn() }
  254. : undefined;
  255. const { errors, validate } = useFieldValidation(
  256. props.bindings?.value ?? "",
  257. config(),
  258. );
  259. const options = () => elementProps().options ?? [];
  260. const name = () => elementProps().name ?? "default";
  261. return (
  262. <div>
  263. {elementProps().label && <label>{elementProps().label}</label>}
  264. <select
  265. data-testid={`select-${name()}`}
  266. value={value()}
  267. onChange={(e) => {
  268. setValue(e.currentTarget.value);
  269. if (hasValidation() && validateOn() === "change") validate();
  270. props.emit("change");
  271. }}
  272. >
  273. <option value="">{elementProps().placeholder ?? "Select..."}</option>
  274. {options().map((opt) => (
  275. <option value={opt}>{opt}</option>
  276. ))}
  277. </select>
  278. {errors().length > 0 && (
  279. <span data-testid={`select-error-${name()}`}>{errors()[0]}</span>
  280. )}
  281. </div>
  282. );
  283. }
  284. function Stack(props: ComponentRenderProps<Record<string, unknown>>) {
  285. return <div data-testid="stack">{props.children}</div>;
  286. }
  287. let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
  288. function StateProbe() {
  289. const ctx = useStateStore();
  290. probeGetSnapshot = ctx.getSnapshot;
  291. return <div data-testid="state-probe" />;
  292. }
  293. const registry = { Button, Text, Input: InputField, Select: SelectField };
  294. function getState(): Record<string, unknown> {
  295. return probeGetSnapshot!();
  296. }
  297. // =============================================================================
  298. // $computed expressions in rendering
  299. // =============================================================================
  300. describe("$computed expressions in rendering", () => {
  301. it("resolves a $computed prop using provided functions", () => {
  302. const spec: Spec = {
  303. state: { first: "Jane", last: "Doe" },
  304. root: "main",
  305. elements: {
  306. main: {
  307. type: "Text",
  308. props: {
  309. text: {
  310. $computed: "fullName",
  311. args: {
  312. first: { $state: "/first" },
  313. last: { $state: "/last" },
  314. },
  315. },
  316. },
  317. children: [],
  318. },
  319. },
  320. };
  321. const functions = {
  322. fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
  323. };
  324. render(() => (
  325. <JSONUIProvider
  326. registry={registry}
  327. initialState={spec.state}
  328. functions={functions}
  329. >
  330. <Renderer spec={spec} registry={registry} />
  331. </JSONUIProvider>
  332. ));
  333. expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
  334. });
  335. it("renders gracefully when functions prop is omitted", () => {
  336. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
  337. const spec: Spec = {
  338. state: {},
  339. root: "main",
  340. elements: {
  341. main: {
  342. type: "Text",
  343. props: {
  344. text: { $computed: "missing" },
  345. },
  346. children: [],
  347. },
  348. },
  349. };
  350. render(() => (
  351. <JSONUIProvider registry={registry} initialState={spec.state}>
  352. <Renderer spec={spec} registry={registry} />
  353. </JSONUIProvider>
  354. ));
  355. expect(screen.getByTestId("text").textContent).toBe("");
  356. warnSpy.mockRestore();
  357. });
  358. });
  359. // =============================================================================
  360. // $template expressions in rendering
  361. // =============================================================================
  362. describe("$template expressions in rendering", () => {
  363. it("interpolates state values into a template string", () => {
  364. const spec: Spec = {
  365. state: { user: { name: "Alice" }, count: 3 },
  366. root: "main",
  367. elements: {
  368. main: {
  369. type: "Text",
  370. props: {
  371. text: {
  372. $template: "Hello, ${/user/name}! You have ${/count} messages.",
  373. },
  374. },
  375. children: [],
  376. },
  377. },
  378. };
  379. render(() => (
  380. <JSONUIProvider registry={registry} initialState={spec.state}>
  381. <Renderer spec={spec} registry={registry} />
  382. </JSONUIProvider>
  383. ));
  384. expect(screen.getByTestId("text").textContent).toBe(
  385. "Hello, Alice! You have 3 messages.",
  386. );
  387. });
  388. it("resolves missing paths to empty string", () => {
  389. const spec: Spec = {
  390. state: {},
  391. root: "main",
  392. elements: {
  393. main: {
  394. type: "Text",
  395. props: {
  396. text: { $template: "Hi ${/name}!" },
  397. },
  398. children: [],
  399. },
  400. },
  401. };
  402. render(() => (
  403. <JSONUIProvider registry={registry} initialState={spec.state}>
  404. <Renderer spec={spec} registry={registry} />
  405. </JSONUIProvider>
  406. ));
  407. expect(screen.getByTestId("text").textContent).toBe("Hi !");
  408. });
  409. });
  410. // =============================================================================
  411. // Watchers
  412. // =============================================================================
  413. describe("watchers (watch field)", () => {
  414. it("does not fire on initial render, fires when watched state changes", async () => {
  415. probeGetSnapshot = undefined;
  416. const loadCities = vi.fn(async (_params: Record<string, unknown>) => {});
  417. const reg = { ...registry, Stack };
  418. const spec: Spec = {
  419. state: { form: { country: "" }, citiesLoaded: false },
  420. root: "wrapper",
  421. elements: {
  422. wrapper: {
  423. type: "Stack",
  424. props: {},
  425. children: ["btn", "watcher"],
  426. },
  427. btn: {
  428. type: "Button",
  429. props: { label: "Set Country" },
  430. on: {
  431. press: [
  432. {
  433. action: "setState",
  434. params: { statePath: "/form/country", value: "US" },
  435. },
  436. ],
  437. },
  438. children: [],
  439. },
  440. watcher: {
  441. type: "Select",
  442. props: { value: { $state: "/form/country" } },
  443. watch: {
  444. "/form/country": {
  445. action: "loadCities",
  446. params: { country: { $state: "/form/country" } },
  447. },
  448. },
  449. children: [],
  450. },
  451. },
  452. };
  453. render(() => (
  454. <JSONUIProvider
  455. registry={reg}
  456. initialState={spec.state}
  457. handlers={{ loadCities }}
  458. >
  459. <Renderer spec={spec} registry={reg} />
  460. <StateProbe />
  461. </JSONUIProvider>
  462. ));
  463. expect(loadCities).not.toHaveBeenCalled();
  464. fireEvent.click(screen.getByTestId("btn"));
  465. await waitFor(() => {
  466. expect(loadCities).toHaveBeenCalledTimes(1);
  467. });
  468. expect(loadCities).toHaveBeenCalledWith(
  469. expect.objectContaining({ country: "US" }),
  470. );
  471. });
  472. it("fires multiple action bindings on the same watch path", async () => {
  473. probeGetSnapshot = undefined;
  474. const action1 = vi.fn();
  475. const action2 = vi.fn();
  476. const reg = { ...registry, Stack };
  477. const spec: Spec = {
  478. state: { value: "a" },
  479. root: "wrapper",
  480. elements: {
  481. wrapper: {
  482. type: "Stack",
  483. props: {},
  484. children: ["btn", "watcher"],
  485. },
  486. btn: {
  487. type: "Button",
  488. props: { label: "Change" },
  489. on: {
  490. press: [
  491. {
  492. action: "setState",
  493. params: { statePath: "/value", value: "b" },
  494. },
  495. ],
  496. },
  497. children: [],
  498. },
  499. watcher: {
  500. type: "Text",
  501. props: { text: { $state: "/value" } },
  502. watch: {
  503. "/value": [{ action: "action1" }, { action: "action2" }],
  504. },
  505. children: [],
  506. },
  507. },
  508. };
  509. render(() => (
  510. <JSONUIProvider
  511. registry={reg}
  512. initialState={spec.state}
  513. handlers={{ action1, action2 }}
  514. >
  515. <Renderer spec={spec} registry={reg} />
  516. </JSONUIProvider>
  517. ));
  518. fireEvent.click(screen.getByTestId("btn"));
  519. await waitFor(() => {
  520. expect(action1).toHaveBeenCalledTimes(1);
  521. expect(action2).toHaveBeenCalledTimes(1);
  522. });
  523. });
  524. });
  525. describe("defineRegistry reactivity", () => {
  526. it("updates $state-backed props after setState actions", async () => {
  527. const { registry: definedRegistry } = defineRegistry(exampleCatalog, {
  528. components: exampleComponents,
  529. actions: {
  530. increment: async () => {},
  531. decrement: async () => {},
  532. reset: async () => {},
  533. toggleItem: async () => {},
  534. switchToVue: async () => {},
  535. switchToReact: async () => {},
  536. switchToSvelte: async () => {},
  537. switchToSolid: async () => {},
  538. },
  539. });
  540. const spec: Spec = {
  541. state: { value: "a" },
  542. root: "wrapper",
  543. elements: {
  544. wrapper: {
  545. type: "Stack",
  546. props: {},
  547. children: ["btn", "text"],
  548. },
  549. btn: {
  550. type: "Button",
  551. props: { label: "Change" },
  552. on: {
  553. press: {
  554. action: "setState",
  555. params: { statePath: "/value", value: "b" },
  556. },
  557. },
  558. children: [],
  559. },
  560. text: {
  561. type: "Text",
  562. props: { content: { $state: "/value" } },
  563. children: [],
  564. },
  565. },
  566. };
  567. render(() => (
  568. <JSONUIProvider registry={definedRegistry} initialState={spec.state}>
  569. <Renderer spec={spec} registry={definedRegistry} />
  570. </JSONUIProvider>
  571. ));
  572. expect(screen.getByTestId("defined-text").textContent).toBe("a");
  573. fireEvent.click(screen.getByTestId("defined-btn"));
  574. await waitFor(() => {
  575. expect(screen.getByTestId("defined-text").textContent).toBe("b");
  576. });
  577. });
  578. });
  579. // =============================================================================
  580. // validateForm action
  581. // =============================================================================
  582. describe("validateForm action", () => {
  583. it("writes { valid: false } when a required field is empty", async () => {
  584. probeGetSnapshot = undefined;
  585. const reg = { ...registry, Stack };
  586. const spec: Spec = {
  587. state: { form: { email: "" }, result: null },
  588. root: "wrapper",
  589. elements: {
  590. wrapper: {
  591. type: "Stack",
  592. props: {},
  593. children: ["emailInput", "submitBtn"],
  594. },
  595. emailInput: {
  596. type: "Input",
  597. props: {
  598. label: "Email",
  599. value: { $bindState: "/form/email" },
  600. checks: [{ type: "required", message: "Email is required" }],
  601. },
  602. children: [],
  603. },
  604. submitBtn: {
  605. type: "Button",
  606. props: { label: "Submit" },
  607. on: {
  608. press: [
  609. {
  610. action: "validateForm",
  611. params: { statePath: "/result" },
  612. },
  613. ],
  614. },
  615. children: [],
  616. },
  617. },
  618. };
  619. render(() => (
  620. <JSONUIProvider registry={reg} initialState={spec.state}>
  621. <Renderer spec={spec} registry={reg} />
  622. <StateProbe />
  623. </JSONUIProvider>
  624. ));
  625. fireEvent.click(screen.getByTestId("btn"));
  626. await waitFor(() => {
  627. const state = getState();
  628. expect(state.result).toEqual({
  629. valid: false,
  630. errors: { "/form/email": ["Email is required"] },
  631. });
  632. });
  633. });
  634. it("writes { valid: true } when all fields pass validation", async () => {
  635. probeGetSnapshot = undefined;
  636. const reg = { ...registry, Stack };
  637. const spec: Spec = {
  638. state: { form: { email: "test@example.com" }, result: null },
  639. root: "wrapper",
  640. elements: {
  641. wrapper: {
  642. type: "Stack",
  643. props: {},
  644. children: ["emailInput", "submitBtn"],
  645. },
  646. emailInput: {
  647. type: "Input",
  648. props: {
  649. label: "Email",
  650. value: { $bindState: "/form/email" },
  651. checks: [{ type: "required", message: "Email is required" }],
  652. },
  653. children: [],
  654. },
  655. submitBtn: {
  656. type: "Button",
  657. props: { label: "Submit" },
  658. on: {
  659. press: [
  660. {
  661. action: "validateForm",
  662. params: { statePath: "/result" },
  663. },
  664. ],
  665. },
  666. children: [],
  667. },
  668. },
  669. };
  670. render(() => (
  671. <JSONUIProvider registry={reg} initialState={spec.state}>
  672. <Renderer spec={spec} registry={reg} />
  673. <StateProbe />
  674. </JSONUIProvider>
  675. ));
  676. fireEvent.click(screen.getByTestId("btn"));
  677. await waitFor(() => {
  678. const state = getState();
  679. expect(state.result).toEqual({ valid: true, errors: {} });
  680. });
  681. });
  682. it("defaults to /formValidation when no statePath is provided", async () => {
  683. probeGetSnapshot = undefined;
  684. const reg = { ...registry, Stack };
  685. const spec: Spec = {
  686. state: { form: { name: "filled" } },
  687. root: "wrapper",
  688. elements: {
  689. wrapper: {
  690. type: "Stack",
  691. props: {},
  692. children: ["nameInput", "submitBtn"],
  693. },
  694. nameInput: {
  695. type: "Input",
  696. props: {
  697. label: "Name",
  698. value: { $bindState: "/form/name" },
  699. checks: [{ type: "required", message: "Required" }],
  700. },
  701. children: [],
  702. },
  703. submitBtn: {
  704. type: "Button",
  705. props: { label: "Submit" },
  706. on: {
  707. press: [{ action: "validateForm" }],
  708. },
  709. children: [],
  710. },
  711. },
  712. };
  713. render(() => (
  714. <JSONUIProvider registry={reg} initialState={spec.state}>
  715. <Renderer spec={spec} registry={reg} />
  716. <StateProbe />
  717. </JSONUIProvider>
  718. ));
  719. fireEvent.click(screen.getByTestId("btn"));
  720. await waitFor(() => {
  721. const state = getState();
  722. expect(state.formValidation).toEqual({ valid: true, errors: {} });
  723. });
  724. });
  725. });
  726. // =============================================================================
  727. // Select validate-on-change timing (#151)
  728. // =============================================================================
  729. describe("Select validate-on-change sees the new value, not the stale value", () => {
  730. const regWithSelect = {
  731. ...registry,
  732. Stack,
  733. Select: ValidatedSelect,
  734. };
  735. it("does not show 'required' error when selecting the first value", async () => {
  736. probeGetSnapshot = undefined;
  737. const spec: Spec = {
  738. state: { form: { country: "" } },
  739. root: "wrapper",
  740. elements: {
  741. wrapper: {
  742. type: "Stack",
  743. props: {},
  744. children: ["countrySelect"],
  745. },
  746. countrySelect: {
  747. type: "Select",
  748. props: {
  749. label: "Country",
  750. name: "country",
  751. options: ["US", "Canada", "UK"],
  752. placeholder: "Choose a country",
  753. value: { $bindState: "/form/country" },
  754. checks: [{ type: "required", message: "Country is required" }],
  755. validateOn: "change",
  756. },
  757. children: [],
  758. },
  759. },
  760. };
  761. render(() => (
  762. <JSONUIProvider registry={regWithSelect} initialState={spec.state}>
  763. <Renderer spec={spec} registry={regWithSelect} />
  764. <StateProbe />
  765. </JSONUIProvider>
  766. ));
  767. fireEvent.change(screen.getByTestId("select-country"), {
  768. target: { value: "US" },
  769. });
  770. await waitFor(() => {
  771. const state = getState();
  772. expect((state.form as Record<string, unknown>).country).toBe("US");
  773. });
  774. expect(screen.queryByTestId("select-error-country")).toBeNull();
  775. });
  776. it("does not show 'required' error when selecting the first city after country change resets it", async () => {
  777. probeGetSnapshot = undefined;
  778. const spec: Spec = {
  779. state: {
  780. form: { country: "US", city: "" },
  781. availableCities: ["New York", "Chicago"],
  782. },
  783. root: "wrapper",
  784. elements: {
  785. wrapper: {
  786. type: "Stack",
  787. props: {},
  788. children: ["citySelect"],
  789. },
  790. citySelect: {
  791. type: "Select",
  792. props: {
  793. label: "City",
  794. name: "city",
  795. options: ["New York", "Chicago"],
  796. placeholder: "Select a city",
  797. value: { $bindState: "/form/city" },
  798. checks: [{ type: "required", message: "City is required" }],
  799. validateOn: "change",
  800. },
  801. children: [],
  802. },
  803. },
  804. };
  805. render(() => (
  806. <JSONUIProvider registry={regWithSelect} initialState={spec.state}>
  807. <Renderer spec={spec} registry={regWithSelect} />
  808. <StateProbe />
  809. </JSONUIProvider>
  810. ));
  811. fireEvent.change(screen.getByTestId("select-city"), {
  812. target: { value: "New York" },
  813. });
  814. await waitFor(() => {
  815. const state = getState();
  816. expect((state.form as Record<string, unknown>).city).toBe("New York");
  817. });
  818. expect(screen.queryByTestId("select-error-city")).toBeNull();
  819. });
  820. });