@antelopejs/dms 0.7.9 → 0.7.11

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 (173) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/config.d.ts +2 -0
  3. package/dist/config.js +9 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/implementations/dms/api-base-url.d.ts +1 -0
  6. package/dist/implementations/dms/api-base-url.js +6 -0
  7. package/dist/implementations/dms/api-base-url.js.map +1 -0
  8. package/dist/implementations/dms/frontend-modules.js +7 -6
  9. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  10. package/dist/implementations/dms/page.js +8 -2
  11. package/dist/implementations/dms/page.js.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/test/unit/api-base-url.test.d.ts +1 -0
  15. package/dist/test/unit/api-base-url.test.js +31 -0
  16. package/dist/test/unit/api-base-url.test.js.map +1 -0
  17. package/dist/test/unit/interfaces/dms/page-layout-query.test.d.ts +1 -0
  18. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +106 -0
  19. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -0
  20. package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -0
  21. package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
  22. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.d.ts +1 -0
  23. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +46 -0
  24. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -0
  25. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.d.ts +1 -0
  26. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +26 -0
  27. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -0
  28. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +10 -0
  29. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
  30. package/dist/test/unit/interfaces/dms-base/guard.test.d.ts +1 -0
  31. package/dist/test/unit/interfaces/dms-base/guard.test.js +53 -0
  32. package/dist/test/unit/interfaces/dms-base/guard.test.js.map +1 -0
  33. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +23 -0
  34. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/page-live.test.js +55 -0
  36. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -1
  37. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +17 -0
  38. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -1
  39. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +2 -0
  40. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  41. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +28 -0
  42. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  43. package/frontend-vue/layers/dms-core/app/build/composables/user/preferencesCookie.ts +149 -0
  44. package/frontend-vue/layers/dms-core/app/build/composables/user/tablePreferences.ts +321 -0
  45. package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +4 -3
  46. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +4 -3
  47. package/frontend-vue/layers/dms-core/app/composables/period/types.ts +3 -1
  48. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +2 -2
  49. package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +9 -2
  50. package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +48 -16
  51. package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +21 -4
  52. package/frontend-vue/layers/dms-core/app/composables/user/usePreferences.ts +81 -49
  53. package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +35 -0
  54. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  55. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  56. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +10 -1
  57. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +32 -1
  58. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +13 -3
  59. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +27 -3
  60. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +16 -0
  61. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +75 -0
  62. package/frontend-vue/layers/dms-layout/app/composables/page/usePageLayout.ts +8 -1
  63. package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +13 -2
  64. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +7 -0
  65. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +7 -0
  66. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -4
  67. package/frontend-vue/layers/dms-layout/app/utils/pageQuery.ts +59 -0
  68. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  69. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  70. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +2 -0
  71. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockInlineStatus.vue +67 -0
  72. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +60 -20
  73. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +15 -8
  74. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +14 -0
  75. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +29 -4
  76. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +76 -28
  77. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +26 -2
  78. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useGuard.ts +115 -0
  79. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +1 -1
  80. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +58 -3
  81. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +86 -0
  82. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +5 -0
  83. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +6 -0
  84. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveCadence.ts +42 -0
  85. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveHolds.ts +66 -0
  86. package/frontend-vue/layers/dms-ui/app/build/composables/live/livePoller.ts +42 -0
  87. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveStorage.ts +42 -0
  88. package/frontend-vue/layers/dms-ui/app/build/composables/live/missedTally.ts +65 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +247 -88
  90. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLivePeriodSpan.ts +33 -0
  91. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +79 -5
  92. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodComparisonControl.ts +70 -0
  93. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +55 -0
  94. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +41 -5
  95. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodDraft.ts +22 -9
  96. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +13 -6
  97. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTable.ts +1 -0
  98. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableColumns.ts +27 -2
  99. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +18 -3
  100. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +19 -26
  101. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useScrolledPastTop.ts +66 -0
  102. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +7 -1
  103. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +65 -11
  104. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTablePreferences.ts +149 -0
  105. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +39 -8
  106. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  107. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +16 -11
  108. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +39 -16
  109. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/pinnedRows.ts +29 -0
  110. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -5
  111. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/sortableColumns.ts +17 -0
  112. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +79 -0
  113. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +46 -1
  114. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +4 -0
  115. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +40 -2
  116. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +11 -1
  117. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +12 -2
  118. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +129 -4
  119. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +6 -0
  120. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +6 -1
  121. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +33 -0
  122. package/frontend-vue/layers/dms-ui/app/components/form/components/ChoiceCards.vue +22 -4
  123. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +75 -0
  124. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -17
  125. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +15 -0
  126. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +12 -15
  127. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +104 -16
  128. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +104 -98
  129. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +11 -1
  130. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +2 -0
  131. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +11 -1
  132. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +15 -1
  133. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +86 -10
  134. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewShiftF.ts +20 -0
  135. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +12 -0
  136. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +5 -0
  137. package/frontend-vue/layers/dms-ui/app/composables/table-view/useTableViewEmptyState.ts +55 -0
  138. package/frontend-vue/layers/dms-ui/app/types/live.ts +47 -1
  139. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +13 -4
  140. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +12 -3
  141. package/frontend-vue/tests/activity-feed-fill-height.test.ts +1 -0
  142. package/frontend-vue/tests/activity-feed-head.test.ts +140 -0
  143. package/frontend-vue/tests/block-not-found.test.ts +220 -0
  144. package/frontend-vue/tests/choice-cards-columns.test.ts +60 -0
  145. package/frontend-vue/tests/confirm-secondary-action.test.ts +269 -0
  146. package/frontend-vue/tests/guard.test.ts +242 -0
  147. package/frontend-vue/tests/header-action-page-query.test.ts +286 -0
  148. package/frontend-vue/tests/live-toggle.test.ts +87 -1
  149. package/frontend-vue/tests/page-layout-registration.test.ts +1 -0
  150. package/frontend-vue/tests/page-live-parity.test.ts +367 -0
  151. package/frontend-vue/tests/page-realtime-remount.test.ts +185 -0
  152. package/frontend-vue/tests/period-selector-parity.test.ts +138 -0
  153. package/frontend-vue/tests/period-selector-url.test.ts +84 -6
  154. package/frontend-vue/tests/period-url-query.test.ts +52 -0
  155. package/frontend-vue/tests/preferences-cookie-size.test.ts +311 -0
  156. package/frontend-vue/tests/row-side-panel.test.ts +109 -10
  157. package/frontend-vue/tests/tab-slot-spacing.test.ts +8 -0
  158. package/frontend-vue/tests/table-actions-search-label.test.ts +57 -1
  159. package/frontend-vue/tests/table-clear-filters.test.ts +99 -1
  160. package/frontend-vue/tests/table-density-menu.test.ts +4 -3
  161. package/frontend-vue/tests/table-live-hold.test.ts +95 -5
  162. package/frontend-vue/tests/table-row-action-labels.test.ts +181 -0
  163. package/frontend-vue/tests/table-source-period-scope.test.ts +31 -3
  164. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +14 -17
  165. package/frontend-vue/tests/table-view-pinned-rows.test.ts +143 -0
  166. package/frontend-vue/tests/table-view-sortable-columns.test.ts +45 -0
  167. package/frontend-vue/tests/table-view-views.test.ts +56 -15
  168. package/frontend-vue/tests/top-list-card-value-suffix.test.ts +107 -0
  169. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  170. package/package.json +2 -2
  171. package/skills/dms/SKILL.md +9 -3
  172. package/skills/dms-module/SKILL.md +5 -0
  173. package/skills/dms-pages/REFERENCE.md +23 -2
