@antelopejs/dms 0.7.8 → 0.7.10

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 (184) hide show
  1. package/CHANGELOG.md +45 -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/attachment-host/capabilities.d.ts +25 -0
  15. package/dist/test/attachment-host/capabilities.js +53 -1
  16. package/dist/test/attachment-host/capabilities.js.map +1 -1
  17. package/dist/test/integration/table-view-capabilities.test.js +13 -0
  18. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  19. package/dist/test/unit/api-base-url.test.d.ts +1 -0
  20. package/dist/test/unit/api-base-url.test.js +31 -0
  21. package/dist/test/unit/api-base-url.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms/page-layout-query.test.d.ts +1 -0
  23. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +106 -0
  24. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -0
  25. package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -0
  26. package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
  27. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.d.ts +1 -0
  28. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +46 -0
  29. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -0
  30. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.d.ts +1 -0
  31. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +26 -0
  32. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -0
  33. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +10 -0
  34. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/guard.test.d.ts +1 -0
  36. package/dist/test/unit/interfaces/dms-base/guard.test.js +53 -0
  37. package/dist/test/unit/interfaces/dms-base/guard.test.js.map +1 -0
  38. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +23 -0
  39. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
  40. package/dist/test/unit/interfaces/dms-base/page-live.test.js +55 -0
  41. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -1
  42. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +17 -0
  43. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -1
  44. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +8 -1
  45. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  46. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +28 -0
  47. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  48. package/frontend-vue/layers/dms-core/app/build/composables/user/preferencesCookie.ts +149 -0
  49. package/frontend-vue/layers/dms-core/app/build/composables/user/tablePreferences.ts +321 -0
  50. package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +4 -3
  51. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +4 -3
  52. package/frontend-vue/layers/dms-core/app/composables/period/types.ts +3 -1
  53. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +2 -2
  54. package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +9 -2
  55. package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +48 -16
  56. package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +21 -4
  57. package/frontend-vue/layers/dms-core/app/composables/user/usePreferences.ts +81 -49
  58. package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +35 -0
  59. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  60. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  61. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +10 -1
  62. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +32 -1
  63. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +13 -3
  64. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +27 -3
  65. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +16 -0
  66. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +75 -0
  67. package/frontend-vue/layers/dms-layout/app/composables/page/usePageLayout.ts +8 -1
  68. package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +13 -2
  69. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +7 -0
  70. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +7 -0
  71. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -4
  72. package/frontend-vue/layers/dms-layout/app/utils/pageQuery.ts +59 -0
  73. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  74. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  75. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +2 -0
  76. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockInlineStatus.vue +67 -0
  77. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +60 -20
  78. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +15 -8
  79. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +14 -0
  80. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +29 -4
  81. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +76 -28
  82. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +26 -2
  83. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useGuard.ts +115 -0
  84. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +1 -1
  85. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +58 -3
  86. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +86 -0
  87. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +5 -0
  88. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +6 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveCadence.ts +42 -0
  90. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveHolds.ts +66 -0
  91. package/frontend-vue/layers/dms-ui/app/build/composables/live/livePoller.ts +42 -0
  92. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveStorage.ts +42 -0
  93. package/frontend-vue/layers/dms-ui/app/build/composables/live/missedTally.ts +65 -0
  94. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +247 -88
  95. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLivePeriodSpan.ts +33 -0
  96. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +79 -5
  97. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodComparisonControl.ts +70 -0
  98. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +55 -0
  99. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +41 -5
  100. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodDraft.ts +22 -9
  101. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +13 -6
  102. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTable.ts +1 -0
  103. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableColumns.ts +27 -2
  104. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +18 -3
  105. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +19 -26
  106. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useScrolledPastTop.ts +66 -0
  107. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +7 -1
  108. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +65 -11
  109. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTablePreferences.ts +149 -0
  110. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +39 -8
  111. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  112. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +16 -11
  113. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +39 -16
  114. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/pinnedRows.ts +29 -0
  115. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -5
  116. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/sortableColumns.ts +17 -0
  117. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +79 -0
  118. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +46 -1
  119. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
  120. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +4 -0
  121. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +40 -2
  122. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +11 -1
  123. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +12 -2
  124. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +129 -4
  125. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +6 -0
  126. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +6 -1
  127. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +33 -0
  128. package/frontend-vue/layers/dms-ui/app/components/form/components/ChoiceCards.vue +22 -4
  129. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +74 -0
  130. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -17
  131. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +15 -0
  132. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +12 -15
  133. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +104 -16
  134. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +113 -99
  135. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +11 -1
  136. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +2 -0
  137. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +11 -1
  138. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +15 -1
  139. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +86 -10
  140. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewShiftF.ts +20 -0
  141. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +12 -0
  142. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +5 -0
  143. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
  144. package/frontend-vue/layers/dms-ui/app/composables/table-view/useTableViewEmptyState.ts +55 -0
  145. package/frontend-vue/layers/dms-ui/app/types/live.ts +47 -1
  146. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +2 -0
  147. package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
  148. package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
  149. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +13 -4
  150. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +12 -3
  151. package/frontend-vue/tests/activity-feed-fill-height.test.ts +1 -0
  152. package/frontend-vue/tests/activity-feed-head.test.ts +140 -0
  153. package/frontend-vue/tests/block-not-found.test.ts +220 -0
  154. package/frontend-vue/tests/choice-cards-columns.test.ts +60 -0
  155. package/frontend-vue/tests/component-id.test.ts +18 -0
  156. package/frontend-vue/tests/confirm-secondary-action.test.ts +269 -0
  157. package/frontend-vue/tests/guard.test.ts +242 -0
  158. package/frontend-vue/tests/header-action-page-query.test.ts +286 -0
  159. package/frontend-vue/tests/live-toggle.test.ts +87 -1
  160. package/frontend-vue/tests/page-layout-registration.test.ts +1 -0
  161. package/frontend-vue/tests/page-live-parity.test.ts +367 -0
  162. package/frontend-vue/tests/page-realtime-remount.test.ts +185 -0
  163. package/frontend-vue/tests/period-selector-parity.test.ts +138 -0
  164. package/frontend-vue/tests/period-selector-url.test.ts +84 -6
  165. package/frontend-vue/tests/period-url-query.test.ts +52 -0
  166. package/frontend-vue/tests/preferences-cookie-size.test.ts +311 -0
  167. package/frontend-vue/tests/row-side-panel.test.ts +109 -10
  168. package/frontend-vue/tests/table-actions-search-label.test.ts +57 -1
  169. package/frontend-vue/tests/table-clear-filters.test.ts +99 -1
  170. package/frontend-vue/tests/table-density-menu.test.ts +4 -3
  171. package/frontend-vue/tests/table-live-hold.test.ts +95 -5
  172. package/frontend-vue/tests/table-row-action-labels.test.ts +181 -0
  173. package/frontend-vue/tests/table-row-hover.test.ts +32 -0
  174. package/frontend-vue/tests/table-source-period-scope.test.ts +31 -3
  175. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +14 -17
  176. package/frontend-vue/tests/table-view-pinned-rows.test.ts +143 -0
  177. package/frontend-vue/tests/table-view-sortable-columns.test.ts +45 -0
  178. package/frontend-vue/tests/table-view-views.test.ts +56 -15
  179. package/frontend-vue/tests/top-list-card-value-suffix.test.ts +107 -0
  180. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  181. package/package.json +2 -2
  182. package/skills/dms/SKILL.md +9 -3
  183. package/skills/dms-module/SKILL.md +5 -0
  184. package/skills/dms-pages/REFERENCE.md +23 -2
