@antelopejs/dms 0.7.9 → 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 (172) hide show
  1. package/CHANGELOG.md +29 -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 +74 -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/table-actions-search-label.test.ts +57 -1
  158. package/frontend-vue/tests/table-clear-filters.test.ts +99 -1
  159. package/frontend-vue/tests/table-density-menu.test.ts +4 -3
  160. package/frontend-vue/tests/table-live-hold.test.ts +95 -5
  161. package/frontend-vue/tests/table-row-action-labels.test.ts +181 -0
  162. package/frontend-vue/tests/table-source-period-scope.test.ts +31 -3
  163. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +14 -17
  164. package/frontend-vue/tests/table-view-pinned-rows.test.ts +143 -0
  165. package/frontend-vue/tests/table-view-sortable-columns.test.ts +45 -0
  166. package/frontend-vue/tests/table-view-views.test.ts +56 -15
  167. package/frontend-vue/tests/top-list-card-value-suffix.test.ts +107 -0
  168. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  169. package/package.json +2 -2
  170. package/skills/dms/SKILL.md +9 -3
  171. package/skills/dms-module/SKILL.md +5 -0
  172. package/skills/dms-pages/REFERENCE.md +23 -2
@@ -70,6 +70,7 @@ import {
70
70
  defaultSortingState,
71
71
  resolveDefaultSortConfig,
72
72
  sanitizeSorting,
73
+ sortingOrDefault,
73
74
  sortableColumnIds,
74
75
  sortValueKind,
75
76
  } from "../../build/composables/table-view/utils/sortableColumns";
@@ -100,6 +101,7 @@ import {
100
101
  rowCustomActions,
101
102
  } from "../../utils/rowClickAction";
102
103
  import { usePermissionPreview } from "#dms-core/app/build/composables/auth/usePermissionPreview";
104
+ import type { RowActionRule } from "#dms-core/app/types/row-action";
103
105
 
104
106
  import UTable from "../../build/components/table/Table.vue";
105
107
  import ExpandedRowDetail from "../../build/components/table-view/ExpandedRowDetail.vue";
@@ -114,7 +116,9 @@ import type {
114
116
  } from "../../build/components/table/Table.vue";
115
117
  import {
116
118
  defineAsyncComponent,
119
+ useTemplateRef,
117
120
  type Component,
121
+ type ComponentPublicInstance,
118
122
  type WatchOptions,
119
123
  type WatchSource,
120
124
  } from "vue";
@@ -124,9 +128,13 @@ import { useGroupedRows } from "../../build/composables/table-view/useGroupedRow
124
128
  import { useTableFooter } from "../../build/composables/table-view/useTableFooter";
125
129
  import { useAccumulatedPages } from "../../build/composables/table-view/useAccumulatedPages";
126
130
  import { usePersistedPagination } from "../../build/composables/table-view/usePersistedPagination";
131
+ import { useTablePreferences } from "../../build/composables/table-view/useTablePreferences";
132
+ import type { Ref } from "vue";
133
+ import type { TableField } from "#dms-core/app/build/composables/user/tablePreferences";
127
134
  import { useTableRows } from "../../build/composables/table-view/useTableRows";
128
135
  import { useRefreshTriggers } from "../../build/composables/blocks/useRefreshTriggers";
129
136
  import { useTableLiveHold } from "../../build/composables/table-view/useTableLiveHold";
137
+ import type { TableViewLiveHoldOptions } from "../../types/live";
130
138
  import { usePageLive } from "../../composables/live/usePageLive";
131
139
  import LiveHoldBanner from "../../build/components/table-view/LiveHoldBanner.vue";
132
140
  import { useTableReorder } from "../../build/composables/table-view/useTableReorder";
