@antelopejs/dms 0.7.19 → 0.7.21

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.
Files changed (26) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.d.ts +1 -0
  3. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js +27 -0
  4. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js.map +1 -0
  5. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +11 -0
  6. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  7. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +13 -3
  8. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +1 -1
  9. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +14 -0
  10. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +70 -8
  11. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +93 -0
  12. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +42 -12
  13. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +3 -2
  14. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -0
  15. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -16
  16. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +18 -0
  17. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/views.ts +29 -0
  18. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +43 -54
  19. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +2 -0
  20. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +6 -0
  21. package/frontend-vue/tests/row-side-panel.test.ts +93 -2
  22. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +117 -0
  23. package/frontend-vue/tests/table-view-cards-reread.test.ts +77 -0
  24. package/frontend-vue/tests/table-view-record-link.test.ts +207 -0
  25. package/frontend-vue/tests/table-view-url-tab.test.ts +84 -2
  26. package/package.json +2 -2
@@ -27,3 +27,21 @@ export function withoutQueryParam<Query extends Record<string, unknown>>(
27
27
  const { [param]: _removed, ...rest } = query;
28
28
  return rest as Query;
29
29
  }
30
+
31
+ /** The query of a URL, absolute or relative to the page, one value per key. */
32
+ export function queryOfUrl(url: string): Record<string, string> {
33
+ return Object.fromEntries(new URL(url, "http://dms.invalid").searchParams);
34
+ }
35
+
36
+ /**
37
+ * The query the browser shows, else the route's on a server render. A table
38
+ * writes some keys in place (the open `?record=`, removed on close), so the
39
+ * route, which follows the last visit, can still hold a key the browser no
40
+ * longer shows: a visit rebuilt from it would bring the key back.
41
+ */
42
+ export function currentQuery(
43
+ routeQuery: Record<string, unknown>,
44
+ ): Record<string, unknown> {
45
+ if (typeof window === "undefined") return routeQuery;
46
+ return queryOfUrl(window.location.href);
47
+ }
@@ -183,6 +183,35 @@ export interface UrlKeyReading {
183
183
  ignoredShortKey?: boolean;
184
184
  }
185
185
 
186
+ /**
187
+ * The key this table writes `key` (`tab`, `record`) under: the short one on
188
+ * its page's only table, else prefixed with its id (`content.tab`).
189
+ */
190
+ export function tableUrlKey(scope: TableUrlScope, key: string): string {
191
+ return scope.isSoleTableView || !scope.tableId
192
+ ? key
193
+ : `${scope.tableId}.${key}`;
194
+ }
195
+
196
+ /**
197
+ * Writes (or, without a value, removes) `key` for this table in the browser's
198
+ * URL in place: no visit, no history entry. Returns the URL written, or
199
+ * `undefined` on a server render.
200
+ */
201
+ export function writeTableUrlKey(
202
+ scope: TableUrlScope,
203
+ key: string,
204
+ value?: string,
205
+ ): URL | undefined {
206
+ if (typeof window === "undefined") return undefined;
207
+ const url = new URL(window.location.href);
208
+ const scopedKey = tableUrlKey(scope, key);
209
+ if (value) url.searchParams.set(scopedKey, value);
210
+ else url.searchParams.delete(scopedKey);
211
+ window.history.replaceState(window.history.state, "", url);
212
+ return url;
213
+ }
214
+
186
215
  /**
187
216
  * The value the URL gives `key` (`view`, `tab`) for this table: under its
188
217
  * prefixed key (`content.view`) or, when it is its page's only table, the
@@ -78,12 +78,8 @@ import { selectedRowIds } from "../../build/composables/table-view/utils/bulkAct
78
78
  import { isBulkAction } from "../../build/composables/actions/bulkSelection";
79
79
  import { selectionScopeKey } from "../../build/utils/selectionScope";
80
80
  import {
81
- type DeepLinkOutcome,
82
- deepLinkOutcome,
83
81
  isDeepLinkAction,
84
- readRecordId,
85
82
  resolveDeepLinkedRow,
86
- writeRecordId,
87
83
  } from "../../build/composables/table-view/utils/recordLink";
88
84
  import {
89
85
  rowSidePanelOwnerKey,
@@ -119,6 +115,7 @@ import type {
119
115
  TableViewSwitcherItem,
120
116
  } from "../../build/components/table/Table.vue";
121
117
  import {
118
+ currentQuery,
122
119
  visibleUrlFilters,
123
120
  withoutQueryParam,
124
121
  } from "../../build/composables/table-view/utils/urlFilters";
@@ -155,6 +152,7 @@ import {
155
152
  readTableUrlTab,
156
153
  useTableUrlTab,
157
154
  } from "../../build/composables/table-view/useTableUrlTab";
155
+ import { useTableRecordLink } from "../../build/composables/table-view/useTableRecordLink";
158
156
  import TableViews, {
159
157
  type TableViewItem,
160
158
  } from "../../build/components/table/Views.vue";
@@ -269,6 +267,11 @@ const kanbanGroupByOptions = kanbanEligibleColumns.map((column) => ({
269
267
  const { displays: registeredDisplays, getById } = useTableViewDisplays();
270
268
 
271
269
  const offeredDisplayIds = new Set(resolvedDisplays.map((d) => d.id));
270
+ // Where a stale or unavailable choice lands: the grid, unless the table view
271
+ // drops it, then its default display.
272
+ const fallbackDisplayId = offeredDisplayIds.has(TABLE_DISPLAY_ID)
273
+ ? TABLE_DISPLAY_ID
274
+ : defaultDisplay;
272
275
  const resolvedDisplay = (id: string): TableViewDisplayConfig | undefined =>
273
276
  resolvedDisplays.find((d) => d.id === id);
274
277
  const optionsForDisplay = (id: string): Record<string, unknown> | undefined =>
@@ -295,7 +298,7 @@ const activeDisplayId = ref<string>(
295
298
  ? defaultDisplay
296
299
  : offeredDisplayIds.has(persistedDisplay)
297
300
  ? persistedDisplay
298
- : TABLE_DISPLAY_ID,
301
+ : fallbackDisplayId,
299
302
  );
300
303
 
301
304
  /** A display the backend config renders by itself, with no client plugin. */
