@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
@@ -0,0 +1,149 @@
1
+ import { getCurrentInstance, onMounted, type Ref } from "vue";
2
+ import {
3
+ type CompactTableEntry,
4
+ decodeServerField,
5
+ encodeServerField,
6
+ isDefaultValue,
7
+ isServerTableField,
8
+ type LocalTableField,
9
+ readLocalTableEntry,
10
+ SERVER_TABLE_FIELDS,
11
+ type ServerTableField,
12
+ type TableField,
13
+ TABLES_PREFERENCE_KEY,
14
+ tablePreferenceId,
15
+ writeLocalTableEntry,
16
+ } from "#dms-core/app/build/composables/user/tablePreferences";
17
+
18
+ /**
19
+ * The preferences of one table view. A field the server render needs
20
+ * (`ServerTableField`) is read in setup from the cookie, on the server too; a
21
+ * field only the browser draws (`LocalTableField`) reads as its default in
22
+ * setup, on both sides, and is restored from `localStorage` once mounted.
23
+ */
24
+ export interface TablePreferences {
25
+ /** A server field's stored value, else `defaultValue`; a local field's default. */
26
+ get: <T>(field: TableField, defaultValue: T) => T;
27
+ /** Stores `value`, or forgets the field when `value` is its default. */
28
+ set: <T>(field: TableField, value: T, defaultValue: T) => void;
29
+ /**
30
+ * Sets `state` to the local field's stored value once the table is
31
+ * mounted, unless `isValid` refuses it: the server and the hydrating
32
+ * client render the default, so no mismatch.
33
+ */
34
+ restore: <T>(
35
+ field: LocalTableField,
36
+ state: Ref<T>,
37
+ isValid?: (value: T) => boolean,
38
+ ) => void;
39
+ }
40
+
41
+ interface PendingRestore {
42
+ field: LocalTableField;
43
+ apply: (value: unknown) => void;
44
+ }
45
+
46
+ /** The table's entry in the cookie: `tables.<id>`, its server fields. */
47
+ function useServerTableEntry(id: string) {
48
+ const { getPreference, setPreference, removePreference } = usePreferences();
49
+ const path = `${TABLES_PREFERENCE_KEY}.${id}`;
50
+
51
+ const entry = (): CompactTableEntry | undefined =>
52
+ getPreference<CompactTableEntry | undefined>(path, undefined);
53
+ const tables = () =>
54
+ getPreference<Record<string, unknown>>(TABLES_PREFERENCE_KEY, {});
55
+
56
+ // The last entry takes the collection with it: no empty branch left over.
57
+ const forget = () => {
58
+ const isLastEntry = Object.keys(tables()).every((key) => key === id);
59
+ removePreference(isLastEntry ? TABLES_PREFERENCE_KEY : path);
60
+ };
61
+
62
+ const set = (
63
+ field: ServerTableField,
64
+ value: unknown,
65
+ defaultValue: unknown,
66
+ ) => {
67
+ const { key } = SERVER_TABLE_FIELDS[field];
68
+ const next = { ...entry() };
69
+ const encoded = encodeServerField(field, value, defaultValue);
70
+ if (JSON.stringify(next[key]) === JSON.stringify(encoded)) return;
71
+ if (encoded === undefined) Reflect.deleteProperty(next, key);
72
+ else next[key] = encoded;
73
+ if (Object.keys(next).length === 0) forget();
74
+ else setPreference(path, next);
75
+ };
76
+
77
+ // The table used last is evicted last: opening it counts as a write.
78
+ const markUsed = () => {
79
+ const current = entry();
80
+ if (current && Object.keys(tables()).at(-1) !== id) {
81
+ setPreference(path, current);
82
+ }
83
+ };
84
+
85
+ return { entry, set, markUsed };
86
+ }
87
+
88
+ const setLocalField = (
89
+ id: string,
90
+ field: LocalTableField,
91
+ value: unknown,
92
+ defaultValue: unknown,
93
+ ) => {
94
+ const entry = readLocalTableEntry(id);
95
+ if (isDefaultValue(value, defaultValue)) {
96
+ Reflect.deleteProperty(entry, field);
97
+ } else {
98
+ entry[field] = JSON.parse(JSON.stringify(value));
99
+ }
100
+ writeLocalTableEntry(id, entry);
101
+ };
102
+
103
+ const restoreLocalFields = (id: string, pending: PendingRestore[]) => {
104
+ const stored = readLocalTableEntry(id);
105
+ pending
106
+ .filter(({ field }) => stored[field] !== undefined)
107
+ .forEach(({ field, apply }) => apply(stored[field]));
108
+ };
109
+
110
+ /**
111
+ * The preferences of the table view `componentId` on page `pageId`, under a
112
+ * short hash of both: the cookie entry `tables.<id>`, and the
113
+ * `localStorage` key `dms-table-preferences:v1:<id>`.
114
+ */
115
+ export function useTablePreferences(
116
+ componentId: string | undefined,
117
+ pageId: string | undefined,
118
+ ): TablePreferences {
119
+ const id = tablePreferenceId(componentId ?? "", pageId ?? "");
120
+ const server = useServerTableEntry(id);
121
+ const pending: PendingRestore[] = [];
122
+
123
+ if (getCurrentInstance()) {
124
+ onMounted(() => {
125
+ restoreLocalFields(id, pending);
126
+ server.markUsed();
127
+ });
128
+ }
129
+
130
+ return {
131
+ get: (field, defaultValue) =>
132
+ isServerTableField(field)
133
+ ? decodeServerField(field, server.entry(), defaultValue)
134
+ : defaultValue,
135
+ set: (field, value, defaultValue) =>
136
+ isServerTableField(field)
137
+ ? server.set(field, value, defaultValue)
138
+ : setLocalField(id, field, value, defaultValue),
139
+ restore: (field, state, isValid) => {
140
+ pending.push({
141
+ field,
142
+ apply: (value) => {
143
+ const restored = value as typeof state.value;
144
+ if (!isValid || isValid(restored)) state.value = restored;
145
+ },
146
+ });
147
+ },
148
+ };
149
+ }
@@ -9,6 +9,8 @@ import {
9
9
  type ReplaceUrlVariablesContext,
10
10
  resolveUrlVariables,
11
11
  } from "../../utils/urlVariables";
