@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
@@ -4,6 +4,8 @@ import type { LocationQuery } from "vue-router";
4
4
  import { useAccumulatedPages } from "../layers/dms-ui/app/build/composables/table-view/useAccumulatedPages";
5
5
  import { usePersistedPagination } from "../layers/dms-ui/app/build/composables/table-view/usePersistedPagination";
6
6
  import { usePreferences } from "../layers/dms-core/app/composables/user/usePreferences";
7
+ import { useTablePreferences } from "../layers/dms-ui/app/build/composables/table-view/useTablePreferences";
8
+ import { tablePreferenceId } from "../layers/dms-core/app/build/composables/user/tablePreferences";
7
9
 
8
10
  const PAGE_SIZE = 10;
9
11
  const THIRD_PAGE = 2;
@@ -16,19 +18,18 @@ const hiddenFiltersOf = (query: () => LocationQuery) =>
16
18
  : [{ accessorKey: "status", mode: "is", value: query().status }],
17
19
  );
18
20
 
19
- const TABLE_KEY = "tables.tasks.query-filters";
20
- const preferenceKey = (suffix: string) => `${TABLE_KEY}.${suffix}`;
21
+ const COMPONENT_ID = "tasks";
22
+ const PAGE_ID = "query-filters";
21
23
 
22
- // The `user-preferences` cookie, surviving the remounts of a page.
23
- let cookie: Ref<Record<string, unknown>>;
24
+ // The `dms-preferences` cookie, surviving the remounts of a page.
25
+ let cookie: Ref<string>;
24
26
 
25
27
  // What a table view does on mount: its page, read back from the cookie,
26
28
  // under the hidden filters the URL sets.