@@ -411,9 +414,11 @@ const shownUrlFilters = computed<TableUrlFilter[]>(() =>
411
414
  visibleUrlFilters(queryParamFilters, route.query as Record<string, unknown>),
412
415
  );
413
416
 
417
+ // Rebuilt from the URL the browser shows: a `?record=` a closed drawer
418
+ // removed in place must not come back with the visit.
414
419
  const removeUrlFilter = (param: string) =>
415
420
  router.replace({
416
- query: withoutQueryParam(route.query, param),
421
+ query: withoutQueryParam(currentQuery(route.query), param),
417
422
  });
418
423
 
419
424
  const routeParamHiddenFilters = computed<TableFilter[]>(() => {
@@ -587,7 +592,7 @@ const {
587
592
  ),
588
593
  display: offeredDisplayIds.has(defaultDisplay)
589
594
  ? defaultDisplay
590
- : TABLE_DISPLAY_ID,
595
+ : fallbackDisplayId,
591
596
  density: props.density ?? DEFAULT_DENSITY,
592
597
  displays: offeredDisplayIds,
593
598
  },
@@ -1296,8 +1301,10 @@ const {
1296
1301
  rowNavigation: {
1297
1302
  rows: () => (shownResults.value ?? []) as Data[],
1298
1303
  rowIdKey: props.rowIdKey ?? ROW_ID_DEFAULT_KEY,
1304
+ getRow: (id) => tableRows.getRow(id) as Promise<Data | undefined>,
1299
1305
  },
1300
1306
  recordScope: urlScope,
1307
+ onRecordShown: (id) => recordLink?.shown(id),
1301
1308
  });
1302
1309
 
1303
1310
  // Custom actions offered on the selection bar. "Select all N matching"
