@antelopejs/dms 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/config.d.ts +2 -0
  3. package/dist/config.js +9 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/implementations/dms/api-base-url.d.ts +1 -0
  6. package/dist/implementations/dms/api-base-url.js +6 -0
  7. package/dist/implementations/dms/api-base-url.js.map +1 -0
  8. package/dist/implementations/dms/frontend-modules.js +7 -6
  9. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  10. package/dist/implementations/dms/page.js +8 -2
  11. package/dist/implementations/dms/page.js.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/test/attachment-host/capabilities.d.ts +25 -0
  15. package/dist/test/attachment-host/capabilities.js +53 -1
  16. package/dist/test/attachment-host/capabilities.js.map +1 -1
  17. package/dist/test/integration/table-view-capabilities.test.js +13 -0
  18. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  19. package/dist/test/unit/api-base-url.test.d.ts +1 -0
  20. package/dist/test/unit/api-base-url.test.js +31 -0
  21. package/dist/test/unit/api-base-url.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms/page-layout-query.test.d.ts +1 -0
  23. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +106 -0
  24. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -0
  25. package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -0
  26. package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
  27. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.d.ts +1 -0
  28. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +46 -0
  29. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -0
  30. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.d.ts +1 -0
  31. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +26 -0
  32. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -0
  33. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +10 -0
  34. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/guard.test.d.ts +1 -0
  36. package/dist/test/unit/interfaces/dms-base/guard.test.js +53 -0
  37. package/dist/test/unit/interfaces/dms-base/guard.test.js.map +1 -0
  38. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +23 -0
  39. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
  40. package/dist/test/unit/interfaces/dms-base/page-live.test.js +55 -0
  41. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -1
  42. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +17 -0
  43. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -1
  44. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +8 -1
  45. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  46. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +28 -0
  47. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  48. package/frontend-vue/layers/dms-core/app/build/composables/user/preferencesCookie.ts +149 -0
  49. package/frontend-vue/layers/dms-core/app/build/composables/user/tablePreferences.ts +321 -0
  50. package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +4 -3
  51. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +4 -3
  52. package/frontend-vue/layers/dms-core/app/composables/period/types.ts +3 -1
  53. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +2 -2
  54. package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +9 -2
  55. package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +48 -16
  56. package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +21 -4
  57. package/frontend-vue/layers/dms-core/app/composables/user/usePreferences.ts +81 -49
  58. package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +35 -0
  59. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  60. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  61. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +10 -1
  62. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +32 -1
  63. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +13 -3
  64. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +27 -3
  65. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +16 -0
  66. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +75 -0
  67. package/frontend-vue/layers/dms-layout/app/composables/page/usePageLayout.ts +8 -1
  68. package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +13 -2
  69. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +7 -0
  70. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +7 -0
  71. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -4
  72. package/frontend-vue/layers/dms-layout/app/utils/pageQuery.ts +59 -0
  73. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  74. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  75. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +2 -0
  76. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockInlineStatus.vue +67 -0
  77. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +60 -20
  78. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +15 -8
  79. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +14 -0
  80. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +29 -4
  81. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +76 -28
  82. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +26 -2
  83. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useGuard.ts +115 -0
  84. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +1 -1
  85. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +58 -3
  86. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +86 -0
  87. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +5 -0
  88. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +6 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveCadence.ts +42 -0
  90. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveHolds.ts +66 -0
  91. package/frontend-vue/layers/dms-ui/app/build/composables/live/livePoller.ts +42 -0
  92. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveStorage.ts +42 -0
  93. package/frontend-vue/layers/dms-ui/app/build/composables/live/missedTally.ts +65 -0
  94. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +247 -88
  95. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLivePeriodSpan.ts +33 -0
  96. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +79 -5
  97. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodComparisonControl.ts +70 -0
  98. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +55 -0
  99. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +41 -5
  100. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodDraft.ts +22 -9
  101. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +13 -6
  102. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTable.ts +1 -0
  103. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableColumns.ts +27 -2
  104. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +18 -3
  105. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +19 -26
  106. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useScrolledPastTop.ts +66 -0
  107. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +7 -1
  108. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +65 -11
  109. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTablePreferences.ts +149 -0
  110. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +39 -8
  111. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  112. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +16 -11
  113. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +39 -16
  114. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/pinnedRows.ts +29 -0
  115. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -5
  116. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/sortableColumns.ts +17 -0
  117. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +79 -0
  118. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +46 -1
  119. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
  120. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +4 -0
  121. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +40 -2
  122. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +11 -1
  123. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +12 -2
  124. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +129 -4
  125. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +6 -0
  126. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +6 -1
  127. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +33 -0
  128. package/frontend-vue/layers/dms-ui/app/components/form/components/ChoiceCards.vue +22 -4
  129. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +74 -0
  130. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -17
  131. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +15 -0
  132. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +12 -15
  133. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +104 -16
  134. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +113 -99
  135. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +11 -1
  136. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +2 -0
  137. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +11 -1
  138. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +15 -1
  139. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +86 -10
  140. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewShiftF.ts +20 -0
  141. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +12 -0
  142. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +5 -0
  143. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
  144. package/frontend-vue/layers/dms-ui/app/composables/table-view/useTableViewEmptyState.ts +55 -0
  145. package/frontend-vue/layers/dms-ui/app/types/live.ts +47 -1
  146. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +2 -0
  147. package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
  148. package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
  149. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +13 -4
  150. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +12 -3
  151. package/frontend-vue/tests/activity-feed-fill-height.test.ts +1 -0
  152. package/frontend-vue/tests/activity-feed-head.test.ts +140 -0
  153. package/frontend-vue/tests/block-not-found.test.ts +220 -0
  154. package/frontend-vue/tests/choice-cards-columns.test.ts +60 -0
  155. package/frontend-vue/tests/component-id.test.ts +18 -0
  156. package/frontend-vue/tests/confirm-secondary-action.test.ts +269 -0
  157. package/frontend-vue/tests/guard.test.ts +242 -0
  158. package/frontend-vue/tests/header-action-page-query.test.ts +286 -0
  159. package/frontend-vue/tests/live-toggle.test.ts +87 -1
  160. package/frontend-vue/tests/page-layout-registration.test.ts +1 -0
  161. package/frontend-vue/tests/page-live-parity.test.ts +367 -0
  162. package/frontend-vue/tests/page-realtime-remount.test.ts +185 -0
  163. package/frontend-vue/tests/period-selector-parity.test.ts +138 -0
  164. package/frontend-vue/tests/period-selector-url.test.ts +84 -6
  165. package/frontend-vue/tests/period-url-query.test.ts +52 -0
  166. package/frontend-vue/tests/preferences-cookie-size.test.ts +311 -0
  167. package/frontend-vue/tests/row-side-panel.test.ts +109 -10
  168. package/frontend-vue/tests/table-actions-search-label.test.ts +57 -1
  169. package/frontend-vue/tests/table-clear-filters.test.ts +99 -1
  170. package/frontend-vue/tests/table-density-menu.test.ts +4 -3
  171. package/frontend-vue/tests/table-live-hold.test.ts +95 -5
  172. package/frontend-vue/tests/table-row-action-labels.test.ts +181 -0
  173. package/frontend-vue/tests/table-row-hover.test.ts +32 -0
  174. package/frontend-vue/tests/table-source-period-scope.test.ts +31 -3
  175. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +14 -17
  176. package/frontend-vue/tests/table-view-pinned-rows.test.ts +143 -0
  177. package/frontend-vue/tests/table-view-sortable-columns.test.ts +45 -0
  178. package/frontend-vue/tests/table-view-views.test.ts +56 -15
  179. package/frontend-vue/tests/top-list-card-value-suffix.test.ts +107 -0
  180. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  181. package/package.json +2 -2
  182. package/skills/dms/SKILL.md +9 -3
  183. package/skills/dms-module/SKILL.md +5 -0
  184. package/skills/dms-pages/REFERENCE.md +23 -2
