@cosmicdrift/kumiko-renderer-web 0.194.0 → 0.196.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.194.0",
3
+ "version": "0.196.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
20
- "@cosmicdrift/kumiko-headless": "0.194.0",
21
- "@cosmicdrift/kumiko-renderer": "0.194.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.196.0",
20
+ "@cosmicdrift/kumiko-headless": "0.196.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.196.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -327,7 +327,15 @@ describe("createKumikoApp", () => {
327
327
  }
328
328
  function EditorProbe(): ReactNode {
329
329
  const Editor = useContentEditor("rich");
330
- return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
330
+ return (
331
+ <Editor
332
+ id="ca-rich-editor-probe"
333
+ value="hello"
334
+ onChange={() => {}}
335
+ variables={[]}
336
+ readOnly={false}
337
+ />
338
+ );
331
339
  }
332
340
  const probeSchema: FeatureSchema = {
333
341
  featureName: "tasks",
@@ -74,6 +74,38 @@ describe("entityEdit redirect (#1942)", () => {
74
74
  await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
75
75
  });
76
76
 
77
+ test("create: successful save with redirect set carries the newly created entityId along (#1945)", async () => {
78
+ const navigated: NavTarget[] = [];
79
+ const dispatcher = createMockDispatcher({
80
+ write: (async () => ({
81
+ isSuccess: true,
82
+ data: { id: "new-99" },
83
+ })) as unknown as Dispatcher["write"],
84
+ });
85
+ render(
86
+ <DispatcherProvider dispatcher={dispatcher}>
87
+ <NavProvider
88
+ value={{
89
+ route: { screenId: "shop:screen:product-edit" },
90
+ navigate: (target) => navigated.push(target),
91
+ replace: () => {},
92
+ hrefFor: () => "",
93
+ searchParams: {},
94
+ setSearchParams: () => {},
95
+ }}
96
+ >
97
+ <KumikoScreen schema={buildSchema("product-detail")} qn="shop:screen:product-edit" />
98
+ </NavProvider>
99
+ </DispatcherProvider>,
100
+ );
101
+
102
+ fillNameAndSubmit();
103
+
104
+ await waitFor(() =>
105
+ expect(navigated).toEqual([{ screenId: "product-detail", entityId: "new-99" }]),
106
+ );
107
+ });
108
+
77
109
  test("create: no redirect set falls back to the entity's list screen", async () => {
78
110
  const navigated: NavTarget[] = [];
79
111
  render(
@@ -14,7 +14,7 @@ import {
14
14
  type RenderEditControls,
15
15
  useExtensionFormSubmit,
16
16
  } from "@cosmicdrift/kumiko-renderer";
17
- import { useState } from "react";
17
+ import { type ReactNode, useCallback, useState } from "react";
18
18
  import { z } from "zod";
19
19
  import {
20
20
  act,
@@ -757,6 +757,55 @@ describe("RenderEdit — controlled mode (#1887)", () => {
757
757
  expect(controls?.getValues().count).toBe("Acme".length);
758
758
  });
759
759
 
760
+ // fw#1899: a caller that supplies onControlsReady only after this mount
761
+ // (e.g. `onControlsReady={ready ? handler : undefined}`) must still get
762
+ // delivered to for THIS mount — the effect's deps previously excluded
763
+ // onControlsReady itself, so it never re-ran once mounted without it.
764
+ test("onControlsReady delivers to a handler supplied after mount, not just at mount time", () => {
765
+ function Host(): ReactNode {
766
+ const [ready, setReady] = useState(false);
767
+ const [received, setReceived] = useState<RenderEditControls<TestValues> | undefined>(
768
+ undefined,
769
+ );
770
+ // Stable identity across renders, like a real caller's useCallback:
771
+ // an inline arrow here would change on every render and defeat the
772
+ // re-delivery guard's identity check, looping the effect.
773
+ const onControlsReady = useCallback((c: RenderEditControls<TestValues>) => {
774
+ setReceived(c);
775
+ }, []);
776
+ return (
777
+ <>
778
+ <button type="button" data-testid="make-ready" onClick={() => setReady(true)}>
779
+ ready
780
+ </button>
781
+ <div data-testid="received">{received !== undefined ? "yes" : "no"}</div>
782
+ <RenderEdit<TestValues>
783
+ screen={makeScreen()}
784
+ entity={orderEntity}
785
+ featureName="orders"
786
+ initial={{ title: "", count: 0, isUrgent: false }}
787
+ writeCommand="order:create"
788
+ {...(ready && { onControlsReady })}
789
+ />
790
+ </>
791
+ );
792
+ }
793
+
794
+ render(
795
+ <DispatcherProvider dispatcher={makeDispatcher()}>
796
+ <Host />
797
+ </DispatcherProvider>,
798
+ );
799
+
800
+ expect(screen.getByTestId("received").textContent).toBe("no");
801
+
802
+ act(() => {
803
+ fireEvent.click(screen.getByTestId("make-ready"));
804
+ });
805
+
806
+ expect(screen.getByTestId("received").textContent).toBe("yes");
807
+ });
808
+
760
809
  test("controls.patch sets values from outside without losing edits already made in other fields", () => {
761
810
  let controls: RenderEditControls<TestValues> | undefined;
762
811
  render(
@@ -1230,6 +1279,122 @@ describe("RenderEdit wizard mode", () => {
1230
1279
  expect(screen.queryByTestId("field-title-errors")).toBeNull();
1231
1280
  });
1232
1281
 
1282
+ // fw#1901: handleWizardNext used to validate every field in the section,
1283
+ // including ones currently hidden by their own condition. A required
1284
+ // field that only applies conditionally (e.g. a VAT id shown only for
1285
+ // companies) must not permanently block "Next" while it's hidden. Note:
1286
+ // form-controller.ts's runValidate() already excludes hidden-field
1287
+ // issues from every validate() call via its own `hiddenFields` set
1288
+ // (form-controller.ts:200), independent of scope; this test protects
1289
+ // that existing guard, not the fieldNames narrowing added here.
1290
+ test("Weiter is not blocked by a required field that's currently hidden by its own condition", async () => {
1291
+ const companyEntity = {
1292
+ fields: {
1293
+ isCompany: { type: "boolean" },
1294
+ vatId: { type: "text", required: true },
1295
+ count: { type: "number" },
1296
+ },
1297
+ } as unknown as EntityDefinition;
1298
+ const wizardScreen: EntityEditScreenDefinition = {
1299
+ id: "orders:screen:order-wizard-conditional",
1300
+ type: "entityEdit",
1301
+ entity: "order",
1302
+ layout: {
1303
+ mode: "wizard",
1304
+ sections: [
1305
+ {
1306
+ title: "Basics",
1307
+ columns: 1,
1308
+ fields: [
1309
+ "isCompany",
1310
+ {
1311
+ field: "vatId",
1312
+ visible: { field: "isCompany", eq: true },
1313
+ required: { field: "isCompany", eq: true },
1314
+ },
1315
+ ],
1316
+ },
1317
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
1318
+ ],
1319
+ },
1320
+ };
1321
+ // A schema that (realistically) doesn't special-case the conditional
1322
+ // requirement itself — it's the FieldCondition's hidden-field exclusion
1323
+ // that's supposed to keep this from blocking, not the schema.
1324
+ const schema = z.object({ isCompany: z.boolean(), vatId: z.string().min(1) });
1325
+
1326
+ render(
1327
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1328
+ <RenderEdit
1329
+ screen={wizardScreen}
1330
+ entity={companyEntity}
1331
+ featureName="orders"
1332
+ initial={{ isCompany: false, vatId: "", count: 0 } as never}
1333
+ writeCommand="order:create"
1334
+ schema={schema}
1335
+ />
1336
+ </DispatcherProvider>,
1337
+ );
1338
+
1339
+ expect(screen.queryByTestId("field-vatId")).toBeNull();
1340
+
1341
+ await act(async () => {
1342
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1343
+ await Promise.resolve();
1344
+ });
1345
+
1346
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1347
+ expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
1348
+ });
1349
+
1350
+ // fw#1901: a section that's entirely hidden (every field in it currently
1351
+ // condition-hidden) must not occupy its own wizard step — it would render
1352
+ // empty and the step count/progress would include a step nobody can see.
1353
+ test("a fully-hidden section is skipped in the wizard step count instead of rendering an empty step", async () => {
1354
+ const wizardScreen: EntityEditScreenDefinition = {
1355
+ id: "orders:screen:order-wizard-hidden-section",
1356
+ type: "entityEdit",
1357
+ entity: "order",
1358
+ layout: {
1359
+ mode: "wizard",
1360
+ sections: [
1361
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
1362
+ {
1363
+ title: "Company-only",
1364
+ columns: 1,
1365
+ fields: [{ field: "notes", visible: { field: "isUrgent", eq: true } }],
1366
+ },
1367
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
1368
+ ],
1369
+ },
1370
+ };
1371
+
1372
+ render(
1373
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1374
+ <RenderEdit<TestValues>
1375
+ screen={wizardScreen}
1376
+ entity={orderEntity}
1377
+ featureName="orders"
1378
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
1379
+ writeCommand="order:create"
1380
+ />
1381
+ </DispatcherProvider>,
1382
+ );
1383
+
1384
+ // Only 2 real steps (Basics, Details) — "Company-only" is entirely
1385
+ // hidden (isUrgent is false) and must not count as a step.
1386
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("of 2");
1387
+
1388
+ await act(async () => {
1389
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1390
+ await Promise.resolve();
1391
+ });
1392
+
1393
+ // Landed directly on Details, skipping the hidden "Company-only" step.
1394
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1395
+ expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
1396
+ });
1397
+
1233
1398
  // A server-side field error can land on a field belonging to a step the
1234
1399
  // user isn't currently viewing — the old `setFormError(fieldIssues.length
1235
1400
  // === 0 ? result.error : null)` rule suppressed the banner in that case
@@ -1517,6 +1682,59 @@ describe("RenderEdit wizard draft", () => {
1517
1682
  expect(titleInputAgain.value).toBe("Acme");
1518
1683
  });
1519
1684
 
1685
+ // fw#1929: bare crypto.randomUUID() breaks in non-secure contexts and
1686
+ // React Native/Hermes without a polyfill. With it deleted, minting a
1687
+ // draftId must still work through the mintDraftId() fallback instead of
1688
+ // throwing.
1689
+ test("draft id still mints when crypto.randomUUID is unavailable (fw#1929)", async () => {
1690
+ const cryptoRef = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
1691
+ const original = cryptoRef?.randomUUID;
1692
+ // `randomUUID` lives on crypto's prototype (non-configurable) — `delete`
1693
+ // is a silent no-op there, so shadow it with an own `undefined` instead.
1694
+ if (cryptoRef) cryptoRef.randomUUID = undefined;
1695
+
1696
+ try {
1697
+ const draftKeys: string[] = [];
1698
+ const dispatcher = createMockDispatcher({
1699
+ query: (async () => ({ isSuccess: true, data: {} })) as Dispatcher["query"],
1700
+ write: (async (type: string, payload: unknown) => {
1701
+ if (type === "form-draft:write:save") {
1702
+ draftKeys.push((payload as { draftKey: string }).draftKey);
1703
+ }
1704
+ return { isSuccess: true, data: { id: "1" } };
1705
+ }) as Dispatcher["write"],
1706
+ });
1707
+
1708
+ render(
1709
+ <DispatcherProvider dispatcher={dispatcher}>
1710
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1711
+ <RenderEdit<TestValues>
1712
+ screen={makeDraftWizardScreen(true)}
1713
+ entity={orderEntity}
1714
+ featureName="orders"
1715
+ initial={{ title: "", count: 0 }}
1716
+ writeCommand="order:create"
1717
+ />
1718
+ </DraftStorageProvider>
1719
+ </DispatcherProvider>,
1720
+ );
1721
+
1722
+ fireEvent.change(
1723
+ screen.getByTestId("field-title").querySelector("input") as HTMLInputElement,
1724
+ { target: { value: "Acme" } },
1725
+ );
1726
+ await act(async () => {
1727
+ fireEvent.submit(screen.getByTestId("render-edit-form"));
1728
+ await Promise.resolve();
1729
+ });
1730
+
1731
+ expect(draftKeys.length).toBeGreaterThan(0);
1732
+ expect(draftKeys[0]).toContain(":new:draft-");
1733
+ } finally {
1734
+ if (cryptoRef && original) cryptoRef.randomUUID = original;
1735
+ }
1736
+ });
1737
+
1520
1738
  test("a successful submit discards the draft", async () => {
1521
1739
  const { dispatcher, store, calls } = makeDraftDispatcher();
1522
1740
 
@@ -1552,6 +1770,62 @@ describe("RenderEdit wizard draft", () => {
1552
1770
  expect(calls).toContain("form-draft:write:discard");
1553
1771
  });
1554
1772
 
1773
+ // fw#1978: payloadMode "changes" + an untouched form means
1774
+ // controller.submit() never calls dispatcher.write (nothing changed to
1775
+ // send) — handleSubmit must not treat that no-write success like a normal
1776
+ // submit and discard the draft underneath it, or a host driving
1777
+ // controls.submit() on a pre-filled, untouched form silently loses it.
1778
+ test("payloadMode 'changes' + an unchanged form: submit() is a no-op that keeps the draft alive", async () => {
1779
+ const { dispatcher, store, calls } = makeDraftDispatcher();
1780
+ let controls: RenderEditControls<TestValues> | undefined;
1781
+ let submitResult: SubmitResult<unknown> | undefined;
1782
+
1783
+ render(
1784
+ <DispatcherProvider dispatcher={dispatcher}>
1785
+ <RenderEdit<TestValues>
1786
+ screen={makeDraftWizardScreen(true)}
1787
+ entity={orderEntity}
1788
+ featureName="orders"
1789
+ initial={{ title: "Acme", count: 0 }}
1790
+ writeCommand="order:create"
1791
+ payloadMode="changes"
1792
+ onControlsReady={(c) => {
1793
+ controls = c;
1794
+ }}
1795
+ onSubmit={(result) => {
1796
+ submitResult = result;
1797
+ }}
1798
+ />
1799
+ </DispatcherProvider>,
1800
+ );
1801
+
1802
+ // Step to the last wizard step without touching any field — saveDraft()
1803
+ // persists the current (unmodified) values, so a draft exists, but the
1804
+ // form itself is still not dirty.
1805
+ await act(async () => {
1806
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1807
+ await Promise.resolve();
1808
+ });
1809
+ await waitFor(() => expect(store.current).not.toBeNull());
1810
+ calls.length = 0;
1811
+
1812
+ // The built-in Save button stays disabled on an unchanged form — drive
1813
+ // the write the way a host would (RenderEditControls.submit(), the API
1814
+ // this changeset's "pre-filled, untouched form" use case documents).
1815
+ await act(async () => {
1816
+ await controls?.submit();
1817
+ });
1818
+
1819
+ expect(submitResult?.validationBlocked).toBe(false);
1820
+ if (submitResult?.validationBlocked === false) {
1821
+ expect(submitResult.isSuccess).toBe(true);
1822
+ expect(submitResult.isNoOp).toBe(true);
1823
+ }
1824
+ expect(calls).not.toContain("order:create");
1825
+ expect(calls).not.toContain("form-draft:write:discard");
1826
+ expect(store.current).not.toBeNull();
1827
+ });
1828
+
1555
1829
  test("without layout.draft nothing hits the form-draft feature", async () => {
1556
1830
  const { dispatcher, calls } = makeDraftDispatcher();
1557
1831
 
@@ -1810,6 +2084,41 @@ describe("RenderEdit wizard draft", () => {
1810
2084
  expect(savesSoFar()).toBe(before + 1);
1811
2085
  });
1812
2086
 
2087
+ // fw#1932: a patch() inside the 500ms debounce window followed by an
2088
+ // unmount (navigation away, dialog close) must not drop the save — the
2089
+ // old cleanup only cleared the timer, silently discarding the last edit.
2090
+ test("a pending debounced patch-save flushes on unmount instead of being dropped", async () => {
2091
+ const { dispatcher, store } = makeDraftDispatcher();
2092
+ let controls: RenderEditControls<TestValues> | undefined;
2093
+
2094
+ const rendered = render(
2095
+ <DispatcherProvider dispatcher={dispatcher}>
2096
+ <RenderEdit<TestValues>
2097
+ screen={makeDraftWizardScreen(true)}
2098
+ entity={orderEntity}
2099
+ featureName="orders"
2100
+ initial={{ title: "Acme", count: 0 }}
2101
+ writeCommand="order:create"
2102
+ onControlsReady={(c) => {
2103
+ controls = c;
2104
+ }}
2105
+ />
2106
+ </DispatcherProvider>,
2107
+ );
2108
+
2109
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
2110
+ expect(screen.getByTestId("field-count")).toBeTruthy();
2111
+
2112
+ // Arms the debounce, then unmounts well inside the 500ms window — before
2113
+ // the timer could ever fire on its own.
2114
+ act(() => {
2115
+ controls?.patch({ count: 7 });
2116
+ });
2117
+ rendered.unmount();
2118
+
2119
+ await waitFor(() => expect(store.current?.values["count"]).toBe(7));
2120
+ });
2121
+
1813
2122
  // #1914's debounce must not resurrect a draft after it was intentionally
1814
2123
  // ended: discardDraft() clears the pending timer (render-edit.tsx:735-738)
1815
2124
  // specifically so a patch() immediately followed by submit can't have its
@@ -2560,6 +2869,90 @@ describe("RenderEdit locked state (#1896)", () => {
2560
2869
  const titleInput = screen.getByTestId("field-title").querySelector("input");
2561
2870
  expect((titleInput as HTMLInputElement).disabled).toBe(false);
2562
2871
  });
2872
+
2873
+ test("disabled prevents the Delete button from invoking onDelete (fw#1909)", async () => {
2874
+ const onDelete = mock(async () => {});
2875
+ render(
2876
+ <DispatcherProvider dispatcher={makeDispatcher()}>
2877
+ <RenderEdit<TestValues>
2878
+ screen={makeScreen()}
2879
+ entity={orderEntity}
2880
+ featureName="orders"
2881
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
2882
+ writeCommand="order:create"
2883
+ onDelete={onDelete}
2884
+ disabled
2885
+ />
2886
+ </DispatcherProvider>,
2887
+ );
2888
+
2889
+ const deleteButton = screen.getByTestId("render-edit-delete") as HTMLButtonElement;
2890
+ expect(deleteButton.disabled).toBe(true);
2891
+ fireEvent.click(deleteButton);
2892
+ expect(screen.queryByTestId("render-edit-delete-dialog")).toBeNull();
2893
+ expect(onDelete).not.toHaveBeenCalled();
2894
+ });
2895
+
2896
+ test("disabled prevents picking a draft candidate from adopting it (fw#1909)", async () => {
2897
+ const screenDef: EntityEditScreenDefinition = {
2898
+ id: "orders:screen:order-wizard-locked-draftpicker",
2899
+ type: "entityEdit",
2900
+ entity: "order",
2901
+ layout: {
2902
+ mode: "wizard",
2903
+ draft: true,
2904
+ sections: [
2905
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
2906
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
2907
+ ],
2908
+ },
2909
+ };
2910
+ const candidates = [
2911
+ {
2912
+ id: `${screenDef.id}:new:draft-1`,
2913
+ draftKey: `${screenDef.id}:new:draft-1`,
2914
+ stepIndex: 0,
2915
+ savedAt: "2026-01-01T00:00:00Z",
2916
+ },
2917
+ {
2918
+ id: `${screenDef.id}:new:draft-2`,
2919
+ draftKey: `${screenDef.id}:new:draft-2`,
2920
+ stepIndex: 0,
2921
+ savedAt: "2026-01-02T00:00:00Z",
2922
+ },
2923
+ ];
2924
+ const dispatcher = createMockDispatcher({
2925
+ query: (async (type: string) => {
2926
+ if (type === "form-draft:query:list")
2927
+ return { isSuccess: true, data: { drafts: candidates } };
2928
+ return { isSuccess: true, data: {} };
2929
+ }) as Dispatcher["query"],
2930
+ write: (async () => ({ isSuccess: true, data: { id: "1" } })) as Dispatcher["write"],
2931
+ });
2932
+
2933
+ render(
2934
+ <DispatcherProvider dispatcher={dispatcher}>
2935
+ <DraftStorageProvider value={createFakeDraftStorage()}>
2936
+ <RenderEdit<TestValues>
2937
+ screen={screenDef}
2938
+ entity={orderEntity}
2939
+ featureName="orders"
2940
+ initial={{ title: "", count: 0 }}
2941
+ writeCommand="order:create"
2942
+ disabled
2943
+ />
2944
+ </DraftStorageProvider>
2945
+ </DispatcherProvider>,
2946
+ );
2947
+
2948
+ await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
2949
+ const pickSecond = screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:draft-2`);
2950
+ fireEvent.click(pickSecond);
2951
+
2952
+ expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy();
2953
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2954
+ expect(titleInput.value).toBe("");
2955
+ });
2563
2956
  });
2564
2957
 
2565
2958
  describe("RenderEdit hideActions (host-driven action bar)", () => {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
2
3
  import { type ReactNode, useState } from "react";
3
4
  import { fireEvent, render, screen } from "../../__tests__/test-utils";
4
5
  import { PlainContentEditor } from "../plain-content-editor";
@@ -6,21 +7,41 @@ import { PlainContentEditor } from "../plain-content-editor";
6
7
  function Controlled({ initial }: { readonly initial: string }): ReactNode {
7
8
  const [value, setValue] = useState(initial);
8
9
  return (
9
- <PlainContentEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />
10
+ <PlainContentEditor
11
+ id={CONTENT_EDITOR_ELEMENT_ID}
12
+ value={value}
13
+ onChange={setValue}
14
+ variables={["name"]}
15
+ readOnly={false}
16
+ />
10
17
  );
11
18
  }
12
19
 
13
20
  describe("PlainContentEditor", () => {
14
21
  test("renders the textarea plus one chip per variable", () => {
15
22
  render(
16
- <PlainContentEditor value="" onChange={() => {}} variables={["name"]} readOnly={false} />,
23
+ <PlainContentEditor
24
+ id={CONTENT_EDITOR_ELEMENT_ID}
25
+ value=""
26
+ onChange={() => {}}
27
+ variables={["name"]}
28
+ readOnly={false}
29
+ />,
17
30
  );
18
31
  expect(screen.getByRole("textbox")).toBeTruthy();
19
32
  expect(screen.getByText("{{name}}")).toBeTruthy();
20
33
  });
21
34
 
22
35
  test("no variables → no chips", () => {
23
- render(<PlainContentEditor value="" onChange={() => {}} variables={[]} readOnly={false} />);
36
+ render(
37
+ <PlainContentEditor
38
+ id={CONTENT_EDITOR_ELEMENT_ID}
39
+ value=""
40
+ onChange={() => {}}
41
+ variables={[]}
42
+ readOnly={false}
43
+ />,
44
+ );
24
45
  expect(screen.queryAllByRole("button")).toHaveLength(0);
25
46
  });
26
47
 
@@ -7,6 +7,7 @@ describe("RichContentEditor", () => {
7
7
  test("falls back to the plain textarea while the tiptap chunk loads, then swaps in the editor", async () => {
8
8
  render(
9
9
  <RichContentEditor
10
+ id={CONTENT_EDITOR_ELEMENT_ID}
10
11
  value="<p>hello</p>"
11
12
  onChange={() => {}}
12
13
  variables={[]}