27
29
  const mountTable = (query: Ref<LocationQuery>) => {
28
30
  const hiddenFilters = hiddenFiltersOf(() => query.value);
29
31
  const pagination = usePersistedPagination({
30
- preferences: usePreferences(),
31
- preferenceKey,
32
+ preferences: useTablePreferences(COMPONENT_ID, PAGE_ID),
32
33
  defaults: { pageIndex: 0, pageSize: PAGE_SIZE },
33
34
  scope: () => [hiddenFilters.value, []],
34
35
  });
@@ -36,8 +37,9 @@ const mountTable = (query: Ref<LocationQuery>) => {
36
37
  };
37
38
 
38
39
  beforeEach(() => {
39
- cookie = ref({});
40
+ cookie = ref("");
40
41
  vi.stubGlobal("useDmsCookie", () => cookie);
42
+ vi.stubGlobal("usePreferences", usePreferences);
41
43
  vi.stubGlobal("ref", ref);
42
44
  vi.stubGlobal("readonly", readonly);
43
45
  vi.stubGlobal("computed", computed);
@@ -100,16 +102,11 @@ describe("a hidden filter changed by the URL", () => {
100
102
  expect(again.pagination.value.pageIndex).toBe(THIRD_PAGE);
101
103
  });
102
104
 
103
- it("keeps a page stored before its hidden filters were", () => {
104
- cookie.value = {
105
- tables: {
106
- tasks: {
107
- "query-filters": {
108
- pagination: { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE },
109
- },
110
- },
111
- },
112
- };
105
+ it("keeps a page stored without its hidden filters", () => {
106
+ usePreferences().setPreference(
107
+ `tables.${tablePreferenceId(COMPONENT_ID, PAGE_ID)}`,
108
+ { p: [PAGE_SIZE, THIRD_PAGE] },
109
+ );
113
110
  const table = mountTable(ref<LocationQuery>({}));
114
111
  expect(table.pagination.value.pageIndex).toBe(THIRD_PAGE);
115
112
  });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `pinnedRows`: the rows a rule accepts lead the list whatever the sort — the
3
+ * base language of a languages table stays on top whichever column the user
4
+ * sorts on, and on the first page.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { describe, expect, it, vi } from "vitest";
9
+ import {
10
+ processSourceRows,
11
+ useTableRows,
12
+ } from "../layers/dms-ui/app/build/composables/table-view/useTableRows";
13
+ import { pinRows } from "../layers/dms-ui/app/build/composables/table-view/utils/pinnedRows";
14
+ import type { TableViewColumn } from "../layers/dms-ui/app/composables/table-view/types";
15
+
16
+ interface LanguageRow extends Record<string, unknown> {
17
+ _id: string;
18
+ name: string;
19
+ coverage: number;
20
+ isBase: boolean;
21
+ }
22
+
23
+ const LANGUAGES: LanguageRow[] = [
24
+ { _id: "de", name: "Deutsch", coverage: 40, isBase: false },
25
+ { _id: "en", name: "English", coverage: 100, isBase: true },
26
+ { _id: "fr", name: "Français", coverage: 90, isBase: false },
27
+ { _id: "es", name: "Español", coverage: 10, isBase: false },
28
+ ];
29
+
30
+ const COLUMNS = [
31
+ { id: "name", accessorKey: "name" },
32
+ { id: "coverage", accessorKey: "coverage" },
33
+ ] as TableViewColumn[];
34
+
35
+ const BASE = { field: "isBase", equals: true };
36
+
37
+ type Api = Parameters<typeof useTableRows>[0]["api"];
38
+
39
+ const ids = (rows: LanguageRow[]) => rows.map((row) => row._id);
40
+
41
+ describe("pinRows", () => {
42
+ it("puts the rows the rule accepts first, each part in its order", () => {
43
+ expect(
44
+ ids(pinRows(LANGUAGES, { field: "coverage", in: [10, 100] })),
45
+ ).toEqual(["en", "es", "de", "fr"]);
46
+ });
47
+
48
+ it("leaves the rows as they are without a rule, or when none matches", () => {
49
+ expect(pinRows(LANGUAGES, undefined)).toBe(LANGUAGES);
50
+ expect(pinRows(LANGUAGES, { field: "_id", equals: "it" })).toBe(LANGUAGES);
51
+ });
52
+ });
53
+
54
+ describe("a source sorted in the browser", () => {
55
+ it("keeps the pinned row first in either direction", () => {
56
+ const ascending = processSourceRows(
57
+ LANGUAGES,
58
+ { sortKey: "coverage", sortDirection: "asc" },
59
+ {},
60
+ COLUMNS,
61
+ BASE,
62
+ );
63
+ const descending = processSourceRows(
64
+ LANGUAGES,
65
+ { sortKey: "name", sortDirection: "desc" },
66
+ {},
67
+ COLUMNS,
68
+ BASE,
69
+ );
70
+ expect(ids(ascending.results)).toEqual(["en", "es", "de", "fr"]);
71
+ expect(ids(descending.results)).toEqual(["en", "fr", "es", "de"]);
72
+ });
73
+
74
+ it("pins before the page is cut: the pinned row opens the first page", () => {
75
+ const query = { sortKey: "coverage", sortDirection: "asc", limit: 2 };
76
+ const first = processSourceRows(LANGUAGES, query, {}, COLUMNS, BASE);
77
+ const second = processSourceRows(
78
+ LANGUAGES,
79
+ { ...query, offset: 2 },
80
+ {},
81
+ COLUMNS,
82
+ BASE,
83
+ );
84
+ expect(ids(first.results)).toEqual(["en", "es"]);
85
+ expect(ids(second.results)).toEqual(["de", "fr"]);
86
+ expect(first.total).toBe(4);
87
+ });
88
+ });
89
+
90
+ describe("rows a route sorts", () => {
91
+ it("pins within the list a source route sorted itself", async () => {
92
+ const sortedByRoute = [...LANGUAGES].sort(
93
+ (a, b) => a.coverage - b.coverage,
94
+ );
95
+ const api = vi.fn(async () => ({ results: sortedByRoute, total: 4 }));
96
+ const rows = useTableRows<LanguageRow>({
97
+ api: api as unknown as Api,
98
+ location: "/api/languages",
99
+ source: { fetchUrl: "/api/languages", capabilities: { sort: true } },
100
+ columns: COLUMNS,
101
+ pinnedRows: () => BASE,
102
+ });
103
+ const page = await rows.list({ sortKey: "coverage", sortDirection: "asc" });
104
+ expect(ids(page!.results)).toEqual(["en", "es", "de", "fr"]);
105
+ });
106
+
107
+ it("pins within the page a controller answers, read on each request", async () => {
108
+ const api = vi.fn(async () => ({
109
+ results: LANGUAGES,
110
+ total: 40,
111
+ offset: 0,
112
+ limit: 4,
113
+ }));
114
+ let pinned: typeof BASE | undefined = BASE;
115
+ const rows = useTableRows<LanguageRow>({
116
+ api: api as unknown as Api,
117
+ location: "/api/languages",
118
+ columns: COLUMNS,
119
+ pinnedRows: () => pinned,
120
+ });
121
+ expect(ids((await rows.list({}))!.results)).toEqual([
122
+ "en",
123
+ "de",
124
+ "fr",
125
+ "es",
126
+ ]);
127
+ pinned = undefined;
128
+ expect(ids((await rows.list({}))!.results)).toEqual(ids(LANGUAGES));
129
+ });
130
+ });
131
+
132
+ it("hands the table view's pinnedRows to its rows, except while grouped", () => {
133
+ const source = readFileSync(
134
+ resolve(
135
+ __dirname,
136
+ "../layers/dms-ui/app/components/table-view/TableView.vue",
137
+ ),
138
+ "utf8",
139
+ );
140
+ expect(source).toMatch(
141
+ /pinnedRows: \(\) => \(isGroupedDisplay\.value \? undefined : props\.pinnedRows\)/,
142
+ );
143
+ });
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { describe, expect, it, vi } from "vitest";
2
4
  import enGB from "../layers/dms-ui/i18n/locales/ui-en-GB.json";
3
5
  import frFR from "../layers/dms-ui/i18n/locales/ui-fr-FR.json";
@@ -13,6 +15,7 @@ import {
13
15
  sanitizeSorting,
14
16
  sortableColumnIds,
15
17
  sortDirectionLabelKey,
18
+ sortingOrDefault,
16
19
  sortValueKind,
17
20
  } from "../layers/dms-ui/app/build/composables/table-view/utils/sortableColumns";
18
21
 
@@ -92,6 +95,48 @@ describe("sanitizeSorting", () => {
92
95
  });
93
96
  });
94
97
 
98
+ describe("sortingOrDefault", () => {
99
+ const byDueDate = [{ id: "due_date", desc: true }];
100
+
101
+ it("keeps a sort the route serves", () => {
102
+ const sorting = [{ id: "completion_percentage", desc: false }];
103
+ expect(sortingOrDefault(sorting, sortable, byDueDate)).toEqual(sorting);
104
+ });
105
+
106
+ // Clearing the sort (a third click on a header) used to be remembered as
107
+ // `[]`: the table then listed in the database's natural order for good.
108
+ it("goes back to the default sort once the user clears theirs", () => {
109
+ expect(sortingOrDefault([], sortable, byDueDate)).toEqual(byDueDate);
110
+ expect(sortingOrDefault(null, sortable, byDueDate)).toEqual(byDueDate);
111
+ expect(
112
+ sortingOrDefault([{ id: "name", desc: false }], sortable, byDueDate),
113
+ ).toEqual(byDueDate);
114
+ });
115
+
116
+ it("stays unsorted without a default, or when the route refuses it", () => {
117
+ expect(sortingOrDefault([], sortable, [])).toEqual([]);
118
+ expect(
119
+ sortingOrDefault([], new Set(["completion_percentage"]), byDueDate),
120
+ ).toEqual([]);
121
+ });
122
+
123
+ it("is what the table view restores, and corrects, its sort with", () => {
124
+ const tableView = readFileSync(
125
+ resolve(
126
+ __dirname,
127
+ "../layers/dms-ui/app/components/table-view/TableView.vue",
128
+ ),
129
+ "utf8",
130
+ );
131
+ expect(tableView).toMatch(
132
+ /const sorting = ref<SortingState>\(\s*sortingOrDefault\(/,
133
+ );
134
+ expect(tableView).toMatch(
135
+ /const sanitized = sortingOrDefault\(\s*next,\s*sortableIds\.value,\s*defaultSortState,\s*\);/,
136
+ );
137
+ });
138
+ });
139
+
95
140
  describe("resolveDefaultSort", () => {
96
141
  it("uses a sortable default sort", () => {
97
142
  expect(
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
15
  import TableViews from "../layers/dms-ui/app/build/components/table/Views.vue";
16
16
  import { mergeColumnOrder } from "../layers/dms-ui/app/build/composables/table/utils/columnOrder";
17
17
  import { useTableViews } from "../layers/dms-ui/app/build/composables/table-view/useTableViews";
18
+ import type { TablePreferences } from "../layers/dms-ui/app/build/composables/table-view/useTablePreferences";
18
19
  import {
19
20
  isSameTableState,
20
21
  readTableUrlKey,
@@ -211,12 +212,22 @@ describe("useTableViews", () => {
211
212
  const preferences = new Map<string, unknown>(
212
213
  Object.entries(options.preferences ?? {}),
213
214
  );
214
- vi.stubGlobal("usePreferences", () => ({
215
- getPreference: (key: string, fallback: unknown) =>
216
- preferences.has(key) ? preferences.get(key) : fallback,
217
- setPreference: (key: string, value: unknown) =>
218
- preferences.set(key, value),
219
- }));
215
+ // `useTablePreferences`: the browser-only fields come back once mounted.
216
+ const restores: Array<() => void> = [];
217
+ const tablePreferences: TablePreferences = {
218
+ get: <V>(field: string, fallback: V) =>
219
+ (preferences.has(field) ? preferences.get(field) : fallback) as V,
220
+ set: (field, value) => {
221
+ preferences.set(field, value);
222
+ },
223
+ restore: (field, state) => {
224
+ restores.push(() => {
225
+ if (preferences.has(field)) {
226
+ state.value = preferences.get(field) as typeof state.value;
227
+ }
228
+ });
229
+ },
230
+ };
220
231
  vi.stubGlobal("useUserSession", () => ({
221
232
  user: ref({ _id: options.userId ?? "u1" }),
222
233
  }));
@@ -244,11 +255,15 @@ describe("useTableViews", () => {
244
255
  app = createApp(
245
256
  defineComponent({
246
257
  setup() {
258
+ onMounted(() => restores.forEach((restore) => restore()));
259
+ // As the table view does, before its views.
260
+ tablePreferences.restore("density", state.density);
261
+ tablePreferences.restore("columnOrder", state.columnOrder);
247
262
  api = useTableViews({
248
263
  views: options.views,
249
264
  defaults: DEFAULTS,
250
265
  state,
251
- preferenceKey: (suffix) => `tables.content.page.${suffix}`,
266
+ preferences: tablePreferences,
252
267
  urlScope: { tableId: "content", isSoleTableView: true },
253
268
  });
254
269
  return () => h("div");
@@ -300,7 +315,7 @@ describe("useTableViews", () => {
300
315
  const { api, state } = await mountViews({
301
316
  views: VIEWS,
302
317
  query: { view: "mine" },
303
- preferences: { "tables.content.page.activeView": "all" },
318
+ preferences: { activeView: "all" },
304
319
  });
305
320
  expect(api.activeViewId.value).toBe("mine");
306
321
  expect(state.columnFilters.value).toEqual([
@@ -323,13 +338,43 @@ describe("useTableViews", () => {
323
338
  ]);
324
339
  });
325
340
 
341
+ it("opens a user view the URL names once its views are restored in the browser", async () => {
342
+ const { api } = await mountViews({
343
+ views: VIEWS,
344
+ query: { view: "user-mine" },
345
+ preferences: {
346
+ userViews: [
347
+ { id: "user-mine", label: "Mine", ownerId: "u1", search: "x" },
348
+ ],
349
+ },
350
+ });
351
+ expect(api.activeViewId.value).toBe("user-mine");
352
+ expect(toasts).toEqual([]);
353
+ });
354
+
355
+ it("restores the browser-only state, a view opened on arrival keeping its own", async () => {
356
+ const opened = await mountViews({
357
+ views: VIEWS,
358
+ query: { view: "all" },
359
+ preferences: { density: "compact" },
360
+ });
361
+ expect(opened.state.density.value).toBe("default");
362
+ app?.unmount();
363
+
364
+ const kept = await mountViews({
365
+ views: VIEWS,
366
+ preferences: { density: "compact", activeView: "" },
367
+ });
368
+ expect(kept.state.density.value).toBe("compact");
369
+ });
370
+
326
371
  it("opens the default view on a first visit only", async () => {
327
372
  const first = await mountViews({ views: VIEWS });
328
373
  expect(first.api.activeViewId.value).toBe("all");
329
374
  app?.unmount();
330
375
  const later = await mountViews({
331
376
  views: VIEWS,
332
- preferences: { "tables.content.page.activeView": "" },
377
+ preferences: { activeView: "" },
333
378
  });
334
379
  expect(later.api.activeViewId.value).toBe(undefined);
335
380
  });
@@ -354,9 +399,7 @@ describe("useTableViews", () => {
354
399
  views: VIEWS,
355
400
  viewName: " Printers ",
356
401
  preferences: {
357
- "tables.content.page.userViews": [
358
- { id: "user-x", label: "Theirs", ownerId: "u2" },
359
- ],
402
+ userViews: [{ id: "user-x", label: "Theirs", ownerId: "u2" }],
360
403
  },
361
404
  });
362
405
  state.globalFilter.value = "printer";
@@ -373,9 +416,7 @@ describe("useTableViews", () => {
373
416
  "i18n:tickets.mine",
374
417
  "Printers",
375
418
  ]);
376
- expect(
377
- (preferences.get("tables.content.page.userViews") as unknown[]).length,
378
- ).toBe(2);
419
+ expect((preferences.get("userViews") as unknown[]).length).toBe(2);
379
420
 
380
421
  state.density.value = "compact";
381
422
  await nextTick();
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A TopListCard writes its unit after each formatted value (`valueSuffix`),
3
+ * and a row's `valueLabel` replaces the formatted value outright.
4
+ */
5
+ import { createSSRApp, defineComponent, h, ref } from "vue";
6
+ import { renderToString } from "vue/server-renderer";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import TopListCard from "../layers/dms-ui/app/components/top-list/TopListCard.vue";
9
+
10
+ const rows = ref<Array<Record<string, unknown>>>([]);
11
+
12
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
13
+ useChartFetch: () => ({
14
+ data: ref({ items: rows.value }),
15
+ isLoading: ref(false),
16
+ }),
17
+ }));
18
+
19
+ const Slot = defineComponent({
20
+ setup:
21
+ (_, { slots }) =>
22
+ () =>
23
+ h("div", slots.default?.()),
24
+ });
25
+
26
+ async function render(props: Record<string, unknown>): Promise<string> {
27
+ const app = createSSRApp({
28
+ render: () =>
29
+ h(TopListCard, {
30
+ title: "Slowest routes",
31
+ componentId: "slowest",
32
+ pageId: "overview",
33
+ showDelta: false,
34
+ valuePrecision: 0,
35
+ ...props,
36
+ }),
37
+ });
38
+ for (const name of [
39
+ "DmsCard",
40
+ "DmsLink",
41
+ "UBadge",
42
+ "UIcon",
43
+ "USkeleton",
44
+ "UButton",
45
+ ]) {
46
+ app.component(name, Slot);
47
+ }
48
+ return renderToString(app);
49
+ }
50
+
51
+ /** The text of each row's value cell. */
52
+ function values(html: string): string[] {
53
+ return [...html.matchAll(/<span class="text-toned[^"]*"[^>]*>([^<]*)</g)].map(
54
+ (match) => (match[1] ?? "").trim(),
55
+ );
56
+ }
57
+
58
+ beforeEach(() => {
59
+ rows.value = [
60
+ { id: "a", title: "GET /orders", value: 1864 },
61
+ { id: "b", title: "GET /cart", value: 296 },
62
+ ];
63
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en-US"), t: String }));
64
+ vi.stubGlobal("useTranslation", () => ({
65
+ processI18n: (text: string) => (text.startsWith("$") ? `t(${text})` : text),
66
+ }));
67
+ vi.stubGlobal("useComponentEvent", () => ({}));
68
+ vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
69
+ });
70
+
71
+ afterEach(() => vi.unstubAllGlobals());
72
+
73
+ describe("TopListCard value unit", () => {
74
+ it("writes the bare formatted value by default", async () => {
75
+ expect(values(await render({}))).toEqual(["1,864", "296"]);
76
+ });
77
+
78
+ it("writes the suffix right after each value", async () => {
79
+ expect(values(await render({ valueSuffix: " ms" }))).toEqual([
80
+ "1,864 ms",
81
+ "296 ms",
82
+ ]);
83
+ });
84
+
85
+ it("translates a `$` suffix", async () => {
86
+ expect(values(await render({ valueSuffix: "$units.ms" }))).toEqual([
87
+ "1,864t($units.ms)",
88
+ "296t($units.ms)",
89
+ ]);
90
+ });
91
+
92
+ it("writes a row's valueLabel as is, without the suffix", async () => {
93
+ rows.value = [
94
+ {
95
+ id: "a",
96
+ title: "GET /orders",
97
+ value: 1864,
98
+ valueLabel: "1.9 s (partial)",
99
+ },
100
+ { id: "b", title: "GET /cart", value: 296 },
101
+ ];
102
+ expect(values(await render({ valueSuffix: " ms" }))).toEqual([
103
+ "1.9 s (partial)",
104
+ "296 ms",
105
+ ]);
106
+ });
107
+ });
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
4
 
5
- const EXPECTED_VUE_FILES = 276;
5
+ const EXPECTED_VUE_FILES = 278;
6
6
 
7
7
  function walk(directory: string): string[] {
8
8
  return readdirSync(directory).flatMap((name) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"
@@ -37,7 +37,7 @@
37
37
  "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
38
38
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
39
39
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
40
- "@antelopejs/interface-dms": ">=0.5.5 <0.6.0",
40
+ "@antelopejs/interface-dms": ">=0.5.6 <0.6.0",
41
41
  "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
42
42
  "@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
43
43
  "archiver": "^7.0.1",
@@ -39,9 +39,15 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
39
39
  (which keeps its period in the page URL, `?<id>.period=`), and
40
40
  `realtimeTopic` re-fetches a chart, list block, `Meter`, `Tab` badges or `TableView` on each `PublishMessage`.
41
41
  A monitoring page that must not refetch on every event declares `live: { maxRate }` in its page options
42
- and places a `LiveToggle()` (Direct / Pause, L key); `liveHold: true` keeps a table's rows while paused.
43
- A custom component follows that state with `usePageLive()`; `refreshPageBlocks()` (user actions) always reads,
44
- `refreshLivePageBlocks()` (timers, feeds) is held while paused.
42
+ (`slowRateAfter: { period: "7d", maxRate: 60 }` for long periods, `rememberKey: "<module>"` to share the
43
+ choice between pages; `maxRate` is also the polling interval while the stream is down) and places a
44
+ `LiveToggle()` (Live / Pause, L key; `variant: "pill"`, `missedLabel` for "N new requests").
45
+ `liveHold: true` keeps a table's rows while paused; `liveHold: { whileRowOpen, whileScrolled }` also
46
+ holds the table alone. Paused, an event counts as its `payload.ids` rows or its `payload.count`, so a
47
+ module may batch publishes with `{ count }`. A hidden tab and `?live=0` pause the page too.
48
+ A custom component follows that state with `usePageLive()`, holds the page with `hold(reason)` /
49
+ `release()` and adds a breakdown with `setMissedDetail()`; `refreshPageBlocks()` (user actions) always
50
+ reads, `refreshLivePageBlocks()` (timers, feeds) is held while paused.
45
51
  A status the backend computes (health, outage) is a `Banner({ fetchUrl })`: the route answers
46
52
  a `BannerContent` (its buttons may run `api` / modal / quick-action targets with `confirm`)
47
53
  or `null` for no banner — the block then draws nothing.
@@ -82,6 +82,11 @@ releases the frontend module runs on go in the frontend module's own `package.js
82
82
  `"engines": { "@antelopejs/dms-frontend": ">=0.4.0 <0.5.0" }`: the loader refuses the module
83
83
  outside that range. Cap it below the next loader minor while the loader is 0.x.
84
84
 
85
+ An absolute link (a public file, a snippet to copy, a mail) is built on `await GetApiBaseUrl()`
86
+ (`@antelopejs/interface-dms/api-base-url`, the backend) or `await GetClientBaseUrl()`
87
+ (`/client-base-url`, the dashboard), never on `Host` / `X-Forwarded-*`: a reverse proxy may not
88
+ forward them. Both answer the DMS config (`apiBaseUrl`, `clientBaseUrl`).
89
+
85
90
  ## Navigation & access — module pages are owner-only
86
91
 
87
92
  Three constraints are enforced at registration: a page's `category` must **descend from the
@@ -9,7 +9,7 @@ Main builders: `Form`; `TableView(controller, options)`; the charts (`ChartLine`
9
9
  `ChartArea`, `ChartBar`, `ChartColumn`, `ChartDonut`, `ChartPie`, `ChartScatter`,
10
10
  `ChartMixed`, `ChartRadar`, `ChartRadialBar`, `ChartHeatmap`, `ChartCandlestick`, usually
11
11
  inside `ChartCard`); layout `Grid` + `GridRow`, `HStack` / `VStack` / `Spacer`, `Tab`,
12
- `Placeholder`; dashboard widgets `KpiCard`, `TopListCard`, `PeriodSelector`; display blocks
12
+ `Guard`, `Placeholder`; dashboard widgets `KpiCard`, `TopListCard`, `PeriodSelector`; display blocks
13
13
  `StatGroup`, `KeyValueList`, `NavCardGrid`, `EmptyState` (its `code` shows a snippet to
14
14
  start with), `Banner`, `Card`, and `CodeBlock` (read-only highlighted code with a copy
15
15
  button: `code` + `language`, or `fetchUrl` answering `{ code, language? }` — see
@@ -19,6 +19,15 @@ button: `code` + `language`, or `fetchUrl` answering `{ code, language? }` — s
19
19
  behavioral DSL — permissioned `.action()`s and `.watch()` reactivity — see
20
20
  `docs/02.building/05.actions-and-reactivity.md`.
21
21
 
22
+ `Guard({ fetchUrl, field?, when?, fallback? })` wraps any blocks and shows one `EmptyState`
23
+ (`fallback`) in their place when the route says there is nothing: `when: "empty"` (default)
24
+ checks the value at `field` (absent / `null` / `false` / `0` / `""` / `[]`), `when: "notFound"`
25
+ a 404. The children show while loading and on any other failure; an `emptyState` in the
26
+ answer is laid over `fallback` — `docs/04.components/03.layout-and-containers.md#guard`.
27
+ A block whose `fetchUrl` answers 404 shows its own "Not found" (no retry), the 404 body's
28
+ `message` / `emptyState` over it; a `Guard` with `when: "notFound"` draws the same state once
29
+ for the blocks it wraps — `docs/04.components/11.display-blocks.md#not-found`.
30
+
22
31
  A `TableView` scoped by the page URL takes `queryParamFilters: { status: { field: "status" } }`
23
32
  (hidden filters read from `?status=`; `routeParamFilters` for slug params like `:id`). No
24
33
  glue is needed around it: an active query filter shows the `filtered` empty state, and a
@@ -26,16 +35,28 @@ link changing its value sends the table back to page 1. A drawer or modal row ac
26
35
  gets `navigation` (J / K, `index` / `total` in the order shown) —
27
36
  `docs/04.components/06.tables/8.row-drawers.md`.
28
37
 
38
+ A page's `headerActions` follow its URL: a target URL takes `{{params.X}}` / `{{query.X}}`
39
+ (`?workspace={{query.workspace}}`, a pair the page has no value for is dropped), and an
40
+ `availability` reads `context.query`, asked again when the query changes — no need to build
41
+ the header in Vue with `usePageHeaderActions` to carry the scope —
42
+ `docs/07.interfaces/3.base/6.layout.md#buttons-that-follow-the-page-url`.
43
+
29
44
  A button or row action (`customButtons`, `rowActions.custom`, a page's `headerActions`)
30
45
  points at an `ActionTarget`. A `drawer` target opens from the bottom; give it
31
46
  `direction: "right"` for a side sheet (an inspector, a detail panel) instead of hosting your
32
47
  own drawer in a `CustomComponent`.
33
48
  To read rows one after the other next to the table (a request log), use a `sidePanel`
34
49
  target instead: a non-modal panel docked on the right whose header the DMS draws (title,
35
- `positionLabel` "x of y", previous / next, J / K, close), `?record=` kept by default (`deepLink: false` opts out) and the table's
50
+ `rowTitle` naming the row from its fields, `positionLabel` "x of y", previous / next, J / K, close), `?record=` kept by default (`deepLink: false` opts out), a "not found" state on a 404 (`notFound: { title, description }`) and the table's
36
51
  `rowClick: "single"` (a `TableView.fromSource` table reads a deep-linked row beyond its first page through `fetchRowUrl: "/api/x/{id}"`) — never bridge the table's DOM to build one —
37
52
  `docs/04.components/06.tables/8.row-drawers.md#side-panel`.
38
53
 
54
+ A row that must stay on top of a table under any sort (a base language, a default
55
+ workspace) takes `pinnedRows: { field: "isBase", equals: true }` — never sort on the
56
+ server for it. A row action names its row with `{field}` braces in its `label`
57
+ (`"Translate {name}"`), and `rowActions.menuLabel` (`"Actions for {name}"`) names the
58
+ row's ⋯ button — `docs/04.components/06.tables/0.index.md#pinned-rows`.
59
+
39
60
  ## DataTypes
40
61
 
41
62
  A field's `type` is a **DataType instance**: `new DefaultDataTypes.StringType({ … })`. The