@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
@@ -67,7 +67,7 @@ function endOfYear(date: Date, yearOffset = 0): Date {
67
67
 
68
68
  const PRESET_RANGE_RESOLVERS: Record<
69
69
  Exclude<PeriodPreset, "custom">,
70
- (now: Date) => PeriodRange
70
+ (now: Date) => PeriodRange | null
71
71
  > = {
72
72
  "last-hour": (now) => ({ from: addHours(now, -1), to: now }),
73
73
  "last-24h": (now) => ({ from: addDays(now, -1), to: now }),
@@ -112,6 +112,7 @@ const PRESET_RANGE_RESOLVERS: Record<
112
112
  from: startOfYear(now, -1),
113
113
  to: endOfYear(now, -1),
114
114
  }),
115
+ all: () => null,
115
116
  };
116
117
 
117
118
  const RELATIVE_PRESETS = new Set<PeriodPreset>(["last-hour", "last-24h"]);
@@ -130,13 +131,13 @@ export function isRelativePreset(preset: PeriodPreset): boolean {
130
131
  *
131
132
  * Calendar presets snap to day boundaries (start-of-day to end-of-day), while
132
133
  * the relative ones (`last-hour`, `last-24h`) keep instant bounds so an ops
133
- * dashboard can read sub-day granularity.
134
+ * dashboard can read sub-day granularity. `all` has no bounds: `null`.
134
135
  */
135
136
  export function resolvePresetRange(
136
137
  preset: PeriodPreset,
137
138
  now: Date,
138
139
  fallbackRange: PeriodRange,
139
- ): PeriodRange {
140
+ ): PeriodRange | null {
140
141
  if (preset === "custom") return fallbackRange;
141
142
  return PRESET_RANGE_RESOLVERS[preset](now);
142
143
  }
@@ -12,6 +12,7 @@ export type PeriodPreset =
12
12
  | "last-quarter"
13
13
  | "ytd"
14
14
  | "last-year"
15
+ | "all"
15
16
  | "custom";
16
17
 
17
18
  export type PeriodComparison =
@@ -28,7 +29,8 @@ export interface PeriodRange {
28
29
  export interface PeriodState {
29
30
  preset: PeriodPreset;
30
31
  comparison: PeriodComparison;
31
- range: PeriodRange;
32
+ /** The window, or `null` for `all`: every period, with no bounds. */
33
+ range: PeriodRange | null;
32
34
  compareRange: PeriodRange | null;
33
35
  key: string;
34
36
  }
@@ -100,8 +100,8 @@ function buildKey(state: Omit<PeriodState, "key">): string {
100
100
  return [
101
101
  state.preset,
102
102
  state.comparison,
103
- state.range.from.toISOString(),
104
- state.range.to.toISOString(),
103
+ state.range ? state.range.from.toISOString() : "-",
104
+ state.range ? state.range.to.toISOString() : "-",
105
105
  state.compareRange ? state.compareRange.from.toISOString() : "-",
106
106
  state.compareRange ? state.compareRange.to.toISOString() : "-",
107
107
  ].join("|");
@@ -67,14 +67,21 @@ export function usePeriodScope(
67
67
  });
68
68
  }
69
69
 
70
+ /**
71
+ * `url` with `state` as query parameters: `from` / `to`, `compareFrom` /
72
+ * `compareTo` when a comparison range is set, `preset` and `comparison`.
73
+ * Every period (`all`) has no bounds, so it sends no `from` / `to`.
74
+ */
70
75
  export function appendPeriodToUrl(
71
76
  url: string | undefined,
72
77
  state: PeriodState | null,
73
78
  ): string | undefined {
74
79
  if (!url || !state) return url;
75
80
  const params = new URLSearchParams();
76
- params.set("from", state.range.from.toISOString());
77
- params.set("to", state.range.to.toISOString());
81
+ if (state.range) {
82
+ params.set("from", state.range.from.toISOString());
83
+ params.set("to", state.range.to.toISOString());
84
+ }
78
85
  if (state.compareRange) {
79
86
  params.set("compareFrom", state.compareRange.from.toISOString());
80
87
  params.set("compareTo", state.compareRange.to.toISOString());
@@ -12,27 +12,59 @@ export interface PageRealtimeApi {
12
12
  pageId: Ref<string | undefined>;
13
13
  }
14
14
 
15
+ interface PageClaim {
16
+ pageId: string | undefined;
17
+ }
18
+
19
+ /**
20
+ * Every mounted page instance holding a page id, in the order each last set it.
21
+ * A page remounted at the same path (only its query changed) sets up its new
22
+ * instance before or after the old one is disposed, depending on the
23
+ * transition; the session follows the latest live claim, so neither order
24
+ * drops the page.
25
+ */
26
+ const pageClaims: PageClaim[] = [];
27
+
28
+ const applyLatestClaim = (user: UserRealtimeApi): void => {
29
+ void user.setPage(pageClaims.at(-1)?.pageId ?? null);
30
+ };
31
+
32
+ const raiseClaim = (claim: PageClaim): void => {
33
+ const index = pageClaims.indexOf(claim);
34
+ if (index !== -1) pageClaims.splice(index, 1);
35
+ pageClaims.push(claim);
36
+ };
37
+
38
+ const dropClaim = (claim: PageClaim): void => {
39
+ const index = pageClaims.indexOf(claim);
40
+ if (index !== -1) pageClaims.splice(index, 1);
41
+ };
42
+
15
43
  export function usePageRealtime(
16
44
  pageId: MaybeRefOrGetter<string>,
17
45
  ): PageRealtimeApi {
18
46
  const user: UserRealtimeApi = useUserRealtime();
19
47
  const resolvedPageId = computed(() => toValue(pageId) || undefined);
20
- let lastSet: string | undefined;
21
-
22
- watch(
23
- resolvedPageId,
24
- (next) => {
25
- lastSet = next;
26
- void user.setPage(next ?? null);
27
- },
28
- { immediate: true },
29
- );
30
-
31
- onScopeDispose(() => {
32
- if (user.pageId.value === lastSet) {
33
- void user.setPage(null);
34
- }
35
- });
48
+
49
+ if (import.meta.env.SSR) {
50
+ // No dispose runs on the server, so a claim there would never be released.
51
+ void user.setPage(resolvedPageId.value ?? null);
52
+ } else {
53
+ const claim: PageClaim = { pageId: undefined };
54
+ watch(
55
+ resolvedPageId,
56
+ (next) => {
57
+ claim.pageId = next;
58
+ raiseClaim(claim);
59
+ applyLatestClaim(user);
60
+ },
61
+ { immediate: true },
62
+ );
63
+ onScopeDispose(() => {
64
+ dropClaim(claim);
65
+ applyLatestClaim(user);
66
+ });
67
+ }
36
68
 
37
69
  return {
38
70
  subscribe: user.subscribe,
@@ -60,6 +60,7 @@ interface UserRealtimeState {
60
60
  appliedPageId: Ref<string | null>;
61
61
  handlers: Map<string, Set<RealtimeTopicHandler>>;
62
62
  snapshots: Map<string, RealtimeSnapshotEvent>;
63
+ reconciling: Promise<void>;
63
64
  cancelConnect?: () => void;
64
65
  }
65
66
 
@@ -247,7 +248,7 @@ const callPageEndpoint = async (
247
248
  });
248
249
  };
249
250
 
250
- const reconcilePage = async (
251
+ const applyDesiredPage = async (
251
252
  state: UserRealtimeState,
252
253
  baseUrl: string,
253
254
  ): Promise<void> => {
@@ -259,9 +260,24 @@ const reconcilePage = async (
259
260
  // A reconnect during the call replaced the session: the page is applied to
260
261
  // the dead one, and the new one still needs it.
261
262
  if (state.sessionId.value !== sessionId) return;
262
- if ((state.pageId.value ?? null) === desired) {
263
- state.appliedPageId.value = desired;
264
- }
263
+ state.appliedPageId.value = desired;
264
+ };
265
+
266
+ /**
267
+ * Runs one page call at a time. Two overlapping calls would each compare the
268
+ * desired page with what the server held before either landed: an unsubscribe
269
+ * in flight followed by a subscribe to the same page would skip the
270
+ * subscribe, and the session would end up with no page at all.
271
+ */
272
+ const reconcilePage = (
273
+ state: UserRealtimeState,
274
+ baseUrl: string,
275
+ ): Promise<void> => {
276
+ const run = state.reconciling
277
+ .catch(() => undefined)
278
+ .then(() => applyDesiredPage(state, baseUrl));
279
+ state.reconciling = run;
280
+ return run;
265
281
  };
266
282
 
267
283
  /**
@@ -290,6 +306,7 @@ const buildApi = (): UserRealtimeApi => {
290
306
  appliedPageId,
291
307
  handlers: new Map(),
292
308
  snapshots: new Map(),
309
+ reconciling: Promise.resolve(),
293
310
  };
294
311
 
295
312
  const config = useDmsRuntimeConfig();
@@ -1,74 +1,106 @@
1
- import { isObject } from "../../utils/type-check";
1
+ import {
2
+ decodePreferencesTree,
3
+ encodePreferencesTree,
4
+ fitPreferencesBudget,
5
+ isBranch,
6
+ markWritten,
7
+ PREFERENCES_COOKIE_NAME,
8
+ readAt,
9
+ writeAt,
10
+ } from "../../build/composables/user/preferencesCookie";
11
+ import { clearLocalTableEntries } from "../../build/composables/user/tablePreferences";
2
12
 
3
13
  export interface UserPreferences {
4
14
  [key: string]: unknown;
5
15
  }
6
16
 
7
- const COOKIE_NAME = "user-preferences";
8
17
  const COOKIE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365;
9
18
 
19
+ /**
20
+ * The cookie the preferences were kept in up to `@antelopejs/dms` 0.7.9, as
21
+ * URI-encoded JSON that grew with every table the user opened. It is not
22
+ * read: the browser deletes it, so that it stops weighing on every request.
23
+ */
24
+ const LEGACY_COOKIE_NAME = "user-preferences";
25
+
26
+ /**
27
+ * The most the preferences cookie may weigh, name and value together.
28
+ * Browsers drop a cookie over 4 KB, and every request carries it next to the
29
+ * session cookies under the server's 16 KB header limit.
30
+ */
31
+ export const MAX_PREFERENCES_COOKIE_BYTES = 3 * 1024;
32
+
33
+ // A plain copy, as the cookie will read it back: the refs hand out reactive
34
+ // proxies, which a caller may keep mutating after the write.
35
+ const toPlain = <T>(value: T): T =>
36
+ value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T);
37
+
38
+ const expireLegacyCookie = (): void => {
39
+ if (typeof document === "undefined") return;
40
+ const isPresent = document.cookie
41
+ .split(/;\s*/)
42
+ .some((entry) => entry.startsWith(`${LEGACY_COOKIE_NAME}=`));
43
+ if (isPresent) document.cookie = `${LEGACY_COOKIE_NAME}=; path=/; max-age=0`;
44
+ };
45
+
46
+ /**
47
+ * The user's preferences, kept in the `dms-preferences` cookie so that a
48
+ * server render reads them too: a tree addressed by dotted paths, stored as
49
+ * base64url JSON. The cookie is bounded by `MAX_PREFERENCES_COOKIE_BYTES`:
50
+ * past it, the table view states, then the remembered periods, written least
51
+ * recently are dropped, and a write that would not fit even then is refused.
52
+ */
10
53
  export const usePreferences = () => {
11
- const preferenceCookie = useDmsCookie<UserPreferences>(COOKIE_NAME, {
12
- default: () => ({}),
54
+ const preferenceCookie = useDmsCookie<string>(PREFERENCES_COOKIE_NAME, {
55
+ default: () => "",
13
56
  maxAge: COOKIE_MAX_AGE_ONE_YEAR,
14
57
  sameSite: "lax",
15
58
  secure: true,
16
59
  });
17
-
18
- const preferences = ref<UserPreferences>(preferenceCookie.value);
19
-
20
- const getPreference = <T = unknown>(keyPath: string, defaultValue?: T): T => {
21
- const keys = keyPath.split(".");
22
- let current: unknown = preferences.value;
23
-
24
- for (const key of keys) {
25
- if (current && isObject(current) && key in current) {
26
- current = (current as Record<string, unknown>)[key];
27
- } else {
28
- return defaultValue as T;
60
+ expireLegacyCookie();
61
+
62
+ // The cookie ref is shared by name, so every caller decodes the same value.
63
+ const tree = computed<UserPreferences>(() =>
64
+ typeof preferenceCookie.value === "string"
65
+ ? decodePreferencesTree(preferenceCookie.value)
66
+ : {},
67
+ );
68
+
69
+ const commit = (next: UserPreferences): boolean => {
70
+ if (!fitPreferencesBudget(next, MAX_PREFERENCES_COOKIE_BYTES)) {
71
+ if (import.meta.env.DEV) {
72
+ console.warn(
73
+ `[DMS] A preference was not saved: the "${PREFERENCES_COOKIE_NAME}" cookie would exceed ${MAX_PREFERENCES_COOKIE_BYTES} bytes.`,
74
+ );
29
75
  }
76
+ return false;
30
77
  }
78
+ preferenceCookie.value = encodePreferencesTree(next);
79
+ return true;
80
+ };
31
81
 
32
- return current as T;
82
+ const getPreference = <T = unknown>(keyPath: string, defaultValue?: T): T => {
83
+ const value = readAt(tree.value, keyPath.split("."));
84
+ return (value === undefined ? defaultValue : value) as T;
33
85
  };
34
86
 
35
87
  const setPreference = (keyPath: string, value: unknown): void => {
36
88
  const keys = keyPath.split(".");
37
- const lastKey = keys.pop()!;
38
-
39
- let current = preferences.value;
40
-
41
- for (const key of keys) {
42
- if (!current[key] || !isObject(current[key])) {
43
- current[key] = {};
44
- }
45
- current = current[key] as UserPreferences;
46
- }
47
-
48
- (current as Record<string, unknown>)[lastKey] = value;
49
-
50
- preferenceCookie.value = preferences.value;
89
+ const next = toPlain(tree.value);
90
+ writeAt(next, keys, toPlain(value));
91
+ markWritten(next, keys);
92
+ commit(next);
51
93
  };
52
94
 
53
95
  const removePreference = (keyPath: string): void => {
54
96
  const keys = keyPath.split(".");
55
97
  const lastKey = keys.pop()!;
98
+ const next = toPlain(tree.value);
99
+ const parent = keys.length === 0 ? next : readAt(next, keys);
100
+ if (!isBranch(parent) || !(lastKey in parent)) return;
56
101
 
57
- let current = preferences.value;
58
-
59
- for (const key of keys) {
60
- if (!current[key] || !isObject(current[key])) {
61
- return;
62
- }
63
- current = current[key] as UserPreferences;
64
- }
65
-
66
- if (lastKey in current) {
67
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
68
- delete (current as Record<string, unknown>)[lastKey];
69
- }
70
-
71
- preferenceCookie.value = preferences.value;
102
+ Reflect.deleteProperty(parent, lastKey);
103
+ commit(next);
72
104
  };
73
105
 
74
106
  const updatePreference = <T = unknown>(
@@ -81,12 +113,12 @@ export const usePreferences = () => {
81
113
  };
82
114
 
83
115
  const clearAllPreferences = (): void => {
84
- preferences.value = {};
85
- preferenceCookie.value = {};
116
+ preferenceCookie.value = "";
117
+ clearLocalTableEntries();
86
118
  };
87
119
 
88
120
  return {
89
- preferences: readonly(computed(() => preferences.value)),
121
+ preferences: readonly(tree),
90
122
 
91
123
  getPreference,
92
124
  setPreference,
@@ -30,6 +30,39 @@ export type ConfirmDialogColor =
30
30
  | "info"
31
31
  | "neutral";
32
32
 
33
+ /**
34
+ * A file a dialog's secondary action downloads (backend
35
+ * `ConfirmDialogDownloadTarget`), fetched with the user's session.
36
+ */
37
+ export interface ConfirmDialogDownloadTarget {
38
+ type: "download";
39
+ url: string;
40
+ /** Saved under; the response's `Content-Disposition` name, else the URL's. */
41
+ filename?: string;
42
+ }
43
+
44
+ /** A link opened by a dialog's secondary action. */
45
+ export interface ConfirmDialogLinkTarget {
46
+ type: "external";
47
+ url: string;
48
+ newTab?: boolean;
49
+ }
50
+
51
+ /** Where a dialog's secondary action leads (backend `ConfirmDialogSecondaryTarget`). */
52
+ export type ConfirmDialogSecondaryTarget =
53
+ | ConfirmDialogLinkTarget
54
+ | ConfirmDialogDownloadTarget;
55
+
56
+ /**
57
+ * A button next to cancel and confirm, running its target with the dialog
58
+ * left open (backend `ConfirmDialogSecondaryAction`).
59
+ */
60
+ export interface ConfirmDialogSecondaryAction {
61
+ label: string;
62
+ icon?: string;
63
+ target: ConfirmDialogSecondaryTarget;
64
+ }
65
+
33
66
  /**
34
67
  * The dialog an action asks in before it runs (backend `ConfirmDialog`),
35
68
  * the same for every action. Texts are i18n keys (with `$`) or literals,
@@ -44,6 +77,8 @@ export interface ConfirmDialog {
44
77
  confirmLabel?: string;
45
78
  cancelLabel?: string;
46
79
  impact?: ConfirmDialogImpact[];
80
+ /** A button next to cancel and confirm, leaving the dialog open. */
81
+ secondaryAction?: ConfirmDialogSecondaryAction;
47
82
  /** The action cannot run: the dialog only explains why. */
48
83
  blocked?: boolean;
49
84
  /** Text the user must type to confirm. */
@@ -445,6 +445,7 @@
445
445
  "preset_aria": "Preset",
446
446
  "comparison_aria": "Comparison",
447
447
  "custom_range_aria": "Custom range",
448
+ "since": "since {from}",
448
449
  "presets": {
449
450
  "last-hour": "Last hour",
450
451
  "last-24h": "Last 24 hours",
@@ -459,6 +460,7 @@
459
460
  "last-quarter": "Last quarter",
460
461
  "ytd": "Year to date",
461
462
  "last-year": "Last year",
463
+ "all": "All time",
462
464
  "custom": "Custom"
463
465
  },
464
466
  "comparisons": {
@@ -445,6 +445,7 @@
445
445
  "preset_aria": "Préréglage",
446
446
  "comparison_aria": "Comparaison",
447
447
  "custom_range_aria": "Plage personnalisée",
448
+ "since": "depuis {from}",
448
449
  "presets": {
449
450
  "last-hour": "Dernière heure",
450
451
  "last-24h": "24 dernières heures",
@@ -459,6 +460,7 @@
459
460
  "last-quarter": "Trimestre dernier",
460
461
  "ytd": "Année en cours",
461
462
  "last-year": "Année dernière",
463
+ "all": "Toute la période",
462
464
  "custom": "Personnalisé"
463
465
  },
464
466
  "comparisons": {
@@ -13,6 +13,11 @@ interface Props {
13
13
  description?: string;
14
14
  /** Header buttons declared by the backend page (`headerActions`). */
15
15
  headerActions?: LayoutHeaderAction[];
16
+ /**
17
+ * The page query the server resolved the buttons' availability for, set
18
+ * when one declares an `availability` (`headerActionsQuery`).
19
+ */
20
+ headerActionsQuery?: Record<string, string>;
16
21
  /** Space between the page header and the page. */
17
22
  headerClass?: string;
18
23
  }
@@ -23,6 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
23
28
  title: undefined,
24
29
  description: undefined,
25
30
  headerActions: () => [],
31
+ headerActionsQuery: undefined,
26
32
  headerClass: "pb-6",
27
33
  });
28
34
 
@@ -58,7 +64,10 @@ const PAGE_SLOT_CLASS =
58
64
  :class="props.headerClass"
59
65
  >
60
66
  <template #actions>
61
- <PageHeaderActionsOutlet :actions="props.headerActions" />
67
+ <PageHeaderActionsOutlet
68
+ :actions="props.headerActions"
69
+ :served-query="props.headerActionsQuery"
70
+ />
62
71
  </template>
63
72
  </PageHeader>
64
73
  <div :class="isPageLoading ? 'hidden' : PAGE_SLOT_CLASS">
@@ -15,6 +15,10 @@ import { PREVIEW_LOCK_ICON } from "#dms-ui/app/build/utils/permissionPreview";
15
15
  import { runMountedQuickAction } from "#dms-ui/app/build/utils/quickActionTargets";
16
16
  import { useActionTargets } from "#dms-ui/app/build/composables/actions/useActionTargets";
17
17
  import { refreshPageBlocks } from "#dms-ui/app/utils/blockRefresh";
18
+ import {
19
+ hasUrlVariables,
20
+ resolveLinkVariables,
21
+ } from "#dms-ui/app/build/utils/urlVariables";
18
22
  import {
19
23
  dispatchQuickActionTarget,
20
24
  findServedQuickAction,
@@ -111,6 +115,32 @@ function baseButton(action: LayoutHeaderAction): ButtonProps {
111
115
  }
112
116
 
113
117
  type LinkTarget = Extract<ActionTarget, { type: "page" | "external" }>;
118
+ type UrlTarget = Extract<ActionTarget, { url: string }>;
119
+
120
+ const hasTargetUrl = (target: ActionTarget): target is UrlTarget =>
121
+ typeof (target as Partial<UrlTarget>).url === "string";
122
+
123
+ // The values of the page a target's URL may name: a button declared once on
124
+ // the page leads to the record the page shows (`{{params.id}}`) or keeps its
125
+ // scope (`?workspace={{query.workspace}}`), following the URL as it changes.
126
+ const pageUrlValues = computed(() => ({
127
+ routeParams: siteLayout.findMatchingRoute(route.path)?.params,
128
+ routeQuery: route.query as Record<string, unknown>,
129
+ }));
130
+
131
+ function withPageValues(action: LayoutHeaderAction): LayoutHeaderAction {
132
+ const { target } = action;
133
+ if (!target || !hasTargetUrl(target) || !hasUrlVariables(target.url)) {
134
+ return action;
135
+ }
136
+ return {
137
+ ...action,
138
+ target: {
139
+ ...target,
140
+ url: resolveLinkVariables(target.url, pageUrlValues.value),
141
+ },
142
+ };
143
+ }
114
144
 
115
145
  // A link target (without a confirmation) renders as a link: middle click and
116
146
  // "open in a new tab" keep working.
@@ -157,8 +187,9 @@ function resolveQuickActionButton(
157
187
  }
158
188
 
159
189
  function resolveAction(
160
- action: LayoutHeaderAction,
190
+ declared: LayoutHeaderAction,
161
191
  ): ResolvedHeaderAction | null {
192
+ const action = withPageValues(declared);
162
193
  const base = baseButton(action);
163
194
  const disabledReason =
164
195
  action.disabled && action.disabledReason
@@ -4,13 +4,23 @@ import PageHeaderActionBar, {
4
4
  type LayoutHeaderAction,
5
5
  } from "./PageHeaderActionBar.vue";
6
6
  import { injectPageHeaderActionsHost } from "../../../composables/layout/usePageHeaderActions";
7
+ import { useServedHeaderActions } from "../../../composables/layout/useServedHeaderActions";
7
8
 
8
9
  interface PageHeaderActionsOutletProps {
9
10
  /** Header buttons declared by the backend page (`headerActions`). */
10
11
  actions: LayoutHeaderAction[];
12
+ /** The page query the server resolved their availability for. */
13
+ servedQuery?: Record<string, string>;
11
14
  }
12
15
 
13
- const props = defineProps<PageHeaderActionsOutletProps>();
16
+ const props = withDefaults(defineProps<PageHeaderActionsOutletProps>(), {
17
+ servedQuery: undefined,
18
+ });
19
+
20
+ const actions = useServedHeaderActions({
21
+ actions: () => props.actions,
22
+ servedQuery: () => props.servedQuery,
23
+ });
14
24
 
15
25
  const ROOT_SELECTOR = "[data-dms-page-header-actions]";
16
26
  // The actions take their own full-width row below `md`, the end of the
@@ -26,7 +36,7 @@ onServerPrefetch(() => host?.whenReady());
26
36
 
27
37
  const pageActions = computed(() => host?.actions.value ?? null);
28
38
  const hasActions = computed(
29
- () => props.actions.length > 0 || !!pageActions.value,
39
+ () => actions.value.length > 0 || !!pageActions.value,
30
40
  );
31
41
 
32
42
  // Hydrating before the page has set up: its actions are not known yet, but
@@ -56,7 +66,7 @@ const showsServerMarkup = computed(
56
66
  data-dms-page-header-actions
57
67
  :class="ROOT_CLASS"
58
68
  >
59
- <PageHeaderActionBar :actions="props.actions" />
69
+ <PageHeaderActionBar :actions="actions" />
60
70
  <component :is="pageActions" v-if="pageActions" />
61
71
  </div>
62
72
  </template>
@@ -5,9 +5,12 @@
5
5
  * among the rows the table shows, previous and next (J and K too) and close
6
6
  * (Escape too). Below it, the module's component receives the row as a
7
7
  * drawer's component does, mounted anew on each row. The position follows the
8
- * table: a refresh that moves or drops the row updates it.
8
+ * table: a refresh that moves or drops the row updates it. A row the link
9
+ * names but its route answered 404 for shows the "not found" state instead,
10
+ * with a close button.
9
11
  */
10
12
  import { computed, onBeforeUnmount, onMounted } from "vue";
13
+ import DmsEmptyState from "#dms-ui/app/components/empty-state/EmptyState.vue";
11
14
  import {
12
15
  listenRowPanelClose,
13
16
  listenRowSteps,
@@ -46,8 +49,20 @@ const componentProps = computed(() => ({
46
49
  navigation: navigation.value,
47
50
  }));
48
51
 
52
+ const heading = computed(() => content.value?.rowTitle || content.value?.title);
53
+
49
54
  const close = () => closeRowSidePanel();
50
55
 
56
+ const notFoundActions = computed(() => [
57
+ {
58
+ label: t("side_panel.close"),
59
+ icon: "i-ph-x",
60
+ color: "neutral" as const,
61
+ variant: "outline" as const,
62
+ onClick: close,
63
+ },
64
+ ]);
65
+
51
66
  let stopKeys: Array<() => void> = [];
52
67
 
53
68
  onMounted(() => {
@@ -74,7 +89,7 @@ onBeforeUnmount(() => {
74
89
  <h2
75
90
  class="text-highlighted truncate text-[15px] leading-[1.3] font-[650] tracking-[-0.015em]"
76
91
  >
77
- {{ content.title }}
92
+ {{ heading }}
78
93
  </h2>
79
94
  <p
80
95
  v-if="position"
@@ -122,7 +137,16 @@ onBeforeUnmount(() => {
122
137
  </div>
123
138
  </header>
124
139
  <div class="min-h-0 flex-1 overflow-y-auto p-4">
125
- <Suspense>
140
+ <DmsEmptyState
141
+ v-if="content.notFound"
142
+ data-dms-row-side-panel-not-found
143
+ variant="no-result"
144
+ :title="content.notFound.title"
145
+ :description="content.notFound.description"
146
+ :actions="notFoundActions"
147
+ class="py-10"
148
+ />
149
+ <Suspense v-else>
126
150
  <component
127
151
  :is="content.component"
128
152
  :key="content.rowKey"