@cosmicdrift/kumiko-renderer 0.270.0 → 0.274.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.
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
2
  import type { EntityEditScreenDefinition, RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
4
- import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
4
+ import { act, fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
5
5
  import type { ComponentType, ReactNode } from "react";
6
6
  import { AppFeaturesProvider } from "../../app/app-features-context";
7
7
  import type { FeatureSchema } from "../../app/feature-schema";
@@ -175,7 +175,12 @@ function renderRelatedList(
175
175
  <DispatcherProvider dispatcher={dispatcher}>
176
176
  <PrimitivesProvider value={testPrimitives()}>
177
177
  <NavProvider value={nav}>
178
- <RelatedListSection section={section} parentId="order-1" featureName="orders" />
178
+ <RelatedListSection
179
+ section={section}
180
+ parentId="order-1"
181
+ record={{ id: "order-1" }}
182
+ featureName="orders"
183
+ />
179
184
  </NavProvider>
180
185
  </PrimitivesProvider>
181
186
  </DispatcherProvider>
@@ -184,7 +189,7 @@ function renderRelatedList(
184
189
  }
185
190
 
186
191
  describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
187
- test("hideTitle (tabs mode) renders the list without a Section wrapper and marks the table chromeless + scrollBody", async () => {
192
+ test("hideTitle (tabs mode) renders the list without a Section wrapper, keeps the table frame, and marks scrollBody", async () => {
188
193
  const { dispatcher } = stubDispatcher();
189
194
  let capturedChromeless: boolean | undefined;
190
195
  let capturedScrollBody: boolean | undefined;
@@ -204,6 +209,7 @@ describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
204
209
  <RelatedListSection
205
210
  section={historySection}
206
211
  parentId="order-1"
212
+ record={{ id: "order-1" }}
207
213
  featureName="orders"
208
214
  hideTitle
209
215
  />
@@ -215,7 +221,7 @@ describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
215
221
 
216
222
  await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
217
223
  expect(rtlScreen.queryByTestId(`related-list-${historySection.title}`)).toBeNull();
218
- expect(capturedChromeless).toBe(true);
224
+ expect(capturedChromeless).toBeUndefined();
219
225
  expect(capturedScrollBody).toBe(true);
220
226
  });
221
227
 
@@ -239,6 +245,7 @@ describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
239
245
  <RelatedListSection
240
246
  section={historySection}
241
247
  parentId="order-1"
248
+ record={{ id: "order-1" }}
242
249
  featureName="orders"
243
250
  />
244
251
  </NavProvider>
@@ -254,6 +261,404 @@ describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
254
261
  });
255
262
  });
256
263
 
264
+ describe("RelatedListSection — list-screen toolbar in tabs mode", () => {
265
+ test("hideTitle (tabs mode) still surfaces search, facets and a toolbarActions button", async () => {
266
+ const { dispatcher } = stubDispatcher();
267
+ let capturedFilterFacets: DataTableProps["filterFacets"];
268
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => {
269
+ capturedFilterFacets = props.filterFacets;
270
+ return (
271
+ <>
272
+ {props.toolbarEnd}
273
+ {testDataTable(props)}
274
+ </>
275
+ );
276
+ };
277
+ const testButton = ({
278
+ children,
279
+ testId,
280
+ onClick,
281
+ }: {
282
+ readonly children?: ReactNode;
283
+ readonly testId?: string;
284
+ readonly onClick?: () => void;
285
+ }) => (
286
+ <button type="button" data-testid={testId} onClick={() => onClick?.()}>
287
+ {children}
288
+ </button>
289
+ );
290
+ const section: EditRelatedListSectionViewModel = {
291
+ ...historySection,
292
+ searchable: true,
293
+ facets: [
294
+ {
295
+ field: "name",
296
+ type: "select",
297
+ label: "Name",
298
+ options: [{ value: "Alice", label: "Alice" }],
299
+ },
300
+ ],
301
+ toolbarActions: [
302
+ {
303
+ kind: "navigate",
304
+ id: "create",
305
+ label: "New notification",
306
+ screen: "notification-create",
307
+ },
308
+ ],
309
+ };
310
+ render(
311
+ <LocaleProvider
312
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
313
+ fallbackBundles={[kumikoDefaultTranslations]}
314
+ >
315
+ <DispatcherProvider dispatcher={dispatcher}>
316
+ <PrimitivesProvider
317
+ value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
318
+ >
319
+ <NavProvider value={stubNav().nav}>
320
+ <RelatedListSection
321
+ section={section}
322
+ parentId="order-1"
323
+ record={{ id: "order-1" }}
324
+ featureName="orders"
325
+ hideTitle
326
+ />
327
+ </NavProvider>
328
+ </PrimitivesProvider>
329
+ </DispatcherProvider>
330
+ </LocaleProvider>,
331
+ );
332
+
333
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
334
+ expect(capturedFilterFacets?.length).toBe(1);
335
+ expect(rtlScreen.getByTestId("render-list-toolbar-action-create")).toBeTruthy();
336
+ });
337
+
338
+ test("clicking a toolbarAction navigates to its screen and prefills the parent id under parentParam", async () => {
339
+ const { dispatcher } = stubDispatcher();
340
+ const { nav, navigations, searchParams } = stubNav();
341
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => (
342
+ <>
343
+ {props.toolbarEnd}
344
+ {testDataTable(props)}
345
+ </>
346
+ );
347
+ const testButton = ({
348
+ children,
349
+ testId,
350
+ onClick,
351
+ }: {
352
+ readonly children?: ReactNode;
353
+ readonly testId?: string;
354
+ readonly onClick?: () => void;
355
+ }) => (
356
+ <button type="button" data-testid={testId} onClick={() => onClick?.()}>
357
+ {children}
358
+ </button>
359
+ );
360
+ const section: EditRelatedListSectionViewModel = {
361
+ ...historySection,
362
+ parentParam: "leaseId",
363
+ toolbarActions: [
364
+ { kind: "navigate", id: "create", label: "Add position", screen: "position-create" },
365
+ ],
366
+ };
367
+ render(
368
+ <LocaleProvider
369
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
370
+ fallbackBundles={[kumikoDefaultTranslations]}
371
+ >
372
+ <DispatcherProvider dispatcher={dispatcher}>
373
+ <PrimitivesProvider
374
+ value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
375
+ >
376
+ <NavProvider value={nav}>
377
+ <RelatedListSection
378
+ section={section}
379
+ parentId="lease-9"
380
+ record={{ id: "lease-9" }}
381
+ featureName="leases"
382
+ />
383
+ </NavProvider>
384
+ </PrimitivesProvider>
385
+ </DispatcherProvider>
386
+ </LocaleProvider>,
387
+ );
388
+
389
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
390
+ rtlScreen.getByTestId("render-list-toolbar-action-create").click();
391
+
392
+ await waitFor(() => expect(navigations).toHaveLength(1));
393
+ expect(navigations[0]).toEqual({ screenId: "position-create" });
394
+ expect(searchParams).toEqual([{ leaseId: "lease-9" }]);
395
+ });
396
+
397
+ test("a toolbarAction with a visible condition that the parent record fails is not rendered", async () => {
398
+ const { dispatcher } = stubDispatcher();
399
+ const { nav } = stubNav();
400
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => (
401
+ <>
402
+ {props.toolbarEnd}
403
+ {testDataTable(props)}
404
+ </>
405
+ );
406
+ const testButton = ({
407
+ children,
408
+ testId,
409
+ }: {
410
+ readonly children?: ReactNode;
411
+ readonly testId?: string;
412
+ }) => (
413
+ <button type="button" data-testid={testId}>
414
+ {children}
415
+ </button>
416
+ );
417
+ const section: EditRelatedListSectionViewModel = {
418
+ ...historySection,
419
+ parentParam: "leaseId",
420
+ toolbarActions: [
421
+ {
422
+ kind: "navigate",
423
+ id: "create",
424
+ label: "Add position",
425
+ screen: "position-create",
426
+ visible: { field: "status", eq: "active" },
427
+ },
428
+ ],
429
+ };
430
+ render(
431
+ <LocaleProvider
432
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
433
+ fallbackBundles={[kumikoDefaultTranslations]}
434
+ >
435
+ <DispatcherProvider dispatcher={dispatcher}>
436
+ <PrimitivesProvider
437
+ value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
438
+ >
439
+ <NavProvider value={nav}>
440
+ <RelatedListSection
441
+ section={section}
442
+ parentId="lease-9"
443
+ record={{ id: "lease-9", status: "closed" }}
444
+ featureName="leases"
445
+ />
446
+ </NavProvider>
447
+ </PrimitivesProvider>
448
+ </DispatcherProvider>
449
+ </LocaleProvider>,
450
+ );
451
+
452
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
453
+ expect(rtlScreen.queryByTestId("render-list-toolbar-action-create")).toBeNull();
454
+ });
455
+
456
+ test("a toolbarAction with a visible condition the parent record satisfies is rendered", async () => {
457
+ const { dispatcher } = stubDispatcher();
458
+ const { nav } = stubNav();
459
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => (
460
+ <>
461
+ {props.toolbarEnd}
462
+ {testDataTable(props)}
463
+ </>
464
+ );
465
+ const testButton = ({
466
+ children,
467
+ testId,
468
+ }: {
469
+ readonly children?: ReactNode;
470
+ readonly testId?: string;
471
+ }) => (
472
+ <button type="button" data-testid={testId}>
473
+ {children}
474
+ </button>
475
+ );
476
+ const section: EditRelatedListSectionViewModel = {
477
+ ...historySection,
478
+ parentParam: "leaseId",
479
+ toolbarActions: [
480
+ {
481
+ kind: "navigate",
482
+ id: "create",
483
+ label: "Add position",
484
+ screen: "position-create",
485
+ visible: { field: "status", eq: "active" },
486
+ },
487
+ ],
488
+ };
489
+ render(
490
+ <LocaleProvider
491
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
492
+ fallbackBundles={[kumikoDefaultTranslations]}
493
+ >
494
+ <DispatcherProvider dispatcher={dispatcher}>
495
+ <PrimitivesProvider
496
+ value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
497
+ >
498
+ <NavProvider value={nav}>
499
+ <RelatedListSection
500
+ section={section}
501
+ parentId="lease-9"
502
+ record={{ id: "lease-9", status: "active" }}
503
+ featureName="leases"
504
+ />
505
+ </NavProvider>
506
+ </PrimitivesProvider>
507
+ </DispatcherProvider>
508
+ </LocaleProvider>,
509
+ );
510
+
511
+ await waitFor(() =>
512
+ expect(rtlScreen.getByTestId("render-list-toolbar-action-create")).toBeTruthy(),
513
+ );
514
+ });
515
+
516
+ test("a toolbarAction's params extractor maps the parent record and replaces the implicit parentParam prefill", async () => {
517
+ const { dispatcher } = stubDispatcher();
518
+ const { nav, navigations, searchParams } = stubNav();
519
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => (
520
+ <>
521
+ {props.toolbarEnd}
522
+ {testDataTable(props)}
523
+ </>
524
+ );
525
+ const testButton = ({
526
+ children,
527
+ testId,
528
+ onClick,
529
+ }: {
530
+ readonly children?: ReactNode;
531
+ readonly testId?: string;
532
+ readonly onClick?: () => void;
533
+ }) => (
534
+ <button type="button" data-testid={testId} onClick={() => onClick?.()}>
535
+ {children}
536
+ </button>
537
+ );
538
+ const section: EditRelatedListSectionViewModel = {
539
+ ...historySection,
540
+ // Default parentParam ("id") would prefill `{ id: "lease-9" }` — the
541
+ // declared params extractor below must produce `{ leaseId: ... }`
542
+ // instead, proving params replaces rather than merges with it.
543
+ toolbarActions: [
544
+ {
545
+ kind: "navigate",
546
+ id: "create",
547
+ label: "Add position",
548
+ screen: "position-create",
549
+ params: { map: { leaseId: "id" } },
550
+ },
551
+ ],
552
+ };
553
+ render(
554
+ <LocaleProvider
555
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
556
+ fallbackBundles={[kumikoDefaultTranslations]}
557
+ >
558
+ <DispatcherProvider dispatcher={dispatcher}>
559
+ <PrimitivesProvider
560
+ value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
561
+ >
562
+ <NavProvider value={nav}>
563
+ <RelatedListSection
564
+ section={section}
565
+ parentId="lease-9"
566
+ record={{ id: "lease-9" }}
567
+ featureName="leases"
568
+ />
569
+ </NavProvider>
570
+ </PrimitivesProvider>
571
+ </DispatcherProvider>
572
+ </LocaleProvider>,
573
+ );
574
+
575
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
576
+ rtlScreen.getByTestId("render-list-toolbar-action-create").click();
577
+
578
+ await waitFor(() => expect(navigations).toHaveLength(1));
579
+ expect(searchParams).toEqual([{ leaseId: "lease-9" }]);
580
+ });
581
+
582
+ test("a reference facet loads its options from the referenced entity's list query, and selecting one sends an id filter", async () => {
583
+ const calls: Array<{ type: string; payload: unknown }> = [];
584
+ const dispatcher: Dispatcher = {
585
+ write: (async () => ({ isSuccess: true, data: null })) as Dispatcher["write"],
586
+ query: (async (type: string, payload: unknown) => {
587
+ calls.push({ type, payload });
588
+ if (type === "orders:query:tenant:list") {
589
+ return {
590
+ isSuccess: true,
591
+ data: {
592
+ rows: [{ id: "t1", name: "Acme" }],
593
+ nextCursor: null,
594
+ },
595
+ };
596
+ }
597
+ return { isSuccess: true, data: { rows: [{ id: "r1", name: "Alice" }], nextCursor: null } };
598
+ }) as Dispatcher["query"],
599
+ batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
600
+ statusStore: {
601
+ getState: () => "online",
602
+ subscribe: () => () => {},
603
+ } as unknown as Dispatcher["statusStore"],
604
+ async *stream() {},
605
+ pendingWrites: () => [],
606
+ pendingFiles: () => [],
607
+ };
608
+ let capturedFilterFacets: DataTableProps["filterFacets"];
609
+ let capturedOnFilterChange: DataTableProps["onFilterChange"];
610
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => {
611
+ capturedFilterFacets = props.filterFacets;
612
+ capturedOnFilterChange = props.onFilterChange;
613
+ return testDataTable(props);
614
+ };
615
+ const section: EditRelatedListSectionViewModel = {
616
+ ...historySection,
617
+ facets: [
618
+ { field: "name", type: "reference", label: "Tenant", entity: "tenant", labelField: "name" },
619
+ ],
620
+ };
621
+ render(
622
+ <LocaleProvider
623
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
624
+ fallbackBundles={[kumikoDefaultTranslations]}
625
+ >
626
+ <DispatcherProvider dispatcher={dispatcher}>
627
+ <PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
628
+ <NavProvider value={stubNav().nav}>
629
+ <RelatedListSection
630
+ section={section}
631
+ parentId="order-1"
632
+ record={{ id: "order-1" }}
633
+ featureName="orders"
634
+ hideTitle
635
+ />
636
+ </NavProvider>
637
+ </PrimitivesProvider>
638
+ </DispatcherProvider>
639
+ </LocaleProvider>,
640
+ );
641
+
642
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
643
+ await waitFor(() => expect(capturedFilterFacets?.[0]?.options).toHaveLength(1));
644
+ expect(capturedFilterFacets).toEqual([
645
+ { field: "name", label: "Tenant", options: [{ value: "t1", label: "Acme" }] },
646
+ ]);
647
+
648
+ const countBeforeSelect = calls.length;
649
+ await act(async () => {
650
+ capturedOnFilterChange?.("name", ["t1"]);
651
+ });
652
+ await waitFor(() => expect(calls.length).toBeGreaterThan(countBeforeSelect));
653
+ const lastListCall = [...calls]
654
+ .reverse()
655
+ .find((c) => c.type === "orders:query:notifications:list");
656
+ expect(lastListCall?.payload).toMatchObject({
657
+ filters: [{ field: "name", op: "in", value: ["t1"] }],
658
+ });
659
+ });
660
+ });
661
+
257
662
  describe("RelatedListSection — rowActions", () => {
258
663
  test("clicking a row action dispatches through the configured write-handler with the extracted payload, then refetches", async () => {
259
664
  const { dispatcher, writes, queryCount } = stubDispatcher();
@@ -359,6 +764,7 @@ describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
359
764
  ],
360
765
  }}
361
766
  parentId="order-1"
767
+ record={{ id: "order-1" }}
362
768
  featureName="orders"
363
769
  onOpenDrawer={(action, initialValues) =>
364
770
  openDrawerCalls.push([action, initialValues])
@@ -456,6 +862,7 @@ describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
456
862
  ],
457
863
  }}