@@ -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"
@@ -21,6 +21,17 @@ export const ROW_SIDE_PANEL_ID = "dms:row-side-panel";
21
21
  /** Registered name of `RowSidePanel.vue` (the layer's `Dms` prefix applied). */
22
22
  const ROW_SIDE_PANEL_COMPONENT = "DmsRowSidePanel";
23
23
 
24
+ /**
25
+ * The state the panel draws in place of the component on a row that is gone,
26
+ * its texts translated.
27
+ *
28
+ * @internal
29
+ */
30
+ export interface RowSidePanelNotFound {
31
+ title: string;
32
+ description: string;
33
+ }
34
+
24
35
  /**
25
36
  * What the row side panel shows: the header the DMS draws, the module's
26
37
  * component and the row it receives.
@@ -32,7 +43,12 @@ export interface RowSidePanelContent {
32
43
  session: symbol;
33
44
  /** The table (or button bar) that opened the panel; see {@link rowSidePanelOwnerKey}. */
34
45
  ownerKey: string;
46
+ /** The panel's title: its accessible name, and its header without a row. */
35
47
  title: string;
48
+ /** The header's title while `row` is open (the target's `rowTitle`). */
49
+ rowTitle?: string;
50
+ /** The row the link names is gone: this state replaces the component. */
51
+ notFound?: RowSidePanelNotFound;
36
52
  /** i18n key (`$`-prefixed) of the position, receiving `{ index, total }`. */
