@cosmicdrift/kumiko-renderer-web 0.195.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 +4 -4
- package/src/__tests__/render-edit.test.tsx +341 -1
- package/src/layout/nav-tree.tsx +8 -0
- package/src/lib/__tests__/resize-image.test.ts +8 -3
- package/src/lib/accept-attr.ts +5 -0
- package/src/primitives/embedded-list-input.tsx +12 -0
- package/src/primitives/file-upload.tsx +1 -6
- package/src/primitives/index.tsx +7 -0
- package/src/widgets/drawer.tsx +6 -2
- package/src/widgets/infinity-list.tsx +33 -5
- package/src/widgets/upload-zone.tsx +1 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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",
|
|
@@ -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
|
|
@@ -1605,6 +1770,62 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1605
1770
|
expect(calls).toContain("form-draft:write:discard");
|
|
1606
1771
|
});
|
|
1607
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
|
+
|
|
1608
1829
|
test("without layout.draft nothing hits the form-draft feature", async () => {
|
|
1609
1830
|
const { dispatcher, calls } = makeDraftDispatcher();
|
|
1610
1831
|
|
|
@@ -1863,6 +2084,41 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1863
2084
|
expect(savesSoFar()).toBe(before + 1);
|
|
1864
2085
|
});
|
|
1865
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
|
+
|
|
1866
2122
|
// #1914's debounce must not resurrect a draft after it was intentionally
|
|
1867
2123
|
// ended: discardDraft() clears the pending timer (render-edit.tsx:735-738)
|
|
1868
2124
|
// specifically so a patch() immediately followed by submit can't have its
|
|
@@ -2613,6 +2869,90 @@ describe("RenderEdit locked state (#1896)", () => {
|
|
|
2613
2869
|
const titleInput = screen.getByTestId("field-title").querySelector("input");
|
|
2614
2870
|
expect((titleInput as HTMLInputElement).disabled).toBe(false);
|
|
2615
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
|
+
});
|
|
2616
2956
|
});
|
|
2617
2957
|
|
|
2618
2958
|
describe("RenderEdit hideActions (host-driven action bar)", () => {
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -101,6 +101,7 @@ import {
|
|
|
101
101
|
SidebarMenuSub,
|
|
102
102
|
SidebarMenuSubButton,
|
|
103
103
|
SidebarMenuSubItem,
|
|
104
|
+
useSidebar,
|
|
104
105
|
} from "../ui/sidebar";
|
|
105
106
|
import { useDispatchTarget } from "./target-resolver-stub";
|
|
106
107
|
import { parseTargetFromSearchParams } from "./target-url";
|
|
@@ -224,7 +225,14 @@ export function NavTree({
|
|
|
224
225
|
}, []);
|
|
225
226
|
|
|
226
227
|
const t = useTranslation();
|
|
228
|
+
const { state: sidebarState } = useSidebar();
|
|
227
229
|
const [filter, setFilter] = useState("");
|
|
230
|
+
// The search box is only CSS-hidden (`group-data-[collapsible=icon]:hidden`)
|
|
231
|
+
// when the sidebar collapses to icon rail, not unmounted — without this,
|
|
232
|
+
// a stale filter re-applies invisibly on the next expand (fw#1816).
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (sidebarState === "collapsed") setFilter("");
|
|
235
|
+
}, [sidebarState]);
|
|
228
236
|
const q = filter.trim().toLowerCase();
|
|
229
237
|
const matches = useCallback(
|
|
230
238
|
(raw: string): boolean => {
|
|
@@ -9,11 +9,16 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
test("fehlt OffscreenCanvas, bleibt das Bild unverändert", async () => {
|
|
12
|
+
const originalOffscreenCanvas = globalThis.OffscreenCanvas;
|
|
12
13
|
// @ts-expect-error simulate a browser without OffscreenCanvas support
|
|
13
14
|
globalThis.OffscreenCanvas = undefined;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
try {
|
|
16
|
+
const file = new File(["hi"], "photo.jpg", { type: "image/jpeg" });
|
|
17
|
+
const result = await resizeImageBeforeUpload(file);
|
|
18
|
+
expect(result).toBe(file);
|
|
19
|
+
} finally {
|
|
20
|
+
globalThis.OffscreenCanvas = originalOffscreenCanvas;
|
|
21
|
+
}
|
|
17
22
|
});
|
|
18
23
|
|
|
19
24
|
test("lässt SVGs unverändert (Vektor würde beim Re-Encode zerstört)", async () => {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
|
|
2
|
+
export function toAcceptAttr(accept?: readonly string[]): string | undefined {
|
|
3
|
+
if (accept === undefined || accept.length === 0) return undefined;
|
|
4
|
+
return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
|
|
5
|
+
}
|
|
@@ -443,6 +443,18 @@ export function EmbeddedListInput({
|
|
|
443
443
|
const isTabForward = event.key === "Tab" && !event.shiftKey;
|
|
444
444
|
const isEnter = event.key === "Enter";
|
|
445
445
|
if (!isTabForward && !isEnter) return;
|
|
446
|
+
// A select/reference last column renders its picker trigger as a
|
|
447
|
+
// <button> (or an open cmdk popover's own input) in the cell — Enter
|
|
448
|
+
// there is the browser's native button-activation, not "append a row".
|
|
449
|
+
// Returning before preventDefault() lets that activation proceed;
|
|
450
|
+
// Tab still falls through to the append-row behavior below.
|
|
451
|
+
if (
|
|
452
|
+
isEnter &&
|
|
453
|
+
event.target instanceof HTMLElement &&
|
|
454
|
+
event.target.closest('button, [role="combobox"], [cmdk-input]') !== null
|
|
455
|
+
) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
446
458
|
if (maxItems !== undefined && rows.length >= maxItems) return;
|
|
447
459
|
// Enter bubbles up from the cell control through this TableCell — an
|
|
448
460
|
// ancestor of any <form> the caller wraps the whole field in.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
7
7
|
import { ImageIcon, Loader2, Upload } from "lucide-react";
|
|
8
8
|
import { type ChangeEvent, type ReactNode, useRef, useState } from "react";
|
|
9
|
+
import { toAcceptAttr } from "../lib/accept-attr";
|
|
9
10
|
import { resizeImageBeforeUpload } from "../lib/resize-image";
|
|
10
11
|
import { Button as UiButton } from "../ui/button";
|
|
11
12
|
|
|
@@ -22,12 +23,6 @@ export type FileUploadInputProps = {
|
|
|
22
23
|
readonly capture?: "environment" | "user";
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
// "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
|
|
26
|
-
function toAcceptAttr(accept?: readonly string[]): string | undefined {
|
|
27
|
-
if (accept === undefined || accept.length === 0) return undefined;
|
|
28
|
-
return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
26
|
export function FileUploadInput({
|
|
32
27
|
kind,
|
|
33
28
|
id,
|
package/src/primitives/index.tsx
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
type ChangeEvent,
|
|
70
70
|
type CSSProperties,
|
|
71
71
|
createContext,
|
|
72
|
+
type MouseEvent,
|
|
72
73
|
type ReactNode,
|
|
73
74
|
useContext,
|
|
74
75
|
useEffect,
|
|
@@ -732,6 +733,12 @@ function DefaultDataTable({
|
|
|
732
733
|
// if the sum of the columns gets too wide.
|
|
733
734
|
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
734
735
|
title={cellTitle(row.values[col.field])}
|
|
736
|
+
// A click into the editable cell's widget must not also
|
|
737
|
+
// trigger the row's onClick (typically "Open Detail") —
|
|
738
|
+
// same reasoning as the actions cell below.
|
|
739
|
+
{...(onCellChange !== undefined && {
|
|
740
|
+
onClick: (e: MouseEvent) => e.stopPropagation(),
|
|
741
|
+
})}
|
|
735
742
|
>
|
|
736
743
|
<DataTableCell
|
|
737
744
|
value={row.values[col.field]}
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
1
2
|
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
2
3
|
import { type ReactNode, useRef, useState } from "react";
|
|
3
4
|
import { clamp } from "../lib/clamp";
|
|
@@ -79,6 +80,7 @@ export function Drawer({
|
|
|
79
80
|
resize,
|
|
80
81
|
backdrop,
|
|
81
82
|
}: DrawerProps): ReactNode {
|
|
83
|
+
const t = useTranslation();
|
|
82
84
|
const canResize = resize !== undefined && (side === "left" || side === "right");
|
|
83
85
|
const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
|
|
84
86
|
const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
|
|
@@ -152,7 +154,9 @@ export function Drawer({
|
|
|
152
154
|
type="button"
|
|
153
155
|
onClick={() => setMaximized((m) => !m)}
|
|
154
156
|
aria-pressed={maximized}
|
|
155
|
-
aria-label={
|
|
157
|
+
aria-label={
|
|
158
|
+
maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize")
|
|
159
|
+
}
|
|
156
160
|
className="absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
|
|
157
161
|
>
|
|
158
162
|
{maximized ? (
|
|
@@ -175,7 +179,7 @@ export function Drawer({
|
|
|
175
179
|
<div
|
|
176
180
|
role="separator"
|
|
177
181
|
aria-orientation="vertical"
|
|
178
|
-
aria-label="
|
|
182
|
+
aria-label={t("kumiko.widget.drawer.resize")}
|
|
179
183
|
aria-valuenow={effectiveWidthPx}
|
|
180
184
|
aria-valuemin={minWidthPx}
|
|
181
185
|
aria-valuemax={effectiveMaxWidthPx()}
|
|
@@ -20,6 +20,12 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
|
|
|
20
20
|
/** Must derive from row content (e.g. `row.id`), not from `index` — a
|
|
21
21
|
* live refresh reorders rows (new/changed rows move to the front). */
|
|
22
22
|
readonly rowId: (row: TRow, index: number) => string;
|
|
23
|
+
/** Pass this when `rowId` depends on list position — the live-merge path
|
|
24
|
+
* needs a position-independent identity. Without it, an index-based
|
|
25
|
+
* `rowId` (legitimate when live-merge isn't used) can collide between
|
|
26
|
+
* the freshly-fetched first page's local indices and the accumulated
|
|
27
|
+
* list's indices, silently dropping an unrelated mid-list row on merge. */
|
|
28
|
+
readonly rowKey?: (row: TRow) => string;
|
|
23
29
|
readonly renderRow: (row: TRow) => ReactNode;
|
|
24
30
|
readonly emptyState?: ReactNode;
|
|
25
31
|
readonly className?: string;
|
|
@@ -49,6 +55,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
49
55
|
rows,
|
|
50
56
|
nextCursor,
|
|
51
57
|
rowId,
|
|
58
|
+
rowKey,
|
|
52
59
|
renderRow,
|
|
53
60
|
emptyState,
|
|
54
61
|
className,
|
|
@@ -73,6 +80,16 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
73
80
|
nextCursorRef.current = nextCursor;
|
|
74
81
|
const rowIdRef = useRef(rowId);
|
|
75
82
|
rowIdRef.current = rowId;
|
|
83
|
+
const rowKeyRef = useRef(rowKey);
|
|
84
|
+
rowKeyRef.current = rowKey;
|
|
85
|
+
// Position-independent when rowKey is supplied, otherwise falls back to
|
|
86
|
+
// the (possibly position-dependent) rowId — same identity function used
|
|
87
|
+
// for both sides of the live-merge diff below so a position-based rowId
|
|
88
|
+
// can no longer collide across the fresh page's local indices and the
|
|
89
|
+
// accumulated list's indices (fw#1829).
|
|
90
|
+
const identifyRef = useRef((row: TRow, index: number): string =>
|
|
91
|
+
rowKeyRef.current !== undefined ? rowKeyRef.current(row) : rowIdRef.current(row, index),
|
|
92
|
+
);
|
|
76
93
|
|
|
77
94
|
// Discards a response whose request was superseded by a newer one before
|
|
78
95
|
// it resolved (e.g. two searches fired in quick succession) — without
|
|
@@ -102,8 +119,11 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
102
119
|
}
|
|
103
120
|
const nextRows = rowsRef.current(res.data);
|
|
104
121
|
if (cursor === null) {
|
|
122
|
+
// Same identity as refreshFirstPage's freshIds/staleRows diff below
|
|
123
|
+
// — this ref is what that diff's previousFirstPageIds reads, so it
|
|
124
|
+
// must agree on rowKey vs rowId or the two id spaces never overlap.
|
|
105
125
|
firstPageIdsRef.current = new Set(
|
|
106
|
-
nextRows.map((row, index) =>
|
|
126
|
+
nextRows.map((row, index) => identifyRef.current(row, index)),
|
|
107
127
|
);
|
|
108
128
|
}
|
|
109
129
|
setState((prev) => {
|
|
@@ -154,7 +174,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
154
174
|
// skip: background live refresh failed, keep showing the current rows
|
|
155
175
|
if (!res.isSuccess) return;
|
|
156
176
|
const freshRows = rowsRef.current(res.data);
|
|
157
|
-
const freshIds = new Set(freshRows.map((row, index) =>
|
|
177
|
+
const freshIds = new Set(freshRows.map((row, index) => identifyRef.current(row, index)));
|
|
158
178
|
// A row absent from freshIds is stale for one of two reasons: it was
|
|
159
179
|
// dropped from page 1 (deleted, or filtered out elsewhere) and must be
|
|
160
180
|
// pruned, or it belongs to an already-accumulated later page and must
|
|
@@ -163,10 +183,18 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
163
183
|
const previousFirstPageIds = firstPageIdsRef.current;
|
|
164
184
|
firstPageIdsRef.current = freshIds;
|
|
165
185
|
setState((prev) => {
|
|
166
|
-
// skip:
|
|
167
|
-
|
|
186
|
+
// skip: the initial load() is still in flight and owns the eventual
|
|
187
|
+
// state — a concurrent refresh landing here has nothing accumulated
|
|
188
|
+
// to merge into yet.
|
|
189
|
+
if (prev.kind === "loading") return prev;
|
|
190
|
+
if (prev.kind !== "ready") {
|
|
191
|
+
// prev.kind === "error": this refresh succeeded — promote straight
|
|
192
|
+
// to "ready" instead of leaving the list stuck on the earlier
|
|
193
|
+
// failure forever (a live event should be able to recover it).
|
|
194
|
+
return { kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) };
|
|
195
|
+
}
|
|
168
196
|
const staleRows = prev.rows.filter((row, index) => {
|
|
169
|
-
const id =
|
|
197
|
+
const id = identifyRef.current(row, index);
|
|
170
198
|
return !freshIds.has(id) && !previousFirstPageIds.has(id);
|
|
171
199
|
});
|
|
172
200
|
return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
|
|
3
3
|
import { type DragEvent, type ReactNode, useId, useRef, useState } from "react";
|
|
4
|
+
import { toAcceptAttr } from "../lib/accept-attr";
|
|
4
5
|
import { cn } from "../lib/cn";
|
|
5
6
|
import { resizeImageBeforeUpload } from "../lib/resize-image";
|
|
6
7
|
|
|
@@ -39,12 +40,6 @@ export type UploadZoneProps = {
|
|
|
39
40
|
readonly testId?: string;
|
|
40
41
|
};
|
|
41
42
|
|
|
42
|
-
// "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
|
|
43
|
-
function toAcceptAttr(accept?: readonly string[]): string | undefined {
|
|
44
|
-
if (accept === undefined || accept.length === 0) return undefined;
|
|
45
|
-
return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
43
|
// `accept` on the native <input> only filters the file-picker dialog — a
|
|
49
44
|
// drag&drop drop is never routed through it, so any file type lands in
|
|
50
45
|
// `onUpload` regardless of what `accept` promises. Same matching rules as
|