458
864
  parentId="order-1"
865
+ record={{ id: "order-1" }}
459
866
  featureName="orders"
460
867
  onOpenDrawer={noop}
461
868
  />
@@ -532,6 +939,7 @@ describe("RelatedListSection — sorting (fw#2722)", () => {
532
939
  defaultSort: { field: "amount", dir: "asc" },
533
940
  }}
534
941
  parentId="order-1"
942
+ record={{ id: "order-1" }}
535
943
  featureName="orders"
536
944
  />
537
945
  </NavProvider>
@@ -562,6 +970,7 @@ describe("RelatedListSection — sorting (fw#2722)", () => {
562
970
  columns: [{ field: "amount", sortable: true }],
563
971
  }}
564
972
  parentId="order-1"
973
+ record={{ id: "order-1" }}
565
974
  featureName="orders"
566
975
  />
567
976
  </NavProvider>
@@ -597,6 +1006,7 @@ describe("RelatedListSection — sorting (fw#2722)", () => {
597
1006
  columns: [{ field: "amount" }],
598
1007
  }}
599
1008
  parentId="order-1"
1009
+ record={{ id: "order-1" }}
600
1010
  featureName="orders"
601
1011
  />
602
1012
  </NavProvider>
@@ -757,6 +1167,7 @@ function renderWithDataTable(
757
1167
  <RelatedListSection
758
1168
  section={section}
759
1169
  parentId="order-1"
1170
+ record={{ id: "order-1" }}
760
1171
  featureName="orders"
761
1172
  {...(hideTitle === true && { hideTitle: true })}
762
1173
  />
@@ -942,6 +1353,196 @@ describe("RelatedListSection — search + facets (fw#2740)", () => {
942
1353
  });
943
1354
  });