12
+ import type { RowActionRule } from "#dms-core/app/types/row-action";
13
+ import { pinRows } from "./utils/pinnedRows";
12
14
  import {
13
15
  appendPeriodToUrl,
14
16
  usePeriodScope,
@@ -34,6 +36,11 @@ export interface TableRowsOptions {
34
36
  * read again on every request.
35
37
  */
36
38
  route?: () => ReplaceUrlVariablesContext;
39
+ /**
40
+ * The rows listed first whatever the sort (backend `pinnedRows`), read on
41
+ * every request: `undefined` while the display pins nothing.
42
+ */
43
+ pinnedRows?: () => RowActionRule | undefined;
37
44
  }
38
45
 
39
46
  const FILTER_PREFIX = "filter_";
@@ -94,13 +101,15 @@ const compareValues = (a: unknown, b: unknown): number => {
94
101
  /**
95
102
  * The rows the browser narrows, sorts and pages itself, for what the source
96
103
  * route left to it: a search over the listed columns' text, the sort on one
97
- * column, the page.
104
+ * column, the page. The pinned rows lead before the page is cut, so they
105
+ * open the first one.
98
106
  */
99
107
  export function processSourceRows<T extends Record<string, unknown>>(
100
108
  rows: T[],
101
109
  query: Query,
102
110
  capabilities: SourceCapabilities,
103
111
  columns: TableViewColumn[],
112
+ pinnedRows?: RowActionRule,
104
113
  ): TableViewListResponse<T> {
105
114
  let processed = rows;
106
115
  const search = typeof query.search === "string" ? query.search.trim() : "";
@@ -120,6 +129,7 @@ export function processSourceRows<T extends Record<string, unknown>>(
120
129
  (a, b) => direction * compareValues(get(a, sortKey), get(b, sortKey)),
121
130
  );
122
131
  }
132
+ processed = pinRows(processed, pinnedRows);
123
133
  const offset = Number(query.offset) || 0;
124
134
  const limit = Number(query.limit) || processed.length;
125
135
  return {
@@ -151,7 +161,8 @@ function emptySourcePage<T>(query: Query): TableViewListResponse<T> {
151
161
  * source's `periodScope` adds the period of its selector to the URL, like a
152
162
  * chart's; until that selector registers, nothing is requested and the list
153
163
  * answers `null`, which the table draws as its skeleton. `sourceUrl` is the
154
- * URL requested, for the caller to list again when it changes.
164
+ * URL requested, for the caller to list again when it changes. The rows
165
+ * `pinnedRows` accepts lead each list answered.
155
166
  */
156
167
  export function useTableRows<T extends Record<string, unknown>>(
157
168
  options: TableRowsOptions,
@@ -189,13 +200,26 @@ export function useTableRows<T extends Record<string, unknown>>(
189
200
  limit: Number(query.limit) || 0,
190
201
  };
191
202
  }
192
- return processSourceRows(response.results, query, capabilities, columns);
203
+ return processSourceRows(
204
+ response.results,
205
+ query,
206
+ capabilities,
207
+ columns,
208
+ options.pinnedRows?.(),
209
+ );
193
210
  };
194
211
 
195
- const list = (query: Query): Promise<TableViewListResponse<T> | null> =>
196
- source
197
- ? listFromSource(source, query)
198
- : api<TableViewListResponse<T>>(`${location}/list`, { query });
212
+ const withPinnedRows = (
213
+ page: TableViewListResponse<T> | null,
214
+ ): TableViewListResponse<T> | null =>
215
+ page && { ...page, results: pinRows(page.results, options.pinnedRows?.()) };
216
+
217
+ const list = async (query: Query): Promise<TableViewListResponse<T> | null> =>
218
+ withPinnedRows(
219
+ source
220
+ ? await listFromSource(source, query)
221
+ : await api<TableViewListResponse<T>>(`${location}/list`, { query }),
222
+ );
199
223
 
200
224
  // A source answers its totals one list at a time.
201
225
  const countBatch = async (
@@ -242,5 +266,12 @@ export function useTableRows<T extends Record<string, unknown>>(
242
266
  ? getSourceRow(source, id)
243
267
  : api<T>(`${location}/get`, { query: { id } });
244
268
 
245
- return { isSource: !!source, sourceUrl, list, countBatch, getRow };
269
+ return {
270
+ isSource: !!source,
271
+ sourceUrl,
272
+ isPeriodPending,
273
+ list,
274
+ countBatch,
275
+ getRow,
276
+ };
246
277
  }
@@ -122,6 +122,7 @@ export const useTableViewConfig = <T extends Data>(
122
122
  caption: processI18n(config.caption ?? ""),
123
123
  maxHeight: config.maxHeight,
124
124
  rowIdKey: config.rowIdKey,
125
+ labelKey: config.labelKey,
125
126
  rowActions: config.rowActions,
126
127
  rowClick: config.rowClick,
127
128
  customNavItems: config.customNavItems,
@@ -801,17 +801,21 @@ export const useTableRowActions = <T extends Data>(
801
801
  await proceed();
802
802
  };
803
803
 
804
- const { handleCustomButton, handleCustomRowAction, handleBulkCustomAction } =
805
- useActionTargets({
806
- api: config.api,
807
- pageId: config.pageId,
808
- componentId: config.componentId,
809
- refreshCallback: config.refreshCallback,
810
- handleApiError,
811
- rowNavigation: config.rowNavigation,
812
- recordScope: config.recordScope,
813
- rowIdKey: config.rowIdKey,
814
- });
804
+ const {
805
+ handleCustomButton,
806
+ handleCustomRowAction,
807
+ handleBulkCustomAction,
808
+ handleMissingRow,
809
+ } = useActionTargets({
810
+ api: config.api,
811
+ pageId: config.pageId,
812
+ componentId: config.componentId,
813
+ refreshCallback: config.refreshCallback,
814
+ handleApiError,
815
+ rowNavigation: config.rowNavigation,
816
+ recordScope: config.recordScope,
817
+ rowIdKey: config.rowIdKey,
818
+ });
815
819
 
816
820
  return {
817
821
  createFormContainer,
@@ -826,6 +830,7 @@ export const useTableRowActions = <T extends Data>(
826
830
  handleCustomButton,
827
831
  handleCustomRowAction,
828
832
  handleBulkCustomAction,
833
+ handleMissingRow,
829
834
  handleApiError,
830
835
  runConfirmedBuiltIn,
831
836
  };
@@ -23,6 +23,7 @@ import {
23
23
  type UserTableView,
24
24
  viewSnapshot,
25
25
  } from "./utils/views";
26
+ import type { TablePreferences } from "./useTablePreferences";
26
27
 
27
28
  /** The table's state a view reads and sets. */
28
29
  export interface TableViewsState {
@@ -40,8 +41,8 @@ export interface TableViewsOptions {
40
41
  views: TableViewViewsConfig | undefined;
41
42
  defaults: TableStateDefaults;
42
43
  state: TableViewsState;
43
- /** Preference key of the table, for one of its settings. */
44
- preferenceKey: (suffix: string) => string;
44
+ /** The table's preferences (`useTablePreferences`). */
45
+ preferences: TablePreferences;
45
46
  urlScope: TableUrlScope;
46
47
  }
47
48
 
@@ -52,8 +53,6 @@ export interface ResolvedTableView extends TableViewViewConfig {
52
53
  }
53
54
 
54
55
  export const VIEW_URL_KEY = "view";
55
- const ACTIVE_VIEW_PREFERENCE = "activeView";
56
- const USER_VIEWS_PREFERENCE = "userViews";
57
56
  const NO_VIEW = "";
58
57
  const VIEW_NAME_FIELD = "label";
59
58
 
@@ -64,8 +63,7 @@ const VIEW_NAME_FIELD = "label";
64
63
  * a change made to a view is not kept when another is opened.
65
64
  */
66
65
  export function useTableViews(options: TableViewsOptions) {
67
- const { views, defaults, state, preferenceKey, urlScope } = options;
68
- const { getPreference, setPreference } = usePreferences();
66
+ const { views, defaults, state, preferences, urlScope } = options;
69
67
  const { user } = useUserSession();
70
68
  const { t } = useI18n();
71
69
  const { processI18n } = useTranslation();
@@ -74,9 +72,9 @@ export function useTableViews(options: TableViewsOptions) {
74
72
  const route = useDmsRoute();
75
73
 
76
74
  const userId = computed(() => user.value?._id as string | undefined);
77
- const storedUserViews = ref<UserTableView[]>(
78
- getPreference<UserTableView[]>(preferenceKey(USER_VIEWS_PREFERENCE), []),
79
- );
75
+ // Kept in the browser: none on the server, restored once mounted.
76
+ const storedUserViews = ref<UserTableView[]>([]);
77
+ preferences.restore("userViews", storedUserViews, Array.isArray);
80
78
  const ownUserViews = computed(() =>
81
79
  views?.userViews && userId.value
82
80
  ? storedUserViews.value.filter((view) => view.ownerId === userId.value)
@@ -132,25 +130,50 @@ export function useTableViews(options: TableViewsOptions) {
132
130
 
133
131
  // Read once, on arrival: a view named by the URL wins over the state kept
134
132
  // from the last visit; a view the caller may not open falls back to the
135
- // default one, with a word (on the client, once mounted).
133
+ // default one, with a word (on the client, once mounted). The user's views
134
+ // are kept in the browser, so a view that is not the module's is resolved
135
+ // once mounted.
136
136
  const urlView = readTableUrlKey(route.query, urlScope, VIEW_URL_KEY);
137
- const persistedViewId = getPreference<string | undefined>(
138
- preferenceKey(ACTIVE_VIEW_PREFERENCE),
137
+ const persistedViewId = preferences.get<string | undefined>(
138
+ "activeView",
139
139
  undefined,
140
140
  );
141
141
  let isUrlViewUnavailable = false;
142
+ let isUrlViewPending = false;
143
+ let isOpenedOnArrival = false;
142
144
  if (urlView.value && findView(urlView.value)) {
143
145
  openView(urlView.value);
146
+ isOpenedOnArrival = true;
144
147
  } else if (urlView.value) {
145
- isUrlViewUnavailable = true;
146
- openView(views?.defaultView);
148
+ isUrlViewPending = true;
147
149
  } else if (persistedViewId === undefined) {
148
150
  if (views?.defaultView) openView(views.defaultView);
151
+ isOpenedOnArrival = !!views?.defaultView;
149
152
  } else {
150
153
  activeViewId.value = findView(persistedViewId)?.id;
151
154
  }
152
155
 
156
+ // The browser-only state (order, density) is restored over the view opened
157
+ // in setup: the view's own wins, as it did when everything was read there.
158
+ const reapplyBrowserState = () => {
159
+ const snapshot = viewSnapshot(activeView.value, defaults);
160
+ state.columnOrder.value = snapshot.order;
161
+ state.density.value = snapshot.density;
162
+ };
163
+
164
+ const arriveOnceMounted = () => {
165
+ if (isUrlViewPending) {
166
+ isUrlViewUnavailable = !findView(urlView.value);
167
+ openView(isUrlViewUnavailable ? views?.defaultView : urlView.value);
168
+ } else if (isOpenedOnArrival) {
169
+ reapplyBrowserState();
170
+ } else if (persistedViewId && !activeViewId.value) {
171
+ activeViewId.value = findView(persistedViewId)?.id;
172
+ }
173
+ };
174
+
153
175
  onMounted(() => {
176
+ arriveOnceMounted();
154
177
  if (urlView.ignoredShortKey && import.meta.env.DEV) {
155
178
  console.warn(
156
179
  `[DMS] "?${VIEW_URL_KEY}=" is ignored on a page with several table views: name the table, "?${urlScope.tableId}.${VIEW_URL_KEY}=".`,
@@ -166,7 +189,7 @@ export function useTableViews(options: TableViewsOptions) {
166
189
  });
167
190
 
168
191
  watch(activeViewId, (id) => {
169
- setPreference(preferenceKey(ACTIVE_VIEW_PREFERENCE), id ?? NO_VIEW);
192
+ preferences.set<string | undefined>("activeView", id ?? NO_VIEW, undefined);
170
193
  });
171
194
 
172
195
  const isModified = computed(() => {
@@ -181,7 +204,7 @@ export function useTableViews(options: TableViewsOptions) {
181
204
 
182
205
  const writeUserViews = (next: UserTableView[]) => {
183
206
  storedUserViews.value = next;
184
- setPreference(preferenceKey(USER_VIEWS_PREFERENCE), next);
207
+ preferences.set("userViews", next, []);
185
208
  };
186
209
 
187
210
  /** Back to the open view's own state. */
@@ -0,0 +1,29 @@
1
+ import type { RowActionRule } from "#dms-core/app/types/row-action";
2
+ import { evaluateRowActionRule } from "#dms-core/app/utils/row-action-rule-evaluator";
3
+
4
+ interface PartitionedRows<T> {
5
+ pinned: T[];
6
+ rest: T[];
7
+ }
8
+
9
+ /**
10
+ * The rows a table view's `pinnedRows` accepts first, then the others, each
11
+ * in the order they came in: the sort keeps ordering both, it no longer
12
+ * decides which rows lead.
13
+ */
14
+ export function pinRows<T extends Record<string, unknown>>(
15
+ rows: T[],
16
+ pinnedRows: RowActionRule | undefined,
17
+ ): T[] {
18
+ if (!pinnedRows) return rows;
19
+ const { pinned, rest } = rows.reduce<PartitionedRows<T>>(
20
+ (parts, row) => {
21
+ (evaluateRowActionRule(pinnedRows, row) ? parts.pinned : parts.rest).push(
22
+ row,
23
+ );
24
+ return parts;
25
+ },
26
+ { pinned: [], rest: [] },
27
+ );
28
+ return pinned.length === 0 ? rows : [...pinned, ...rest];
29
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CustomRowAction } from "../../../../types/row-action";
2
2
  import { readTableUrlKey, type TableUrlScope } from "./views";
3
+ import { readFetchNotFound } from "../../../utils/fetchNotFound";
3
4
 
4
5
  /** Query key of the row a `deepLink` action has open. */
5
6
  export const RECORD_URL_KEY = "record";
@@ -76,16 +77,28 @@ export interface DeepLinkedRowSource<T> {
76
77
  getRow: (id: string) => Promise<T | undefined>;
77
78
  }
78
79
 
80
+ /** What a deep link found: its row, or a route answering it is gone. */
81
+ export interface DeepLinkedRow<T> {
82
+ row?: T;
83
+ /** The route answered 404: the row the link names no longer exists. */
84
+ isMissing: boolean;
85
+ }
86
+
79
87
  /**
80
88
  * The row a deep link names: the listed one, else the one read by id — a row
81
- * beyond the first page. A row the route cannot answer (gone, a 404) opens
82
- * nothing.
89
+ * beyond the first page. A route answering 404 marks it missing; any other
90
+ * failure (a refusal, the network) or no route at all finds nothing.
83
91
  */
84
92
  export async function resolveDeepLinkedRow<T>(
85
93
  id: string,
86
94
  source: DeepLinkedRowSource<T>,
87
- ): Promise<T | undefined> {
95
+ ): Promise<DeepLinkedRow<T>> {
88
96
  const listed = source.listed?.find((row) => source.idOf(row) === id);
89
- if (listed) return listed;
90
- return source.getRow(id).catch(() => undefined);
97
+ if (listed) return { row: listed, isMissing: false };
98
+ try {
99
+ const row = await source.getRow(id);
100
+ return { row: row ?? undefined, isMissing: false };
101
+ } catch (error: unknown) {
102
+ return { isMissing: readFetchNotFound(error) !== null };
103
+ }
91
104
  }
@@ -54,6 +54,23 @@ export const sanitizeSorting = (
54
54
  return [{ id: first.id, desc: first.desc === true }];
55
55
  };
56
56
 
57
+ /**
58
+ * The sort the list route can serve ({@link sanitizeSorting}), or the
59
+ * default sort when that leaves none: a sort the user cleared, or one naming
60
+ * a column gone, goes back to the table's default rather than to the
61
+ * database's natural order — and is remembered so.
62
+ */
63
+ export const sortingOrDefault = (
64
+ sorting: SortingState | null | undefined,
65
+ sortable: ReadonlySet<string>,
66
+ defaultSorting: SortingState,
67
+ ): SortingState => {
68
+ const sanitized = sanitizeSorting(sorting, sortable);
69
+ return sanitized.length > 0
70
+ ? sanitized
71
+ : sanitizeSorting(defaultSorting, sortable);
72
+ };
73
+
57
74
  /** Whether two sorting states request the same order. */
58
75
  export const isSameSorting = (a: SortingState, b: SortingState): boolean =>
59
76
  a.length === b.length &&
@@ -0,0 +1,79 @@
1
+ import { HTTP_NOT_FOUND } from "../../../../dms-core/app/utils/http-status";
2
+ import type { I18nTranslate } from "#dms-core/app/composables/translation/useTranslation";
3
+ import type { BlockAction } from "../components/blocks/BlockActions.vue";
4
+ import { userFacingText } from "../composables/confirm/actionError";
5
+ import type { Tone } from "../../types/tone";
6
+
7
+ /** Translation key of the not-found title, shared by blocks and guards. */
8
+ export const NOT_FOUND_TITLE_KEY = "dms.blocks.not_found";
9
+ /** Translation key of the not-found hint, when the 404 says nothing usable. */
10
+ export const NOT_FOUND_HINT_KEY = "dms.blocks.not_found_hint";
11
+
12
+ /** What the body of a 404 may lay over a not-found state. */
13
+ export interface NotFoundEmptyState {
14
+ title?: string;
15
+ description?: string;
16
+ icon?: string;
17
+ tone?: Tone;
18
+ actions?: BlockAction[];
19
+ }
20
+
21
+ /**
22
+ * What a data source answering 404 says about it, read from the error body:
23
+ * `{ message?, emptyState? }`, both optional.
24
+ */
25
+ export interface FetchNotFound {
26
+ /** The body's `message`: an i18n key or a sentence for the user. */
27
+ message?: string;
28
+ /** Laid over the not-found state drawn (title, icon, actions…). */
29
+ emptyState?: NotFoundEmptyState;
30
+ }
31
+
32
+ interface FetchFailure {
33
+ statusCode?: number;
34
+ status?: number;
35
+ response?: { status?: number; _data?: unknown };
36
+ data?: unknown;
37
+ }
38
+
39
+ const isObject = (value: unknown): value is Record<string, unknown> =>
40
+ typeof value === "object" && value !== null;
41
+
42
+ const statusOf = (error: FetchFailure): number | undefined =>
43
+ error.statusCode ?? error.status ?? error.response?.status;
44
+
45
+ /**
46
+ * The not-found answer a failed request carries, or `null` when it failed any
47
+ * other way (network, refusal, crash): a 404 is the server's final word that
48
+ * what is read does not exist, not a load error to retry.
49
+ */
50
+ export function readFetchNotFound(error: unknown): FetchNotFound | null {
51
+ if (!isObject(error)) return null;
52
+ const failure = error as FetchFailure;
53
+ if (statusOf(failure) !== HTTP_NOT_FOUND) return null;
54
+ const data = failure.data ?? failure.response?._data;
55
+ const body = isObject(data) ? data : {};
56
+ return {
57
+ message: typeof body.message === "string" ? body.message : undefined,
58
+ emptyState: isObject(body.emptyState)
59
+ ? (body.emptyState as NotFoundEmptyState)
60
+ : undefined,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The default texts of a not-found state, before the 404's `emptyState` is
66
+ * laid over them: a translated "Not found", and the body's `message` when it
67
+ * is user-facing, else a translated hint. A block and a `Guard` draw the same.
68
+ */
69
+ export function notFoundTexts(
70
+ notFound: FetchNotFound | null | undefined,
71
+ translate: I18nTranslate,
72
+ ): { title: string; description: string } {
73
+ return {
74
+ title: translate(NOT_FOUND_TITLE_KEY, {}),
75
+ description:
76
+ userFacingText(notFound?.message, translate) ??
77
+ translate(NOT_FOUND_HINT_KEY, {}),
78
+ };
79
+ }
@@ -3,7 +3,8 @@
3
3
  // a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
4
4
  // table view (see extractRouteParams).
5
5
  const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
6
- const QUERY_TOKEN = /\{\{query\.(\w+)\}\}/g;
6
+ // A query key may carry dots and dashes: a period selector writes `<id>.period`.
7
+ const QUERY_TOKEN = /\{\{query\.([\w.-]+)\}\}/g;
7
8
  const RESPONSE_TOKEN = /\{\{response\.(\w+)\}\}/g;
8
9
  const TOKEN_OPENING = "{{";
9
10
 
@@ -118,3 +119,47 @@ export function resolveTextVariables(
118
119
  const resolved = fillRouteTokens(text, context, TEXT_ENCODING);
119
120
  return hasUrlVariables(resolved) ? undefined : resolved;
120
121
  }
122
+
123
+ // A route token left without a value: `{{params.id}}`, `{{query.workspace}}`.
124
+ const UNRESOLVED_ROUTE_TOKEN = /\{\{(?:params|query)\.\w+(?::\d+)?\}\}/g;
125
+ const UNRESOLVED_ROUTE_VALUE = /^\{\{(?:params|query)\.\w+(?::\d+)?\}\}$/;
126
+ const QUERY_SEPARATOR = "?";
127
+ const FRAGMENT_SEPARATOR = "#";
128
+ const PAIR_SEPARATOR = "&";
129
+ const VALUE_SEPARATOR = "=";
130
+
131
+ /** The query of a URL without the pairs whose whole value is a token left empty. */
132
+ function dropUnresolvedPairs(search: string): string {
133
+ return search
134
+ .split(PAIR_SEPARATOR)
135
+ .filter((pair) => {
136
+ const value = pair.slice(pair.indexOf(VALUE_SEPARATOR) + 1);
137
+ return (
138
+ !pair.includes(VALUE_SEPARATOR) || !UNRESOLVED_ROUTE_VALUE.test(value)
139
+ );
140
+ })
141
+ .join(PAIR_SEPARATOR);
142
+ }
143
+
144
+ /**
145
+ * A link's URL with the `{{params.X}}` / `{{query.X}}` tokens of the page it
146
+ * sits on filled, for a button that always leads somewhere: a query pair
147
+ * whose value is a token the page has no value for is left out
148
+ * (`?workspace={{query.workspace}}` on a page without `?workspace=` leads to
149
+ * the target's default), any other such token is left empty.
150
+ */
151
+ export function resolveLinkVariables(
152
+ url: string,
153
+ context: ReplaceUrlVariablesContext,
154
+ ): string {
155
+ const filled = replaceUrlVariables(url, context);
156
+ if (!hasUrlVariables(filled)) return filled;
157
+ const [beforeFragment = "", ...fragment] = filled.split(FRAGMENT_SEPARATOR);
158
+ const [path = "", ...search] = beforeFragment.split(QUERY_SEPARATOR);
159
+ const query = dropUnresolvedPairs(search.join(QUERY_SEPARATOR));
160
+ const rebuilt = [
161
+ query ? `${path}${QUERY_SEPARATOR}${query}` : path,
162
+ ...fragment,
163
+ ].join(FRAGMENT_SEPARATOR);
164
+ return rebuilt.replace(UNRESOLVED_ROUTE_TOKEN, "");
165
+ }