37
53
  positionLabel?: string;
38
54
  component: Component;
@@ -0,0 +1,75 @@
1
+ import { computed, shallowRef, watch, type ComputedRef } from "vue";
2
+ import type { LayoutHeaderAction } from "../../build/components/layout/PageHeaderActionBar.vue";
3
+ import type { PageLayout } from "../../types/page";
4
+ import {
5
+ isSamePageQuery,
6
+ layoutUrlWithQuery,
7
+ pageQueryValues,
8
+ } from "../../utils/pageQuery";
9
+
10
+ interface ServedHeaderActionsOptions {
11
+ /** The buttons the page layout was served with. */
12
+ actions: () => LayoutHeaderAction[];
13
+ /**
14
+ * The page query their availability was resolved for, set when one of them
15
+ * declares an `availability` (`headerActionsQuery`).
16
+ */
17
+ servedQuery: () => Record<string, string> | undefined;
18
+ }
19
+
20
+ /**
21
+ * The page header's buttons for the page query of the moment. A button whose
22
+ * `availability` depends on the query (`?workspace=` names the record the
23
+ * page acts on) was resolved for the query the layout was loaded with: when
24
+ * the URL's query moves away from it, the header asks the page layout again
25
+ * for that query and takes its buttons, keeping the last ones meanwhile.
26
+ * Buttons without an availability never cause a request.
27
+ */
28
+ export function useServedHeaderActions({
29
+ actions,
30
+ servedQuery,
31
+ }: ServedHeaderActionsOptions): ComputedRef<LayoutHeaderAction[]> {
32
+ const route = useDmsRoute();
33
+ const siteLayout = useSiteLayout();
34
+ const refreshed = shallowRef<LayoutHeaderAction[] | null>(null);
35
+ let latestRequest = 0;
36
+
37
+ const layoutUrl = () =>
38
+ siteLayout.findMatchingRoute(route.path)?.metadata?.layoutUrl;
39
+
40
+ async function askFor(url: string, query: Record<string, string>) {
41
+ const request = ++latestRequest;
42
+ const { $authFetch } = useAuthFetch();
43
+ try {
44
+ const layout = await $authFetch<PageLayout>(
45
+ layoutUrlWithQuery(url, query),
46
+ );
47
+ if (request !== latestRequest) return;
48
+ const options = layout.layout.options as
49
+ | { headerActions?: LayoutHeaderAction[] }
50
+ | undefined;
51
+ refreshed.value = options?.headerActions ?? [];
52
+ } catch {
53
+ // The buttons resolved for the previous query stay: the operation
54
+ // behind each one still refuses on its own.
55
+ }
56
+ }
57
+
58
+ function follow() {
59
+ const served = servedQuery();
60
+ const current = pageQueryValues(route.query as Record<string, unknown>);
61
+ const url = layoutUrl();
62
+ if (!served || !url || isSamePageQuery(served, current)) {
63
+ latestRequest++;
64
+ refreshed.value = null;
65
+ return;
66
+ }
67
+ void askFor(url, current);
68
+ }
69
+
70
+ if (!import.meta.env.SSR) {
71
+ watch([() => route.query, servedQuery], follow, { immediate: true });
72
+ }
73
+
74
+ return computed(() => refreshed.value ?? actions());
75
+ }
@@ -1,3 +1,5 @@
1
+ import { pageQueryValues } from "../../utils/pageQuery";
2
+
1
3
  /**
2
4
  * Reads the same page cache that Inertia hydrates before navigation. Missing
3
5
  * layouts (including newly registered dev pages) use the shared deduped loader.
@@ -17,7 +19,12 @@ export async function usePageLayout() {
17
19
  );
18
20
  const load = async () => {
19
21
  if (layoutUrl.value && !pageLayout.value) {
20
- await siteLayout.loadPageLayout(layoutUrl.value);
22
+ // Asked with the page query, for the header buttons whose availability
23
+ // depends on it; cached for the page whatever its query.
24
+ await siteLayout.loadPageLayout(
25
+ layoutUrl.value,
26
+ pageQueryValues(route.query),
27
+ );
21
28
  }
22
29
  };
23
30
  watch([layoutUrl, () => siteLayout.pageLayouts.value], load);
@@ -1,3 +1,4 @@
1
+ import { layoutUrlWithQuery } from "../../utils/pageQuery";
1
2
  import { withoutTrailingSlash } from "ufo";
2
3
 
3
4
  const STALE_THRESHOLD_MS = 300_000;
@@ -282,7 +283,15 @@ export const useSiteLayout = () => {
282
283
  await refresh();
283
284
  };
284
285
 
285
- const loadPageLayout = async (slug: string): Promise<PageLayout> => {
286
+ /**
287
+ * The layout of a page, cached by its layout URL. `query` is the page
288
+ * URL's, which the first request carries for the header buttons whose
289
+ * availability reads it (the header asks again for another query).
290
+ */
291
+ const loadPageLayout = async (
292
+ slug: string,
293
+ query?: Record<string, string>,
294
+ ): Promise<PageLayout> => {
286
295
  if (pageLayouts.value[slug]) {
287
296
  return pageLayouts.value[slug];
288
297
  }
@@ -294,7 +303,9 @@ export const useSiteLayout = () => {
294
303
  }
295
304
 
296
305
  const { $authFetch } = useAuthFetch();
297
- const request = $authFetch<PageLayout>(slug).then((result) => {
306
+ const request = $authFetch<PageLayout>(
307
+ layoutUrlWithQuery(slug, query ?? {}),
308
+ ).then((result) => {
298
309
  if (pageLayouts.value === cache) cache[slug] = result;
299
310
  return result;
300
311
  });
@@ -12,6 +12,11 @@ interface Props {
12
12
  description?: string;
13
13
  /** Header buttons declared by the backend page (`headerActions`). */
14
14
  headerActions?: LayoutHeaderAction[];
15
+ /**
16
+ * The page query the server resolved the buttons' availability for, set
17
+ * when one declares an `availability` (`headerActionsQuery`).
18
+ */
19
+ headerActionsQuery?: Record<string, string>;
15
20
  }
16
21
 
17
22
  const props = withDefaults(defineProps<Props>(), {
@@ -22,6 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
22
27
  title: undefined,
23
28
  description: undefined,
24
29
  headerActions: () => [],
30
+ headerActionsQuery: undefined,
25
31
  });
26
32
  </script>
27
33
 
@@ -33,6 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
33
39
  :title="props.title"
34
40
  :description="props.description"
35
41
  :header-actions="props.headerActions"
42
+ :header-actions-query="props.headerActionsQuery"
36
43
  >
37
44
  <slot />
38
45
  </DashboardPageBody>
@@ -12,6 +12,11 @@ interface Props {
12
12
  description?: string;
13
13
  /** Header buttons declared by the backend page (`headerActions`). */
14
14
  headerActions?: LayoutHeaderAction[];
15
+ /**
16
+ * The page query the server resolved the buttons' availability for, set
17
+ * when one declares an `availability` (`headerActionsQuery`).
18
+ */
19
+ headerActionsQuery?: Record<string, string>;
15
20
  }
16
21
 
17
22
  const props = withDefaults(defineProps<Props>(), {
@@ -21,6 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
21
26
  title: undefined,
22
27
  description: undefined,
23
28
  headerActions: () => [],
29
+ headerActionsQuery: undefined,
24
30
  });
25
31
  </script>
26
32
 
@@ -33,6 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
33
39
  :title="props.title"
34
40
  :description="props.description"
35
41
  :header-actions="props.headerActions"
42
+ :header-actions-query="props.headerActionsQuery"
36
43
  header-class="pb-5"
37
44
  >
38
45
  <slot />
@@ -218,10 +218,13 @@ const realtimePageId = computed(() => pagelayoutMetadata.value?.fullId ?? "");
218
218
  const realtime = usePageRealtime(realtimePageId);
219
219
  providePageRealtime(realtime);
220
220
  // Before the blocks set up: their refetches go through the page's live gate.
221
- usePageLiveHost(() => ({
222
- pageId: pagelayoutMetadata.value?.fullId,
223
- live: pagelayoutMetadata.value?.live,
224
- }));
221
+ usePageLiveHost(
222
+ () => ({
223
+ pageId: pagelayoutMetadata.value?.fullId,
224
+ live: pagelayoutMetadata.value?.live,
225
+ }),
226
+ { isStreamConnected: () => !!realtime.sessionId.value },
227
+ );
225
228
 
226
229
  function buildPageSetupContext(
227
230
  pageInfo: PageInfo,