944
1355
 
1356
+ // A dispatcher stub that filters a fixed row set by `payload.filter` (single
1357
+ // eq clause), `payload.search` (substring on `name`) and `payload.filters`
1358
+ // (facet membership) together — proves parentFilter composes with search
1359
+ // and facets instead of being clobbered by either.
1360
+ function parentFilterDispatcher(rows: readonly Record<string, unknown>[]): {
1361
+ dispatcher: Dispatcher;
1362
+ payloads: Record<string, unknown>[];
1363
+ } {
1364
+ const payloads: Record<string, unknown>[] = [];
1365
+ const dispatcher: Dispatcher = {
1366
+ write: (async () => ({ isSuccess: true, data: null })) as Dispatcher["write"],
1367
+ query: (async (_type: string, payload: unknown) => {
1368
+ const p = payload as {
1369
+ filter?: { field: string; op: "eq"; value: unknown };
1370
+ search?: string;
1371
+ filters?: readonly { field: string; op: "in"; value: readonly unknown[] }[];
1372
+ };
1373
+ payloads.push(p);
1374
+ let result = rows;
1375
+ if (p.filter !== undefined) {
1376
+ const { field, value } = p.filter;
1377
+ result = result.filter((r) => r[field] === value);
1378
+ }
1379
+ if (p.search !== undefined && p.search !== "") {
1380
+ const term = p.search.toLowerCase();
1381
+ result = result.filter((r) =>
1382
+ String(r["name"] ?? "")
1383
+ .toLowerCase()
1384
+ .includes(term),
1385
+ );
1386
+ }
1387
+ for (const f of p.filters ?? []) {
1388
+ result = result.filter((r) => f.value.includes(r[f.field]));
1389
+ }
1390
+ return { isSuccess: true, data: { rows: result, nextCursor: null } };
1391
+ }) as Dispatcher["query"],
1392
+ batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
1393
+ statusStore: {
1394
+ getState: () => "online",
1395
+ subscribe: () => () => {},
1396
+ } as unknown as Dispatcher["statusStore"],
1397
+ async *stream() {},
1398
+ pendingWrites: () => [],
1399
+ pendingFiles: () => [],
1400
+ };
1401
+ return { dispatcher, payloads };
1402
+ }
1403
+
1404
+ describe("RelatedListSection — parentFilter", () => {
1405
+ const rows = [
1406
+ { id: "r1", parentId: "order-1", name: "Alice", status: "active" },
1407
+ { id: "r2", parentId: "order-1", name: "Bob", status: "ended" },
1408
+ { id: "r3", parentId: "order-2", name: "Carol", status: "active" },
1409
+ ];
1410
+ const section: EditRelatedListSectionViewModel = {
1411
+ kind: "relatedList",
1412
+ title: "Positions",
1413
+ query: "orders:query:items:list",
1414
+ columns: [{ field: "name" }, { field: "status" }],
1415
+ parentFilter: { field: "parentId" },
1416
+ pageSize: 25,
1417
+ searchable: true,
1418
+ facets: [
1419
+ {
1420
+ field: "status",
1421
+ type: "select",
1422
+ label: "Status",
1423
+ options: [
1424
+ { value: "active", label: "Active" },
1425
+ { value: "ended", label: "Ended" },
1426
+ ],
1427
+ },
1428
+ ],
1429
+ };
1430
+
1431
+ test("sends payload.filter eq parentId (no top-level parentParam key), combined with limit/search/facets, and only the parent's rows render", async () => {
1432
+ const { dispatcher, payloads } = parentFilterDispatcher(rows);
1433
+ renderWithDataTable(dispatcher, section, searchFacetDataTable);
1434
+
1435
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1436
+ expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
1437
+ expect(rtlScreen.queryByTestId("row-r3")).toBeNull();
1438
+
1439
+ const firstPayload = payloads[0];
1440
+ expect(firstPayload).toEqual({
1441
+ filter: { field: "parentId", op: "eq", value: "order-1" },
1442
+ limit: 25,
1443
+ });
1444
+ expect(firstPayload?.["id"]).toBeUndefined();
1445
+ expect(firstPayload?.["parentId"]).toBeUndefined();
1446
+ });
1447
+
1448
+ test("typing a search term keeps payload.filter and narrows via payload.search together", async () => {
1449
+ const { dispatcher, payloads } = parentFilterDispatcher(rows);
1450
+ renderWithDataTable(dispatcher, section, searchFacetDataTable);
1451
+
1452
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1453
+ fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
1454
+ target: { value: "ali" },
1455
+ });
1456
+
1457
+ await waitFor(
1458
+ () => {
1459
+ const last = payloads[payloads.length - 1];
1460
+ expect(last?.["search"]).toBe("ali");
1461
+ },
1462
+ { timeout: 2000 },
1463
+ );
1464
+ const last = payloads[payloads.length - 1];
1465
+ expect(last?.["filter"]).toEqual({ field: "parentId", op: "eq", value: "order-1" });
1466
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1467
+ expect(rtlScreen.queryByTestId("row-r2")).toBeNull();
1468
+ });
1469
+
1470
+ test("selecting a facet leaves payload.filter unchanged and adds payload.filters", async () => {
1471
+ const { dispatcher, payloads } = parentFilterDispatcher(rows);
1472
+ renderWithDataTable(dispatcher, section, searchFacetDataTable);
1473
+
1474
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1475
+ fireEvent.click(rtlScreen.getByTestId("facet-status-active"));
1476
+
1477
+ await waitFor(() => expect(rtlScreen.queryByTestId("row-r2")).toBeNull());
1478
+ expect(rtlScreen.getByTestId("row-r1")).toBeTruthy();
1479
+ const last = payloads[payloads.length - 1];
1480
+ expect(last?.["filter"]).toEqual({ field: "parentId", op: "eq", value: "order-1" });
1481
+ expect(last?.["filters"]).toEqual([{ field: "status", op: "in", value: ["active"] }]);
1482
+ });
1483
+
1484
+ test("a toolbarAction without params prefills the parent id under parentFilter.field, not parentParam/id", async () => {
1485
+ const { dispatcher } = parentFilterDispatcher(rows);
1486
+ const { nav, navigations, searchParams } = stubNav();
1487
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => (
1488
+ <>
1489
+ {props.toolbarEnd}
1490
+ {testDataTable(props)}
1491
+ </>
1492
+ );
1493
+ const testButton = ({
1494
+ children,
1495
+ testId,
1496
+ onClick,
1497
+ }: {
1498
+ readonly children?: ReactNode;
1499
+ readonly testId?: string;
1500
+ readonly onClick?: () => void;
1501
+ }) => (
1502
+ <button type="button" data-testid={testId} onClick={() => onClick?.()}>
1503
+ {children}
1504
+ </button>
1505
+ );
1506
+ render(
1507
+ <LocaleProvider
1508
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
1509
+ fallbackBundles={[kumikoDefaultTranslations]}
1510
+ >
1511
+ <DispatcherProvider dispatcher={dispatcher}>
1512
+ <PrimitivesProvider
1513
+ value={{ ...testPrimitives(), DataTable: capturingDataTable, Button: testButton }}
1514
+ >
1515
+ <NavProvider value={nav}>
1516
+ <RelatedListSection
1517
+ section={{
1518
+ ...section,
1519
+ toolbarActions: [
1520
+ {
1521
+ kind: "navigate",
1522
+ id: "create",
1523
+ label: "Add position",
1524
+ screen: "item-create",
1525
+ },
1526
+ ],
1527
+ }}
1528
+ parentId="order-1"
1529
+ record={{ id: "order-1" }}
1530
+ featureName="orders"
1531
+ />
1532
+ </NavProvider>
1533
+ </PrimitivesProvider>
1534
+ </DispatcherProvider>
1535
+ </LocaleProvider>,
1536
+ );
1537
+
1538
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1539
+ rtlScreen.getByTestId("render-list-toolbar-action-create").click();
1540
+
1541
+ await waitFor(() => expect(navigations).toHaveLength(1));
1542
+ expect(searchParams).toEqual([{ parentId: "order-1" }]);
1543
+ });
1544
+ });
1545
+
945
1546
  // Same findEditScreenFor/buildDefaultEditRowAction resolution as
946
1547
  // entityList/projectionList, driven by rowClick.entity and rowClick.idColumn.
947
1548
  describe("RelatedListSection — default edit row action", () => {
@@ -972,7 +1573,12 @@ describe("RelatedListSection — default edit row action", () => {
972
1573
  <UserRolesProvider roles={userRoles}>
973
1574
  <PrimitivesProvider value={testPrimitives()}>
974
1575
  <NavProvider value={nav}>
975
- <RelatedListSection section={section} parentId="order-1" featureName="orders" />
1576
+ <RelatedListSection
1577
+ section={section}
1578
+ parentId="order-1"
1579
+ record={{ id: "order-1" }}
1580
+ featureName="orders"
1581
+ />
976
1582
  </NavProvider>
977
1583
  </PrimitivesProvider>
978
1584
  </UserRolesProvider>