@cosmicdrift/kumiko-renderer-web 0.192.0 → 0.193.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 +435 -15
- package/src/__tests__/wizard-form-validation.test.tsx +2 -2
- package/src/primitives/__tests__/data-table-logic.test.ts +1 -1
- package/src/primitives/__tests__/embedded-list-input.test.tsx +34 -14
- package/src/primitives/embedded-list-input.tsx +9 -2
- package/src/primitives/index.tsx +14 -5
- package/src/widgets/__tests__/infinity-list.test.tsx +113 -0
- package/src/widgets/infinity-list.tsx +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.193.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.193.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.193.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.193.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",
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
EntityDefinition,
|
|
4
4
|
EntityEditScreenDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
-
import type { Dispatcher, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
6
|
+
import type { Dispatcher, FormSnapshot, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
7
7
|
import {
|
|
8
8
|
DispatcherProvider,
|
|
9
9
|
DraftStorageProvider,
|
|
@@ -815,6 +815,44 @@ describe("RenderEdit — controlled mode (#1887)", () => {
|
|
|
815
815
|
expect(titleInput.value).toBe("Acme");
|
|
816
816
|
expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
|
|
817
817
|
});
|
|
818
|
+
|
|
819
|
+
// A caller like `schema={z.object({...})}` builds a fresh schema object on
|
|
820
|
+
// every render — unstable identity. If the effect depended on `schema`
|
|
821
|
+
// directly (not a ref), a parent that re-renders on every onChange call
|
|
822
|
+
// would refire the effect every time even though nothing in the form
|
|
823
|
+
// actually changed, risking an infinite update loop.
|
|
824
|
+
test("a parent re-rendering with a fresh schema object on every onChange does not loop", () => {
|
|
825
|
+
let calls = 0;
|
|
826
|
+
function Wrapper() {
|
|
827
|
+
const [, setTick] = useState(0);
|
|
828
|
+
const schema = z.object({ title: z.string().min(1) });
|
|
829
|
+
return (
|
|
830
|
+
<RenderEdit<TestValues>
|
|
831
|
+
screen={makeScreen()}
|
|
832
|
+
entity={orderEntity}
|
|
833
|
+
featureName="orders"
|
|
834
|
+
initial={{ title: "", count: 0, isUrgent: false }}
|
|
835
|
+
writeCommand="order:create"
|
|
836
|
+
schema={schema}
|
|
837
|
+
onChange={() => {
|
|
838
|
+
calls += 1;
|
|
839
|
+
// Cap so a still-broken implementation fails fast on a bounded
|
|
840
|
+
// count instead of hanging the test runner in an update loop.
|
|
841
|
+
if (calls < 15) setTick((t) => t + 1);
|
|
842
|
+
}}
|
|
843
|
+
/>
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
render(
|
|
848
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
849
|
+
<Wrapper />
|
|
850
|
+
</DispatcherProvider>,
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
expect(calls).toBeGreaterThanOrEqual(1);
|
|
854
|
+
expect(calls).toBeLessThan(5);
|
|
855
|
+
});
|
|
818
856
|
});
|
|
819
857
|
|
|
820
858
|
// Issue #1916: proves FieldConditions (visible/readOnly/required) react to
|
|
@@ -1015,7 +1053,7 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1015
1053
|
);
|
|
1016
1054
|
|
|
1017
1055
|
expect(screen.getByTestId("field-title")).toBeTruthy();
|
|
1018
|
-
expect(screen.
|
|
1056
|
+
expect(screen.getByTestId("field-count").closest("[hidden]")).not.toBeNull();
|
|
1019
1057
|
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("1");
|
|
1020
1058
|
});
|
|
1021
1059
|
|
|
@@ -1045,7 +1083,7 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1045
1083
|
|
|
1046
1084
|
expect(screen.getByTestId("field-title-errors")).toBeTruthy();
|
|
1047
1085
|
expect(screen.getByTestId("field-title")).toBeTruthy();
|
|
1048
|
-
expect(screen.
|
|
1086
|
+
expect(screen.getByTestId("field-count").closest("[hidden]")).not.toBeNull();
|
|
1049
1087
|
});
|
|
1050
1088
|
|
|
1051
1089
|
test("Weiter advances to the next step once the current step is valid; last step shows the submit button", async () => {
|
|
@@ -1077,7 +1115,7 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1077
1115
|
await Promise.resolve();
|
|
1078
1116
|
});
|
|
1079
1117
|
|
|
1080
|
-
expect(screen.
|
|
1118
|
+
expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
|
|
1081
1119
|
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
1082
1120
|
expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
|
|
1083
1121
|
expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
|
|
@@ -1121,6 +1159,170 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1121
1159
|
expect(titleInputAgain.value).toBe("Acme");
|
|
1122
1160
|
expect(screen.queryByTestId("field-title-errors")).toBeNull();
|
|
1123
1161
|
});
|
|
1162
|
+
|
|
1163
|
+
// A server-side field error can land on a field belonging to a step the
|
|
1164
|
+
// user isn't currently viewing — the old `setFormError(fieldIssues.length
|
|
1165
|
+
// === 0 ? result.error : null)` rule suppressed the banner in that case
|
|
1166
|
+
// (field issues exist) while the field itself was invisible on the
|
|
1167
|
+
// current step, silently hiding the error entirely.
|
|
1168
|
+
test("a server field error for a step that's not currently shown jumps the wizard there and shows the field error", async () => {
|
|
1169
|
+
const write = mock(
|
|
1170
|
+
async () =>
|
|
1171
|
+
({
|
|
1172
|
+
isSuccess: false,
|
|
1173
|
+
error: {
|
|
1174
|
+
code: "validation_failed",
|
|
1175
|
+
httpStatus: 422,
|
|
1176
|
+
i18nKey: "kumiko.errors.validation",
|
|
1177
|
+
message: "Validation failed",
|
|
1178
|
+
details: {
|
|
1179
|
+
fields: [{ path: "title", code: "too_small", i18nKey: "kumiko.errors.required" }],
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
}) as never,
|
|
1183
|
+
);
|
|
1184
|
+
render(
|
|
1185
|
+
<DispatcherProvider dispatcher={makeDispatcher(write)}>
|
|
1186
|
+
<RenderEdit<TestValues>
|
|
1187
|
+
screen={makeWizardScreen()}
|
|
1188
|
+
entity={orderEntity}
|
|
1189
|
+
featureName="orders"
|
|
1190
|
+
initial={{ title: "Acme", count: 0 }}
|
|
1191
|
+
writeCommand="order:create"
|
|
1192
|
+
/>
|
|
1193
|
+
</DispatcherProvider>,
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
await act(async () => {
|
|
1197
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1198
|
+
await Promise.resolve();
|
|
1199
|
+
});
|
|
1200
|
+
const countInput = screen.getByTestId("field-count").querySelector("input") as HTMLInputElement;
|
|
1201
|
+
fireEvent.change(countInput, { target: { value: "5" } });
|
|
1202
|
+
|
|
1203
|
+
await act(async () => {
|
|
1204
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1205
|
+
await Promise.resolve();
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
// Jumped back to the step that owns `title`, where the server error lives.
|
|
1209
|
+
expect(screen.getByTestId("field-title")).toBeTruthy();
|
|
1210
|
+
expect(screen.getByTestId("field-title-errors")).toBeTruthy();
|
|
1211
|
+
expect(screen.getByTestId("field-count").closest("[hidden]")).not.toBeNull();
|
|
1212
|
+
// The field itself shows the error — no redundant top-level banner.
|
|
1213
|
+
expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// A root-level issue (e.g. a cross-field `.refine()`) has no field to jump
|
|
1217
|
+
// to — it must NOT be silently suppressed just because `details.fields`
|
|
1218
|
+
// is non-empty.
|
|
1219
|
+
test("a root-level server error with no matching field is not suppressed — the banner shows", async () => {
|
|
1220
|
+
const write = mock(
|
|
1221
|
+
async () =>
|
|
1222
|
+
({
|
|
1223
|
+
isSuccess: false,
|
|
1224
|
+
error: {
|
|
1225
|
+
code: "validation_failed",
|
|
1226
|
+
httpStatus: 422,
|
|
1227
|
+
i18nKey: "kumiko.errors.validation",
|
|
1228
|
+
message: "Validation failed",
|
|
1229
|
+
details: {
|
|
1230
|
+
fields: [{ path: "(root)", code: "custom", i18nKey: "kumiko.errors.cross-field" }],
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
}) as never,
|
|
1234
|
+
);
|
|
1235
|
+
render(
|
|
1236
|
+
<DispatcherProvider dispatcher={makeDispatcher(write)}>
|
|
1237
|
+
<RenderEdit<TestValues>
|
|
1238
|
+
screen={makeWizardScreen()}
|
|
1239
|
+
entity={orderEntity}
|
|
1240
|
+
featureName="orders"
|
|
1241
|
+
initial={{ title: "Acme", count: 0 }}
|
|
1242
|
+
writeCommand="order:create"
|
|
1243
|
+
/>
|
|
1244
|
+
</DispatcherProvider>,
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
await act(async () => {
|
|
1248
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1249
|
+
await Promise.resolve();
|
|
1250
|
+
});
|
|
1251
|
+
const countInput = screen.getByTestId("field-count").querySelector("input") as HTMLInputElement;
|
|
1252
|
+
fireEvent.change(countInput, { target: { value: "5" } });
|
|
1253
|
+
await act(async () => {
|
|
1254
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1255
|
+
await Promise.resolve();
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
expect(screen.getByTestId("render-edit-form-error")).toBeTruthy();
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
// An extension section on an earlier wizard step used to unmount when the
|
|
1262
|
+
// wizard advanced (only the current step's section was rendered), which
|
|
1263
|
+
// tore down its useExtensionFormSubmit registration (registry.remove on
|
|
1264
|
+
// unmount). Finish then only ran the last-mounted step's handler and
|
|
1265
|
+
// silently dropped the earlier step's write. Steps must stay mounted
|
|
1266
|
+
// (hidden, not unmounted) so every step's handler survives to Finish.
|
|
1267
|
+
test("an extension section on an earlier wizard step still submits on Finish after navigating past it", async () => {
|
|
1268
|
+
const submitSpy = mock();
|
|
1269
|
+
const ComposedCF = (_: { entityName: string; entityId: string | null }) => {
|
|
1270
|
+
useExtensionFormSubmit({
|
|
1271
|
+
dirty: true,
|
|
1272
|
+
onSubmit: async (ctx) => {
|
|
1273
|
+
submitSpy(ctx);
|
|
1274
|
+
return { isSuccess: true as const };
|
|
1275
|
+
},
|
|
1276
|
+
});
|
|
1277
|
+
return <div data-testid="composed-cf" />;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
const screenDef: EntityEditScreenDefinition = {
|
|
1281
|
+
id: "orders:screen:order-wizard-extension",
|
|
1282
|
+
type: "entityEdit",
|
|
1283
|
+
entity: "order",
|
|
1284
|
+
layout: {
|
|
1285
|
+
mode: "wizard",
|
|
1286
|
+
sections: [
|
|
1287
|
+
{
|
|
1288
|
+
kind: "extension",
|
|
1289
|
+
title: "Custom Fields",
|
|
1290
|
+
component: { react: { __component: "ComposedCF" } },
|
|
1291
|
+
},
|
|
1292
|
+
{ title: "Basics", columns: 1, fields: [{ field: "title" }] },
|
|
1293
|
+
],
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
render(
|
|
1298
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
1299
|
+
<ExtensionSectionsProvider value={{ ComposedCF }}>
|
|
1300
|
+
<RenderEdit<TestValues>
|
|
1301
|
+
screen={screenDef}
|
|
1302
|
+
entity={orderEntity}
|
|
1303
|
+
featureName="orders"
|
|
1304
|
+
initial={{ title: "Acme", count: 0 } as TestValues}
|
|
1305
|
+
entityId="order-1"
|
|
1306
|
+
writeCommand="order:update"
|
|
1307
|
+
/>
|
|
1308
|
+
</ExtensionSectionsProvider>
|
|
1309
|
+
</DispatcherProvider>,
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
expect(screen.getByTestId("composed-cf")).toBeTruthy();
|
|
1313
|
+
|
|
1314
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1315
|
+
expect(screen.getByTestId("field-title")).toBeTruthy();
|
|
1316
|
+
// Step 1's extension section stayed mounted (hidden), not unmounted.
|
|
1317
|
+
expect(screen.getByTestId("composed-cf").closest("[hidden]")).not.toBeNull();
|
|
1318
|
+
|
|
1319
|
+
await act(async () => {
|
|
1320
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1321
|
+
await Promise.resolve();
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
expect(submitSpy).toHaveBeenCalledWith({ entityId: "order-1" });
|
|
1325
|
+
});
|
|
1124
1326
|
});
|
|
1125
1327
|
|
|
1126
1328
|
describe("RenderEdit wizard draft", () => {
|
|
@@ -1231,7 +1433,7 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1231
1433
|
);
|
|
1232
1434
|
|
|
1233
1435
|
await waitFor(() => expect(screen.getByTestId("field-count")).toBeTruthy());
|
|
1234
|
-
expect(screen.
|
|
1436
|
+
expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
|
|
1235
1437
|
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
|
|
1236
1438
|
|
|
1237
1439
|
fireEvent.click(screen.getByTestId("render-edit-wizard-back"));
|
|
@@ -1440,6 +1642,94 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1440
1642
|
await waitFor(() => expect(savesSoFar()).toBe(before + 1), { timeout: 3000 });
|
|
1441
1643
|
expect(savesSoFar()).toBe(before + 1);
|
|
1442
1644
|
});
|
|
1645
|
+
|
|
1646
|
+
// A rejected discard write (network error) must be best-effort: the entity
|
|
1647
|
+
// write already succeeded by the time discardDraft() runs, so a failure
|
|
1648
|
+
// here must not propagate out of handleSubmit and break onSubmit/
|
|
1649
|
+
// navigation — that would risk a duplicate submit on retry. Orphaned draft
|
|
1650
|
+
// rows are swept later by cleanup.job.ts.
|
|
1651
|
+
test("a rejected discard write does not break an otherwise successful submit", async () => {
|
|
1652
|
+
const seenResults: SubmitResult<unknown>[] = [];
|
|
1653
|
+
const write = mock(async (type: string) => {
|
|
1654
|
+
if (type === "form-draft:write:discard") throw new Error("network error");
|
|
1655
|
+
return { isSuccess: true, data: { id: "1" } };
|
|
1656
|
+
});
|
|
1657
|
+
const dispatcher = makeDispatcher(write as unknown as Dispatcher["write"]);
|
|
1658
|
+
|
|
1659
|
+
render(
|
|
1660
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1661
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
1662
|
+
<RenderEdit<TestValues>
|
|
1663
|
+
screen={makeDraftWizardScreen(true)}
|
|
1664
|
+
entity={orderEntity}
|
|
1665
|
+
featureName="orders"
|
|
1666
|
+
initial={{ title: "", count: 0 }}
|
|
1667
|
+
writeCommand="order:create"
|
|
1668
|
+
onSubmit={(r) => seenResults.push(r)}
|
|
1669
|
+
/>
|
|
1670
|
+
</DraftStorageProvider>
|
|
1671
|
+
</DispatcherProvider>,
|
|
1672
|
+
);
|
|
1673
|
+
|
|
1674
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1675
|
+
fireEvent.change(titleInput, { target: { value: "Acme" } });
|
|
1676
|
+
await act(async () => {
|
|
1677
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1678
|
+
await Promise.resolve();
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
const countInput = screen.getByTestId("field-count").querySelector("input") as HTMLInputElement;
|
|
1682
|
+
fireEvent.change(countInput, { target: { value: "5" } });
|
|
1683
|
+
await act(async () => {
|
|
1684
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1685
|
+
await Promise.resolve();
|
|
1686
|
+
await Promise.resolve();
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
// handleSubmit completed despite the rejected discard: onSubmit still
|
|
1690
|
+
// fired with the successful entity-write result, not an unhandled
|
|
1691
|
+
// rejection or an aborted handler.
|
|
1692
|
+
expect(seenResults).toHaveLength(1);
|
|
1693
|
+
expect(seenResults[0]?.isSuccess).toBe(true);
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
// `disabled` means "no input/no write", not "no navigation" (#1896). The
|
|
1697
|
+
// old `if (disabled) return` guard sat BEFORE the wizard-next branch, and
|
|
1698
|
+
// Next itself carried `disabled={disabled}` — together they made a
|
|
1699
|
+
// disabled wizard unnavigable past step 0. Next must still step forward,
|
|
1700
|
+
// but without running validate() (would block on an empty required field
|
|
1701
|
+
// nobody can fill in) or saveDraft() (would mint a draftId / dispatch a
|
|
1702
|
+
// write — exactly what `disabled` forbids).
|
|
1703
|
+
test("disabled: clicking Next on step 0 navigates without validating or writing a draft", async () => {
|
|
1704
|
+
const schema = z.object({ title: z.string().min(1), count: z.number().optional() });
|
|
1705
|
+
const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
|
|
1706
|
+
|
|
1707
|
+
render(
|
|
1708
|
+
<DispatcherProvider dispatcher={makeDispatcher(write)}>
|
|
1709
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
1710
|
+
<RenderEdit<TestValues>
|
|
1711
|
+
screen={makeDraftWizardScreen(true)}
|
|
1712
|
+
entity={orderEntity}
|
|
1713
|
+
featureName="orders"
|
|
1714
|
+
initial={{ title: "", count: 0 }}
|
|
1715
|
+
writeCommand="order:create"
|
|
1716
|
+
schema={schema}
|
|
1717
|
+
disabled
|
|
1718
|
+
/>
|
|
1719
|
+
</DraftStorageProvider>
|
|
1720
|
+
</DispatcherProvider>,
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
expect(screen.getByTestId("field-title")).toBeTruthy();
|
|
1724
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1725
|
+
|
|
1726
|
+
// Navigated despite the empty required `title` field.
|
|
1727
|
+
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
1728
|
+
expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
|
|
1729
|
+
// No validate() ran (no field error painted) and no write fired
|
|
1730
|
+
// (neither a form-draft:write:save nor an order:create).
|
|
1731
|
+
expect(write).not.toHaveBeenCalled();
|
|
1732
|
+
});
|
|
1443
1733
|
});
|
|
1444
1734
|
|
|
1445
1735
|
describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
@@ -1574,11 +1864,13 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
1574
1864
|
expect(titles).toEqual(["Session A", "Session B"]);
|
|
1575
1865
|
});
|
|
1576
1866
|
|
|
1577
|
-
test("cleared storage with exactly one open draft
|
|
1867
|
+
test("cleared storage with exactly one open draft still shows the picker — no silent cross-tab adopt", async () => {
|
|
1578
1868
|
const { dispatcher, drafts } = makeMultiDraftDispatcher();
|
|
1579
1869
|
const screenDef = makeDraftWizardScreen();
|
|
1580
1870
|
// Pre-seed one existing create-mode draft, as if minted by an earlier,
|
|
1581
|
-
// now-storage-less session (new tab / cleared sessionStorage)
|
|
1871
|
+
// now-storage-less session (new tab / cleared sessionStorage) — or a
|
|
1872
|
+
// genuinely different parallel session on the same screen. Auto-adopting
|
|
1873
|
+
// it would silently hand this tab someone else's in-progress draft.
|
|
1582
1874
|
drafts.set(`${screenDef.id}:new:existing-id`, {
|
|
1583
1875
|
values: { title: "Resumed", count: 0 },
|
|
1584
1876
|
stepIndex: 0,
|
|
@@ -1599,13 +1891,54 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
1599
1891
|
</DispatcherProvider>,
|
|
1600
1892
|
);
|
|
1601
1893
|
|
|
1602
|
-
await waitFor(() =>
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1894
|
+
await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
|
|
1895
|
+
// No silent adopt: the form stays pristine until the user picks.
|
|
1896
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1897
|
+
expect(titleInput.value).toBe("");
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
test("start-new on a one-candidate picker clears it without adopting, leaving the candidate untouched", async () => {
|
|
1901
|
+
const { dispatcher, drafts } = makeMultiDraftDispatcher();
|
|
1902
|
+
const screenDef = makeDraftWizardScreen();
|
|
1903
|
+
drafts.set(`${screenDef.id}:new:existing-id`, {
|
|
1904
|
+
values: { title: "Resumed", count: 0 },
|
|
1905
|
+
stepIndex: 0,
|
|
1906
|
+
savedAt: "2026-01-01T00:00:00Z",
|
|
1607
1907
|
});
|
|
1908
|
+
|
|
1909
|
+
render(
|
|
1910
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1911
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
1912
|
+
<RenderEdit<TestValues>
|
|
1913
|
+
screen={screenDef}
|
|
1914
|
+
entity={orderEntity}
|
|
1915
|
+
featureName="orders"
|
|
1916
|
+
initial={{ title: "", count: 0 }}
|
|
1917
|
+
writeCommand="order:create"
|
|
1918
|
+
/>
|
|
1919
|
+
</DraftStorageProvider>
|
|
1920
|
+
</DispatcherProvider>,
|
|
1921
|
+
);
|
|
1922
|
+
|
|
1923
|
+
await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
|
|
1924
|
+
fireEvent.click(screen.getByTestId("render-edit-draft-start-new"));
|
|
1608
1925
|
expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
|
|
1926
|
+
|
|
1927
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1928
|
+
fireEvent.change(titleInput, { target: { value: "Brand new" } });
|
|
1929
|
+
await act(async () => {
|
|
1930
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1931
|
+
await Promise.resolve();
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
// Minted a genuinely different draftId — the ignored candidate is untouched.
|
|
1935
|
+
expect(drafts.get(`${screenDef.id}:new:existing-id`)?.values["title"]).toBe("Resumed");
|
|
1936
|
+
const prefix = `${screenDef.id}:new:`;
|
|
1937
|
+
const mintedKeys = [...drafts.keys()].filter(
|
|
1938
|
+
(k) => k.startsWith(prefix) && k !== `${prefix}existing-id`,
|
|
1939
|
+
);
|
|
1940
|
+
expect(mintedKeys).toHaveLength(1);
|
|
1941
|
+
expect(drafts.get(mintedKeys[0] as string)?.values["title"]).toBe("Brand new");
|
|
1609
1942
|
});
|
|
1610
1943
|
|
|
1611
1944
|
test("cleared storage with multiple open drafts shows a picker; picking one resumes it", async () => {
|
|
@@ -1739,8 +2072,10 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
1739
2072
|
</DispatcherProvider>,
|
|
1740
2073
|
);
|
|
1741
2074
|
|
|
1742
|
-
// The lone open draft (`other-session`)
|
|
1743
|
-
//
|
|
2075
|
+
// The lone open draft (`other-session`) shows in the picker on mount —
|
|
2076
|
+
// the user explicitly picks it (no silent cross-tab auto-adopt).
|
|
2077
|
+
await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
|
|
2078
|
+
fireEvent.click(screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:other-session`));
|
|
1744
2079
|
await waitFor(() => {
|
|
1745
2080
|
const titleInput = screen
|
|
1746
2081
|
.getByTestId("field-title")
|
|
@@ -1788,7 +2123,7 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
1788
2123
|
});
|
|
1789
2124
|
|
|
1790
2125
|
expect(listCallCount).toBe(1);
|
|
1791
|
-
expect(screen.
|
|
2126
|
+
expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
|
|
1792
2127
|
expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
|
|
1793
2128
|
const countAfterSubmit = screen
|
|
1794
2129
|
.getByTestId("field-count")
|
|
@@ -1892,6 +2227,91 @@ describe("RenderEdit locked state (#1896)", () => {
|
|
|
1892
2227
|
});
|
|
1893
2228
|
});
|
|
1894
2229
|
|
|
2230
|
+
describe("RenderEdit hideActions (host-driven action bar)", () => {
|
|
2231
|
+
test("hideActions renders the fields but drops RenderEdit's own action bar", () => {
|
|
2232
|
+
render(
|
|
2233
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2234
|
+
<RenderEdit<TestValues>
|
|
2235
|
+
screen={makeScreen()}
|
|
2236
|
+
entity={orderEntity}
|
|
2237
|
+
featureName="orders"
|
|
2238
|
+
initial={{ title: "Acme", count: 1, isUrgent: false }}
|
|
2239
|
+
writeCommand="order:create"
|
|
2240
|
+
hideActions
|
|
2241
|
+
/>
|
|
2242
|
+
</DispatcherProvider>,
|
|
2243
|
+
);
|
|
2244
|
+
|
|
2245
|
+
expect(screen.getByTestId("field-title").querySelector("input")).toBeTruthy();
|
|
2246
|
+
expect(screen.queryByTestId("render-edit-submit")).toBeNull();
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
test("without hideActions, the submit button stays (existing behaviour unchanged)", () => {
|
|
2250
|
+
render(
|
|
2251
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2252
|
+
<RenderEdit<TestValues>
|
|
2253
|
+
screen={makeScreen()}
|
|
2254
|
+
entity={orderEntity}
|
|
2255
|
+
featureName="orders"
|
|
2256
|
+
initial={{ title: "Acme", count: 1, isUrgent: false }}
|
|
2257
|
+
writeCommand="order:create"
|
|
2258
|
+
/>
|
|
2259
|
+
</DispatcherProvider>,
|
|
2260
|
+
);
|
|
2261
|
+
|
|
2262
|
+
expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
test("controls.submit() writes an unchanged, pre-filled form through customSubmit — the built-in button (disabled while unchanged) cannot do this", async () => {
|
|
2266
|
+
const customSubmit = mock(
|
|
2267
|
+
async (_snapshot: FormSnapshot<TestValues>): Promise<SubmitResult<unknown>> => ({
|
|
2268
|
+
validationBlocked: false,
|
|
2269
|
+
isSuccess: true,
|
|
2270
|
+
data: { id: "42" },
|
|
2271
|
+
}),
|
|
2272
|
+
);
|
|
2273
|
+
const onSubmit = mock((_result: SubmitResult<unknown>) => {});
|
|
2274
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
2275
|
+
render(
|
|
2276
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2277
|
+
<RenderEdit<TestValues>
|
|
2278
|
+
screen={makeScreen()}
|
|
2279
|
+
entity={orderEntity}
|
|
2280
|
+
featureName="orders"
|
|
2281
|
+
initial={{ title: "Acme", count: 1, isUrgent: false }}
|
|
2282
|
+
customSubmit={customSubmit}
|
|
2283
|
+
onSubmit={onSubmit}
|
|
2284
|
+
onControlsReady={(c) => {
|
|
2285
|
+
controls = c;
|
|
2286
|
+
}}
|
|
2287
|
+
/>
|
|
2288
|
+
</DispatcherProvider>,
|
|
2289
|
+
);
|
|
2290
|
+
|
|
2291
|
+
// Nothing was ever edited — the built-in save button proves it by
|
|
2292
|
+
// staying disabled. controls.submit() has no such guard and must
|
|
2293
|
+
// still write, which is exactly the point of this test.
|
|
2294
|
+
expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
|
|
2295
|
+
|
|
2296
|
+
await act(async () => {
|
|
2297
|
+
await controls?.submit();
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
expect(customSubmit).toHaveBeenCalledTimes(1);
|
|
2301
|
+
expect(customSubmit.mock.calls[0]?.[0]?.values).toEqual({
|
|
2302
|
+
title: "Acme",
|
|
2303
|
+
count: 1,
|
|
2304
|
+
isUrgent: false,
|
|
2305
|
+
});
|
|
2306
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
2307
|
+
expect(onSubmit.mock.calls[0]?.[0]).toEqual({
|
|
2308
|
+
validationBlocked: false,
|
|
2309
|
+
isSuccess: true,
|
|
2310
|
+
data: { id: "42" },
|
|
2311
|
+
});
|
|
2312
|
+
});
|
|
2313
|
+
});
|
|
2314
|
+
|
|
1895
2315
|
describe("RenderEdit fields filter", () => {
|
|
1896
2316
|
const filterEntity = {
|
|
1897
2317
|
fields: {
|
|
@@ -55,8 +55,8 @@ describe("entityEdit wizard — presence validation on Next (fw#1910)", () => {
|
|
|
55
55
|
|
|
56
56
|
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("1");
|
|
57
57
|
expect(screen.getByTestId("field-fullName-errors")).toBeTruthy();
|
|
58
|
-
// Step 2's field
|
|
59
|
-
expect(
|
|
58
|
+
// Step 2's field stays mounted but hidden — the transition was blocked.
|
|
59
|
+
expect(screen.getByTestId("field-email").closest("[hidden]")).not.toBeNull();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("filling the required field allows Next to advance to step 2", async () => {
|
|
@@ -166,7 +166,7 @@ describe("defaultCellRender", () => {
|
|
|
166
166
|
});
|
|
167
167
|
|
|
168
168
|
test("money → { amount, currency } formatiert, kein [object Object]", () => {
|
|
169
|
-
const result = defaultCellRender({ amount:
|
|
169
|
+
const result = defaultCellRender({ amount: 450, currency: "EUR" }, "money");
|
|
170
170
|
expect(result).not.toBe("[object Object]");
|
|
171
171
|
expect(result.replace(/[^0-9]/g, "")).toBe("45000");
|
|
172
172
|
});
|
|
@@ -326,14 +326,17 @@ describe("EmbeddedListInput — totals", () => {
|
|
|
326
326
|
});
|
|
327
327
|
|
|
328
328
|
describe("EmbeddedListInput — tab-to-add-row", () => {
|
|
329
|
-
|
|
329
|
+
// COLUMNS' last column ("amount") is derived, so its control is rendered
|
|
330
|
+
// disabled and never focusable/keydown-reachable in a real browser — the
|
|
331
|
+
// append-row handler must live on "quantity", the last EDITABLE column.
|
|
332
|
+
test("Tab on the last editable cell of the last row (no maxItems limit) fires onAddRow and prevents default", () => {
|
|
330
333
|
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
331
334
|
const onAddRow = mock(() => {});
|
|
332
335
|
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
|
|
333
336
|
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
334
|
-
const
|
|
335
|
-
const input =
|
|
336
|
-
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
337
|
+
const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
|
|
338
|
+
const input = lastEditableCell.querySelector("input");
|
|
339
|
+
if (input === null) throw new Error("expected an <input> inside the last editable cell");
|
|
337
340
|
|
|
338
341
|
// fireEvent returns false when the event's default was prevented —
|
|
339
342
|
// same return-value convention as native dispatchEvent.
|
|
@@ -347,9 +350,26 @@ describe("EmbeddedListInput — tab-to-add-row", () => {
|
|
|
347
350
|
const onAddRow = mock(() => {});
|
|
348
351
|
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow, maxItems: 1 })} />);
|
|
349
352
|
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
350
|
-
const
|
|
351
|
-
const input =
|
|
352
|
-
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
353
|
+
const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
|
|
354
|
+
const input = lastEditableCell.querySelector("input");
|
|
355
|
+
if (input === null) throw new Error("expected an <input> inside the last editable cell");
|
|
356
|
+
|
|
357
|
+
fireEvent.keyDown(input, { key: "Tab", code: "Tab" });
|
|
358
|
+
expect(onAddRow).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Regression: a derived trailing column (e.g. "amount") used to be treated
|
|
362
|
+
// as the last cell (columns.length - 1), but its control is disabled and
|
|
363
|
+
// unreachable via Tab/Enter in a real browser — the handler must attach
|
|
364
|
+
// to "quantity" (last editable), not "amount" (last, but derived).
|
|
365
|
+
test("Tab on the derived trailing cell ('amount') does not fire onAddRow — it isn't the last editable column", () => {
|
|
366
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
367
|
+
const onAddRow = mock(() => {});
|
|
368
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
|
|
369
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
370
|
+
const derivedCell = desktop.getByTestId("lines-cell-0-amount");
|
|
371
|
+
const input = derivedCell.querySelector("input");
|
|
372
|
+
if (input === null) throw new Error("expected an <input> inside the derived cell");
|
|
353
373
|
|
|
354
374
|
fireEvent.keyDown(input, { key: "Tab", code: "Tab" });
|
|
355
375
|
expect(onAddRow).not.toHaveBeenCalled();
|
|
@@ -405,14 +425,14 @@ describe("EmbeddedListInput — paste", () => {
|
|
|
405
425
|
});
|
|
406
426
|
|
|
407
427
|
describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
|
|
408
|
-
test("Enter on the last cell of the last row fires onAddRow and prevents default, same as Tab", () => {
|
|
428
|
+
test("Enter on the last editable cell of the last row fires onAddRow and prevents default, same as Tab", () => {
|
|
409
429
|
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
410
430
|
const onAddRow = mock(() => {});
|
|
411
431
|
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
|
|
412
432
|
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
413
|
-
const
|
|
414
|
-
const input =
|
|
415
|
-
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
433
|
+
const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
|
|
434
|
+
const input = lastEditableCell.querySelector("input");
|
|
435
|
+
if (input === null) throw new Error("expected an <input> inside the last editable cell");
|
|
416
436
|
|
|
417
437
|
const notPrevented = fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
418
438
|
expect(onAddRow).toHaveBeenCalledTimes(1);
|
|
@@ -427,9 +447,9 @@ describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
|
|
|
427
447
|
const onAddRow = mock(() => {});
|
|
428
448
|
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow, maxItems: 1 })} />);
|
|
429
449
|
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
430
|
-
const
|
|
431
|
-
const input =
|
|
432
|
-
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
450
|
+
const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
|
|
451
|
+
const input = lastEditableCell.querySelector("input");
|
|
452
|
+
if (input === null) throw new Error("expected an <input> inside the last editable cell");
|
|
433
453
|
|
|
434
454
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
435
455
|
expect(onAddRow).not.toHaveBeenCalled();
|
|
@@ -406,6 +406,13 @@ export function EmbeddedListInput({
|
|
|
406
406
|
|
|
407
407
|
const cellId = (rowIndex: number, field: string): string => `${id}-${rowIndex}-${field}`;
|
|
408
408
|
|
|
409
|
+
// handleLastCellKeyDown must attach to the last EDITABLE column, not
|
|
410
|
+
// columns.length - 1 — a derived column there is rendered disabled
|
|
411
|
+
// (renderCellControl) and never receives focus/keydown, which would
|
|
412
|
+
// silently kill Tab/Enter-appends-a-row whenever the table ends in a
|
|
413
|
+
// derived column (e.g. a trailing "amount" total).
|
|
414
|
+
const lastEditableIndex = columns.findLastIndex((column) => !column.derived);
|
|
415
|
+
|
|
409
416
|
const showControls = disabled !== true;
|
|
410
417
|
const addDisabled = maxItems !== undefined && rows.length >= maxItems;
|
|
411
418
|
|
|
@@ -500,7 +507,7 @@ export function EmbeddedListInput({
|
|
|
500
507
|
>
|
|
501
508
|
<TableRow data-testid={testIdFor(`row-${rowIndex}`)}>
|
|
502
509
|
{columns.map((column, columnIndex) => {
|
|
503
|
-
const isLastCell = isLastRow && columnIndex ===
|
|
510
|
+
const isLastCell = isLastRow && columnIndex === lastEditableIndex;
|
|
504
511
|
const issues = cellIssues?.[`${rowIndex}.${column.field}`];
|
|
505
512
|
return (
|
|
506
513
|
<TableCell
|
|
@@ -614,7 +621,7 @@ export function EmbeddedListInput({
|
|
|
614
621
|
className="flex flex-col gap-3 rounded-lg border bg-card p-4"
|
|
615
622
|
>
|
|
616
623
|
{columns.map((column, columnIndex) => {
|
|
617
|
-
const isLastCell = isLastRow && columnIndex ===
|
|
624
|
+
const isLastCell = isLastRow && columnIndex === lastEditableIndex;
|
|
618
625
|
const issues = cellIssues?.[`${rowIndex}.${column.field}`];
|
|
619
626
|
return (
|
|
620
627
|
// biome-ignore lint/a11y/noStaticElementInteractions: paste/keydown are delegated from the focusable cell control rendered inside, not direct interaction on this wrapper div.
|
package/src/primitives/index.tsx
CHANGED
|
@@ -99,7 +99,7 @@ import { FileUploadInput } from "./file-upload";
|
|
|
99
99
|
import { DefaultLightbox } from "./lightbox";
|
|
100
100
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
101
101
|
import { DefaultModal } from "./modal";
|
|
102
|
-
import { formatMoney, MoneyInput } from "./money-input";
|
|
102
|
+
import { currencyDecimals, formatMoney, MoneyInput } from "./money-input";
|
|
103
103
|
import { TimestampInput } from "./timestamp-input";
|
|
104
104
|
import { useToast } from "./toast";
|
|
105
105
|
import { TzInput } from "./tz-input";
|
|
@@ -386,6 +386,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
386
386
|
{...common}
|
|
387
387
|
data-testid={props.testId}
|
|
388
388
|
value={props.value}
|
|
389
|
+
step={props.step}
|
|
389
390
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
390
391
|
const v = e.target.value;
|
|
391
392
|
props.onChange(v === "" ? undefined : Number(v));
|
|
@@ -1444,10 +1445,13 @@ export function defaultCellRender(
|
|
|
1444
1445
|
// can render differently in a table vs. a form on the same screen.
|
|
1445
1446
|
// Mid-term: pass the app locale down here too, analogous to render-field.
|
|
1446
1447
|
//
|
|
1447
|
-
// formatMoney expects minor units
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
|
|
1448
|
+
// formatMoney expects minor units scaled by currencyDecimals(currency).
|
|
1449
|
+
// rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
|
|
1450
|
+
// instead, which disagrees with currencyDecimals for non-2-decimal
|
|
1451
|
+
// currencies (JPY: 0 decimals → 100x too high; BHD: 3 decimals → 10x too
|
|
1452
|
+
// low). Deriving minor units from `amount` keeps this consistent with
|
|
1453
|
+
// render-field.tsx's moneyMinorValue.
|
|
1454
|
+
const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
|
|
1451
1455
|
return formatMoney(minor, value.currency);
|
|
1452
1456
|
}
|
|
1453
1457
|
if (type === "select") {
|
|
@@ -1718,6 +1722,7 @@ function DefaultSection({
|
|
|
1718
1722
|
actions,
|
|
1719
1723
|
variant = "default",
|
|
1720
1724
|
testId,
|
|
1725
|
+
hidden,
|
|
1721
1726
|
}: SectionProps): ReactNode {
|
|
1722
1727
|
const insideForm = useContext(InsideFormContext);
|
|
1723
1728
|
|
|
@@ -1753,9 +1758,11 @@ function DefaultSection({
|
|
|
1753
1758
|
return (
|
|
1754
1759
|
<section
|
|
1755
1760
|
data-testid={testId}
|
|
1761
|
+
hidden={hidden}
|
|
1756
1762
|
className={cn(
|
|
1757
1763
|
"flex flex-col gap-4 px-6 py-6",
|
|
1758
1764
|
variant === "destructive" && "border-l-2 border-destructive/40",
|
|
1765
|
+
hidden && "hidden",
|
|
1759
1766
|
)}
|
|
1760
1767
|
>
|
|
1761
1768
|
{header}
|
|
@@ -1782,10 +1789,12 @@ function DefaultSection({
|
|
|
1782
1789
|
return (
|
|
1783
1790
|
<div
|
|
1784
1791
|
data-testid={testId}
|
|
1792
|
+
hidden={hidden}
|
|
1785
1793
|
className={cn(
|
|
1786
1794
|
cardSurface(),
|
|
1787
1795
|
"overflow-hidden",
|
|
1788
1796
|
variant === "destructive" && "border-destructive/40",
|
|
1797
|
+
hidden && "hidden",
|
|
1789
1798
|
)}
|
|
1790
1799
|
>
|
|
1791
1800
|
<div className="flex flex-col gap-4 px-6 py-6">
|
|
@@ -295,6 +295,119 @@ describe("InfinityList", () => {
|
|
|
295
295
|
expect(calls[2]).toEqual({ limit: 1 });
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
// Regression for the "logic inversion" bug: staleRows used to keep
|
|
299
|
+
// exactly the rows MISSING from the fresh page-1 response, so a row
|
|
300
|
+
// deleted elsewhere never left the list.
|
|
301
|
+
test("Row aus Seite 1 gelöscht → verschwindet nach Refresh", async () => {
|
|
302
|
+
const calls: Array<Readonly<Record<string, unknown>>> = [];
|
|
303
|
+
const dispatcher = createMockDispatcher({
|
|
304
|
+
query: ((_type: string, payload: Readonly<Record<string, unknown>>) => {
|
|
305
|
+
calls.push(payload);
|
|
306
|
+
if (calls.length === 1) {
|
|
307
|
+
return Promise.resolve({
|
|
308
|
+
isSuccess: true,
|
|
309
|
+
data: {
|
|
310
|
+
rows: [
|
|
311
|
+
{ id: "m1", subject: "Erste" },
|
|
312
|
+
{ id: "m2", subject: "Zweite" },
|
|
313
|
+
],
|
|
314
|
+
nextCursor: null,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// Live refresh: m1 was deleted elsewhere, only m2 remains on page 1.
|
|
319
|
+
return Promise.resolve({
|
|
320
|
+
isSuccess: true,
|
|
321
|
+
data: { rows: [{ id: "m2", subject: "Zweite" }], nextCursor: null },
|
|
322
|
+
});
|
|
323
|
+
}) as unknown as Dispatcher["query"],
|
|
324
|
+
});
|
|
325
|
+
const fake = makeFakeLiveEvents();
|
|
326
|
+
|
|
327
|
+
renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
|
|
328
|
+
|
|
329
|
+
await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
|
|
330
|
+
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
331
|
+
|
|
332
|
+
act(() => {
|
|
333
|
+
fake.inject("message.deleted", {
|
|
334
|
+
id: "m1",
|
|
335
|
+
aggregateType: "message",
|
|
336
|
+
version: 1,
|
|
337
|
+
payload: {},
|
|
338
|
+
createdAt: "",
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await waitFor(() => expect(screen.queryByText("Erste")).toBeNull());
|
|
343
|
+
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// A row that was never part of page 1 (it lives on an already-loaded
|
|
347
|
+
// page 2) must survive a page-1-only live refresh even when it isn't in
|
|
348
|
+
// the fresh response — the fix must not treat "not in freshIds" alone
|
|
349
|
+
// as reason to drop a row.
|
|
350
|
+
test("Row aus Seite 2 bleibt nach Seite-1-Refresh erhalten", async () => {
|
|
351
|
+
const calls: Array<Readonly<Record<string, unknown>>> = [];
|
|
352
|
+
const dispatcher = createMockDispatcher({
|
|
353
|
+
query: ((_type: string, payload: Readonly<Record<string, unknown>>) => {
|
|
354
|
+
calls.push(payload);
|
|
355
|
+
if (calls.length === 1) {
|
|
356
|
+
return Promise.resolve({
|
|
357
|
+
isSuccess: true,
|
|
358
|
+
data: { rows: [{ id: "m1", subject: "Erste" }], nextCursor: "c1" },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (calls.length === 2) {
|
|
362
|
+
return Promise.resolve({
|
|
363
|
+
isSuccess: true,
|
|
364
|
+
data: { rows: [{ id: "m2", subject: "Zweite" }], nextCursor: null },
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Live refresh of page 1: m1 was deleted elsewhere, m3 arrives new.
|
|
368
|
+
// m2 lives on page 2 and is outside this fetch's scope.
|
|
369
|
+
return Promise.resolve({
|
|
370
|
+
isSuccess: true,
|
|
371
|
+
data: { rows: [{ id: "m3", subject: "Neu" }], nextCursor: null },
|
|
372
|
+
});
|
|
373
|
+
}) as unknown as Dispatcher["query"],
|
|
374
|
+
});
|
|
375
|
+
const fake = makeFakeLiveEvents();
|
|
376
|
+
|
|
377
|
+
renderWithLive(
|
|
378
|
+
<InfinityList<Page, Row>
|
|
379
|
+
query="inbox:query:message:list"
|
|
380
|
+
pageSize={1}
|
|
381
|
+
rows={(data) => data.rows}
|
|
382
|
+
nextCursor={(data) => data.nextCursor}
|
|
383
|
+
rowId={(row) => row.id}
|
|
384
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
385
|
+
testId="inbox"
|
|
386
|
+
/>,
|
|
387
|
+
dispatcher,
|
|
388
|
+
fake.subscriber,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
|
|
392
|
+
fireIntersect();
|
|
393
|
+
await waitFor(() => expect(screen.getByText("Zweite")).toBeTruthy());
|
|
394
|
+
expect(calls.length).toBe(2);
|
|
395
|
+
|
|
396
|
+
act(() => {
|
|
397
|
+
fake.inject("message.created", {
|
|
398
|
+
id: "m3",
|
|
399
|
+
aggregateType: "message",
|
|
400
|
+
version: 1,
|
|
401
|
+
payload: {},
|
|
402
|
+
createdAt: "",
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
|
|
407
|
+
expect(screen.queryByText("Erste")).toBeNull();
|
|
408
|
+
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
409
|
+
});
|
|
410
|
+
|
|
298
411
|
test("live=false: SSE-Event wird ignoriert, kein Refetch", async () => {
|
|
299
412
|
let calls = 0;
|
|
300
413
|
const dispatcher = createMockDispatcher({
|
|
@@ -80,6 +80,12 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
80
80
|
// overwrite it, leaving stale rows on screen (fw#1705).
|
|
81
81
|
const requestSeq = useRef(0);
|
|
82
82
|
|
|
83
|
+
// IDs of the most recently loaded first page. refreshFirstPage() needs
|
|
84
|
+
// this to tell "row dropped from page 1 elsewhere" (must be pruned) apart
|
|
85
|
+
// from "row belongs to an already-accumulated later page" (must survive)
|
|
86
|
+
// — both are simply absent from the fresh page's id set.
|
|
87
|
+
const firstPageIdsRef = useRef<ReadonlySet<string>>(new Set());
|
|
88
|
+
|
|
83
89
|
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
84
90
|
const load = useCallback(
|
|
85
91
|
async (cursor: string | null): Promise<void> => {
|
|
@@ -95,6 +101,11 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
95
101
|
return;
|
|
96
102
|
}
|
|
97
103
|
const nextRows = rowsRef.current(res.data);
|
|
104
|
+
if (cursor === null) {
|
|
105
|
+
firstPageIdsRef.current = new Set(
|
|
106
|
+
nextRows.map((row, index) => rowIdRef.current(row, index)),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
98
109
|
setState((prev) => ({
|
|
99
110
|
kind: "ready",
|
|
100
111
|
rows: cursor === null || prev.kind !== "ready" ? nextRows : [...prev.rows, ...nextRows],
|
|
@@ -131,12 +142,20 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
131
142
|
if (!res.isSuccess) return;
|
|
132
143
|
const freshRows = rowsRef.current(res.data);
|
|
133
144
|
const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
|
|
145
|
+
// A row absent from freshIds is stale for one of two reasons: it was
|
|
146
|
+
// dropped from page 1 (deleted, or filtered out elsewhere) and must be
|
|
147
|
+
// pruned, or it belongs to an already-accumulated later page and must
|
|
148
|
+
// survive. previousFirstPageIds — captured before it's overwritten below
|
|
149
|
+
// — is what tells those two cases apart.
|
|
150
|
+
const previousFirstPageIds = firstPageIdsRef.current;
|
|
151
|
+
firstPageIdsRef.current = freshIds;
|
|
134
152
|
setState((prev) => {
|
|
135
153
|
// skip: not showing an accumulated list yet, nothing to merge into
|
|
136
154
|
if (prev.kind !== "ready") return prev;
|
|
137
|
-
const staleRows = prev.rows.filter(
|
|
138
|
-
|
|
139
|
-
|
|
155
|
+
const staleRows = prev.rows.filter((row, index) => {
|
|
156
|
+
const id = rowIdRef.current(row, index);
|
|
157
|
+
return !freshIds.has(id) && !previousFirstPageIds.has(id);
|
|
158
|
+
});
|
|
140
159
|
return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
|
|
141
160
|
});
|
|
142
161
|
}, [dispatcher, query, pageSize, payloadKey]);
|