@@ -12,6 +12,7 @@ import {
12
12
  type App,
13
13
  } from "vue";
14
14
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import { defineShortcuts } from "@nuxt/ui/runtime/composables/defineShortcuts.js";
15
16
  import TableActions from "../layers/dms-ui/app/build/components/table/Actions.vue";
16
17
  import { FULL_TABLE_CHROME } from "../layers/dms-ui/app/build/composables/table-view/utils/chrome";
17
18
 
@@ -48,7 +49,11 @@ const InputStub = defineComponent({
48
49
 
49
50
  let app: App | undefined;
50
51
 
51
- function mountActions(searchPlaceholder: string | undefined, search = "") {
52
+ function mountActions(
53
+ searchPlaceholder: string | undefined,
54
+ search = "",
55
+ searchShortcut?: string,
56
+ ) {
52
57
  const Host = defineComponent({
53
58
  setup() {
54
59
  provide(
@@ -76,6 +81,7 @@ function mountActions(searchPlaceholder: string | undefined, search = "") {
76
81
  return () =>
77
82
  h(TableActions, {
78
83
  searchPlaceholder,
84
+ searchShortcut,
79
85
  chrome: { ...FULL_TABLE_CHROME, refresh: false, menu: false },
80
86
  });
81
87
  },
@@ -166,3 +172,53 @@ describe("table toolbar search", () => {
166
172
  );
167
173
  });
168
174
  });
175
+
176
+ describe("table search shortcut", () => {
177
+ const press = (
178
+ key: string,
179
+ init: KeyboardEventInit = {},
180
+ target: EventTarget = document.body,
181
+ ) =>
182
+ target.dispatchEvent(
183
+ new KeyboardEvent("keydown", { key, bubbles: true, ...init }),
184
+ );
185
+ const settle = async () => {
186
+ await nextTick();
187
+ await nextTick();
188
+ };
189
+
190
+ beforeEach(() => {
191
+ vi.stubGlobal("defineShortcuts", defineShortcuts);
192
+ vi.stubGlobal("nextTick", nextTick);
193
+ });
194
+
195
+ it("opens and focuses the search on ⇧F by default", async () => {
196
+ const container = mountActions(undefined);
197
+ press("F", { shiftKey: true });
198
+ await settle();
199
+ const input = container.querySelector("input");
200
+ expect(input).not.toBeNull();
201
+ expect(document.activeElement).toBe(input);
202
+ });
203
+
204
+ it("opens it on the table's own key, ⇧F no longer bound", async () => {
205
+ const container = mountActions(undefined, "", "/");
206
+ press("F", { shiftKey: true });
207
+ await settle();
208
+ expect(container.querySelector("input")).toBeNull();
209
+ press("/");
210
+ await settle();
211
+ expect(document.activeElement).toBe(container.querySelector("input"));
212
+ });
213
+
214
+ it("leaves / to a field the user types in", async () => {
215
+ const container = mountActions(undefined, "", "/");
216
+ const field = document.createElement("input");
217
+ document.body.append(field);
218
+ field.focus();
219
+ press("/", {}, field);
220
+ await settle();
221
+ expect(container.querySelector("input")).toBeNull();
222
+ expect(document.activeElement).toBe(field);
223
+ });
224
+ });
@@ -15,14 +15,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
15
  import type { PaginationState } from "@tanstack/vue-table";
16
16
  import type { TableFilter } from "../layers/dms-ui/app/build/components/table/Table.vue";
17
17
  import {
18
+ clearTableColumnFilters,
18
19
  clearTableFilters,
19
20
  clearTableFiltersLabelKey,
21
+ clearTableSearch,
20
22
  clearableTableFilters,
21
23
  isTableNarrowed,
22
24
  resetColumnFilters,
23
25
  type TableNarrowingState,
24
26
  } from "../layers/dms-ui/app/build/composables/table/utils/clearTableFilters";
25
27
  import TableEmpty from "../layers/dms-ui/app/build/components/table/Empty.vue";
28
+ import { useTableViewEmptyState } from "../layers/dms-ui/app/composables/table-view/useTableViewEmptyState";
29
+ import type { TableViewEmptyStatesConfig } from "../layers/dms-ui/app/composables/table-view/types";
26
30
 
27
31
  const pinnedStatus = (value: unknown): TableFilter => ({
28
32
  accessorKey: "status",
@@ -173,12 +177,45 @@ describe("clearTableFilters", () => {
173
177
  });
174
178
  });
175
179
 
180
+ describe("clearing the search or the filters alone", () => {
181
+ it("empties the search, the chips kept, back to page 1", () => {
182
+ const state = createState({
183
+ columnFilters: [dueDate],
184
+ globalFilter: "late",
185
+ pageIndex: 2,
186
+ });
187
+ clearTableSearch(state);
188
+ expect(state.globalFilter.value).toBe("");
189
+ expect(state.columnFilters.value).toEqual([dueDate]);
190
+ expect(state.pagination.value.pageIndex).toBe(0);
191
+ });
192
+
193
+ it("clears the chips, the search kept, back to page 1", () => {
194
+ const state = createState({
195
+ columnFilters: [pinnedStatus("closed"), dueDate],
196
+ globalFilter: "late",
197
+ pageIndex: 2,
198
+ });
199
+ clearTableColumnFilters(state);
200
+ expect(state.globalFilter.value).toBe("late");
201
+ expect(state.columnFilters.value).toEqual([pinnedStatus("open")]);
202
+ expect(state.pagination.value.pageIndex).toBe(0);
203
+ });
204
+ });
205
+
176
206
  describe("table empty state", () => {
177
207
  let app: App | undefined;
178
208
 
179
- const mountEmpty = (state: Harness) => {
209
+ const mountEmpty = (
210
+ state: Harness,
211
+ emptyStates?: TableViewEmptyStatesConfig,
212
+ ) => {
180
213
  const sharedData = shallowRef({
181
214
  resetFilters: () => clearTableFilters(state),
215
+ clearSearch: () => clearTableSearch(state),
216
+ clearFilters: () => clearTableColumnFilters(state),
217
+ globalFilterState: state.globalFilter,
218
+ emptyStates,
182
219
  clearableFilters: computed(() => clearableTableFilters(state)),
183
220
  isFiltered: computed(() => isTableNarrowed(state)),
184
221
  showArchivedState: ref(false),
@@ -266,6 +303,67 @@ describe("table empty state", () => {
266
303
  expect(container.querySelector("button")).toBeNull();
267
304
  });
268
305
 
306
+ it("hands a custom state a clear for the search and one for the filters", async () => {
307
+ const received: Record<string, unknown>[] = [];
308
+ vi.stubGlobal("resolveDmsComponent", () =>
309
+ defineComponent({
310
+ props: {
311
+ state: String,
312
+ clearSearch: Function,
313
+ clearFilters: Function,
314
+ },
315
+ setup: (props) => () => {
316
+ received.push({ ...props });
317
+ return h("p", "custom");
318
+ },
319
+ }),
320
+ );
321
+ const state = createState({
322
+ columnFilters: [dueDate],
323
+ globalFilter: "late",
324
+ });
325
+ mountEmpty(state, {
326
+ filtered: { title: "", component: { componentName: "LogsEmpty" } },
327
+ });
328
+ const props = received.at(-1)!;
329
+ expect(props.state).toBe("filtered");
330
+ (props.clearSearch as () => void)();
331
+ await nextTick();
332
+ expect(state.globalFilter.value).toBe("");
333
+ expect(state.columnFilters.value).toEqual([dueDate]);
334
+ expect(received.at(-1)!.clearSearch).toBeUndefined();
335
+ (received.at(-1)!.clearFilters as () => void)();
336
+ expect(state.columnFilters.value).toEqual([]);
337
+ });
338
+
339
+ it("lets a component inside the table clear through useTableViewEmptyState", async () => {
340
+ const state = createState({
341
+ columnFilters: [dueDate],
342
+ globalFilter: "late",
343
+ });
344
+ let controls: ReturnType<typeof useTableViewEmptyState> | undefined;
345
+ vi.stubGlobal("resolveDmsComponent", () =>
346
+ defineComponent({
347
+ setup: () => {
348
+ controls = useTableViewEmptyState();
349
+ return () => h("p");
350
+ },
351
+ }),
352
+ );
353
+ mountEmpty(state, {
354
+ filtered: { title: "", component: { componentName: "LogsEmpty" } },
355
+ });
356
+ expect(controls!.search.value).toBe("late");
357
+ expect(controls!.isFiltered.value).toBe(true);
358
+ expect(controls!.canClearSearch.value).toBe(true);
359
+ expect(controls!.canClearFilters.value).toBe(true);
360
+ controls!.clearFilters();
361
+ expect(controls!.canClearFilters.value).toBe(false);
362
+ expect(state.globalFilter.value).toBe("late");
363
+ controls!.clearSearch();
364
+ expect(controls!.isFiltered.value).toBe(false);
365
+ });
366
+
269
367
  it("offers no clear action when only default filters narrow the rows", () => {
270
368
  const container = mountEmpty(
271
369
  createState({ columnFilters: [pinnedStatus("open")] }),
@@ -118,11 +118,12 @@ describe("table density menu", () => {
118
118
 
119
119
  it("keeps the user's density with the table state, the module's as default", () => {
120
120
  expect(tableViewSource).toMatch(
121
- /getTablePreferenceKey\("density"\),\s*props\.density \?\? DEFAULT_DENSITY/,
121
+ /defaultDensity = props\.density \?\? DEFAULT_DENSITY/,
122
122
  );
123
- expect(tableViewSource).toMatch(
124
- /groupedGroupBy,\s*density,\s*\} as const;/,
123
+ expect(tableViewSource).toContain(
124
+ 'tablePreferences.restore("density", density);',
125
125
  );
126
+ expect(tableViewSource).toMatch(/\["density", density, defaultDensity\]/);
126
127
  expect(tableViewSource).toContain('v-model:density="density"');
127
128
  });
128
129
  });
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { readFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
- import { createApp, defineComponent, h, nextTick, type App } from "vue";
11
+ import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
12
12
  import { refreshPageBlocks } from "../layers/dms-ui/app/utils/blockRefresh";
13
13
  import { afterEach, beforeEach, expect, it, vi } from "vitest";
14
14
  import type { RealtimeTopicHandler } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
@@ -21,7 +21,11 @@ import {
21
21
  LIVE_HELD_AT_QUERY_KEY,
22
22
  sourceRouteQuery,
23
23
  } from "../layers/dms-ui/app/build/composables/table-view/useTableRows";
24
- import type { TableLiveHold } from "../layers/dms-ui/app/build/composables/table-view/useTableLiveHold";
24
+ import type {
25
+ TableLiveHold,
26
+ TableLiveHoldSignals,
27
+ } from "../layers/dms-ui/app/build/composables/table-view/useTableLiveHold";
28
+ import type { TableViewLiveHoldOptions } from "../layers/dms-ui/app/types/live";
25
29
 
26
30
  const topicHandlers = new Map<string, RealtimeTopicHandler>();
27
31
 
@@ -53,7 +57,13 @@ let live: PageLiveController;
53
57
  const publish = () =>
54
58
  topicHandlers.get(TOPIC)?.({ topic: TOPIC, type: "request", ts: 1 });
55
59
 
56
- function mountTable(options: { liveHold: boolean; isSource?: boolean }) {
60
+ interface MountedTableOptions {
61
+ liveHold: boolean | TableViewLiveHoldOptions;
62
+ isSource?: boolean;
63
+ signals?: TableLiveHoldSignals;
64
+ }
65
+
66
+ function mountTable(options: MountedTableOptions) {
57
67
  const refresh = vi.fn();
58
68
  let hold: TableLiveHold | undefined;
59
69
  const app = createApp(
@@ -61,9 +71,10 @@ function mountTable(options: { liveHold: boolean; isSource?: boolean }) {
61
71
  setup() {
62
72
  const gate = useRefreshTriggers(TOPIC, refresh);
63
73
  hold = useTableLiveHold({
64
- isEnabled: options.liveHold,
74
+ liveHold: options.liveHold,
65
75
  isSource: options.isSource ?? true,
66
76
  gate: () => gate,
77
+ signals: options.signals,
67
78
  });
68
79
  return () => h("div");
69
80
  },
@@ -193,7 +204,13 @@ it("reads on a user's refresh while held, and holds from then", async () => {
193
204
  });
194
205
 
195
206
  it("wires the table view's rows, row events and banner to the hold", () => {
196
- expect(tableViewSource).toMatch(/liveHold\?: boolean;/);
207
+ expect(tableViewSource).toMatch(
208
+ /liveHold\?: boolean \| TableViewLiveHoldOptions;/,
209
+ );
210
+ expect(tableViewSource).toContain(
211
+ "isRowOpen: () => openRowId.value !== undefined,",
212
+ );
213
+ expect(tableViewSource).toContain('ref="tableRoot"');
197
214
  expect(tableViewSource).toContain("heldQuery: liveHold.heldQuery,");
198
215
  expect(tableViewSource).toContain("refreshCallback: refreshByUser,");
199
216
  expect(tableViewSource).toContain('@refresh="refreshByUser"');
@@ -202,3 +219,76 @@ it("wires the table view's rows, row events and banner to the hold", () => {
202
219
  /<LiveHoldBanner[\s\S]*@show="liveHold.showNew"/,
203
220
  );
204
221
  });
222
+
223
+ it("holds the table alone while a row is open, the page staying live", async () => {
224
+ const isRowOpen = ref(false);
225
+ const { refresh, hold } = mountTable({
226
+ liveHold: { whileRowOpen: true },
227
+ signals: { isRowOpen: () => isRowOpen.value },
228
+ });
229
+
230
+ isRowOpen.value = true;
231
+ await nextTick();
232
+ publish();
233
+ publish();
234
+ expect(live.isPaused.value).toBe(false);
235
+ expect(live.missedCount.value).toBe(0);
236
+ expect(refresh).not.toHaveBeenCalled();
237
+ expect(hold.isHolding.value).toBe(true);
238
+ expect(hold.newCount.value).toBe(2);
239
+
240
+ // Closing the panel reads what the table missed, once.
241
+ isRowOpen.value = false;
242
+ await nextTick();
243
+ expect(refresh).toHaveBeenCalledTimes(1);
244
+ expect(hold.isHolding.value).toBe(false);
245
+ publish();
246
+ expect(refresh).toHaveBeenCalledTimes(2);
247
+ });
248
+
249
+ it("keeps today's hold when liveHold is true: an open row changes nothing", async () => {
250
+ const { refresh, hold } = mountTable({
251
+ liveHold: true,
252
+ signals: { isRowOpen: () => true },
253
+ });
254
+ publish();
255
+ expect(refresh).toHaveBeenCalledTimes(1);
256
+ expect(hold.isHolding.value).toBe(false);
257
+ });
258
+
259
+ it("holds the table while its first row is scrolled out of view", async () => {
260
+ const element = document.createElement("div");
261
+ element.innerHTML = "<table><tbody><tr><td>1</td></tr></tbody></table>";
262
+ document.body.append(element);
263
+ const row = element.querySelector("tr")!;
264
+ const rect = (bottom: number) => ({ bottom, top: bottom - 40 }) as DOMRect;
265
+ row.getBoundingClientRect = () => rect(120);
266
+ vi.stubGlobal("requestAnimationFrame", (run: () => void) =>
267
+ setTimeout(run, 16),
268
+ );
269
+ const scroll = async () => {
270
+ window.dispatchEvent(new Event("scroll"));
271
+ vi.advanceTimersByTime(16);
272
+ await nextTick();
273
+ };
274
+
275
+ const { refresh, hold } = mountTable({
276
+ liveHold: { whileScrolled: true },
277
+ signals: { tableElement: () => element },
278
+ });
279
+ await nextTick();
280
+ publish();
281
+ expect(refresh).toHaveBeenCalledTimes(1);
282
+
283
+ row.getBoundingClientRect = () => rect(-200);
284
+ await scroll();
285
+ publish();
286
+ expect(hold.isHolding.value).toBe(true);
287
+ expect(hold.newCount.value).toBe(1);
288
+ expect(refresh).toHaveBeenCalledTimes(1);
289
+
290
+ row.getBoundingClientRect = () => rect(120);
291
+ await scroll();
292
+ expect(refresh).toHaveBeenCalledTimes(2);
293
+ element.remove();
294
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A row's actions are named after the row: a custom action's label fills its
3
+ * `{field}` braces from the row ("Translate {name}"), and the ⋯ button opening
4
+ * the row's menu has an accessible name — "Actions for <label>" by default,
5
+ * or the table's own `rowActions.menuLabel`.
6
+ */
7
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
8
+ import {
9
+ computed,
10
+ h,
11
+ ref,
12
+ shallowRef,
13
+ watch,
14
+ watchEffect,
15
+ type VNode,
16
+ } from "vue";
17
+ import { useTableColumns } from "../layers/dms-ui/app/build/composables/table/useTableColumns";
18
+ import * as ruleEvaluator from "../layers/dms-core/app/utils/row-action-rule-evaluator";
19
+ import * as colors from "../layers/dms-core/app/types/color";
20
+ import * as buttons from "../layers/dms-core/app/types/button";
21
+ import * as sizes from "../layers/dms-core/app/types/size";
22
+
23
+ vi.mock("@nuxt/ui/components/Button.vue", () => ({
24
+ default: { name: "UButton", render: () => null },
25
+ }));
26
+ vi.mock("@nuxt/ui/components/Checkbox.vue", () => ({
27
+ default: { render: () => null },
28
+ }));
29
+ vi.mock("@nuxt/ui/components/DropdownMenu.vue", () => ({
30
+ default: { name: "UDropdownMenu", render: () => null },
31
+ }));
32
+ vi.mock("@nuxt/ui/runtime/utils/index.js", () => ({
33
+ get: (row: Record<string, unknown>, key: string) => row[key],
34
+ }));
35
+
36
+ type Row = Record<string, unknown>;
37
+
38
+ const ROW: Row = { _id: "fr", name: "Français", code: "fr" };
39
+
40
+ const translateKey = (key: string, params: Record<string, unknown> = {}) =>
41
+ Object.keys(params).length > 0 ? `${key}(${JSON.stringify(params)})` : key;
42
+
43
+ beforeEach(() => {
44
+ for (const [name, value] of Object.entries({
45
+ ...ruleEvaluator,
46
+ ...colors,
47
+ ...buttons,
48
+ ...sizes,
49
+ })) {
50
+ vi.stubGlobal(name, value);
51
+ }
52
+ vi.stubGlobal("computed", computed);
53
+ vi.stubGlobal("ref", ref);
54
+ vi.stubGlobal("watch", watch);
55
+ vi.stubGlobal("watchEffect", watchEffect);
56
+ vi.stubGlobal("shallowRef", shallowRef);
57
+ vi.stubGlobal("isFunction", (value: unknown) => typeof value === "function");
58
+ vi.stubGlobal("h", h);
59
+ vi.stubGlobal("isString", (value: unknown) => typeof value === "string");
60
+ vi.stubGlobal("useI18n", () => ({ t: translateKey }));
61
+ vi.stubGlobal("useDmsRouter", () => ({ push: vi.fn() }));
62
+ vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
63
+ vi.stubGlobal("useDmsAppConfig", () => ({
64
+ ui: { icons: { ellipsis: "i-ellipsis" } },
65
+ }));
66
+ vi.stubGlobal("useTranslation", () => ({
67
+ processI18n: (key: string, params?: Record<string, unknown>) =>
68
+ key.startsWith("$") ? translateKey(key.slice(1), params ?? {}) : key,
69
+ }));
70
+ vi.stubGlobal("useConfirm", () => ({ confirm: vi.fn() }));
71
+ vi.stubGlobal("useAuthFetch", () => ({ $authFetch: vi.fn() }));
72
+ vi.stubGlobal("useDataTypes", () => ({ getDataType: () => undefined }));
73
+ });
74
+
75
+ afterEach(() => {
76
+ vi.unstubAllGlobals();
77
+ });
78
+
79
+ interface ActionsCellOptions {
80
+ custom: Array<Record<string, unknown>>;
81
+ menuLabel?: string;
82
+ labelKey?: string;
83
+ }
84
+
85
+ function actionsCell({ custom, menuLabel, labelKey }: ActionsCellOptions) {
86
+ const { columns } = useTableColumns<Row>({
87
+ data: computed(() => [ROW]),
88
+ columns: [{ accessorKey: "name", header: "Name" }],
89
+ rowIdKey: "_id",
90
+ rowActions: {
91
+ add: false,
92
+ copyLink: false,
93
+ delete: false,
94
+ details: false,
95
+ duplicate: false,
96
+ edit: false,
97
+ custom: custom as never,
98
+ menuLabel,
99
+ },
100
+ labelKey,
101
+ emits: (() => undefined) as never,
102
+ ui: computed(() => ({ rowAction: () => "" }) as never),
103
+ });
104
+ const column = columns.value.find((entry) => entry.id === "actions");
105
+ const cell = column?.cell as (context: unknown) => VNode;
106
+ return cell({ row: { original: ROW, id: "fr" } });
107
+ }
108
+
109
+ const childrenOf = (node: VNode) => node.children as VNode[];
110
+
111
+ function menuButtonProps(cell: VNode): Record<string, unknown> {
112
+ const menu = childrenOf(cell).find(
113
+ (child) => (child.type as { name?: string }).name === "UDropdownMenu",
114
+ );
115
+ const slots = menu?.children as { default: () => VNode };
116
+ return slots.default().props ?? {};
117
+ }
118
+
119
+ const MENU_ACTION = { label: "Remove", target: { type: "api", url: "/x" } };
120
+
121
+ it("names the ⋯ button after the row's label field by default", () => {
122
+ const cell = actionsCell({ custom: [MENU_ACTION], labelKey: "name" });
123
+ expect(menuButtonProps(cell)["aria-label"]).toBe(
124
+ 'dms.table.row_actions_for({"label":"Français"})',
125
+ );
126
+ });
127
+
128
+ it("names the ⋯ button generically without a label field", () => {
129
+ const cell = actionsCell({ custom: [MENU_ACTION] });
130
+ expect(menuButtonProps(cell)["aria-label"]).toBe("dms.table.row_actions");
131
+ });
132
+
133
+ it("names the ⋯ button with menuLabel, filled from the row", () => {
134
+ const literal = actionsCell({
135
+ custom: [MENU_ACTION],
136
+ menuLabel: "Actions for {name} ({code})",
137
+ });
138
+ expect(menuButtonProps(literal)["aria-label"]).toBe(
139
+ "Actions for Français (fr)",
140
+ );
141
+ const key = actionsCell({
142
+ custom: [MENU_ACTION],
143
+ menuLabel: "$dms_lang.languages.menu",
144
+ });
145
+ expect(menuButtonProps(key)["aria-label"]).toContain(
146
+ 'dms_lang.languages.menu({"_id":"fr","name":"Français"',
147
+ );
148
+ });
149
+
150
+ it("fills a custom action's label from the row, inline and in the menu", () => {
151
+ const cell = actionsCell({
152
+ custom: [
153
+ {
154
+ label: "Translate {name}",
155
+ isVisible: true,
156
+ showLabel: true,
157
+ target: { type: "page", url: "/lang?locale={code}" },
158
+ },
159
+ {
160
+ label: "$dms_lang.languages.remove",
161
+ target: { type: "api", url: "/x" },
162
+ },
163
+ {
164
+ label: "Keep {unknown} as written",
165
+ target: { type: "api", url: "/y" },
166
+ },
167
+ ],
168
+ });
169
+ const [inline] = childrenOf(cell);
170
+ expect(inline.props?.label).toBe("Translate Français");
171
+ expect(inline.props?.["aria-label"]).toBe("Translate Français");
172
+
173
+ const menu = childrenOf(cell).find(
174
+ (child) => (child.type as { name?: string }).name === "UDropdownMenu",
175
+ );
176
+ const items = (menu?.props?.items as Array<Array<{ label: string }>>).flat();
177
+ expect(items.map((item) => item.label)).toEqual([
178
+ expect.stringContaining('dms_lang.languages.remove({"_id":"fr"'),
179
+ "Keep {unknown} as written",
180
+ ]);
181
+ });
@@ -24,7 +24,7 @@ vi.stubGlobal("useDmsLazyAsyncData", engine.useDmsLazyAsyncData);
24
24
  const { useTableRows } = await import(
25
25
  "../layers/dms-ui/app/build/composables/table-view/useTableRows"
26
26
  );
27
- const { useTableListData } = await import(
27
+ const { useTableCountsData, useTableListData } = await import(
28
28
  "../layers/dms-ui/app/build/composables/table-view/useTableListData"
29
29
  );
30
30
  const { usePeriod } = await import(
@@ -49,6 +49,7 @@ const api = (async (url: string) => {
49
49
  const mounted: App[] = [];
50
50
  const cleanups: Array<() => void> = [];
51
51
  let listed: { value: unknown } | undefined;
52
+ let counted: { value: unknown } | undefined;
52
53
 
53
54
  const SourceTable = defineComponent({
54
55
  setup() {
@@ -68,6 +69,15 @@ const SourceTable = defineComponent({
68
69
  }).then(({ data }) => {
69
70
  listed = data;
70
71
  });
72
+ void useTableCountsData({
73
+ componentId: "report",
74
+ pageId: "reports",
75
+ rows,
76
+ queries: computed(() => [{ id: "all", query: {} }]),
77
+ archiveQuery: computed(() => ({})),
78
+ }).then(({ data }) => {
79
+ counted = data;
80
+ });
71
81
  return () => h("div");
72
82
  },
73
83
  });
@@ -98,6 +108,7 @@ const periodOf = (url: string) =>
98
108
  beforeEach(() => {
99
109
  requests.length = 0;
100
110
  listed = undefined;
111
+ counted = undefined;
101
112
  });
102
113
 
103
114
  afterEach(() => {
@@ -114,13 +125,30 @@ describe("a source table bound to a period scope", () => {
114
125
  expect(listed!.value).toBeNull();
115
126
  });
116
127
 
128
+ // The server never registers a scope: an empty object there drew the tabs
129
+ // with no skeleton, where the browser (its key carrying the period) drew
130
+ // one, and hydration mismatched.
131
+ it("keeps its tab counters' skeleton too until the scope registers", async () => {
132
+ mountTable();
133
+ await settle();
134
+ expect(counted).toBeDefined();
135
+ expect(counted!.value).toBeNull();
136
+ });
137
+
138
+ it("counts its tabs with the period once the scope registers", async () => {
139
+ registerSelector();
140
+ mountTable();
141
+ await settle();
142
+ expect(counted!.value).toEqual({ all: 1 });
143
+ });
144
+
117
145
  it("sends the period with its request once the scope registers", async () => {
118
146
  const period = registerSelector();
119
147
  mountTable();
120
148
  await settle();
121
- expect(requests).toHaveLength(1);
149
+ expect(requests).toHaveLength(2);
122
150
  expect(listed!.value).toMatchObject({ total: 1 });
123
- const { range } = period.state.value;
151
+ const range = period.state.value.range!;
124
152
  expect(periodOf(requests[0]!)).toEqual({
125
153
  from: range.from.toISOString(),
126
154
  to: range.to.toISOString(),