@@ -1704,59 +1711,40 @@ watch(rowSelect, (selection) => {
1704
1711
  const deepLinkAction = (tableProps.value.rowActions?.custom ?? []).find(
1705
1712
  isDeepLinkAction,
1706
1713
  );
1707
- const openRecordFromUrl = async () => {
1708
- const id = deepLinkAction ? readRecordId(route.query, urlScope) : undefined;
1709
- if (!deepLinkAction || !id) return;
1710
- await until(isListLoading).toBe(false);
1711
- const { row, isMissing } = await resolveDeepLinkedRow(id, {
1712
- listed: shownResults.value ?? undefined,
1713
- idOf: getRowId,
1714
- getRow: tableRows.getRow,
1715
- });
1716
- const outcome = deepLinkOutcome({ row, isMissing }, deepLinkAction);
1717
- deepLinkOutcomes[outcome](deepLinkAction, id, row as Data);
1718
- };
1714
+ const recordLink = deepLinkAction
1715
+ ? useTableRecordLink<T>({
1716
+ route,
1717
+ urlScope,
1718
+ action: deepLinkAction,
1719
+ resolve: async (id) => {
1720
+ await until(isListLoading).toBe(false);
1721
+ return resolveDeepLinkedRow(id, {
1722
+ listed: shownResults.value ?? undefined,
1723
+ idOf: getRowId,
1724
+ getRow: tableRows.getRow,
1725
+ });
1726
+ },
1727
+ open: (row) => handleCustomRowAction(deepLinkAction, row as Data),
1728
+ showMissing: (id) => handleMissingRow(deepLinkAction, id),
1729
+ notify: (outcome) => toast.add(recordLinkToasts[outcome]()),
1730
+ })
1731
+ : undefined;
1719
1732
 
1720
- const notifyUnresolvedRecordLink = () => {
1721
- writeRecordId(urlScope);
1722
- toast.add({
1733
+ const recordLinkToasts = {
1734
+ notify: () => ({
1723
1735
  title: t("dms.record_link.not_found_title"),
1724
1736
  description: t("dms.record_link.not_found_description"),
1725
1737
  color: Color.warning,
1726
1738
  icon: "i-ph-link-break",
1727
- });
1728
- };
1729
-
1730
- const notifyFailedRecordLink = () => {
1731
- writeRecordId(urlScope);
1732
- toast.add({
1739
+ }),
1740
+ notifyFailed: () => ({
1733
1741
  title: t("dms.record_link.failed_title"),
1734
1742
  description: t("dms.record_link.failed_description"),
1735
1743
  color: Color.error,
1736
1744
  icon: "i-ph-warning-circle",
1737
- });
1738
- };
1739
-
1740
- const deepLinkOutcomes: Record<
1741
- DeepLinkOutcome,
1742
- (action: CustomRowAction, id: string, row: Data) => void
1743
- > = {
1744
- open: (action, _id, row) => handleCustomRowAction(action, row),
1745
- missingPanel: (action, id) => handleMissingRow(action, id),
1746
- notify: notifyUnresolvedRecordLink,
1747
- notifyFailed: notifyFailedRecordLink,
1745
+ }),
1748
1746
  };
1749
1747
 
1750
- // A visit that keeps the page mounted (a link to the same page with another
1751
- // `?record=`) opens the row it names, as a fresh load would. The table's own
1752
- // writes replace the URL in place and never reach the route.
1753
- watch(
1754
- () => readRecordId(route.query, urlScope),
1755
- (id, previous) => {
1756
- if (id && id !== previous) void openRecordFromUrl();
1757
- },
1758
- );
1759
-
1760
1748
  const selectedIds = computed(() => selectedRowIds(rowSelect.value));
1761
1749
 
1762
1750
  const isRowActionEnabled = (
@@ -1872,6 +1860,7 @@ const displayContext = computed(
1872
1860
  items: shownResults.value ?? EMPTY_ITEMS,
1873
1861
  columns: props.columns,
1874
1862
  loading: isListLoading.value,
1863
+ firstPageLoading: isFirstPageLoading.value,
1875
1864
  selection: { ids: selectedIds.value, ...displaySelection },
1876
1865
  pagination: {
1877
1866
  pageIndex: paginationState.value.pageIndex,
@@ -2086,17 +2075,16 @@ const checkUrlParameter = () => {
2086
2075
 
2087
2076
  onMounted(() => {
2088
2077
  checkUrlParameter();
2089
- void openRecordFromUrl();
2090
2078
  void loadRelationQuickFilters();
2091
2079
  void refreshLinkTabCounts();
2092
- // The registry is client-only; once hydrated, degrade to the table display if
2093
- // the active id is no longer available (e.g. kanban with no eligible column).
2080
+ // The registry is client-only; once hydrated, degrade to the fallback display
2081
+ // if the active id is no longer available (e.g. kanban with no eligible column).
2094
2082
  if (
2095
- activeDisplayId.value !== TABLE_DISPLAY_ID &&
2083
+ activeDisplayId.value !== fallbackDisplayId &&
2096
2084
  !isConfigRenderedDisplay(activeDisplayId.value) &&
2097
2085
  !availableDisplays.value.some((d) => d.id === activeDisplayId.value)
2098
2086
  ) {
2099
- activeDisplayId.value = TABLE_DISPLAY_ID;
2087
+ activeDisplayId.value = fallbackDisplayId;
2100
2088
  }
2101
2089
  });
2102
2090
  </script>
@@ -2142,6 +2130,7 @@ onMounted(() => {
2142
2130
  :tabs="tabsWithCount"
2143
2131
  :initial-column-visibility="initialVisibility"
2144
2132
  :loading="isListLoading"
2133
+ :first-page-loading="isFirstPageLoading"
2145
2134
  :load-error="listLoadError"
2146
2135
  :data="shownResults || []"
2147
2136
  :pagination-options="{ manualPagination: true, rowCount: data?.total }"
@@ -296,6 +296,8 @@ export interface TableViewConfig<T extends Data>
296
296
  tabs?: TableViewTab[];
297
297
  /** Displays this table view offers, beyond the implicit built-in `table`. */
298
298
  displays?: TableViewDisplayConfig[];
299
+ /** Whether the built-in `table` display is offered. Defaults to `true`. */
300
+ tableDisplay?: boolean;
299
301
  /** Display shown by default when the user has no saved preference. Defaults to "table". */
300
302
  defaultDisplay?: string;
301
303
  /** Expandable rows: caret column + detail band. */
@@ -214,7 +214,13 @@ export interface TableViewDisplayContext<T = Record<string, unknown>> {
214
214
  items: T[];
215
215
  /** Full column metadata (types, labels, accessor keys) — for field rendering. */
216
216
  columns: TableViewColumn[];
217
+ /** The shared query is in flight: a first read or any re-read. */
217
218
  loading: boolean;
219
+ /**
220
+ * No answer has arrived yet: draw a skeleton. A re-read keeps the rows,
221
+ * or the empty state, on screen; swapping either for a skeleton flashes.
222
+ */
223
+ firstPageLoading: boolean;
218
224
 
219
225
  selection: TableViewDisplaySelection;
220
226
  pagination: TableViewDisplayPagination;
@@ -188,7 +188,12 @@ interface Mounted {
188
188
  showTable: Ref<boolean>;
189
189
  }
190
190
 
191
- function mountPanel(shownRows: Ref<Row[]> = ref(rows)): Mounted {
191
+ type ReadRow = (id: string) => Promise<Row | undefined>;
192
+
193
+ function mountPanel(
194
+ shownRows: Ref<Row[]> = ref(rows),
195
+ getRow?: ReadRow,
196
+ ): Mounted {
192
197
  let targets: ReturnType<typeof useActionTargets> | undefined;
193
198
  const showTable = ref(true);
194
199
  const Table = defineComponent({
@@ -201,7 +206,11 @@ function mountPanel(shownRows: Ref<Row[]> = ref(rows)): Mounted {
201
206
  refreshCallback: () => {
202
207
  refreshes += 1;
203
208
  },
204
- rowNavigation: { rows: () => shownRows.value, rowIdKey: "_id" },
209
+ rowNavigation: {
210
+ rows: () => shownRows.value,
211
+ rowIdKey: "_id",
212
+ getRow,
213
+ },
205
214
  recordScope: { tableId: "requests", isSoleTableView: true },
206
215
  });
207
216
  return () => h("table");
@@ -480,6 +489,88 @@ describe("deep link to a row the table does not list", () => {
480
489
  });
481
490
  });
482
491
 
492
+ describe("showing another row from the panel", () => {
493
+ const older: Row = { _id: "r9", name: "Older" };
494
+ const notFoundError = () =>
495
+ Object.assign(new Error("Not Found"), { statusCode: 404 });
496
+ const showRow = (rowOrId: Row | string) =>
497
+ (
498
+ useRowSidePanelContent().value?.componentOptions.showRow as (
499
+ rowOrId: Row | string,
500
+ ) => Promise<void>
501
+ )(rowOrId);
502
+
503
+ it("moves the highlight, the position and ?record= onto a listed row", async () => {
504
+ const { host, targets } = mountPanel();
505
+ targets.handleCustomRowAction(panelAction(), rows[0]);
506
+ await settle();
507
+ await showRow("r3");
508
+ await settle();
509
+ expect(shownName(host)).toBe("Third");
510
+ expect(position(host)).toBe("side_panel.position(3/3)");
511
+ expect(useRowSidePanelContent().value?.rowKey).toBe("r3");
512
+ expect(window.location.search).toBe("?record=r3");
513
+ await showRow(rows[1]!);
514
+ await settle();
515
+ expect(position(host)).toBe("side_panel.position(2/3)");
516
+ });
517
+
518
+ it("reads an unlisted row by id, shown without a position", async () => {
519
+ const getRow = vi.fn(async () => older);
520
+ const { host, targets } = mountPanel(ref(rows), getRow);
521
+ targets.handleCustomRowAction(panelAction(), rows[0]);
522
+ await settle();
523
+ await showRow("r9");
524
+ await settle();
525
+ expect(getRow).toHaveBeenCalledWith("r9");
526
+ expect(shownName(host)).toBe("Older");
527
+ expect(position(host)).toBeUndefined();
528
+ expect(useRowSidePanelContent().value?.rowKey).toBe("r9");
529
+ expect(window.location.search).toBe("?record=r9");
530
+ });
531
+
532
+ it("says a row its route answers 404 for is gone", async () => {
533
+ const { host, targets } = mountPanel(ref(rows), async () => {
534
+ throw notFoundError();
535
+ });
536
+ targets.handleCustomRowAction(panelAction(), rows[0]);
537
+ await settle();
538
+ await showRow("gone");
539
+ await settle();
540
+ expect(query(host, "[data-dms-row-side-panel-not-found]")).not.toBeNull();
541
+ expect(useRowSidePanelContent().value?.rowKey).toBeUndefined();
542
+ expect(window.location.search).toBe("?record=gone");
543
+ });
544
+
545
+ it("rejects a row it cannot read, keeping the row it shows", async () => {
546
+ const { host, targets } = mountPanel();
547
+ targets.handleCustomRowAction(panelAction(), rows[0]);
548
+ await settle();
549
+ await expect(showRow("r9")).rejects.toThrow("r9");
550
+ await settle();
551
+ expect(shownName(host)).toBe("First");
552
+ expect(window.location.search).toBe("?record=r1");
553
+ });
554
+
555
+ it("leaves the panel shut when it closed while the row was read", async () => {
556
+ let answer!: (row: Row) => void;
557
+ const { host, targets } = mountPanel(
558
+ ref(rows),
559
+ () => new Promise((resolve) => (answer = resolve)),
560
+ );
561
+ targets.handleCustomRowAction(panelAction(), rows[0]);
562
+ await settle();
563
+ const shown = showRow("r9");
564
+ closeRowSidePanel();
565
+ answer(older);
566
+ await shown;
567
+ await settle();
568
+ expect(useRowSidePanelContent().value).toBeNull();
569
+ expect(query(host, "[data-dms-side-panel]")).toBeNull();
570
+ expect(window.location.search).toBe("");
571
+ });
572
+ });
573
+
483
574
  describe("side panel on a missing row", () => {
484
575
  const notFound = (host: ParentNode) =>
485
576
  query(host, "[data-dms-row-side-panel-not-found]");
@@ -271,9 +271,11 @@ describe("action targets on a selection and on a listed row", () => {
271
271
  it("steps a row's drawer through the rows shown and keeps it in the URL", async () => {
272
272
  window.history.replaceState(null, "", "/runs");
273
273
  const rows = [{ _id: "r1" }, { _id: "r2" }];
274
+ const onRecordShown = vi.fn();
274
275
  const targets = mountTargets({
275
276
  rowNavigation: { rows: () => rows, rowIdKey: "_id" },
276
277
  recordScope: { tableId: "runs", isSoleTableView: true },
278
+ onRecordShown,
277
279
  });
278
280
  targets.handleCustomRowAction(
279
281
  {
@@ -306,10 +308,125 @@ describe("action targets on a selection and on a listed row", () => {
306
308
  await nextTick();
307
309
  await Promise.resolve();
308
310
  expect(window.location.search).toBe("");
311
+ expect(onRecordShown.mock.calls).toEqual([["r1"], ["r2"], [undefined]]);
309
312
  window.dispatchEvent(new KeyboardEvent("keydown", { key: "k" }));
310
313
  expect(drawer!.patches).toHaveLength(1);
311
314
  });
312
315
 
316
+ it("shows another row in a row's drawer by id, read when unlisted", async () => {
317
+ window.history.replaceState(null, "", "/runs");
318
+ const rows = [{ _id: "r1" }, { _id: "r2" }];
319
+ const older = { _id: "r9" };
320
+ const getRow = vi.fn(async (id: string) =>
321
+ id === "r9" ? older : undefined,
322
+ );
323
+ const targets = mountTargets({
324
+ rowNavigation: { rows: () => rows, rowIdKey: "_id", getRow },
325
+ recordScope: { tableId: "runs", isSoleTableView: true },
326
+ });
327
+ targets.handleCustomRowAction(
328
+ {
329
+ label: "Details",
330
+ deepLink: true,
331
+ target: { type: "drawer", component: { componentName: "RunDrawer" } },
332
+ },
333
+ rows[0],
334
+ );
335
+ await vi.waitFor(() => expect(drawers).toHaveLength(1));
336
+ const [drawer] = drawers;
337
+ const options = drawer!.options.componentOptions as Record<string, unknown>;
338
+ const showRow = options.showRow as (row: object | string) => Promise<void>;
339
+
340
+ await showRow("r9");
341
+ expect(drawer!.patches[0]).toMatchObject({ componentKey: "r9" });
342
+ const patched = drawer!.patches[0]!.componentOptions as Record<
343
+ string,
344
+ unknown
345
+ >;
346
+ expect(patched.rowData).toBe(older);
347
+ expect(patched.navigation).toBeUndefined();
348
+ expect(window.location.search).toBe("?record=r9");
349
+
350
+ await showRow("r2");
351
+ expect(getRow).toHaveBeenCalledTimes(1);
352
+ expect(drawer!.patches[1]).toMatchObject({ componentKey: "r2" });
353
+ expect(window.location.search).toBe("?record=r2");
354
+
355
+ await expect(showRow("r404")).rejects.toThrow("r404");
356
+ expect(drawer!.patches).toHaveLength(2);
357
+ drawer!.settle();
358
+ });
359
+
360
+ const deferredRows = () => {
361
+ const pending = new Map<string, (row: object) => void>();
362
+ const getRow = vi.fn(
363
+ (id: string) =>
364
+ new Promise<object | undefined>((resolve) => pending.set(id, resolve)),
365
+ );
366
+ return { getRow, resolve: (id: string) => pending.get(id)!({ _id: id }) };
367
+ };
368
+
369
+ const openDeepLinkedDrawer = (getRow: (id: string) => Promise<unknown>) => {
370
+ window.history.replaceState(null, "", "/runs");
371
+ const rows = [{ _id: "r1" }];
372
+ const targets = mountTargets({
373
+ rowNavigation: { rows: () => rows, rowIdKey: "_id", getRow },
374
+ recordScope: { tableId: "runs", isSoleTableView: true },
375
+ onRecordShown: vi.fn(),
376
+ });
377
+ targets.handleCustomRowAction(
378
+ {
379
+ label: "Details",
380
+ deepLink: true,
381
+ target: { type: "drawer", component: { componentName: "RunDrawer" } },
382
+ },
383
+ rows[0],
384
+ );
385
+ return targets;
386
+ };
387
+
388
+ const showRowOf = (drawer: (typeof drawers)[number]) =>
389
+ (drawer.options.componentOptions as Record<string, unknown>).showRow as (
390
+ row: object | string,
391
+ ) => Promise<void>;
392
+
393
+ it("drops a row read that resolves after its drawer closed", async () => {
394
+ const rows = deferredRows();
395
+ openDeepLinkedDrawer(rows.getRow);
396
+ await vi.waitFor(() => expect(drawers).toHaveLength(1));
397
+ const [drawer] = drawers;
398
+ const shown = showRowOf(drawer!)("r9");
399
+
400
+ drawer!.settle();
401
+ await vi.waitFor(() => expect(window.location.search).toBe(""));
402
+ rows.resolve("r9");
403
+ await shown;
404
+
405
+ expect(window.location.search).toBe("");
406
+ expect(drawer!.patches).toHaveLength(0);
407
+ expect(drawers).toHaveLength(1);
408
+ });
409
+
410
+ it("applies only the latest of two rows read at once", async () => {
411
+ const rows = deferredRows();
412
+ openDeepLinkedDrawer(rows.getRow);
413
+ await vi.waitFor(() => expect(drawers).toHaveLength(1));
414
+ const [drawer] = drawers;
415
+ const showRow = showRowOf(drawer!);
416
+ const first = showRow("r8");
417
+ const second = showRow("r9");
418
+
419
+ rows.resolve("r9");
420
+ await second;
421
+ rows.resolve("r8");
422
+ await first;
423
+
424
+ expect(drawer!.patches).toHaveLength(1);
425
+ expect(drawer!.patches[0]).toMatchObject({ componentKey: "r9" });
426
+ expect(window.location.search).toBe("?record=r9");
427
+ drawer!.settle();
428
+ });
429
+
313
430
  it("hands a bulk drawer the selection instead of a row", async () => {
314
431
  const targets = mountTargets();
315
432
  targets.handleBulkCustomAction(
@@ -0,0 +1,77 @@
1
+ // @vitest-environment jsdom
2
+ import { computed, createApp, h, reactive, ref, type App } from "vue";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import CardsDisplay from "../layers/dms-ui/app/build/components/table-view/CardsDisplay.vue";
5
+ import type { TableViewDisplayContext } from "../layers/dms-ui/app/composables/table-view/types/display";
6
+
7
+ vi.mock("../layers/dms-ui/app/build/components/table/Pagination.vue", () => ({
8
+ default: { render: () => null },
9
+ }));
10
+ vi.mock("../layers/dms-ui/app/build/components/table/Empty.vue", () => ({
11
+ default: { render: () => h("p", { "data-test": "empty" }) },
12
+ }));
13
+ vi.mock(
14
+ "../layers/dms-ui/app/build/components/table-view/CardGridSkeleton.vue",
15
+ () => ({ default: { render: () => h("p", { "data-test": "skeleton" }) } }),
16
+ );
17
+
18
+ interface ReadState {
19
+ loading: boolean;
20
+ firstPageLoading: boolean;
21
+ }
22
+
23
+ function createContext(state: ReadState) {
24
+ return reactive({
25
+ items: [],
26
+ columns: [],
27
+ ...state,
28
+ rowIdKey: "_id",
29
+ labelKey: "name",
30
+ options: {},
31
+ pagination: { pageIndex: 0, pageSize: 10, total: 0 },
32
+ selection: { isSelected: () => false, toggle: () => {} },
33
+ actions: { canAdd: false, open: () => {} },
34
+ }) as unknown as TableViewDisplayContext;
35
+ }
36
+
37
+ describe("the cards display of an empty list", () => {
38
+ let app: App | undefined;
39
+ let container: HTMLElement;
40
+
41
+ const mount = (context: TableViewDisplayContext) => {
42
+ app = createApp({ render: () => h(CardsDisplay, { context }) });
43
+ container = document.createElement("div");
44
+ document.body.append(container);
45
+ app.mount(container);
46
+ };
47
+ const shown = (test: string) =>
48
+ container.querySelector(`[data-test="${test}"]`) !== null;
49
+
50
+ beforeEach(() => {
51
+ vi.stubGlobal("computed", computed);
52
+ vi.stubGlobal("h", h);
53
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en"), t: (t: string) => t }));
54
+ vi.stubGlobal("useTranslation", () => ({ processI18n: (t: string) => t }));
55
+ vi.stubGlobal("useDataTypes", () => ({ getDataType: () => undefined }));
56
+ vi.stubGlobal("resolveDmsComponent", () => undefined);
57
+ });
58
+
59
+ afterEach(() => {
60
+ app?.unmount();
61
+ app = undefined;
62
+ container.remove();
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ it("draws the skeleton until the first answer arrives", () => {
67
+ mount(createContext({ loading: true, firstPageLoading: true }));
68
+ expect(shown("skeleton")).toBe(true);
69
+ expect(shown("empty")).toBe(false);
70
+ });
71
+
72
+ it("keeps the empty state on screen while the list is read again", () => {
73
+ mount(createContext({ loading: true, firstPageLoading: false }));
74
+ expect(shown("empty")).toBe(true);
75
+ expect(shown("skeleton")).toBe(false);
76
+ });
77
+ });