@@ -13,6 +13,7 @@ import {
13
13
  nextTick,
14
14
  reactive,
15
15
  type App,
16
+ type Component,
16
17
  } from "vue";
17
18
  import { renderToString } from "vue/server-renderer";
18
19
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -51,17 +52,36 @@ const PAGE_ID = "dashboard";
51
52
  const PAGE_PATH = "/dashboard";
52
53
  const handles: Record<string, Period> = {};
53
54
 
55
+ const PRESETS: PeriodPreset[] = [
56
+ "last-7-days",
57
+ "last-30-days",
58
+ "this-month",
59
+ "custom",
60
+ ];
61
+
62
+ /** What a test changes of a selector's setup. */
63
+ interface SelectorSetup {
64
+ pageId?: string;
65
+ presets?: PeriodPreset[];
66
+ rememberKey?: string;
67
+ }
68
+
54
69
  /** The selector's state wiring, as PeriodSelector.vue does it in setup. */
55
- function selector(id: string, defaultPreset: PeriodPreset = "this-month") {
70
+ function selector(
71
+ id: string,
72
+ defaultPreset: PeriodPreset = "this-month",
73
+ setup: SelectorSetup = {},
74
+ ) {
56
75
  return defineComponent({
57
76
  setup() {
58
77
  const period = usePeriod({
59
78
  defaultPreset,
60
- presets: ["last-7-days", "last-30-days", "this-month", "custom"],
79
+ presets: setup.presets ?? PRESETS,
61
80
  });
62
81
  usePeriodLocation(period, {
63
82
  id,
64
- pageId: PAGE_ID,
83
+ pageId: setup.pageId ?? PAGE_ID,
84
+ rememberKey: setup.rememberKey,
65
85
  fallback: {
66
86
  preset: period.preset.value,
67
87
  comparison: period.comparison.value,
@@ -78,16 +98,20 @@ function selector(id: string, defaultPreset: PeriodPreset = "this-month") {
78
98
 
79
99
  const mounted: App[] = [];
80
100
 
101
+ function mountApp(root: Component): void {
102
+ const app = createApp(root);
103
+ app.mount(document.createElement("div"));
104
+ mounted.push(app);
105
+ }
106
+
81
107
  function mount(...ids: string[]): void {
82
- const app = createApp({
108
+ mountApp({
83
109
  setup: () => () =>
84
110
  h(
85
111
  "div",
86
112
  ids.map((id) => h(selector(id))),
87
113
  ),
88
114
  });
89
- app.mount(document.createElement("div"));
90
- mounted.push(app);
91
115
  }
92
116
 
93
117
  const search = () => new URLSearchParams(window.location.search);
@@ -266,3 +290,57 @@ describe("a route changed from outside", () => {
266
290
  expect(handles.page!.preset.value).toBe("last-7-days");
267
291
  });
268
292
  });
293
+
294
+ describe("a remembered choice shared by a rememberKey", () => {
295
+ const onPage = (pageId: string) =>
296
+ selector("api", "last-30-days", { pageId, rememberKey: "api" });
297
+
298
+ it("follows the user from one page to the next", async () => {
299
+ mountApp(onPage("overview"));
300
+ handles.api!.setPreset("last-7-days");
301
+ await nextTick();
302
+ mounted.splice(0).forEach((app) => app.unmount());
303
+ route.query = {};
304
+ window.history.replaceState(null, "", "/logs");
305
+ mountApp(onPage("logs"));
306
+ expect(handles.api!.preset.value).toBe("last-7-days");
307
+ });
308
+
309
+ it("stays per page without one", async () => {
310
+ mountApp(selector("api", "last-30-days", { pageId: "overview" }));
311
+ handles.api!.setPreset("last-7-days");
312
+ await nextTick();
313
+ mounted.splice(0).forEach((app) => app.unmount());
314
+ route.query = {};
315
+ mountApp(selector("api", "last-30-days", { pageId: "logs" }));
316
+ expect(handles.api!.preset.value).toBe("last-30-days");
317
+ });
318
+ });
319
+
320
+ describe("a link framing a window no preset matches", () => {
321
+ const PILLS: PeriodPreset[] = ["last-hour", "last-24h", "last-7-days"];
322
+
323
+ it("opens on that window, no preset selected, and is not remembered", async () => {
324
+ remember({ period: "last-7-days" });
325
+ route.query = {
326
+ "page.from": "2026-10-11T09:12:00.000Z",
327
+ "page.to": "2026-10-11T09:40:00.000Z",
328
+ };
329
+ mountApp(selector("page", "last-24h", { presets: PILLS }));
330
+ await nextTick();
331
+ const period = handles.page!;
332
+ expect(period.preset.value).toBe("custom");
333
+ expect(PILLS).not.toContain(period.preset.value);
334
+ expect(period.state.value.range).toEqual({
335
+ from: new Date("2026-10-11T09:12:00.000Z"),
336
+ to: new Date("2026-10-11T09:40:00.000Z"),
337
+ });
338
+ period.setComparison("previous-period");
339
+ await nextTick();
340
+ expect(
341
+ (preferences.get(PERIOD_PREFERENCE_KEY) as Record<string, unknown>)[
342
+ `${PAGE_ID}#page`
343
+ ],
344
+ ).toEqual({ period: "last-7-days" });
345
+ });
346
+ });
@@ -15,7 +15,9 @@ import {
15
15
  type PeriodChoices,
16
16
  type PeriodSelection,
17
17
  readComparisonChoice,
18
+ isRangeOffered,
18
19
  readRangeChoice,
20
+ rememberedPeriodKey,
19
21
  rememberPeriod,
20
22
  resolvePeriodSelection,
21
23
  writePeriodQuery,
@@ -197,6 +199,23 @@ describe("parsing the URL", () => {
197
199
  ).toBeNull();
198
200
  });
199
201
 
202
+ it("takes a linked range the selector does not offer, from a source that always accepts one", () => {
203
+ const query = { "page.from": "2026-10-01", "page.to": "2026-10-15" };
204
+ const choices = { ...CHOICES, presets: ["last-7-days" as const] };
205
+ expect(
206
+ readRangeChoice(
207
+ { query, keys: PAGE, isRangeAlwaysAccepted: true },
208
+ choices,
209
+ ),
210
+ ).toEqual({
211
+ preset: "custom",
212
+ customRange: { from: day(2026, 10, 1), to: endOfDay(2026, 10, 15) },
213
+ });
214
+ expect(
215
+ isRangeOffered({ preset: "custom", customRange: null }, choices),
216
+ ).toBe(false);
217
+ });
218
+
200
219
  it.each([
201
220
  ["an unknown comparison", { "page.compare": "previous-decade" }],
202
221
  ["a custom comparison with no range", { "page.compare": "custom" }],
@@ -349,3 +368,36 @@ describe("the last choices kept in the browser", () => {
349
368
  });
350
369
  });
351
370
  });
371
+
372
+ describe("every period", () => {
373
+ const choices: PeriodChoices = { ...CHOICES, presets: ["last-24h", "all"] };
374
+
375
+ it("is written as a preset with no bounds", () => {
376
+ expect(writePeriodQuery({ ...DEFAULT, preset: "all" }, PAGE)).toEqual({
377
+ "page.period": "all",
378
+ "page.compare": "none",
379
+ });
380
+ });
381
+
382
+ it("is read back when the selector offers it", () => {
383
+ const query = { "page.period": "all" };
384
+ expect(readRangeChoice({ query, keys: PAGE }, choices)).toEqual({
385
+ preset: "all",
386
+ customRange: null,
387
+ });
388
+ expect(readRangeChoice({ query, keys: PAGE }, CHOICES)).toBeNull();
389
+ });
390
+ });
391
+
392
+ describe("the key a last choice is kept under", () => {
393
+ it("is the page and the selector id by default", () => {
394
+ expect(rememberedPeriodKey({ pageId: "logs", id: "api" })).toBe("logs#api");
395
+ });
396
+
397
+ it("is the shared key on every page carrying it", () => {
398
+ const shared = (pageId: string) =>
399
+ rememberedPeriodKey({ pageId, id: "api", rememberKey: "api" });
400
+ expect(shared("logs")).toBe(shared("overview"));
401
+ expect(shared("logs")).not.toContain("#");
402
+ });
403
+ });
@@ -0,0 +1,311 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ computed,
4
+ createSSRApp,
5
+ defineComponent,
6
+ h,
7
+ nextTick,
8
+ readonly,
9
+ ref,
10
+ toRaw,
11
+ watch,
12
+ type Ref,
13
+ } from "vue";
14
+ import { renderToString } from "vue/server-renderer";
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import {
17
+ MAX_PREFERENCES_COOKIE_BYTES,
18
+ usePreferences,
19
+ } from "../layers/dms-core/app/composables/user/usePreferences";
20
+ import {
21
+ decodePreferencesTree,
22
+ preferencesCookieBytes,
23
+ } from "../layers/dms-core/app/build/composables/user/preferencesCookie";
24
+ import {
25
+ LOCAL_TABLE_KEY_PREFIX,
26
+ tablePreferenceId,
27
+ } from "../layers/dms-core/app/build/composables/user/tablePreferences";
28
+ import { useTablePreferences } from "../layers/dms-ui/app/build/composables/table-view/useTablePreferences";
29
+
30
+ // The ids of a table measured in the playground: a long component id, and a
31
+ // page id holding dots.
32
+ const COMPONENT_ID = "content-child-main-child-main-child-languages";
33
+ const PAGE_ID = "modules.lang.manage.workspace";
34
+ const SORTED_ONLY_MAX_BYTES = 50;
35
+ const TYPICAL_MAX_BYTES = 150;
36
+ const TYPICAL_TABLES = 20;
37
+ const VISITED_TABLES = 80;
38
+ const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 10 };
39
+ const DEFAULT_SORT = [{ id: "createdAt", desc: true }];
40
+ const DEFAULT_VISIBILITY = { name: true, email: true, phone: true };
41
+
42
+ let stored: Ref<string>;
43
+ let writes: number[];
44
+
45
+ // `useDmsCookie`: one shared ref per name, every assignment written out.
46
+ const installCookie = (initial: string) => {
47
+ stored = ref(initial);
48
+ writes = [];
49
+ const cookie = computed({
50
+ get: () => stored.value,
51
+ set: (next) => {
52
+ writes.push(preferencesCookieBytes(toRaw(next)));
53
+ stored.value = next;
54
+ },
55
+ });
56
+ vi.stubGlobal("useDmsCookie", () => cookie);
57
+ };
58
+
59
+ const cookieBytes = () => preferencesCookieBytes(stored.value);
60
+ const storedTree = () => decodePreferencesTree(stored.value);
61
+
62
+ const sortByName = (componentId: string, pageId: string) =>
63
+ useTablePreferences(componentId, pageId).set(
64
+ "sorting",
65
+ [{ id: "name", desc: true }],
66
+ DEFAULT_SORT,
67
+ );
68
+
69
+ // Sorted, filtered, 50 rows a page and two columns hidden.
70
+ const visitTypicalTable = (componentId: string, pageId: string) => {
71
+ const table = useTablePreferences(componentId, pageId);
72
+ table.set("sorting", [{ id: "name", desc: true }], DEFAULT_SORT);
73
+ table.set(
74
+ "columnFilters",
75
+ [{ accessorKey: "status", mode: "is", value: "active" }],
76
+ [],
77
+ );
78
+ table.set("pagination", { pageIndex: 0, pageSize: 50 }, DEFAULT_PAGINATION);
79
+ table.set(
80
+ "columnVisibility",
81
+ { name: true, email: false, phone: false },
82
+ DEFAULT_VISIBILITY,
83
+ );
84
+ };
85
+
86
+ const tableIds = (index: number): [string, string] => [
87
+ `${COMPONENT_ID}-${index}`,
88
+ `${PAGE_ID}.${index}`,
89
+ ];
90
+
91
+ beforeEach(() => {
92
+ vi.stubGlobal("ref", ref);
93
+ vi.stubGlobal("readonly", readonly);
94
+ vi.stubGlobal("computed", computed);
95
+ vi.stubGlobal("watch", watch);
96
+ vi.stubGlobal("usePreferences", usePreferences);
97
+ installCookie("");
98
+ localStorage.clear();
99
+ });
100
+
101
+ afterEach(() => {
102
+ vi.unstubAllGlobals();
103
+ });
104
+
105
+ describe("the size of a table's state in the preferences cookie", () => {
106
+ it("is at most 50 bytes for a table only sorted", () => {
107
+ const empty = preferencesCookieBytes("");
108
+ sortByName(COMPONENT_ID, PAGE_ID);
109
+ expect(cookieBytes() - empty).toBeLessThanOrEqual(SORTED_ONLY_MAX_BYTES);
110
+ });
111
+
112
+ it("is at most 150 bytes for a sorted, filtered, paged table with hidden columns", () => {
113
+ sortByName("another-table", "another.page");
114
+ const before = cookieBytes();
115
+ visitTypicalTable(COMPONENT_ID, PAGE_ID);
116
+ expect(cookieBytes() - before).toBeLessThanOrEqual(TYPICAL_MAX_BYTES);
117
+ });
118
+
119
+ it("keeps at least 20 typical tables", () => {
120
+ for (let index = 0; index < TYPICAL_TABLES; index += 1) {
121
+ visitTypicalTable(...tableIds(index));
122
+ }
123
+ expect(cookieBytes()).toBeLessThanOrEqual(MAX_PREFERENCES_COOKIE_BYTES);
124
+ const tables = storedTree().tables as Record<string, unknown>;
125
+ expect(Object.keys(tables)).toHaveLength(TYPICAL_TABLES);
126
+ expect(
127
+ useTablePreferences(...tableIds(0)).get("sorting", DEFAULT_SORT),
128
+ ).toEqual([{ id: "name", desc: true }]);
129
+ });
130
+ });
131
+
132
+ describe("a table's state in the preferences cookie", () => {
133
+ it("is read back in its own shape", () => {
134
+ visitTypicalTable(COMPONENT_ID, PAGE_ID);
135
+ const table = useTablePreferences(COMPONENT_ID, PAGE_ID);
136
+ expect(table.get("columnFilters", [])).toEqual([
137
+ { accessorKey: "status", mode: "is", value: "active" },
138
+ ]);
139
+ expect(table.get("pagination", DEFAULT_PAGINATION)).toEqual({
140
+ pageIndex: 0,
141
+ pageSize: 50,
142
+ });
143
+ expect(table.get("columnVisibility", DEFAULT_VISIBILITY)).toEqual({
144
+ name: true,
145
+ email: false,
146
+ phone: false,
147
+ });
148
+ });
149
+
150
+ it("does not write a value equal to its default, and forgets one back to it", () => {
151
+ const table = useTablePreferences(COMPONENT_ID, PAGE_ID);
152
+ table.set("sorting", DEFAULT_SORT, DEFAULT_SORT);
153
+ table.set("pagination", DEFAULT_PAGINATION, DEFAULT_PAGINATION);
154
+ table.set("columnVisibility", DEFAULT_VISIBILITY, DEFAULT_VISIBILITY);
155
+ expect(stored.value).toBe("");
156
+
157
+ table.set("sorting", [{ id: "name", desc: false }], DEFAULT_SORT);
158
+ expect(stored.value).not.toBe("");
159
+ table.set("sorting", DEFAULT_SORT, DEFAULT_SORT);
160
+ expect(storedTree()).toEqual({});
161
+ });
162
+
163
+ it("keeps a pinned filter on its declared value out of the cookie", () => {
164
+ useTablePreferences(COMPONENT_ID, PAGE_ID).set(
165
+ "columnFilters",
166
+ [
167
+ {
168
+ accessorKey: "status",
169
+ mode: "is",
170
+ value: "open",
171
+ pinned: true,
172
+ initialValue: "open",
173
+ },
174
+ ],
175
+ [],
176
+ );
177
+ expect(stored.value).toBe("");
178
+ });
179
+
180
+ it("lives under one short key, a dotted page id no longer nesting", () => {
181
+ sortByName(COMPONENT_ID, PAGE_ID);
182
+ const id = tablePreferenceId(COMPONENT_ID, PAGE_ID);
183
+ expect(id).toMatch(/^[0-9a-z]{8}$/);
184
+ expect(storedTree()).toEqual({ tables: { [id]: { s: "-name" } } });
185
+ });
186
+
187
+ it("keeps the browser-only state out of the cookie, in localStorage", () => {
188
+ const table = useTablePreferences(COMPONENT_ID, PAGE_ID);
189
+ table.set("columnOrder", ["email", "name"], []);
190
+ table.set("columnSizing", { name: 240 }, {});
191
+ table.set("density", "compact", "default");
192
+ expect(stored.value).toBe("");
193
+ const key = `${LOCAL_TABLE_KEY_PREFIX}${tablePreferenceId(COMPONENT_ID, PAGE_ID)}`;
194
+ expect(JSON.parse(localStorage.getItem(key)!)).toEqual({
195
+ columnOrder: ["email", "name"],
196
+ columnSizing: { name: 240 },
197
+ density: "compact",
198
+ });
199
+
200
+ table.set("density", "default", "default");
201
+ table.set("columnOrder", [], []);
202
+ table.set("columnSizing", {}, {});
203
+ expect(localStorage.getItem(key)).toBeNull();
204
+ });
205
+ });
206
+
207
+ describe("the browser-only state of a table", () => {
208
+ const Density = defineComponent({
209
+ setup() {
210
+ const table = useTablePreferences(COMPONENT_ID, PAGE_ID);
211
+ const density = ref("default");
212
+ table.restore("density", density);
213
+ return () => h("span", density.value);
214
+ },
215
+ });
216
+
217
+ it("is restored once mounted, the server render hydrating as is", async () => {
218
+ useTablePreferences(COMPONENT_ID, PAGE_ID).set(
219
+ "density",
220
+ "compact",
221
+ "default",
222
+ );
223
+ const html = await renderToString(createSSRApp(Density));
224
+ expect(html).toBe("<span>default</span>");
225
+
226
+ const container = document.createElement("div");
227
+ container.innerHTML = html;
228
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
229
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
230
+ const app = createSSRApp(Density);
231
+ app.mount(container);
232
+ await nextTick();
233
+
234
+ expect(container.textContent).toBe("compact");
235
+ const messages = [...warn.mock.calls, ...error.mock.calls].flat();
236
+ expect(messages.join(" ")).not.toMatch(/hydration/i);
237
+ app.unmount();
238
+ warn.mockRestore();
239
+ error.mockRestore();
240
+ });
241
+ });
242
+
243
+ describe("the preferences cookie", () => {
244
+ it("stays under its budget however many tables are visited, dropping the least recently written", () => {
245
+ for (let index = 0; index < VISITED_TABLES; index += 1) {
246
+ visitTypicalTable(...tableIds(index));
247
+ }
248
+ expect(Math.max(...writes)).toBeLessThanOrEqual(
249
+ MAX_PREFERENCES_COOKIE_BYTES,
250
+ );
251
+ const last = tableIds(VISITED_TABLES - 1);
252
+ expect(useTablePreferences(...last).get("sorting", DEFAULT_SORT)).toEqual([
253
+ { id: "name", desc: true },
254
+ ]);
255
+ expect(
256
+ useTablePreferences(...tableIds(0)).get("sorting", DEFAULT_SORT),
257
+ ).toEqual(DEFAULT_SORT);
258
+ });
259
+
260
+ it("drops the table states before the remembered periods", () => {
261
+ const { setPreference, getPreference } = usePreferences();
262
+ setPreference("periods", { "orders#period": { period: "7d" } });
263
+ for (let index = 0; index < VISITED_TABLES; index += 1) {
264
+ visitTypicalTable(...tableIds(index));
265
+ }
266
+ expect(getPreference("periods")).toEqual({
267
+ "orders#period": { period: "7d" },
268
+ });
269
+ });
270
+
271
+ it("never evicts a module's own key, and refuses a write that cannot fit", () => {
272
+ const { setPreference, getPreference } = usePreferences();
273
+ setPreference("dms-lang.workspaceSort", { field: "name", desc: false });
274
+ for (let index = 0; index < VISITED_TABLES; index += 1) {
275
+ visitTypicalTable(...tableIds(index));
276
+ }
277
+ expect(getPreference("dms-lang.workspaceSort")).toEqual({
278
+ field: "name",
279
+ desc: false,
280
+ });
281
+
282
+ const before = stored.value;
283
+ setPreference("dms-lang.huge", "x".repeat(MAX_PREFERENCES_COOKIE_BYTES));
284
+ expect(stored.value).toBe(before);
285
+ expect(getPreference("dms-lang.huge")).toBeUndefined();
286
+ });
287
+
288
+ it("reads a corrupt value as no preferences", () => {
289
+ installCookie("not*base64{");
290
+ const { getPreference, setPreference } = usePreferences();
291
+ expect(getPreference("dms-lang.workspaceSort", "default")).toBe("default");
292
+ setPreference("dms-lang.workspaceSort", "name");
293
+ expect(getPreference("dms-lang.workspaceSort")).toBe("name");
294
+ });
295
+
296
+ it("is shared by every caller", () => {
297
+ const first = usePreferences();
298
+ const second = usePreferences();
299
+ first.setPreference("module.theme", "dense");
300
+ expect(second.getPreference("module.theme")).toBe("dense");
301
+ });
302
+
303
+ it("deletes the user-preferences cookie it replaces, unread", () => {
304
+ document.cookie = `user-preferences=${encodeURIComponent(
305
+ JSON.stringify({ tables: { a: { b: { density: "compact" } } } }),
306
+ )}; path=/`;
307
+ const { preferences } = usePreferences();
308
+ expect(document.cookie).not.toMatch(/user-preferences=/);
309
+ expect(preferences.value).toEqual({});
310
+ });
311
+ });
@@ -425,19 +425,22 @@ describe("deep link to a row the table does not list", () => {
425
425
  const getRow = vi.fn(async () => older);
426
426
  expect(
427
427
  await resolveDeepLinkedRow("r9", { listed: rows, idOf, getRow }),
428
- ).toBe(older);
428
+ ).toEqual({ row: older, isMissing: false });
429
429
  expect(getRow).toHaveBeenCalledWith("r9");
430
430
  });
431
431
 
432
432
  it("takes a listed row as it is, without a request", async () => {
433
433
  const getRow = vi.fn(async () => older);
434
- expect(
435
- await resolveDeepLinkedRow("r2", { listed: rows, idOf, getRow }),
436
- ).toBe(rows[1]);
434
+ const found = await resolveDeepLinkedRow("r2", {
435
+ listed: rows,
436
+ idOf,
437
+ getRow,
438
+ });
439
+ expect(found.row).toBe(rows[1]);
437
440
  expect(getRow).not.toHaveBeenCalled();
438
441
  });
439
442
 
440
- it("opens nothing for a missing row (404) or without a row route", async () => {
443
+ it("marks a row its route answers 404 for as missing", async () => {
441
444
  const missing = vi.fn(async () => {
442
445
  throw Object.assign(new Error("Not Found"), { statusCode: 404 });
443
446
  });
@@ -447,11 +450,20 @@ describe("deep link to a row the table does not list", () => {
447
450
  idOf,
448
451
  getRow: missing,
449
452
  }),
450
- ).toBeUndefined();
453
+ ).toEqual({ isMissing: true });
454
+ });
455
+
456
+ it("finds nothing, missing or not, on a refusal or without a row route", async () => {
457
+ const refused = async () => {
458
+ throw Object.assign(new Error("Forbidden"), { status: 403 });
459
+ };
460
+ expect(
461
+ await resolveDeepLinkedRow("r9", { listed: rows, idOf, getRow: refused }),
462
+ ).toEqual({ isMissing: false });
451
463
  const noRoute = async () => undefined;
452
464
  expect(
453
465
  await resolveDeepLinkedRow("r9", { listed: rows, idOf, getRow: noRoute }),
454
- ).toBeUndefined();
466
+ ).toEqual({ row: undefined, isMissing: false });
455
467
  });
456
468
 
457
469
  it("opens the unlisted row without a position, J and K inert", async () => {
@@ -468,6 +480,87 @@ describe("deep link to a row the table does not list", () => {
468
480
  });
469
481
  });
470
482
 
483
+ describe("side panel on a missing row", () => {
484
+ const notFound = (host: ParentNode) =>
485
+ query(host, "[data-dms-row-side-panel-not-found]");
486
+
487
+ it("says the row is gone under the target's title, with the DMS's words", async () => {
488
+ window.history.replaceState(null, "", "/requests?record=gone");
489
+ const { host, targets } = mountPanel();
490
+ targets.handleMissingRow(panelAction(), "gone");
491
+ await settle();
492
+ expect(openPanelId()).toBe(ROW_SIDE_PANEL_ID);
493
+ expect(title(host)).toBe("Request details");
494
+ expect(position(host)).toBeUndefined();
495
+ expect(shownName(host)).toBeUndefined();
496
+ expect(notFound(host)?.textContent).toContain(
497
+ "$side_panel.not_found.title",
498
+ );
499
+ expect(notFound(host)?.textContent).toContain(
500
+ "$side_panel.not_found.description",
501
+ );
502
+ });
503
+
504
+ it("takes the target's own not-found words", async () => {
505
+ const { host, targets } = mountPanel();
506
+ targets.handleMissingRow(
507
+ panelAction({
508
+ notFound: {
509
+ title: "$api.request.not_found_title",
510
+ description: "$api.request.not_found_description",
511
+ },
512
+ }),
513
+ "gone",
514
+ );
515
+ await settle();
516
+ expect(notFound(host)?.textContent).toContain(
517
+ "$api.request.not_found_title",
518
+ );
519
+ expect(notFound(host)?.textContent).toContain(
520
+ "$api.request.not_found_description",
521
+ );
522
+ });
523
+
524
+ it("closes from its state, removing the link's ?record=", async () => {
525
+ window.history.replaceState(null, "", "/requests?record=gone");
526
+ const { host, targets } = mountPanel();
527
+ targets.handleMissingRow(panelAction(), "gone");
528
+ await settle();
529
+ notFound(host)?.querySelector("button")?.click();
530
+ await settle();
531
+ expect(query(host, "[data-dms-side-panel]")).toBeNull();
532
+ expect(window.location.search).toBe("");
533
+ });
534
+
535
+ it("opens nothing for a drawer action", async () => {
536
+ const { host, targets } = mountPanel();
537
+ targets.handleMissingRow(
538
+ panelAction({ type: "drawer" } as Partial<CustomRowAction["target"]>),
539
+ "gone",
540
+ );
541
+ await settle();
542
+ expect(query(host, "[data-dms-side-panel]")).toBeNull();
543
+ });
544
+ });
545
+
546
+ describe("row side panel title from the row", () => {
547
+ it("names the open row with the target's rowTitle and its fields", async () => {
548
+ const { host, targets } = mountPanel();
549
+ targets.handleCustomRowAction(
550
+ panelAction({ rowTitle: "{name} ({_id})" }),
551
+ rows[0],
552
+ );
553
+ await settle();
554
+ expect(title(host)).toBe("First (r1)");
555
+ expect(
556
+ query(host, "[data-dms-side-panel]")?.getAttribute("aria-label"),
557
+ ).toBe("Request details");
558
+ query(host, "[data-dms-row-side-panel-next]")?.click();
559
+ await settle();
560
+ expect(title(host)).toBe("Second (r2)");
561
+ });
562
+ });
563
+
471
564
  describe("closing the row side panel", () => {
472
565
  it("closes from its button, unregistering the panel", async () => {
473
566
  const { host, targets } = mountPanel();
@@ -631,11 +724,17 @@ describe("open row highlight", () => {
631
724
  )}; return theme;`,
632
725
  )(tv, EYEBROW_CLASS, REORDER_HANDLE_CLASS);
633
726
 
634
- it("tints the open row and its pinned cells", () => {
727
+ it("tints the open row with the accent and its pinned cells opaquely", () => {
635
728
  const ui = theme();
636
- expect(ui.row({})).toContain("data-[open=true]:bg-elevated");
729
+ expect(ui.row({})).toContain("data-[open=true]:bg-primary/10");
637
730
  expect(ui.rowCell({ pinned: "left" })).toContain(
638
- "group-data-[open=true]:bg-elevated",
731
+ "group-data-[open=true]:bg-[color-mix(in_srgb,var(--ui-primary)_10%,var(--dms-surface-card))]",
732
+ );
733
+ });
734
+
735
+ it("draws the accent bar on the open row's first cell", () => {
736
+ expect(theme().row({})).toContain(
737
+ "data-[open=true]:[&>td:first-child]:shadow-[inset_2px_0_0_0_var(--ui-primary)]",
639
738
  );
640
739
  });
641
740