@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
@@ -152,3 +152,58 @@ export function periodDuration(range: PeriodRange): PeriodDuration {
152
152
  }
153
153
  return { unit: "days", count: Math.max(Math.round(span / MS_PER_DAY), 1) };
154
154
  }
155
+
156
+ const DAYS_PER_WEEK = 7;
157
+ const YESTERDAY_OFFSET = -1;
158
+ const WEEKDAY_FORMAT: FormatOptions = { weekday: "short" };
159
+
160
+ function localDayStart(date: Date): number {
161
+ return new Date(
162
+ date.getFullYear(),
163
+ date.getMonth(),
164
+ date.getDate(),
165
+ ).getTime();
166
+ }
167
+
168
+ function daysBefore(date: Date, now: Date): number {
169
+ return Math.round((localDayStart(now) - localDayStart(date)) / MS_PER_DAY);
170
+ }
171
+
172
+ function formatWith(date: Date, locale: string, options: FormatOptions) {
173
+ const formatter = regionalDateTimeFormat(locale, options, {
174
+ keepLocalZone: true,
175
+ });
176
+ return normalizeSpaces(formatter.format(date));
177
+ }
178
+
179
+ function relativeDayPrefix(date: Date, now: Date, locale: string): string {
180
+ const days = daysBefore(date, now);
181
+ if (days <= 0) return "";
182
+ if (days === 1) {
183
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
184
+ return `${format.format(YESTERDAY_OFFSET, "day")} `;
185
+ }
186
+ if (days < DAYS_PER_WEEK) {
187
+ return `${formatWith(date, locale, WEEKDAY_FORMAT)} `;
188
+ }
189
+ const sameYear = date.getFullYear() === now.getFullYear();
190
+ const day: FormatOptions = sameYear
191
+ ? DAY_FORMAT
192
+ : { ...DAY_FORMAT, year: "numeric" };
193
+ return `${formatWith(date, locale, day)}, `;
194
+ }
195
+
196
+ /**
197
+ * An instant as seen from `now`, in the reader's locale: the time alone
198
+ * today ("11:52"), then "yesterday 11:52", the weekday within a week
199
+ * ("Mon 11:52"), and the date beyond ("Oct 4, 11:52", with its year when
200
+ * not this one's). The start of a window labelled relative to now.
201
+ */
202
+ export function formatRelativeInstant(
203
+ date: Date,
204
+ now: Date,
205
+ locale: string,
206
+ ): string {
207
+ const time = formatWith(date, locale, TIME_FORMAT);
208
+ return `${relativeDayPrefix(date, now, locale)}${time}`;
209
+ }
@@ -48,6 +48,11 @@ export interface PeriodQueryKeys {
48
48
  export interface PeriodSource {
49
49
  query: PeriodQuery;
50
50
  keys: PeriodQueryKeys;
51
+ /**
52
+ * Whether a range it names is taken even when `custom` is not offered: a
53
+ * link framing a window (an incident's) opens on it, no preset selected.
54
+ */
55
+ isRangeAlwaysAccepted?: boolean;
51
56
  }
52
57
 
53
58
  const CUSTOM_PRESET: PeriodPreset = "custom";
@@ -160,7 +165,9 @@ function readRange(
160
165
 
161
166
  /**
162
167
  * The range a source names, when the selector offers it: a custom range
163
- * (`from` and `to`) first, then a preset (`period`).
168
+ * (`from` and `to`) first, then a preset (`period`). A source whose ranges
169
+ * are always accepted gives its custom range even when `custom` is not
170
+ * offered.
164
171
  */
165
172
  export function readRangeChoice(
166
173
  source: PeriodSource,
@@ -168,7 +175,9 @@ export function readRangeChoice(
168
175
  ): PeriodRangeChoice | null {
169
176
  const { query, keys } = source;
170
177
  const customRange = readRange(query, keys.from, keys.to);
171
- if (customRange && choices.presets.includes(CUSTOM_PRESET)) {
178
+ const isCustomAccepted =
179
+ source.isRangeAlwaysAccepted || choices.presets.includes(CUSTOM_PRESET);
180
+ if (customRange && isCustomAccepted) {
172
181
  return { preset: CUSTOM_PRESET, customRange };
173
182
  }
174
183
  const preset = choices.presets.find(
@@ -307,9 +316,36 @@ export type RememberedPeriods = Record<string, Record<string, string>>;
307
316
  /** How many selectors' last choices the browser keeps. */
308
317
  export const REMEMBERED_PERIODS_LIMIT = 20;
309
318
 
310
- /** The entry of selector `id` of page `pageId` among the last choices. */
311
- export function rememberedPeriodKey(pageId: string, id: string): string {
312
- return `${pageId}#${id}`;
319
+ /** What a remembered choice is kept under: a page's selector, or a shared key. */
320
+ export interface RememberedPeriodTarget {
321
+ pageId: string;
322
+ id: string;
323
+ /** Shared by every selector carrying it, on any page. */
324
+ rememberKey?: string;
325
+ }
326
+
327
+ // A page key always holds `#`, so a shared key cannot collide with one.
328
+ const SHARED_KEY_PREFIX = "@";
329
+
330
+ /**
331
+ * The entry of a selector among the last choices: its `rememberKey`, shared
332
+ * by every selector carrying it, else selector `id` of page `pageId`.
333
+ */
334
+ export function rememberedPeriodKey(target: RememberedPeriodTarget): string {
335
+ if (target.rememberKey) return `${SHARED_KEY_PREFIX}${target.rememberKey}`;
336
+ return `${target.pageId}#${target.id}`;
337
+ }
338
+
339
+ /**
340
+ * Whether the selector offers `selection`'s range: a range only a link could
341
+ * bring (a custom one without `custom` offered) is not remembered, so the
342
+ * last choice made in the menu stays.
343
+ */
344
+ export function isRangeOffered(
345
+ selection: PeriodRangeChoice,
346
+ choices: PeriodChoices,
347
+ ): boolean {
348
+ return choices.presets.includes(selection.preset);
313
349
  }
314
350
 
315
351
  /**
@@ -11,12 +11,23 @@ import { calendarToRange, rangeToCalendar } from "./periodDisplay";
11
11
 
12
12
  const CUSTOM_PRESET: PeriodPreset = "custom";
13
13
 
14
+ /**
15
+ * Where a custom range starts when nothing on screen has bounds (every
16
+ * period): today.
17
+ */
18
+ export function startingRange(now: Date): PeriodRange {
19
+ return calendarToRange(rangeToCalendar({ from: now, to: now }));
20
+ }
21
+
14
22
  export interface UsePeriodDraftReturn {
15
23
  preset: Ref<PeriodPreset>;
16
24
  comparison: Ref<PeriodComparison>;
17
25
  customRange: Ref<PeriodRange | null>;
18
- /** The range the staged preset (or custom range) resolves to. */
19
- range: ComputedRef<PeriodRange>;
26
+ /**
27
+ * The range the staged preset (or custom range) resolves to; `null` for
28
+ * every period (`all`).
29
+ */
30
+ range: ComputedRef<PeriodRange | null>;
20
31
  compareRange: ComputedRef<PeriodRange | null>;
21
32
  isDirty: ComputedRef<boolean>;
22
33
  /** Start over from the committed period (on open, on cancel). */
@@ -59,13 +70,14 @@ export function usePeriodDraft(period: UsePeriodReturn): UsePeriodDraftReturn {
59
70
  customRange.value = committedCustomRange();
60
71
  }
61
72
 
62
- const range = computed<PeriodRange>(() =>
63
- resolvePresetRange(
73
+ const range = computed<PeriodRange | null>(() => {
74
+ const now = new Date();
75
+ return resolvePresetRange(
64
76
  preset.value,
65
- new Date(),
66
- customRange.value ?? period.state.value.range,
67
- ),
68
- );
77
+ now,
78
+ customRange.value ?? period.state.value.range ?? startingRange(now),
79
+ );
80
+ });
69
81
 
70
82
  const compareRange = computed<PeriodRange | null>(() =>
71
83
  resolveComparisonRange(
@@ -86,7 +98,8 @@ export function usePeriodDraft(period: UsePeriodReturn): UsePeriodDraftReturn {
86
98
  function selectPreset(next: PeriodPreset): void {
87
99
  if (next === CUSTOM_PRESET && !customRange.value) {
88
100
  // Custom starts from the days on screen, ready to be adjusted.
89
- customRange.value = calendarToRange(rangeToCalendar(range.value));
101
+ const shown = range.value ?? startingRange(new Date());
102
+ customRange.value = calendarToRange(rangeToCalendar(shown));
90
103
  }
91
104
  preset.value = next;
92
105
  }
@@ -5,9 +5,11 @@ import {
5
5
  isPeriodQueryCurrent,
6
6
  mergePeriodSearch,
7
7
  periodQueryKeys,
8
+ isRangeOffered,
8
9
  rememberedPeriodKey,
9
10
  rememberPeriod,
10
11
  type RememberedPeriods,
12
+ type RememberedPeriodTarget,
11
13
  type PeriodQuery,
12
14
  type PeriodQueryKeys,
13
15
  type PeriodSelection,
@@ -21,6 +23,8 @@ export interface PeriodLocationOptions {
21
23
  id: string;
22
24
  /** The page it is on: its last choice is kept per page and per id. */
23
25
  pageId: string;
26
+ /** Shares the last choice with every selector carrying it, on any page. */
27
+ rememberKey?: string;
24
28
  /** What the period falls back to when no source names a valid one. */
25
29
  fallback: PeriodSelection;
26
30
  }
@@ -87,9 +91,9 @@ function replacePeriodInUrl(
87
91
  }
88
92
 
89
93
  /** The user's last choice of one selector, kept in the preferences. */
90
- function useRememberedPeriod(pageId: string, id: string) {
94
+ function useRememberedPeriod(target: RememberedPeriodTarget) {
91
95
  const { getPreference, setPreference } = usePreferences();
92
- const key = rememberedPeriodKey(pageId, id);
96
+ const key = rememberedPeriodKey(target);
93
97
  const all = () => getPreference<RememberedPeriods>(PERIOD_PREFERENCE_KEY, {});
94
98
  return {
95
99
  source: (): PeriodSource => ({
@@ -127,8 +131,10 @@ function usePeriodUrlWriter(keys: PeriodQueryKeys) {
127
131
 
128
132
  /**
129
133
  * Keeps a selector's period in the page URL (`?<id>.period=`, see
130
- * `periodQuery`) and its last choice in the user's preferences. The period
131
- * starts from the URL, then from that last choice, then from `fallback`. Both
134
+ * `periodQuery`) and its last choice in the user's preferences, per page or
135
+ * under its `rememberKey`. The period starts from the URL, then from that
136
+ * last choice, then from `fallback`. A URL range is taken even when `custom`
137
+ * is not offered, and is then not remembered. Both
132
138
  * are read in setup, on the server too: the preferences are a cookie, so the
133
139
  * server renders the period the browser will show.
134
140
  *
@@ -145,7 +151,7 @@ export function usePeriodLocation(
145
151
  ): void {
146
152
  const route = useDmsRoute();
147
153
  const urlKeys = periodQueryKeys(options.id);
148
- const remembered = useRememberedPeriod(options.pageId, options.id);
154
+ const remembered = useRememberedPeriod(options);
149
155
  const writer = usePeriodUrlWriter(urlKeys);
150
156
  const choices = {
151
157
  presets: period.presets.value,
@@ -154,6 +160,7 @@ export function usePeriodLocation(
154
160
  const urlSource = (): PeriodSource => ({
155
161
  query: route.query as PeriodQuery,
156
162
  keys: urlKeys,
163
+ isRangeAlwaysAccepted: true,
157
164
  });
158
165
 
159
166
  applySelection(
@@ -170,7 +177,7 @@ export function usePeriodLocation(
170
177
  () => selectionText(readSelection(period)),
171
178
  () => {
172
179
  const selection = readSelection(period);
173
- remembered.remember(selection);
180
+ if (isRangeOffered(selection, choices)) remembered.remember(selection);
174
181
  writer.write(selection);
175
182
  },
176
183
  );
@@ -109,6 +109,7 @@ export const useTable = <T extends Data>(props: UseTableProps<T>) => {
109
109
  showArchived: props.showArchived,
110
110
  columnMenus: props.columnMenus,
111
111
  defaultSort: props.tableProps.defaultSort,
112
+ labelKey: props.tableProps.labelKey,
112
113
  });
113
114
 
114
115
  // A saved order (the user's, a view's) is kept, completed with the
@@ -41,6 +41,10 @@ import {
41
41
  import type { DmsAppConfig } from "#dms-core/shared/types/app-config";
42
42
  import { useColumnValueRenderer } from "../data-types/useColumnValueRenderer";
43
43
  import { useActionConfirm } from "../confirm/useActionConfirm";
44
+ import {
45
+ resolveConfirmText,
46
+ type ConfirmTranslate,
47
+ } from "../confirm/confirmDialogTexts";
44
48
  import {
45
49
  type DefaultSortConfig,
46
50
  type HeaderSortCue,
@@ -160,6 +164,8 @@ interface ColumnConfig<T> {
160
164
  columnMenus?: boolean;
161
165
  /** The table's default sort: its column shows a neutral arrow. */
162
166
  defaultSort?: DefaultSortConfig;
167
+ /** Field naming a row: the default name of its ⋯ menu button. */
168
+ labelKey?: string;
163
169
  }
164
170
 
165
171
  interface ExtendedColumnMeta<T> extends ColumnMeta<T, unknown> {
@@ -406,6 +412,7 @@ export const useTableColumns = <T extends Data>(config: ColumnConfig<T>) => {
406
412
  const renderActionsDropdown = (
407
413
  items: DropdownMenuItem[] | DropdownMenuItem[][],
408
414
  rowId: string,
415
+ label: string,
409
416
  ) =>
410
417
  h(
411
418
  DropdownMenu as Component,
@@ -418,6 +425,7 @@ export const useTableColumns = <T extends Data>(config: ColumnConfig<T>) => {
418
425
  default: () =>
419
426
  h(Button, {
420
427
  id: `row-actions-${rowId}`,
428
+ "aria-label": label,
421
429
  icon: appConfig.ui.icons.ellipsis,
422
430
  color: Color.neutral,
423
431
  variant: ButtonVariant.ghost,
@@ -521,7 +529,9 @@ export const useTableColumns = <T extends Data>(config: ColumnConfig<T>) => {
521
529
  children.unshift(renderRestoreButton(restoreDescriptor, row.id));
522
530
  }
523
531
  if (dropdownItems.length > 0) {
524
- children.push(renderActionsDropdown(dropdownItems, row.id));
532
+ children.push(
533
+ renderActionsDropdown(dropdownItems, row.id, menuLabel(row.original)),
534
+ );
525
535
  }
526
536
 
527
537
  return h(
@@ -580,6 +590,21 @@ export const useTableColumns = <T extends Data>(config: ColumnConfig<T>) => {
580
590
 
581
591
  const { processI18n } = useTranslation();
582
592
 
593
+ // A row's own text, resolved as a confirmation's texts are: a `$` key
594
+ // translated with the row's fields as parameters, or a literal whose
595
+ // `{field}` braces they fill.
596
+ const rowText = (text: string, rowData: T): string =>
597
+ resolveConfirmText(text, rowData, t as ConfirmTranslate);
598
+
599
+ const menuLabel = (rowData: T): string => {
600
+ const declared = config.rowActions?.menuLabel;
601
+ if (declared) return rowText(declared, rowData);
602
+ const label = config.labelKey ? get(rowData, config.labelKey) : undefined;
603
+ return typeof label === "string" || typeof label === "number"
604
+ ? t("dms.table.row_actions_for", { label })
605
+ : t("dms.table.row_actions");
606
+ };
607
+
583
608
  const isCustomActionDisabled = (
584
609
  action: CustomRowAction,
585
610
  rowData: T,
@@ -638,7 +663,7 @@ export const useTableColumns = <T extends Data>(config: ColumnConfig<T>) => {
638
663
 
639
664
  const buildCustomActionDescriptors = (rowData: T): RowActionDescriptor[] =>
640
665
  (config.rowActions?.custom || []).map((action) => ({
641
- label: processI18n(action.label),
666
+ label: rowText(action.label, rowData),
642
667
  // A labelled button may go without an icon.
643
668
  icon: action.icon || (action.showLabel ? "" : "i-ph-lightning"),
644
669
  onSelect: () => config.onCustomRowAction?.(action, rowData),
@@ -79,10 +79,25 @@ export const clearTableFiltersLabelKey = (
79
79
  ? "dms.table.clear_search"
80
80
  : "dms.table.clear_filters";
81
81
 
82
- export const clearTableFilters = (state: TableNarrowingState): void => {
83
- state.columnFilters.value = resetColumnFilters(state.columnFilters.value);
84
- state.globalFilter.value = "";
82
+ const backToFirstPage = (state: TableNarrowingState): void => {
85
83
  if (state.pagination && state.pagination.value.pageIndex !== 0) {
86
84
  state.pagination.value = { ...state.pagination.value, pageIndex: 0 };
87
85
  }
88
86
  };
87
+
88
+ /** Empties the toolbar search alone, back to the first page. */
89
+ export const clearTableSearch = (state: TableNarrowingState): void => {
90
+ state.globalFilter.value = "";
91
+ backToFirstPage(state);
92
+ };
93
+
94
+ /** Clears the filter chips alone (pinned ones reset), back to the first page. */
95
+ export const clearTableColumnFilters = (state: TableNarrowingState): void => {
96
+ state.columnFilters.value = resetColumnFilters(state.columnFilters.value);
97
+ backToFirstPage(state);
98
+ };
99
+
100
+ export const clearTableFilters = (state: TableNarrowingState): void => {
101
+ state.columnFilters.value = resetColumnFilters(state.columnFilters.value);
102
+ clearTableSearch(state);
103
+ };
@@ -1,21 +1,13 @@
1
1
  import type { PaginationState } from "@tanstack/vue-table";
2
2
  import { computed, ref, watch, type Ref } from "vue";
3
+ import { hashText } from "#dms-core/app/build/composables/user/tablePreferences";
4
+ import type { TablePreferences } from "./useTablePreferences";
3
5
 
4
6
  const FIRST_PAGE_INDEX = 0;
5
- const PAGINATION_PREFERENCE = "pagination";
6
- // The hidden filters the stored page was reached under, next to it.
7
- const PAGINATION_SCOPE_PREFERENCE = "paginationScope";
8
-
9
- /** The preference store the page is kept in (`usePreferences`). */
10
- export interface PaginationPreferences {
11
- getPreference: <V>(keyPath: string, defaultValue?: V) => V;
12
- setPreference: (keyPath: string, value: unknown) => void;
13
- }
14
7
 
15
8
  export interface PersistedPaginationOptions {
16
- preferences: PaginationPreferences;
17
- /** The table's preference key of a setting. */
18
- preferenceKey: (suffix: string) => string;
9
+ /** The table's preferences (`useTablePreferences`). */
10
+ preferences: TablePreferences;
19
11
  defaults: PaginationState;
20
12
  /**
21
13
  * What lists other rows without the user picking it: the hidden filters
@@ -30,8 +22,8 @@ const firstPage = (pagination: PaginationState): PaginationState => ({
30
22
  });
31
23
 
32
24
  /**
33
- * The table's page, kept in the user's preferences with the hidden filters
34
- * it was reached under. A page restored under other hidden filters (a link
25
+ * The table's page, kept in the user's preferences with a hash of the hidden
26
+ * filters it was reached under (only past the first page, where it matters). A page restored under other hidden filters (a link
35
27
  * that remounted the page with another `?status=`) starts at the first
36
28
  * page, as does a change of their value while the table is shown; compared
37
29
  * by value, so another query parameter changing keeps the page.
@@ -39,15 +31,14 @@ const firstPage = (pagination: PaginationState): PaginationState => ({
39
31
  export function usePersistedPagination(
40
32
  options: PersistedPaginationOptions,
41
33
  ): Ref<PaginationState> {
42
- const { preferences, preferenceKey } = options;
43
- const scopeKey = computed(() => JSON.stringify(options.scope() ?? null));
44
- const stored = preferences.getPreference<PaginationState>(
45
- preferenceKey(PAGINATION_PREFERENCE),
46
- options.defaults,
34
+ const { preferences, defaults } = options;
35
+ const scopeKey = computed(() =>
36
+ hashText(JSON.stringify(options.scope() ?? null)),
47
37
  );
48
- // A page stored before its scope was: kept, as it always was.
49
- const storedScope = preferences.getPreference<string>(
50
- preferenceKey(PAGINATION_SCOPE_PREFERENCE),
38
+ const stored = preferences.get<PaginationState>("pagination", defaults);
39
+ // A page kept without its scope (the first one): kept as it is.
40
+ const storedScope = preferences.get<string>(
41
+ "paginationScope",
51
42
  scopeKey.value,
52
43
  );
53
44
  const pagination = ref<PaginationState>(
@@ -66,10 +57,12 @@ export function usePersistedPagination(
66
57
  watch(
67
58
  [pagination, scopeKey],
68
59
  ([value, scope]) => {
69
- preferences.setPreference(preferenceKey(PAGINATION_PREFERENCE), value);
70
- preferences.setPreference(
71
- preferenceKey(PAGINATION_SCOPE_PREFERENCE),
72
- scope,
60
+ preferences.set("pagination", value, defaults);
61
+ const isFirstPage = value.pageIndex === FIRST_PAGE_INDEX;
62
+ preferences.set<string | undefined>(
63
+ "paginationScope",
64
+ isFirstPage ? undefined : scope,
65
+ undefined,
73
66
  );
74
67
  },
75
68
  { deep: true },
@@ -0,0 +1,66 @@
1
+ import { onBeforeUnmount, onMounted, ref, type Ref } from "vue";
2
+
3
+ const FIRST_ROW_SELECTOR = "tbody tr";
4
+ const SCROLLABLE_OVERFLOW = /(auto|scroll)/;
5
+
6
+ function scrollParent(element: HTMLElement): HTMLElement | null {
7
+ for (let node = element.parentElement; node; node = node.parentElement) {
8
+ const { overflowY } = getComputedStyle(node);
9
+ if (
10
+ SCROLLABLE_OVERFLOW.test(overflowY) &&
11
+ node.scrollHeight > node.clientHeight
12
+ ) {
13
+ return node;
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+
19
+ /**
20
+ * Whether the first row of the table in `element` is out of view above the
21
+ * area it scrolls in (its scrolling parent, else the window).
22
+ *
23
+ * @internal
24
+ */
25
+ export function isFirstRowScrolledPast(element: HTMLElement): boolean {
26
+ const row = element.querySelector(FIRST_ROW_SELECTOR) ?? element;
27
+ const scroller = scrollParent(element);
28
+ const viewTop = scroller ? scroller.getBoundingClientRect().top : 0;
29
+ return row.getBoundingClientRect().bottom <= viewTop;
30
+ }
31
+
32
+ /**
33
+ * Follows whether the user scrolled the table's first row out of view, on
34
+ * any scroll of the page (read once per frame). Inert when `isEnabled` is
35
+ * false, and on the server.
36
+ *
37
+ * @internal
38
+ */
39
+ export function useScrolledPastTop(
40
+ element: () => HTMLElement | null,
41
+ isEnabled: boolean,
42
+ ): Ref<boolean> {
43
+ const isScrolled = ref(false);
44
+ if (!isEnabled || typeof window === "undefined") return isScrolled;
45
+ let frame: number | null = null;
46
+ const measure = () => {
47
+ frame = null;
48
+ const target = element();
49
+ isScrolled.value = !!target && isFirstRowScrolledPast(target);
50
+ };
51
+ const onScroll = () => {
52
+ frame ??= requestAnimationFrame(measure);
53
+ };
54
+ onMounted(() => {
55
+ window.addEventListener("scroll", onScroll, {
56
+ capture: true,
57
+ passive: true,
58
+ });
59
+ measure();
60
+ });
61
+ onBeforeUnmount(() => {
62
+ window.removeEventListener("scroll", onScroll, { capture: true });
63
+ if (frame !== null) cancelAnimationFrame(frame);
64
+ });
65
+ return isScrolled;
66
+ }
@@ -83,6 +83,11 @@ export function useTableListData<T extends Record<string, unknown>>(
83
83
  /**
84
84
  * The counters of a table view's tabs and views, keyed by id; `null` until
85
85
  * they arrive. Keyed by source URL like the list (see useTableListData).
86
+ *
87
+ * A source waiting on its period scope answers `null` too, like its list:
88
+ * the scope never registers during the server render, so the server and the
89
+ * browser (whose key carries the period, and has no payload yet) both draw
90
+ * the counters' skeleton, and hydration matches.
86
91
  */
87
92
  export function useTableCountsData<T extends Record<string, unknown>>(
88
93
  options: TableCountsDataOptions<T>,
@@ -90,9 +95,10 @@ export function useTableCountsData<T extends Record<string, unknown>>(
90
95
  const { rows, queries, archiveQuery } = options;
91
96
  const heldQuery = options.heldQuery ?? NO_HELD_QUERY;
92
97
  const sourceSuffix = rows.sourceUrl.value ? `-${rows.sourceUrl.value}` : "";
93
- return useServerRenderedAsyncData<Record<string, number>>(
98
+ return useServerRenderedAsyncData<Record<string, number> | null>(
94
99
  `table-view-${options.componentId}-${options.pageId}-tab-counts${sourceSuffix}`,
95
100
  async () => {
101
+ if (rows.isPeriodPending.value) return null;
96
102
  if (queries.value.length === 0) return {};
97
103
  return await rows.countBatch(
98
104
  queries.value.map(({ id, query }) => ({
@@ -1,13 +1,23 @@
1
1
  import { computed, ref, watch, type ComputedRef } from "vue";
2
2
  import { usePageLive } from "../../../composables/live/usePageLive";
3
+ import type { TableViewLiveHoldOptions } from "../../../types/live";
3
4
  import type { LiveGate } from "../live/currentPageLive";
5
+ import { useScrolledPastTop } from "./useScrolledPastTop";
4
6
  import { LIVE_HELD_AT_QUERY_KEY } from "./useTableRows";
5
7
 
6
8
  type Query = Record<string, unknown>;
7
9
 
10
+ /** What the table tells its hold about the user's reading. */
11
+ export interface TableLiveHoldSignals {
12
+ /** One of its rows is open in the side panel. */
13
+ isRowOpen?: () => boolean;
14
+ /** The table's element, whose first row `whileScrolled` follows. */
15
+ tableElement?: () => unknown;
16
+ }
17
+
8
18
  export interface TableLiveHoldOptions {
9
- /** The table declares `liveHold`. */
10
- isEnabled: boolean;
19
+ /** The table's `liveHold`: `true` or an object enables it. */
20
+ liveHold: boolean | TableViewLiveHoldOptions | undefined;
11
21
  /** A `TableView.fromSource` table: its route receives the hold's bound. */
12
22
  isSource: boolean;
13
23
  /**
@@ -15,35 +25,71 @@ export interface TableLiveHoldOptions {
15
25
  * sets it up after the list this hold bounds.
16
26
  */
17
27
  gate: () => LiveGate;
28
+ signals?: TableLiveHoldSignals;
18
29
  }
19
30
 
20
31
  export interface TableLiveHold {
21
- /** The rows are held: the table declares `liveHold` and the page is paused. */
32
+ /** The rows are held: the page is paused, or the table holds by itself. */
22
33
  isHolding: ComputedRef<boolean>;
23
34
  /** Events the table received since its rows were held or last shown. */
24
35
  newCount: ComputedRef<number>;
25
- /** Reads the rows once, staying paused, and holds them from now. */
36
+ /** Reads the rows once, staying held, and holds them from now. */
26
37
  showNew: () => void;
27
38
  /** The bound a source route receives with each request while held. */
28
39
  heldQuery: () => Query;
29
40
  }
30
41
 
31
42
  const NO_QUERY: Query = {};
43
+ const NEVER = () => false;
44
+ const NO_ELEMENT = () => null;
45
+
46
+ function holdOptionsOf(
47
+ liveHold: TableLiveHoldOptions["liveHold"],
48
+ ): TableViewLiveHoldOptions | null {
49
+ if (!liveHold) return null;
50
+ return liveHold === true ? {} : liveHold;
51
+ }
52
+
53
+ // The table alone holds while the page is live: the user reads a row in the
54
+ // side panel, or reads further down than the rows that would move.
55
+ function useLocalHold(
56
+ holdOptions: TableViewLiveHoldOptions | null,
57
+ signals: TableLiveHoldSignals,
58
+ ): ComputedRef<boolean> {
59
+ const isRowOpen = signals.isRowOpen ?? NEVER;
60
+ const tableElement = signals.tableElement ?? NO_ELEMENT;
61
+ const isScrolled = useScrolledPastTop(() => {
62
+ const element = tableElement();
63
+ return element instanceof HTMLElement ? element : null;
64
+ }, !!holdOptions?.whileScrolled);
65
+ return computed(
66
+ () =>
67
+ (!!holdOptions?.whileRowOpen && isRowOpen()) ||
68
+ (!!holdOptions?.whileScrolled && isScrolled.value),
69
+ );
70
+ }
32
71
 
33
72
  /**
34
- * The `liveHold` of a table view: while its live page is paused, the table
35
- * keeps its rows — the gate already requests nothing on its events — and a
36
- * source route is told the moment they were frozen, so the user's own search,
37
- * sort and pagination list the rows as they stood. Show reads them once more
38
- * without leaving the pause.
73
+ * The `liveHold` of a table view: while its live page is paused — or, with
74
+ * `whileRowOpen` / `whileScrolled`, while the user reads a row or scrolled
75
+ * down — the table keeps its rows: the gate requests nothing on its events
76
+ * and counts them, and a source route is told the moment they were frozen, so
77
+ * the user's own search, sort and pagination list the rows as they stood.
78
+ * Show reads them once more without leaving the hold; the end of a hold of
79
+ * the table's own reads them if it missed any.
39
80
  *
40
81
  * @internal
41
82
  */
42
83
  export function useTableLiveHold(options: TableLiveHoldOptions): TableLiveHold {
43
84
  const { isEnabled, isPaused } = usePageLive();
85
+ const holdOptions = holdOptionsOf(options.liveHold);
86
+ const isLocallyHeld = useLocalHold(holdOptions, options.signals ?? {});
44
87
  const pausedAt = ref<number | null>(null);
45
88
  const isHolding = computed(
46
- () => options.isEnabled && isEnabled.value && isPaused.value,
89
+ () =>
90
+ !!holdOptions &&
91
+ isEnabled.value &&
92
+ (isPaused.value || isLocallyHeld.value),
47
93
  );
48
94
 
49
95
  watch(
@@ -53,8 +99,16 @@ export function useTableLiveHold(options: TableLiveHoldOptions): TableLiveHold {
53
99
  },
54
100
  { immediate: true },
55
101
  );
102
+ // Again on a new controller: the gate binds to the page on screen.
103
+ watch(
104
+ [isLocallyHeld, isEnabled],
105
+ ([held, enabled]) => {
106
+ if (enabled) options.gate().setHeld(held);
107
+ },
108
+ { flush: "sync" },
109
+ );
56
110
 
57
- // The rows are as fresh as the pause, or as the last read the user asked
111
+ // The rows are as fresh as the hold, or as the last read the user asked
58
112
  // for since (Show, an action of theirs).
59
113
  const heldAt = () =>
60
114
  pausedAt.value === null