@@ -169,6 +177,9 @@ interface RealtimePresenceActor {
169
177
 
170
178
  type RealtimePresenceMap = Record<string, RealtimePresenceActor[]>;
171
179
 
180
+ /** A kept field of the table, its state, and its default. */
181
+ type TableStatePreference = [TableField, Ref<unknown>, unknown];
182
+
172
183
  // The backend options of a table view, declared here as well so the
173
184
  // component's runtime props never depend on resolving the imported config.
174
185
  interface TableViewProps<T extends Data> extends TableViewConfig<T> {
@@ -176,6 +187,8 @@ interface TableViewProps<T extends Data> extends TableViewConfig<T> {
176
187
  expandable?: TableViewExpandableConfig;
177
188
  /** Placeholder of the search field. */
178
189
  searchPlaceholder?: string;
190
+ /** Key opening the search (`defineShortcuts` notation); `shift_f` else. */
191
+ searchShortcut?: string;
179
192
  /** One-click dropdown filters of the toolbar. */
180
193
  quickFilters?: TableViewQuickFilter[];
181
194
  /** Rows per page while the user picked none. */
@@ -186,6 +199,8 @@ interface TableViewProps<T extends Data> extends TableViewConfig<T> {
186
199
  source?: TableViewSourceConfig;
187
200
  /** Rows ordered by hand on a number column (backend `reorder`). */
188
201
  reorder?: TableViewReorderConfig;
202
+ /** Rows listed first whatever the sort (backend `pinnedRows`). */
203
+ pinnedRows?: RowActionRule;
189
204
  /** Footer texts and figures: row count, hint, summaries and legend. */
190
205
  footer?: TableViewFooter;
191
206
  /** What the empty body says, per reason it is empty. */
@@ -199,7 +214,7 @@ interface TableViewProps<T extends Data> extends TableViewConfig<T> {
199
214
  /** Topics whose events make the table read its rows again. */
200
215
  realtimeTopic?: string | string[];
201
216
  /** Keeps the rows while the live page is paused (backend `liveHold`). */
202
- liveHold?: boolean;
217
+ liveHold?: boolean | TableViewLiveHoldOptions;
203
218
  }
204
219
 
205
220
  const props = defineProps<TableViewProps<T>>();
@@ -230,14 +245,10 @@ const {
230
245
  defaultDisplay,
231
246
  } = useTableViewConfig<T>(props);
232
247
 
233
- const { getPreference, setPreference } = usePreferences();
248
+ const tablePreferences = useTablePreferences(componentId, pageId);
234
249
  const { sendComponentEvent } = useComponentEvent(componentId);
235
250
  const { processI18n } = useTranslation();
236
251
 
237
- const getTablePreferenceKey = (suffix: string) => {
238
- return `tables.${componentId}.${pageId}.${suffix}`;
239
- };
240
-
241
252
  const kanbanEligibleColumns = allColumns.filter(isEligibleKanbanColumn);
242
253
  const kanbanGroupByOptions = kanbanEligibleColumns.map((column) => ({
243
254
  label: processI18n(column.header),
@@ -260,8 +271,8 @@ const groupedOptions = optionsForDisplay(GROUPED_DISPLAY_ID) as
260
271
 
261
272
  // Stored under the legacy "viewMode" key (free migration of "table"/"kanban");
262
273
  // clamped to the offered set so a stale/removed id never renders an un-offered display.
263
- const persistedDisplay = getPreference<string>(
264
- getTablePreferenceKey("viewMode"),
274
+ const persistedDisplay = tablePreferences.get<string>(
275
+ "viewMode",
265
276
  defaultDisplay,
266
277
  );
267
278
  // A default display that draws its whole interface (no table chrome, hence no
@@ -338,30 +349,24 @@ const activeDisplayComponent = computed<Component | string | undefined>(() => {
338
349
  return STATIC_DISPLAY_COMPONENTS[id];
339
350
  });
340
351
 
341
- const kanbanGroupBy = ref<string>(
342
- getPreference<string>(
343
- getTablePreferenceKey("kanbanGroupBy"),
344
- kanbanOptions?.groupByField ?? "",
345
- ),
346
- );
352
+ const defaultKanbanGroupBy = kanbanOptions?.groupByField ?? "";
353
+ const kanbanGroupBy = ref<string>(defaultKanbanGroupBy);
347
354
  // A saved preference may point to a column that no longer exists or is no
348
355
  // longer eligible after a backend config change.
349
- if (
350
- kanbanOptions &&
351
- !kanbanEligibleColumns.some((c) => c.accessorKey === kanbanGroupBy.value)
352
- ) {
353
- kanbanGroupBy.value = kanbanOptions.groupByField;
354
- }
356
+ tablePreferences.restore(
357
+ "kanbanGroupBy",
358
+ kanbanGroupBy,
359
+ (field) =>
360
+ !kanbanOptions ||
361
+ kanbanEligibleColumns.some((c) => c.accessorKey === field),
362
+ );
355
363
 
356
364
  const DEFAULT_DENSITY = "default";
357
365
  // The module's density is the default; the user's pick in the ⋯ menu is kept
358
366
  // with the rest of the table's state.
359
- const density = ref<"default" | "compact">(
360
- getPreference(
361
- getTablePreferenceKey("density"),
362
- props.density ?? DEFAULT_DENSITY,
363
- ),
364
- );
367
+ const defaultDensity = props.density ?? DEFAULT_DENSITY;
368
+ const density = ref<"default" | "compact">(defaultDensity);
369
+ tablePreferences.restore("density", density);
365
370
 
366
371
  const DEFAULT_PAGE_SIZE = 10;
367
372
  const DEFAULT_PAGINATION: PaginationState = {
@@ -406,8 +411,7 @@ const routeParamHiddenFilters = computed<TableFilter[]>(() => {
406
411
  // A hidden filter's value changed with the URL, in place or through a
407
412
  // remount: the page reached among the previous rows lists from page 1.
408
413
  const paginationState = usePersistedPagination({
409
- preferences: { getPreference, setPreference },
410
- preferenceKey: getTablePreferenceKey,
414
+ preferences: tablePreferences,
411
415
  defaults: DEFAULT_PAGINATION,
412
416
  scope: () => [queryParamHiddenFilters.value, routeParamHiddenFilters.value],
413
417
  });
@@ -449,18 +453,14 @@ const groupedGroupByOptions = groupedOptions
449
453
  value: column.accessorKey,
450
454
  }))
451
455
  : [];
452
- const groupedGroupBy = ref<string>(
453
- getPreference<string>(
454
- getTablePreferenceKey("groupedGroupBy"),
455
- groupedOptions?.groupByField ?? "",
456
- ),
456
+ const defaultGroupedGroupBy = groupedOptions?.groupByField ?? "";
457
+ const groupedGroupBy = ref<string>(defaultGroupedGroupBy);
458
+ tablePreferences.restore(
459
+ "groupedGroupBy",
460
+ groupedGroupBy,
461
+ (field) =>
462
+ !groupedOptions || groupedGroupByOptions.some((o) => o.value === field),
457
463
  );
458
- if (
459
- groupedOptions &&
460
- !groupedGroupByOptions.some((o) => o.value === groupedGroupBy.value)
461
- ) {
462
- groupedGroupBy.value = groupedOptions.groupByField;
463
- }
464
464
  const activeGroupedOptions = computed<TableViewGroupedConfig | undefined>(
465
465
  () =>
466
466
  groupedOptions &&
@@ -487,19 +487,14 @@ const defaultSortKind = tableDefaultSort
487
487
  )
488
488
  : undefined;
489
489
  const sorting = ref<SortingState>(
490
- sanitizeSorting(
491
- getPreference<SortingState>(
492
- getTablePreferenceKey("sorting"),
493
- defaultSortState,
494
- ),
490
+ sortingOrDefault(
491
+ tablePreferences.get<SortingState>("sorting", defaultSortState),
495
492
  declaredSortableIds,
493
+ defaultSortState,
496
494
  ),
497
495
  );
498
496
 
499
- const savedFilters = getPreference<TableFilter[]>(
500
- getTablePreferenceKey("columnFilters"),
501
- [],
502
- );
497
+ const savedFilters = tablePreferences.get<TableFilter[]>("columnFilters", []);
503
498
 
504
499
  const buildPinnedFilters = (): TableFilter[] =>
505
500
  (defaultFilters || []).map((def) => {
@@ -521,26 +516,17 @@ const initialFilters = [...buildPinnedFilters(), ...manualFilters];
521
516
 
522
517
  const columnFilters = ref<TableFilter[]>(initialFilters);
523
518
  const columnVisibility = ref<VisibilityState>(
524
- getPreference<VisibilityState>(
525
- getTablePreferenceKey("columnVisibility"),
526
- initialVisibility,
527
- ),
528
- );
529
- const columnSizing = ref<ColumnSizingState>(
530
- getPreference<ColumnSizingState>(getTablePreferenceKey("columnSizing"), {}),
531
- );
532
- const columnOrder = ref<string[]>(
533
- getPreference<string[]>(getTablePreferenceKey("columnOrder"), []),
519
+ tablePreferences.get<VisibilityState>("columnVisibility", initialVisibility),
534
520
  );
521
+ const columnSizing = ref<ColumnSizingState>({});
522
+ tablePreferences.restore("columnSizing", columnSizing);
523
+ const columnOrder = ref<string[]>([]);
524
+ tablePreferences.restore("columnOrder", columnOrder, Array.isArray);
535
525
  const hasEmptyDefaultFilter = (defaultFilters || []).some(
536
526
  (f) => f.value === undefined || f.value === null || f.value === "",
537
527
  );
538
- const filtersRowOpen = ref<boolean>(
539
- getPreference<boolean>(
540
- getTablePreferenceKey("filtersOpen"),
541
- hasEmptyDefaultFilter,
542
- ),
543
- );
528
+ const filtersRowOpen = ref<boolean>(hasEmptyDefaultFilter);
529
+ tablePreferences.restore("filtersOpen", filtersRowOpen);
544
530
  const globalFilter = shallowRef<string>();
545
531
  const globalFilterDebounced = refDebounced(
546
532
  globalFilter,
@@ -595,7 +581,7 @@ const {
595
581
  density,
596
582
  pagination: paginationState,
597
583
  },
598
- preferenceKey: getTablePreferenceKey,
584
+ preferences: tablePreferences,
599
585
  urlScope,
600
586
  });
601
587
 
@@ -607,8 +593,7 @@ const TAB_URL_KEY = "tab";
607
593
  const urlTab = readTableUrlKey(useDmsRoute().query, urlScope, TAB_URL_KEY);
608
594
  // A tab named by the URL wins over the one kept from the last visit.
609
595
  const activeTabId = ref<string>(
610
- urlTab.value ??
611
- getPreference<string>(getTablePreferenceKey("activeTab"), ALL_TAB_ID),
596
+ urlTab.value ?? tablePreferences.get<string>("activeTab", ALL_TAB_ID),
612
597
  );
613
598
 
614
599
  interface ResolvedTab {
@@ -677,6 +662,8 @@ const tableRows = useTableRows<T>({
677
662
  routeParams: props.routeParams,
678
663
  routeQuery: route.query as Record<string, unknown>,
679
664
  }),
665
+ // Groups keep their rows together: the grouped display pins nothing.
666
+ pinnedRows: () => (isGroupedDisplay.value ? undefined : props.pinnedRows),
680
667
  });
681
668
  // Quick filters: dropdowns of the toolbar over a column's values (a select's
682
669
  // items, a boolean, the rows a relation points to), writing that column's
@@ -822,12 +809,26 @@ const archiveQuery = computed(() =>
822
809
  archiveMode ? { showArchived: showArchived.value } : {},
823
810
  );
824
811
 
812
+ // The row a side panel has open, which the grid highlights.
813
+ const rowSidePanel = useRowSidePanelContent();
814
+ const rowSidePanelOwner = rowSidePanelOwnerKey(pageId!, componentId!);
815
+ const openRowId = computed(() =>
816
+ rowSidePanel.value?.ownerKey === rowSidePanelOwner
817
+ ? rowSidePanel.value.rowKey
818
+ : undefined,
819
+ );
820
+
825
821
  // The gate is set up with the other refresh triggers below; the hold only
826
822
  // reads it once the table is on screen.
823
+ const tableRoot = useTemplateRef<ComponentPublicInstance>("tableRoot");
827
824
  const liveHold = useTableLiveHold({
828
- isEnabled: !!props.liveHold,
825
+ liveHold: props.liveHold,
829
826
  isSource: tableRows.isSource,
830
827
  gate: () => liveGate,
828
+ signals: {
829
+ isRowOpen: () => openRowId.value !== undefined,
830
+ tableElement: () => tableRoot.value?.$el ?? null,
831
+ },
831
832
  });
832
833
 
833
834
  const { data, status, error, refresh } = await useTableListData<T>({
@@ -881,18 +882,27 @@ watch(listLoadError, (message) => {
881
882
  });
882
883
 
883
884
  // Whatever sets the sort (a header, the sort menu, a pasted table config), the
884
- // route only ever receives a sort it accepts. A new order lists from page 1:
885
+ // route only ever receives a sort it accepts, and a cleared sort goes back to
886
+ // the default one rather than being remembered empty. A new order lists from page 1:
885
887
  // the page reached in the previous order shows unrelated rows in the new one.
886
888
  // Synchronous, so the list is queried once, with both changes.
887
889
  watch(
888
890
  sorting,
889
891
  (next, previous) => {
890
- const sanitized = sanitizeSorting(next, sortableIds.value);
892
+ const sanitized = sortingOrDefault(
893
+ next,
894
+ sortableIds.value,
895
+ defaultSortState,
896
+ );
891
897
  if (!isSameSorting(sanitized, next)) {
892
898
  sorting.value = sanitized;
893
899
  return;
894
900
  }
895
- const before = sanitizeSorting(previous, sortableIds.value);
901
+ const before = sortingOrDefault(
902
+ previous,
903
+ sortableIds.value,
904
+ defaultSortState,
905
+ );
896
906
  if (
897
907
  !isSameSorting(sanitized, before) &&
898
908
  paginationState.value.pageIndex !== 0
@@ -1238,6 +1248,7 @@ const {
1238
1248
  handleCustomButton,
1239
1249
  handleCustomRowAction,
1240
1250
  handleBulkCustomAction,
1251
+ handleMissingRow,
1241
1252
  runConfirmedBuiltIn,
1242
1253
  } = useTableRowActions<T>({
1243
1254
  api: $authFetch,
@@ -1668,25 +1679,17 @@ const openRecordFromUrl = async () => {
1668
1679
  const id = deepLinkAction ? readRecordId(route.query, urlScope) : undefined;
1669
1680
  if (!deepLinkAction || !id) return;
1670
1681
  await until(isListLoading).toBe(false);
1671
- const row = await resolveDeepLinkedRow(id, {
1682
+ const { row, isMissing } = await resolveDeepLinkedRow(id, {
1672
1683
  listed: shownResults.value ?? undefined,
1673
1684
  idOf: getRowId,
1674
1685
  getRow: tableRows.getRow,
1675
1686
  });
1676
1687
  if (row) handleCustomRowAction(deepLinkAction, row as Data);
1688
+ else if (isMissing) handleMissingRow(deepLinkAction, id);
1677
1689
  };
1678
1690
 
1679
1691
  const selectedIds = computed(() => selectedRowIds(rowSelect.value));
1680
1692
 
1681
- // The row a side panel has open, which the grid highlights.
1682
- const rowSidePanel = useRowSidePanelContent();
1683
- const rowSidePanelOwner = rowSidePanelOwnerKey(pageId!, componentId!);
1684
- const openRowId = computed(() =>
1685
- rowSidePanel.value?.ownerKey === rowSidePanelOwner
1686
- ? rowSidePanel.value.rowKey
1687
- : undefined,
1688
- );
1689
-
1690
1693
  const isRowActionEnabled = (
1691
1694
  config: boolean | RowActionConfig | undefined,
1692
1695
  item: T,
@@ -1922,24 +1925,25 @@ defineShortcuts(
1922
1925
  }),
1923
1926
  );
1924
1927
 
1925
- const tableStatePreferences = {
1926
- sorting,
1927
- columnFilters: columnFilters,
1928
- columnVisibility,
1929
- filtersOpen: filtersRowOpen,
1930
- activeTab: activeTabId,
1931
- viewMode: activeDisplayId,
1932
- columnOrder,
1933
- kanbanGroupBy,
1934
- groupedGroupBy,
1935
- density,
1936
- } as const;
1937
-
1938
- Object.entries(tableStatePreferences).forEach(([key, ref]) => {
1928
+ // Each kept field with its default: a value back to it is forgotten.
1929
+ const tableStatePreferences: TableStatePreference[] = [
1930
+ ["sorting", sorting, defaultSortState],
1931
+ ["columnFilters", columnFilters, []],
1932
+ ["columnVisibility", columnVisibility, initialVisibility],
1933
+ ["filtersOpen", filtersRowOpen, hasEmptyDefaultFilter],
1934
+ ["activeTab", activeTabId, ALL_TAB_ID],
1935
+ ["viewMode", activeDisplayId, defaultDisplay],
1936
+ ["columnOrder", columnOrder, []],
1937
+ ["kanbanGroupBy", kanbanGroupBy, defaultKanbanGroupBy],
1938
+ ["groupedGroupBy", groupedGroupBy, defaultGroupedGroupBy],
1939
+ ["density", density, defaultDensity],
1940
+ ];
1941
+
1942
+ tableStatePreferences.forEach(([field, state, defaultValue]) => {
1939
1943
  watch(
1940
- ref,
1944
+ state,
1941
1945
  (newValue) => {
1942
- setPreference(getTablePreferenceKey(key), newValue);
1946
+ tablePreferences.set(field, newValue, defaultValue);
1943
1947
  },
1944
1948
  { deep: true },
1945
1949
  );
@@ -1951,7 +1955,7 @@ const COLUMN_SIZING_PERSIST_DEBOUNCE_MS = 500;
1951
1955
  let pendingColumnSizingWrite = false;
1952
1956
 
1953
1957
  const persistColumnSizing = () => {
1954
- setPreference(getTablePreferenceKey("columnSizing"), columnSizing.value);
1958
+ tablePreferences.set("columnSizing", columnSizing.value, {});
1955
1959
  pendingColumnSizingWrite = false;
1956
1960
  };
1957
1961
 
@@ -2030,6 +2034,7 @@ onMounted(() => {
2030
2034
 
2031
2035
  <template>
2032
2036
  <UTable
2037
+ ref="tableRoot"
2033
2038
  v-bind="tableProps"
2034
2039
  v-model:pagination="paginationState"
2035
2040
  v-model:global-filter="globalFilter"
@@ -2053,6 +2058,7 @@ onMounted(() => {
2053
2058
  :reorder="reorderState"
2054
2059
  :views-placement="viewsLayout === 'menu' ? 'header' : 'band'"
2055
2060
  :search-placeholder="props.searchPlaceholder"
2061
+ :search-shortcut="props.searchShortcut"
2056
2062
  :quick-filters="resolvedQuickFilters"
2057
2063
  :footer="resolvedFooter"
2058
2064
  :empty-states="props.emptyStates"
@@ -22,6 +22,7 @@ import type {
22
22
  ValuePrecision,
23
23
  } from "../../composables/chart/types";
24
24
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
25
+ import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
25
26
 
26
27
  interface Props extends DefaultComponentProps {
27
28
  title: string;
@@ -32,6 +33,8 @@ interface Props extends DefaultComponentProps {
32
33
  valueFormat?: ValueFormat;
33
34
  currencyCode?: string;
34
35
  valuePrecision?: ValuePrecision;
36
+ /** Unit after each formatted value, space included (" ms"); `$` key or literal. */
37
+ valueSuffix?: string;
35
38
  showRank?: boolean;
36
39
  highlightTopN?: number;
37
40
  rankColor?: UiColor;
@@ -100,6 +103,7 @@ const props = withDefaults(defineProps<Props>(), {
100
103
 
101
104
  const { locale, t } = useI18n();
102
105
  const { processI18n } = useTranslation();
106
+ const { processText } = useComposedText();
103
107
  useComponentEvent(props.componentId);
104
108
  const { state: watchState } = useWatch(
105
109
  props.watchActions || [],
@@ -167,14 +171,20 @@ function barClass(index: number): string {
167
171
  return `${rankFillClass.value} ${QUIET_BAR_OPACITY_CLASS}`;
168
172
  }
169
173
 
174
+ const valueSuffixText = computed(() =>
175
+ props.valueSuffix ? processI18n(props.valueSuffix) : "",
176
+ );
177
+
170
178
  function formattedValue(item: TopListItem): string {
171
- return formatValue(
179
+ if (item.valueLabel) return processText(item.valueLabel);
180
+ const formatted = formatValue(
172
181
  item.value,
173
182
  props.valueFormat,
174
183
  locale.value,
175
184
  props.currencyCode,
176
185
  props.valuePrecision,
177
186
  );
187
+ return `${formatted}${valueSuffixText.value}`;
178
188
  }
179
189
 
180
190
  function sparklineAccentFor(item: TopListItem): string {
@@ -106,6 +106,8 @@ export interface TopListItem {
106
106
  title: BlockText;
107
107
  description?: BlockText;
108
108
  value: number;
109
+ /** The value as written, in place of the formatted one. */
110
+ valueLabel?: BlockText;
109
111
  delta?: number | null;
110
112
  sparkline?: number[];
111
113
  icon?: string;
@@ -16,6 +16,7 @@ import {
16
16
  resolveUrlVariables,
17
17
  } from "../../build/utils/urlVariables";
18
18
  import { useRefreshTriggers } from "../../build/composables/blocks/useRefreshTriggers";
19
+ import { readFetchNotFound } from "../../build/utils/fetchNotFound";
19
20
 
20
21
  export interface UseChartFetchOptions<T> {
21
22
  /**
@@ -41,6 +42,10 @@ export interface UseChartFetchReturn<T> {
41
42
  * (period, watched state, realtime event) until the new answer lands.
42
43
  */
43
44
  isLoading: Ref<boolean>;
45
+ /**
46
+ * Why the last request failed. A 404 also clears `data`: read it with
47
+ * `readFetchNotFound` to draw a not-found state rather than a load error.
48
+ */
44
49
  error: Ref<unknown>;
45
50
  refresh: () => Promise<void>;
46
51
  }
@@ -111,7 +116,12 @@ async function runFetch<T>(
111
116
  data.value = response;
112
117
  } catch (err) {
113
118
  if (runner.activeAbort !== controller) return;
114
- if ((err as { name?: string })?.name !== "AbortError") error.value = err;
119
+ if ((err as { name?: string })?.name === "AbortError") return;
120
+ error.value = err;
121
+ // A 404 says what the block showed is gone (another tab deleted it, the
122
+ // URL now names another record): its last values must not stay on screen
123
+ // as if they still belonged to it. Any other failure keeps them.
124
+ if (readFetchNotFound(err)) data.value = null;
115
125
  } finally {
116
126
  // A request a newer one superseded leaves the loading flag to that one:
117
127
  // clearing it here would show the empty state while the newer answer is
@@ -24,7 +24,14 @@ export type ConfirmValues = Record<string, unknown>;
24
24
  * whose texts are already resolved, plus what only a caller in the browser
25
25
  * can give (render functions, handlers).
26
26
  */
27
- export interface ConfirmOptions extends Omit<ConfirmDialog, "params" | "icon"> {
27
+ export interface ConfirmOptions
28
+ extends Omit<ConfirmDialog, "params" | "icon" | "secondaryAction"> {
29
+ /**
30
+ * A button next to cancel and confirm that leaves the dialog open (a
31
+ * download before removing): its `run` is awaited, the button loading,
32
+ * and a rejection is shown in the dialog's alert.
33
+ */
34
+ secondaryAction?: ConfirmSecondaryAction;
28
35
  /**
29
36
  * The button focused on open (`"cancel"` for a dialog whose safe answer
30
37
  * is to stay); left out, the dialog's first focusable element.
@@ -63,6 +70,13 @@ export interface ConfirmOptions extends Omit<ConfirmDialog, "params" | "icon"> {
63
70
  ) => Promise<void | boolean | ConfirmPartialOutcome>;
64
71
  }
65
72
 
73
+ /** A secondary button of the confirm modal: its text resolved, its handler. */
74
+ export interface ConfirmSecondaryAction {
75
+ label: string;
76
+ icon?: string;
77
+ run: () => Promise<void> | void;
78
+ }
79
+
66
80
  /** A summary shown in the confirm modal's alert: a title and its detail. */
67
81
  export interface ConfirmNotice {
68
82
  title: string;