@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
@@ -19,13 +19,19 @@ import {
19
19
  import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
20
20
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
21
21
  import type { BlockText } from "../../../../dms-core/app/types/composed-text";
22
- import type { BlockEmptyText } from "../../build/components/blocks/BlockStatus.vue";
22
+ import DmsBlockStatus, {
23
+ type BlockEmptyText,
24
+ } from "../../build/components/blocks/BlockStatus.vue";
23
25
 
24
26
  // The renderer's props are optional: DmsActivityFeed is both the backend
25
27
  // `ActivityFeed` block (`dms-activity-feed-block`) and a template component.
26
28
  interface ActivityFeedProps extends Partial<DefaultComponentProps> {
27
29
  /** Eyebrow title of the card head ("Activity"). */
28
30
  title?: string;
31
+ /** Count chip beside the title; a `count` the source answers wins. */
32
+ count?: number;
33
+ /** Soft badge in the card head, before the actions ("Last 24 h"). */
34
+ badge?: BlockText;
29
35
  /** Entries shown when no data source is set. */
30
36
  items?: ActivityFeedItem[];
31
37
  /** Endpoint answering `{ items }`, newest first. */
@@ -65,6 +71,8 @@ interface ActivityFeedProps extends Partial<DefaultComponentProps> {
65
71
 
66
72
  const props = withDefaults(defineProps<ActivityFeedProps>(), {
67
73
  title: undefined,
74
+ count: undefined,
75
+ badge: undefined,
68
76
  items: undefined,
69
77
  fetchUrl: undefined,
70
78
  fetchUrlMethod: undefined,
@@ -91,8 +99,10 @@ const { processText } = useComposedText();
91
99
 
92
100
  const {
93
101
  items: list,
102
+ count: fetchedCount,
94
103
  isPending,
95
104
  hasError,
105
+ notFound,
96
106
  refresh,
97
107
  } = useBlockItems<ActivityFeedItem>({
98
108
  items: () => props.items,
@@ -129,6 +139,17 @@ const trailingOf = (item: ActivityFeedItem): string | undefined => {
129
139
  const textOf = (item: ActivityFeedItem, text: BlockText): string =>
130
140
  processText(activityText(text, item.params));
131
141
 
142
+ const detailOf = (item: ActivityFeedItem): string | undefined =>
143
+ item.detail ? textOf(item, item.detail) : undefined;
144
+
145
+ const headCount = computed(() => fetchedCount.value ?? props.count);
146
+ const badgeText = computed(() =>
147
+ props.badge ? processText(props.badge) : undefined,
148
+ );
149
+ const hasHeadActions = computed(
150
+ () => props.actions.length > 0 || !!badgeText.value,
151
+ );
152
+
132
153
  const metaOf = (item: ActivityFeedItem): string | undefined =>
133
154
  item.meta?.length
134
155
  ? item.meta.map((entry) => textOf(item, entry)).join(" · ")
@@ -186,6 +207,7 @@ const wrapperProps = computed(() =>
186
207
  ? {
187
208
  padded: false,
188
209
  title: props.title ? processI18n(props.title) : undefined,
210
+ count: headCount.value,
189
211
  class: "min-w-0 overflow-hidden",
190
212
  }
191
213
  : { class: "min-w-0" },
@@ -194,8 +216,16 @@ const wrapperProps = computed(() =>
194
216
 
195
217
  <template>
196
218
  <component :is="Wrapper" v-bind="wrapperProps">
197
- <template v-if="props.card && props.actions.length" #actions>
219
+ <template v-if="props.card && hasHeadActions" #actions>
220
+ <UBadge
221
+ v-if="badgeText"
222
+ :label="badgeText"
223
+ color="primary"
224
+ variant="soft"
225
+ size="sm"
226
+ />
198
227
  <DmsBlockActions
228
+ v-if="props.actions.length"
199
229
  :actions="props.actions"
200
230
  size="xs"
201
231
  lead-variant="ghost"
@@ -251,6 +281,13 @@ const wrapperProps = computed(() =>
251
281
  ]"
252
282
  />
253
283
 
284
+ <DmsBlockStatus
285
+ v-else-if="notFound"
286
+ state="not-found"
287
+ :card="false"
288
+ :not-found="notFound"
289
+ />
290
+
254
291
  <DmsEmptyState
255
292
  v-else-if="!entries.length"
256
293
  icon="i-ph-tray"
@@ -287,6 +324,7 @@ const wrapperProps = computed(() =>
287
324
  :icon-color="item.tone ?? 'neutral'"
288
325
  :title="textOf(item, item.title)"
289
326
  :subtitle="metaOf(item)"
327
+ :detail="detailOf(item)"
290
328
  :trailing="trailingOf(item)"
291
329
  :unread="item.unread"
292
330
  :mono="props.mono"
@@ -3,6 +3,7 @@ import { computed } from "vue";
3
3
  import DmsCodeSnippet from "../code-snippet/CodeSnippet.vue";
4
4
  import DmsBlockStatus from "../../build/components/blocks/BlockStatus.vue";
5
5
  import { useChartFetch } from "../../composables/chart/useChartFetch";
6
+ import { readFetchNotFound } from "../../build/utils/fetchNotFound";
6
7
  import { useWatch } from "../../../../dms-core/app/composables/watch/useWatch";
7
8
  import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
8
9
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
@@ -62,10 +63,14 @@ const title = computed(() =>
62
63
  props.title ? processText(props.title) : undefined,
63
64
  );
64
65
  const showSkeleton = computed(() => isLoading.value && !data.value);
66
+ const notFound = computed(() =>
67
+ isLoading.value ? null : readFetchNotFound(error.value),
68
+ );
65
69
  // A failed first load shows a retry in place of the code; a failed refetch
66
70
  // keeps the code on screen.
67
71
  const hasError = computed(
68
- () => !isLoading.value && !data.value && Boolean(error.value),
72
+ () =>
73
+ !isLoading.value && !data.value && Boolean(error.value) && !notFound.value,
69
74
  );
70
75
 
71
76
  const { t } = useI18n();
@@ -73,6 +78,11 @@ const { t } = useI18n();
73
78
 
74
79
  <template>
75
80
  <DmsBlockStatus v-if="hasError" state="error" @retry="refresh()" />
81
+ <DmsBlockStatus
82
+ v-else-if="notFound"
83
+ state="not-found"
84
+ :not-found="notFound"
85
+ />
76
86
  <div
77
87
  v-else-if="showSkeleton"
78
88
  class="border-default grid gap-2 rounded-[10px] border px-3.5 py-3"
@@ -69,7 +69,7 @@ const TRANSLATED_TYPES = new Set([undefined, "text", "status", "link"]);
69
69
 
70
70
  const { processText } = useComposedText();
71
71
 
72
- const { items, isPending, hasError, refresh } =
72
+ const { items, isPending, hasError, notFound, refresh } =
73
73
  useBlockItems<KeyValueListBlockItem>({
74
74
  items: () => props.items,
75
75
  fetchUrl: props.fetchUrl,
@@ -104,7 +104,11 @@ const title = computed(() =>
104
104
  props.title ? processText(props.title) : undefined,
105
105
  );
106
106
  const isEmpty = computed(
107
- () => !isPending.value && !hasError.value && resolvedItems.value.length === 0,
107
+ () =>
108
+ !isPending.value &&
109
+ !hasError.value &&
110
+ !notFound.value &&
111
+ resolvedItems.value.length === 0,
108
112
  );
109
113
  // A card with a head gets its body padding from the list itself, so the rows'
110
114
  // hairlines run to the same inset as the head's.
@@ -122,6 +126,12 @@ const listClass = computed(() => (props.card ? "px-[18px] py-2" : undefined));
122
126
  :card="false"
123
127
  @retry="refresh()"
124
128
  />
129
+ <DmsBlockStatus
130
+ v-else-if="notFound"
131
+ state="not-found"
132
+ :card="false"
133
+ :not-found="notFound"
134
+ />
125
135
  <DmsBlockStatus
126
136
  v-else-if="isEmpty"
127
137
  state="empty"
@@ -4,17 +4,29 @@ import DmsSegmented from "../segmented/Segmented.vue";
4
4
  import { usePageLive } from "../../composables/live/usePageLive";
5
5
  import { formatElapsed } from "../../build/composables/live/formatElapsed";
6
6
  import { useLiveToggleShortcut } from "../../build/composables/live/useLiveToggleShortcut";
7
+ import { HIDDEN_TAB_NOTE } from "../../build/composables/live/pageLiveController";
8
+ import {
9
+ resolveConfirmText,
10
+ type ConfirmTranslate,
11
+ } from "../../build/composables/confirm/confirmDialogTexts";
12
+ import type { PageLiveNote, PageLiveNoteTone } from "../../types/live";
7
13
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
8
14
 
9
15
  interface LiveToggleBlockProps extends DefaultComponentProps {
10
16
  align?: "left" | "center" | "right";
11
17
  /** Shows "updated N s ago". */
12
18
  showUpdatedAt?: boolean;
19
+ /** `segmented` (Live / Pause segments) or `pill` (a pulsing Live pill). */
20
+ variant?: "segmented" | "pill";
21
+ /** What the paused toggle calls the missed events, given `{count}`. */
22
+ missedLabel?: string;
13
23
  }
14
24
 
15
25
  const props = withDefaults(defineProps<LiveToggleBlockProps>(), {
16
26
  align: "right",
17
27
  showUpdatedAt: true,
28
+ variant: "segmented",
29
+ missedLabel: undefined,
18
30
  });
19
31
 
20
32
  const DIRECT = "direct";
@@ -28,8 +40,17 @@ const ALIGN_CLASSES: Record<
28
40
  center: "justify-center",
29
41
  right: "justify-end",
30
42
  };
43
+ const TONE_CLASSES: Record<PageLiveNoteTone, string> = {
44
+ neutral: "",
45
+ warning: "text-warning",
46
+ error: "text-error",
47
+ };
48
+ const PILL_BUTTON_CLASS =
49
+ "focus-visible:outline-primary flex items-center gap-1.5 rounded-[5px] px-2 py-1 text-[12px] font-medium transition-colors focus-visible:outline-2";
50
+ const PILL_IDLE_CLASS = "text-muted hover:text-default hover:bg-elevated";
31
51
 
32
52
  const { t } = useI18n();
53
+ const translate = t as ConfirmTranslate;
33
54
  const live = usePageLive();
34
55
 
35
56
  // The state lives in the browser (its storage, its clock): the server and the
@@ -63,30 +84,134 @@ const updatedLabel = computed(() => {
63
84
  const at = live.lastUpdatedAt.value;
64
85
  if (at === null) return "";
65
86
  const { unit, count } = formatElapsed(now.value - at);
66
- return t(`dms.live.updated_${unit}`, { count }, count);
87
+ const updated = t(`dms.live.updated_${unit}`, { count }, count);
88
+ return live.isPolling.value
89
+ ? `${updated} · ${t("dms.live.polling")}`
90
+ : updated;
91
+ });
92
+
93
+ const noteText = (note: PageLiveNote) =>
94
+ resolveConfirmText(note.text, note.params ?? {}, translate);
95
+
96
+ const missedText = computed(() => {
97
+ const count = live.missedCount.value;
98
+ if (count === 0) return "";
99
+ return props.missedLabel
100
+ ? resolveConfirmText(props.missedLabel, { count }, translate)
101
+ : t("dms.live.new_rows", { count }, count);
102
+ });
103
+
104
+ // "Paused · typing in the Tester · 40 new requests", the detail apart: it
105
+ // takes its own tone.
106
+ const pausedText = computed(() => {
107
+ const reason = live.pauseReason.value;
108
+ return [
109
+ t("dms.live.paused"),
110
+ reason ? noteText(reason) : "",
111
+ missedText.value,
112
+ ]
113
+ .filter((part) => part !== "")
114
+ .join(" · ");
115
+ });
116
+
117
+ const detail = computed(() => {
118
+ const note = live.missedDetail.value;
119
+ if (!note || live.missedCount.value === 0) return null;
120
+ return {
121
+ text: noteText(note),
122
+ toneClass: TONE_CLASSES[note.tone ?? "neutral"],
123
+ };
67
124
  });
68
125
 
126
+ // A hidden tab resumes by itself when it shows again: no button for it.
127
+ const canResume = computed(
128
+ () =>
129
+ live.isPaused.value &&
130
+ live.pauseReason.value?.text !== HIDDEN_TAB_NOTE.text,
131
+ );
132
+
69
133
  useLiveToggleShortcut(() => live.toggle());
70
134
  </script>
71
135
 
72
136
  <template>
73
137
  <div
74
138
  v-if="isMounted && live.isEnabled.value"
75
- class="dms-live-toggle flex flex-wrap items-center gap-2.5"
76
- :class="ALIGN_CLASSES[align]"
139
+ class="dms-live-toggle flex flex-wrap items-center gap-x-2.5 gap-y-1"
140
+ :class="[ALIGN_CLASSES[align], `dms-live-toggle--${variant}`]"
77
141
  >
78
142
  <span
79
- v-if="showUpdatedAt"
143
+ v-if="live.isPaused.value"
144
+ class="dms-live-toggle__status text-muted min-w-0 text-[12.5px] tabular-nums"
145
+ aria-live="polite"
146
+ >
147
+ {{ pausedText }}
148
+ <span v-if="detail" :class="detail.toneClass">· {{ detail.text }}</span>
149
+ </span>
150
+ <span
151
+ v-else-if="showUpdatedAt"
80
152
  class="text-muted text-[12.5px] tabular-nums"
81
153
  aria-live="off"
82
154
  >
83
155
  {{ updatedLabel }}
84
156
  </span>
157
+ <div
158
+ v-if="variant === 'pill'"
159
+ role="group"
160
+ :aria-label="t('dms.live.aria_label')"
161
+ class="border-default bg-default inline-flex shrink-0 items-center gap-0.5 rounded-md border p-0.5"
162
+ >
163
+ <button
164
+ type="button"
165
+ data-value="direct"
166
+ :aria-pressed="live.isLive.value"
167
+ :class="[
168
+ PILL_BUTTON_CLASS,
169
+ live.isLive.value ? 'bg-success/10 text-success' : PILL_IDLE_CLASS,
170
+ ]"
171
+ @click="live.resume()"
172
+ >
173
+ <span
174
+ class="dms-live-toggle__dot size-2 rounded-full"
175
+ :class="
176
+ live.isLive.value
177
+ ? 'bg-success motion-safe:animate-pulse'
178
+ : 'bg-(--ui-border-accented)'
179
+ "
180
+ />
181
+ {{ t("dms.live.direct") }}
182
+ </button>
183
+ <button
184
+ type="button"
185
+ data-value="pause"
186
+ :aria-pressed="live.isPaused.value"
187
+ :class="[
188
+ PILL_BUTTON_CLASS,
189
+ live.isPaused.value
190
+ ? 'bg-elevated text-highlighted'
191
+ : PILL_IDLE_CLASS,
192
+ ]"
193
+ @click="live.pause()"
194
+ >
195
+ <UIcon name="i-ph-pause-fill" class="size-3" />
196
+ {{ t("dms.live.pause") }}
197
+ </button>
198
+ </div>
85
199
  <DmsSegmented
200
+ v-else
86
201
  v-model="mode"
87
202
  :items="items"
88
203
  :aria-label="t('dms.live.aria_label')"
89
204
  size="sm"
90
205
  />
206
+ <UButton
207
+ v-if="variant === 'pill' && canResume"
208
+ data-action="resume"
209
+ size="xs"
210
+ color="neutral"
211
+ variant="soft"
212
+ icon="i-ph-play-fill"
213
+ :label="t('dms.live.resume')"
214
+ @click="live.resume()"
215
+ />
91
216
  </div>
92
217
  </template>
@@ -94,6 +94,7 @@ const {
94
94
  items: blockItems,
95
95
  isPending,
96
96
  hasError,
97
+ notFound,
97
98
  refresh,
98
99
  } = useBlockItems<NavCardGridItem>({
99
100
  items: () => props.items,
@@ -161,6 +162,11 @@ const { t } = useI18n();
161
162
  "
162
163
  />
163
164
  <DmsBlockStatus v-if="hasError" state="error" @retry="refresh()" />
165
+ <DmsBlockStatus
166
+ v-else-if="notFound"
167
+ state="not-found"
168
+ :not-found="notFound"
169
+ />
164
170
  <DmsBlockStatus
165
171
  v-else-if="!isPending && cards.length === 0"
166
172
  state="empty"
@@ -61,7 +61,7 @@ const DEFAULT_SKELETON_COUNT = 4;
61
61
 
62
62
  const { processText } = useComposedText();
63
63
 
64
- const { items, isPending, hasError, refresh } =
64
+ const { items, isPending, hasError, notFound, refresh } =
65
65
  useBlockItems<StatGroupBlockItem>({
66
66
  items: () => props.items,
67
67
  fetchUrl: props.fetchUrl,
@@ -85,6 +85,11 @@ const resolvedItems = computed<StatGroupItem[]>(() =>
85
85
 
86
86
  <template>
87
87
  <DmsBlockStatus v-if="hasError" state="error" @retry="refresh()" />
88
+ <DmsBlockStatus
89
+ v-else-if="notFound"
90
+ state="not-found"
91
+ :not-found="notFound"
92
+ />
88
93
  <DmsBlockStatus
89
94
  v-else-if="!isPending && resolvedItems.length === 0"
90
95
  state="empty"
@@ -12,6 +12,7 @@ import {
12
12
  type ConfirmImpact,
13
13
  type ConfirmNotice,
14
14
  type ConfirmPartialOutcome,
15
+ type ConfirmSecondaryAction,
15
16
  ConfirmTextError,
16
17
  type ConfirmValues,
17
18
  } from "../../composables/confirm/types";
@@ -52,6 +53,11 @@ interface ConfirmModalProps {
52
53
  fields?: ConfirmDialogField[];
53
54
  /** A component drawn in the body, bound to the fields' values. */
54
55
  component?: ComponentInfo;
56
+ /**
57
+ * A button next to cancel and confirm that leaves the modal open: its
58
+ * `run` is awaited, the button loading; a rejection shows in the alert.
59
+ */
60
+ secondaryAction?: ConfirmSecondaryAction;
55
61
  /** Checks the body fields first; `false` keeps the modal open. */
56
62
  validate?: () => boolean | Promise<boolean>;
57
63
  /**
@@ -88,6 +94,7 @@ const props = withDefaults(defineProps<ConfirmModalProps>(), {
88
94
  confirmText: undefined,
89
95
  fields: () => [],
90
96
  component: undefined,
97
+ secondaryAction: undefined,
91
98
  validate: undefined,
92
99
  onConfirm: undefined,
93
100
  });
@@ -153,6 +160,7 @@ const bodyComponent = computed(() => resolveComponentRef(props.component));
153
160
  const isOpen = ref(true);
154
161
  const typedText = ref("");
155
162
  const isPending = ref(false);
163
+ const isSecondaryPending = ref(false);
156
164
  // The failure (or partial outcome) of the last confirm, above the buttons.
157
165
  const notice = ref<ConfirmNotice & { tone: "error" | "warning" }>();
158
166
  // The action went part of the way: nothing left to confirm, closing it
@@ -315,6 +323,20 @@ async function handleConfirm() {
315
323
  close(true);
316
324
  }
317
325
 
326
+ async function handleSecondary() {
327
+ const action = props.secondaryAction;
328
+ if (!action || isPending.value || isSecondaryPending.value) return;
329
+ isSecondaryPending.value = true;
330
+ notice.value = undefined;
331
+ try {
332
+ await action.run();
333
+ } catch (error) {
334
+ notice.value = { ...errorNotice(error), tone: "error" };
335
+ } finally {
336
+ isSecondaryPending.value = false;
337
+ }
338
+ }
339
+
318
340
  function handleCancel() {
319
341
  if (isPending.value) return;
320
342
  close(isSettled.value);
@@ -463,6 +485,17 @@ function handleCancel() {
463
485
 
464
486
  <template #footer>
465
487
  <div class="flex w-full items-center justify-end gap-2">
488
+ <UButton
489
+ v-if="props.secondaryAction"
490
+ :label="props.secondaryAction.label"
491
+ :icon="props.secondaryAction.icon"
492
+ variant="ghost"
493
+ color="neutral"
494
+ :loading="isSecondaryPending"
495
+ :disabled="isPending"
496
+ data-confirm-action="secondary"
497
+ @click="handleSecondary"
498
+ />
466
499
  <DmsModalEscHint
467
500
  class="max-sm:hidden"
468
501
  :label="
@@ -17,17 +17,35 @@ interface ChoiceCardsProps {
17
17
  /** Several cards picked: the value is the list of theirs. */
18
18
  multiple?: boolean;
19
19
  disabled?: boolean;
20
+ /**
21
+ * Cards per row once the field is 480px wide; one column below it.
22
+ * Defaults to 2: three options then wrap 2 + 1, `3` keeps them on one row.
23
+ */
24
+ columns?: number;
20
25
  }
21
26
 
22
- const props = defineProps<ChoiceCardsProps>();
27
+ const DEFAULT_COLUMNS = 2;
28
+
29
+ const props = withDefaults(defineProps<ChoiceCardsProps>(), {
30
+ id: undefined,
31
+ multiple: false,
32
+ disabled: false,
33
+ columns: DEFAULT_COLUMNS,
34
+ });
23
35
  const model = defineModel<ChoiceValue | ChoiceValue[] | null | undefined>();
24
36
 
25
37
  // Nuxt UI's choice cards, as the v2 theme draws them: the options side by
26
- // side, wrapping, each with its description.
38
+ // side, wrapping, each with its description. The column count is a custom
39
+ // property, so any `columns` needs no class of its own.
27
40
  const CARDS_UI = {
28
- fieldset: "grid gap-2.5 @min-[480px]:grid-cols-2",
41
+ fieldset:
42
+ "grid gap-2.5 @min-[480px]:grid-cols-[repeat(var(--dms-choice-columns),minmax(0,1fr))]",
29
43
  };
30
44
 
45
+ const columnsStyle = computed(() => ({
46
+ "--dms-choice-columns": String(Math.max(1, Math.round(props.columns))),
47
+ }));
48
+
31
49
  const items = computed(() => props.items as unknown as RadioGroupItem[]);
32
50
  const pickedMany = computed<ChoiceValue[]>({
33
51
  get: () => (Array.isArray(model.value) ? model.value : []),
@@ -41,7 +59,7 @@ const pickedOne = computed<ChoiceValue | undefined>({
41
59
  </script>
42
60
 
43
61
  <template>
44
- <div class="@container">
62
+ <div class="@container" :style="columnsStyle">
45
63
  <UCheckboxGroup
46
64
  v-if="props.multiple"
47
65
  :id="props.id"
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
4
+ import DmsEmptyStateBlock from "../blocks/EmptyStateBlock.vue";
5
+ import {
6
+ type GuardCondition,
7
+ type GuardFallback,
8
+ type GuardState,
9
+ useGuard,
10
+ } from "../../build/composables/blocks/useGuard";
11
+ import { notFoundTexts } from "../../build/utils/fetchNotFound";
12
+
13
+ // `Guard` block (interface-dms `base/guard`): renders its children unchanged
14
+ // unless a route says there is nothing for them, then one EmptyState (the
15
+ // `fallback`, with the answer's `emptyState` laid over it) in their place.
16
+ // `display: contents`, so the children lay out as if the guard were not there.
17
+ interface GuardProps extends DefaultComponentProps {
18
+ fetchUrl?: string;
19
+ fetchUrlMethod?: string;
20
+ periodScope?: string;
21
+ realtimeTopic?: string | string[];
22
+ when?: GuardCondition;
23
+ /** Dot path of the value `empty` checks. */
24
+ field?: string;
25
+ fallback?: GuardFallback;
26
+ }
27
+
28
+ const props = withDefaults(defineProps<GuardProps>(), {
29
+ fetchUrl: undefined,
30
+ fetchUrlMethod: undefined,
31
+ periodScope: undefined,
32
+ realtimeTopic: undefined,
33
+ when: "empty",
34
+ field: undefined,
35
+ fallback: undefined,
36
+ });
37
+
38
+ const { t } = useI18n();
39
+
40
+ const state = useGuard(
41
+ () => props,
42
+ () => props.routeParams,
43
+ );
44
+
45
+ // `notFound` draws what a block whose source answers 404 draws: the same
46
+ // title, the 404's message (or the same hint) and the same icon.
47
+ const DEFAULT_FALLBACKS: Record<
48
+ GuardCondition,
49
+ (state: GuardState) => GuardFallback
50
+ > = {
51
+ empty: () => ({ title: t("dms.blocks.empty") }),
52
+ notFound: ({ notFound }) => ({
53
+ ...notFoundTexts(notFound, t),
54
+ variant: "no-result",
55
+ }),
56
+ };
57
+
58
+ const fallback = computed<GuardFallback | null>(() => {
59
+ const current = state.value;
60
+ if (!current) return null;
61
+ return {
62
+ ...DEFAULT_FALLBACKS[current.condition](current),
63
+ ...props.fallback,
64
+ ...current.override,
65
+ };
66
+ });
67
+ </script>
68
+
69
+ <template>
70
+ <div class="contents" data-dms-guard>
71
+ <DmsEmptyStateBlock v-if="fallback" v-bind="fallback" />
72
+ <slot v-else />
73
+ </div>
74
+ </template>
@@ -8,6 +8,8 @@ import DmsEyebrow from "../section-header/Eyebrow.vue";
8
8
  import DmsTrendBadge from "../chart/internal/TrendBadge.vue";
9
9
  import DmsSparkline from "../chart/internal/Sparkline.vue";
10
10
  import { useChartFetch } from "../../composables/chart/useChartFetch";
11
+ import { readFetchNotFound } from "../../build/utils/fetchNotFound";
12
+ import DmsBlockInlineStatus from "../../build/components/blocks/BlockInlineStatus.vue";
11
13
  import {
12
14
  ABSENT_VALUE_PARTS,
13
15
  formatValueParts,
@@ -107,6 +109,10 @@ const isFirstLoad = computed(() => isLoading.value && data.value === null);
107
109
  const hasError = computed(
108
110
  () => !isLoading.value && data.value === null && Boolean(error.value),
109
111
  );
112
+ // A 404 is a status too, but nothing to retry: the value does not exist.
113
+ const notFound = computed(() =>
114
+ hasError.value ? readFetchNotFound(error.value) : null,
115
+ );
110
116
  // A refetch keeps the stale value on screen, faded, instead of a skeleton.
111
117
  const isRefreshing = computed(() => isLoading.value && data.value !== null);
112
118
  const showTrend = computed(() => props.showDelta && delta.value !== null);
@@ -201,24 +207,13 @@ const isStat = computed(() => props.variant === "stat");
201
207
  v-if="isFirstLoad"
202
208
  class="mt-1 h-[33px] w-32"
203
209
  />
204
- <div
210
+ <DmsBlockInlineStatus
205
211
  v-else-if="hasError"
206
- class="mt-auto flex items-center justify-between gap-2"
207
- role="alert"
208
- >
209
- <p class="text-error flex min-w-0 items-center gap-1.5 text-[12.5px]">
210
- <UIcon name="i-ph-warning-circle" class="size-4 shrink-0" />
211
- <span class="truncate">{{ t("dms.table.load_error_title") }}</span>
212
- </p>
213
- <UButton
214
- :label="t('dms.table.load_error_retry')"
215
- icon="i-ph-arrows-clockwise"
216
- color="neutral"
217
- variant="outline"
218
- size="xs"
219
- @click="refresh()"
220
- />
221
- </div>
212
+ class="mt-auto"
213
+ :state="notFound ? 'not-found' : 'error'"
214
+ :not-found="notFound"
215
+ @retry="refresh()"
216
+ />
222
217
  <p
223
218
  v-else
224
219
  class="text-highlighted mt-1 text-[30px] leading-[1.1] font-[650] tracking-[-0.035em] tabular-nums transition-opacity"