@antelopejs/dms 0.7.0 → 0.7.2

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 (110) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/hooks/tenant-instances.d.ts +1 -0
  3. package/dist/hooks/tenant-instances.js +13 -0
  4. package/dist/hooks/tenant-instances.js.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
  10. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
  12. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
  13. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
  14. package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
  15. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
  17. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  18. package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
  19. package/dist/test/unit/utils/tenant-instances.test.js +53 -0
  20. package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
  21. package/dist/utils/index.d.ts +1 -0
  22. package/dist/utils/index.js +1 -0
  23. package/dist/utils/index.js.map +1 -1
  24. package/dist/utils/tenant-instances.d.ts +2 -0
  25. package/dist/utils/tenant-instances.js +38 -0
  26. package/dist/utils/tenant-instances.js.map +1 -0
  27. package/dist/validation/auth.schema.d.ts +2 -2
  28. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
  29. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
  30. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
  31. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
  32. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
  33. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
  34. package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
  35. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
  36. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
  38. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
  40. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
  46. package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
  47. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
  48. package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
  49. package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
  50. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
  51. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
  52. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
  54. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
  55. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
  56. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
  57. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
  58. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
  59. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
  60. package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
  61. package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
  63. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
  64. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
  65. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
  66. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
  67. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
  68. package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
  69. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
  70. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
  71. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
  72. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
  73. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
  74. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
  75. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  76. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  77. package/frontend-vue/package.json +2 -2
  78. package/frontend-vue/pnpm-lock.yaml +5 -5
  79. package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
  80. package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
  81. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
  82. package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
  83. package/frontend-vue/tests/block-route-params.test.ts +48 -0
  84. package/frontend-vue/tests/code-block.test.ts +155 -0
  85. package/frontend-vue/tests/code-highlight.test.ts +72 -0
  86. package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
  87. package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
  88. package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
  89. package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
  90. package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
  91. package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
  92. package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
  93. package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
  94. package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
  95. package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
  96. package/frontend-vue/tests/page-fill-height.test.ts +20 -3
  97. package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
  98. package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
  99. package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
  100. package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
  101. package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
  102. package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
  103. package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
  104. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
  105. package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
  106. package/frontend-vue/tests/table-view-sources.test.ts +67 -0
  107. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -3
  110. package/skills/dms-pages/REFERENCE.md +17 -1
@@ -494,10 +494,14 @@ if (import.meta.env.DEV) {
494
494
  :class="stackClass"
495
495
  :css="animateLayout"
496
496
  >
497
+ <!-- `empty:hidden`: a block with nothing to show (a fetched Banner the
498
+ route answered `null`) renders a bare comment; its wrapper must not
499
+ take a gap of the stack. -->
497
500
  <div
498
501
  v-for="component in components"
499
502
  :key="component.id"
500
503
  :class="[
504
+ 'empty:hidden',
501
505
  { 'dms-fresh-modified': freshComponentIds.has(component.id) },
502
506
  component.id === fillingComponentId && fillColumnClass,
503
507
  ]"
@@ -3,6 +3,10 @@ import {
3
3
  regionalDayKey,
4
4
  regionalDayNumber,
5
5
  } from "#dms-core/app/utils/regional";
6
+ import type {
7
+ BlockText,
8
+ ComposedTextParam,
9
+ } from "#dms-core/app/types/composed-text";
6
10
  import type { Tone } from "../../../types/tone";
7
11
 
8
12
  /** One entry of an activity feed, as a page declares it or a source answers. */
@@ -10,19 +14,19 @@ export interface ActivityFeedItem {
10
14
  id?: string;
11
15
  icon?: string;
12
16
  tone?: Tone;
13
- /** Title (i18n key with `$` or literal). */
14
- title: string;
17
+ /** Title (i18n key with `$`, literal, or composed text). */
18
+ title: BlockText;
15
19
  /** Dimmed details, joined by "·". */
16
- meta?: string[];
20
+ meta?: BlockText[];
17
21
  /**
18
- * Values the title and the details interpolate; a `$`-prefixed value is
19
- * translated first.
22
+ * Values the `$` title and details interpolate: typed values are formatted
23
+ * as in a composed text; a `$`-prefixed string is translated first.
20
24
  */
21
- params?: Record<string, string>;
25
+ params?: Record<string, ComposedTextParam>;
22
26
  /** ISO date: files the entry under its day and gives its time. */
23
27
  date?: string;
24
- /** Literal trailing text, in place of the formatted time. */
25
- time?: string;
28
+ /** Trailing text, in place of the formatted time. */
29
+ time?: BlockText;
26
30
  unread?: boolean;
27
31
  /** Makes the row a link. */
28
32
  to?: string;
@@ -143,31 +147,39 @@ export function formatActivityTime(date: string, locale: string): string {
143
147
  }).format(parsed);
144
148
  }
145
149
 
146
- type ActivityTranslate = (
147
- key: string,
148
- params?: Record<string, string> | null,
149
- ) => string;
150
-
151
- const isTextKey = (value: string): boolean => value.startsWith("$");
150
+ const isTextKey = (value: ComposedTextParam): value is string =>
151
+ typeof value === "string" && value.startsWith("$");
152
152
 
153
153
  /**
154
- * The values an entry's title and details interpolate. A `$` value is a text
155
- * of its own, translated with the entry's literal values: "Signed in on
156
- * {device}" with `device` = "{browser} on {os}".
154
+ * The values an entry's title and details interpolate, as composed-text
155
+ * parameters. A `$` string is a text of its own, written with the entry's
156
+ * other values: "Signed in on {device}" with `device` = "{browser} on {os}".
157
157
  */
158
158
  export function resolveActivityParams(
159
- params: Record<string, string> | undefined,
160
- translate: ActivityTranslate,
161
- ): Record<string, string> | null {
162
- if (!params) return null;
159
+ params: Record<string, ComposedTextParam> | undefined,
160
+ ): Record<string, ComposedTextParam> | undefined {
161
+ if (!params) return undefined;
163
162
  const entries = Object.entries(params);
164
- const literals = Object.fromEntries(
163
+ const values = Object.fromEntries(
165
164
  entries.filter(([, value]) => !isTextKey(value)),
166
165
  );
167
166
  return Object.fromEntries(
168
167
  entries.map(([name, value]) => [
169
168
  name,
170
- isTextKey(value) ? translate(value, literals) : value,
169
+ isTextKey(value) ? { key: value, params: values } : value,
171
170
  ]),
172
171
  );
173
172
  }
173
+
174
+ /**
175
+ * A title or a detail of an entry as the text to write: a `$` key takes the
176
+ * entry's values, so a `count` value picks its plural form; a composed text
177
+ * carries its own values, and a literal is written as is.
178
+ */
179
+ export function activityText(
180
+ text: BlockText,
181
+ params: Record<string, ComposedTextParam> | undefined,
182
+ ): BlockText {
183
+ if (!isTextKey(text)) return text;
184
+ return { key: text, params: resolveActivityParams(params) };
185
+ }
@@ -2,6 +2,7 @@
2
2
  import type { ButtonProps } from "@nuxt/ui";
3
3
  import { buttonLinkProps } from "../../utils/link";
4
4
  import type { Tone } from "../../../types/tone";
5
+ import type { CustomButton } from "../../../composables/table-view/types/custom-button";
5
6
 
6
7
  /** A link button a block option declares (interface-dms `BlockLinkAction`). */
7
8
  export interface BlockAction {
@@ -13,12 +14,26 @@ export interface BlockAction {
13
14
  color?: Tone;
14
15
  }
15
16
 
16
- // The link buttons of a display block (empty state, banner, card head). One
17
+ /**
18
+ * A button running a target (interface-dms `BannerButtonAction`): the block
19
+ * that offers it runs it on `press`, with its confirmation.
20
+ */
21
+ export interface BlockButtonAction
22
+ extends Pick<CustomButton, "label" | "icon" | "target" | "confirm"> {
23
+ variant?: ButtonProps["variant"];
24
+ color?: Tone;
25
+ }
26
+
27
+ /** A link or a button a block offers. */
28
+ export type BlockActionItem = BlockAction | BlockButtonAction;
29
+
30
+ // The buttons of a display block (empty state, banner, card head): links, and
31
+ // in a banner, buttons running a target, which the block runs on `press`. One
17
32
  // action leads (`leadVariant` / `leadColor`): the first, or the last in a
18
33
  // banner where the main action sits right-most. The others are quiet buttons,
19
34
  // unless an action sets its own look. Labels follow the `$` i18n convention.
20
35
  interface BlockActionsProps {
21
- actions: BlockAction[];
36
+ actions: BlockActionItem[];
22
37
  size?: ButtonProps["size"];
23
38
  leadVariant?: ButtonProps["variant"];
24
39
  leadColor?: Tone;
@@ -36,8 +51,18 @@ const props = withDefaults(defineProps<BlockActionsProps>(), {
36
51
  leadPosition: "first",
37
52
  });
38
53
 
54
+ const emit = defineEmits<{ press: [action: BlockButtonAction] }>();
55
+
39
56
  const { processI18n } = useTranslation();
40
57
 
58
+ const isButtonAction = (action: BlockActionItem): action is BlockButtonAction =>
59
+ "target" in action && Boolean(action.target);
60
+
61
+ function actionProps(action: BlockActionItem): Record<string, unknown> {
62
+ if (!isButtonAction(action)) return buttonLinkProps(action.to);
63
+ return { onClick: () => emit("press", action) };
64
+ }
65
+
41
66
  function isLead(index: number): boolean {
42
67
  return props.leadPosition === "last"
43
68
  ? index === props.actions.length - 1
@@ -45,7 +70,7 @@ function isLead(index: number): boolean {
45
70
  }
46
71
 
47
72
  function buttonVariant(
48
- action: BlockAction,
73
+ action: BlockActionItem,
49
74
  index: number,
50
75
  ): ButtonProps["variant"] {
51
76
  return (
@@ -53,7 +78,10 @@ function buttonVariant(
53
78
  );
54
79
  }
55
80
 
56
- function buttonColor(action: BlockAction, index: number): ButtonProps["color"] {
81
+ function buttonColor(
82
+ action: BlockActionItem,
83
+ index: number,
84
+ ): ButtonProps["color"] {
57
85
  return action.color ?? (isLead(index) ? props.leadColor : "neutral");
58
86
  }
59
87
  </script>
@@ -61,12 +89,12 @@ function buttonColor(action: BlockAction, index: number): ButtonProps["color"] {
61
89
  <template>
62
90
  <UButton
63
91
  v-for="(action, index) in props.actions"
64
- :key="`${index}-${action.to}`"
92
+ :key="`${index}-${action.label}`"
65
93
  :label="processI18n(action.label ?? '')"
66
94
  :icon="action.icon"
67
95
  :size="props.size"
68
96
  :variant="buttonVariant(action, index)"
69
97
  :color="buttonColor(action, index)"
70
- v-bind="buttonLinkProps(action.to)"
98
+ v-bind="actionProps(action)"
71
99
  />
72
100
  </template>
@@ -39,6 +39,26 @@ const props = withDefaults(defineProps<DynamicDrawerProps>(), {
39
39
  });
40
40
  const emit = defineEmits<DynamicDrawerEmits>();
41
41
 
42
+ // A side drawer is a sheet as wide as a side panel by default (see
43
+ // `useAppSidePanels`), never wider than the window less the drawer's gutter.
44
+ // Its own padding is the gutter: the containers drop theirs.
45
+ const SIDE_DIRECTIONS: readonly DrawerDirection[] = ["left", "right"];
46
+ const SIDE_DRAWER_CONTENT = "w-[440px]";
47
+ const SIDE_CONTAINER_CLASS = "px-0 sm:px-0 lg:px-0";
48
+ const EDGE_CONTAINER_CLASS = "max-sm:px-0";
49
+
50
+ const isSide = computed(() => SIDE_DIRECTIONS.includes(props.direction));
51
+ const containerClass = computed(() =>
52
+ isSide.value ? SIDE_CONTAINER_CLASS : EDGE_CONTAINER_CLASS,
53
+ );
54
+ const drawerUi = computed(() => ({
55
+ ...(isSide.value && { content: SIDE_DRAWER_CONTENT }),
56
+ header: "border-b border-default flex shrink-0 items-center gap-4 pb-4",
57
+ // The scroll area's bottom padding: a form's sticky footer sits flush
58
+ // with the drawer's edge (see Form.vue).
59
+ container: "[--dms-form-foot-pb:1rem]",
60
+ }));
61
+
42
62
  // Let a DmsForm rendered in the body drop its DmsCard (the drawer is the surface).
43
63
  provide("dmsFormContainer", true);
44
64
 
@@ -104,19 +124,17 @@ function onContentResolve() {
104
124
  :description="description"
105
125
  :title="title"
106
126
  :dismissible="false"
107
- :ui="{
108
- header: 'border-b border-default flex shrink-0 items-center gap-4 pb-4',
109
- // The scroll area's bottom padding: a form's sticky footer sits flush
110
- // with the drawer's edge (see Form.vue).
111
- container: '[--dms-form-foot-pb:1rem]',
112
- }"
127
+ :ui="drawerUi"
113
128
  @close:prevent="tryClose"
114
129
  >
115
130
  <template #header>
116
131
  <!-- Same container as the body so the title aligns with the form. On
117
132
  phones the drawer's own padding is the 16px gutter: the container
118
133
  drops its own. -->
119
- <UContainer class="flex w-full flex-1 items-start gap-3 max-sm:px-0">
134
+ <UContainer
135
+ class="flex w-full flex-1 items-start gap-3"
136
+ :class="containerClass"
137
+ >
120
138
  <DmsIconWell v-if="icon" :icon="icon" :tone="color" />
121
139
  <div v-if="headerComponent" class="min-w-0 flex-1">
122
140
  <VisuallyHidden>
@@ -157,7 +175,7 @@ function onContentResolve() {
157
175
  </UContainer>
158
176
  </template>
159
177
  <template #body>
160
- <UContainer class="max-sm:px-0">
178
+ <UContainer :class="containerClass">
161
179
  <Suspense @pending="onContentPending" @resolve="onContentResolve">
162
180
  <component
163
181
  :is="component"
@@ -424,6 +424,7 @@ const uiTableActions = computed(() => uiTableActionsVariant());
424
424
  variant="ghost"
425
425
  size="sm"
426
426
  square
427
+ :aria-label="t('dms.table.search_open')"
427
428
  :class="uiTableActions.trigger()"
428
429
  @click="toggleSearch"
429
430
  />
@@ -433,7 +434,8 @@ const uiTableActions = computed(() => uiTableActionsVariant());
433
434
  v-if="searchActive"
434
435
  ref="searchInputRef"
435
436
  v-model="tableSharedData!.globalFilterState.value"
436
- :placeholder="t('dms.table.search_placeholder')"
437
+ :placeholder="searchPlaceholderText"
438
+ :aria-label="searchPlaceholderText"
437
439
  icon="i-ph-magnifying-glass"
438
440
  size="sm"
439
441
  class="w-full"
@@ -45,7 +45,7 @@ const tableSharedDataRef =
45
45
  injectLocal<ShallowRef<TableSharedData<T>>>("tableSharedData");
46
46
  const tableSharedData = computed(() => tableSharedDataRef?.value);
47
47
 
48
- // A search, a filter chip or a quick filter narrows the rows.
48
+ // A search, a filter chip, a quick filter or a hidden filter narrows the rows.
49
49
  const isFiltered = computed(() => !!tableSharedData.value?.isFiltered?.value);
50
50
  // What the clear action would clear; nothing (only default filters left at
51
51
  // their value) offers no action.
@@ -2,7 +2,8 @@
2
2
  import { useClipboard } from "@vueuse/core";
3
3
 
4
4
  // A value in the mono font (an id, a key, a slug), with a button copying it
5
- // that shows on hover.
5
+ // that shows on hover. The root is a block flex box held to its cell's
6
+ // width: an inline one grows with the value, which then never truncates.
6
7
 
7
8
  interface Props {
8
9
  value: string;
@@ -27,7 +28,7 @@ const copyValue = async () => {
27
28
  </script>
28
29
 
29
30
  <template>
30
- <span class="group/mono inline-flex min-w-0 items-center gap-1">
31
+ <span class="group/mono flex max-w-full min-w-0 items-center gap-1">
31
32
  <span class="text-toned truncate font-mono" :title="props.value">
32
33
  {{ props.value }}
33
34
  </span>
@@ -227,6 +227,12 @@ export interface TableProps<T> {
227
227
  bulkActions?: CustomRowAction[];
228
228
  /** What the empty body says, per reason it is empty. */
229
229
  emptyStates?: TableViewEmptyStatesConfig;
230
+ /**
231
+ * Filters the toolbar does not show narrow the rows (the page URL's
232
+ * `queryParamFilters`): the table reads as filtered, and its empty body
233
+ * says no row matches rather than that there are none yet.
234
+ */
235
+ hiddenFiltersApply?: boolean;
230
236
 
231
237
  data?: T[] | null;
232
238
  columns?: TableColumn<T>[];
@@ -330,7 +336,7 @@ export interface TableSharedData<T> {
330
336
  resetFilters: () => void;
331
337
  /** What `resetFilters` would clear (nothing: no clear action to offer). */
332
338
  clearableFilters: ComputedRef<ClearableTableFilters>;
333
- /** A search or a filter chip narrows the rows. */
339
+ /** A search, a filter chip or a hidden filter narrows the rows. */
334
340
  isFiltered: ComputedRef<boolean>;
335
341
  /** Runs after `resetFilters` (e.g. the toolbar folds its search). */
336
342
  onFiltersCleared: EventHookOn;
@@ -485,6 +491,9 @@ const theme = tv({
485
491
  // never hides past the card edge. Phones drop the content-column indent.
486
492
  expandedBody:
487
493
  "sticky start-0 w-[var(--dms-table-viewport,auto)] pt-4 pb-[18px] pe-[18px] ps-[18px] sm:ps-[68px]",
494
+ // The empty body centres on the visible part of a table wider than its
495
+ // card, not on the whole scroll width.
496
+ emptyBody: "sticky start-0 w-[var(--dms-table-viewport,auto)]",
488
497
 
489
498
  headCell: `${HEADER_MATCH_BG} ${FIRST_HEAD_CELL_GUTTER} border-b-default text-dimmed group relative touch-none select-none overflow-hidden border-b h-9 px-3.5 py-0 ${EYEBROW_CLASS} last:pe-2.5`,
490
499
  headCellInternal: "flex w-full items-center justify-between gap-1",
@@ -740,15 +749,14 @@ const onTableScroll = (event: Event) => {
740
749
  if (!hasScrollArea.value) return;
741
750
  isScrolled.value = (event.target as HTMLElement).scrollTop > 0;
742
751
  };
743
- // Expanded detail bands are as wide as the visible scroll area.
752
+ // Expanded detail bands, group headers and the empty body are as wide as
753
+ // the visible scroll area.
744
754
  const tableRootRef = useTemplateRef<HTMLElement>("tableRoot");
745
755
  const { width: tableViewportWidth } = useElementSize(tableRootRef);
746
756
  const tableRootStyle = computed(() => ({
747
757
  maxHeight: props.maxHeight,
748
758
  "--dms-table-viewport":
749
- (isExpandable || !!props.grouping) && tableViewportWidth.value > 0
750
- ? `${tableViewportWidth.value}px`
751
- : undefined,
759
+ tableViewportWidth.value > 0 ? `${tableViewportWidth.value}px` : undefined,
752
760
  }));
753
761
 
754
762
  // A clipped cell value reads in full in a native tooltip.
@@ -1059,6 +1067,7 @@ const narrowingState: TableNarrowingState = {
1059
1067
  globalFilter: globalFilterState,
1060
1068
  pagination: paginationState,
1061
1069
  searchApplies: computed(() => resolvedCapabilities.value.search),
1070
+ hiddenFiltersApply: computed(() => !!props.hiddenFiltersApply),
1062
1071
  };
1063
1072
  const filtersCleared = createEventHook();
1064
1073
  const resetFilters = () => {
@@ -1621,14 +1630,16 @@ defineShortcuts({
1621
1630
  "
1622
1631
  class="p-0"
1623
1632
  >
1624
- <TableEmpty
1625
- :can-add-row="
1626
- normalizeActionConfig(rowActions?.add).isEnabled
1627
- "
1628
- :load-error="loadError"
1629
- :archived="isShowingArchived"
1630
- :empty-states="emptyStates"
1631
- />
1633
+ <div :class="uiTable.emptyBody()">
1634
+ <TableEmpty
1635
+ :can-add-row="
1636
+ normalizeActionConfig(rowActions?.add).isEnabled
1637
+ "
1638
+ :load-error="loadError"
1639
+ :archived="isShowingArchived"
1640
+ :empty-states="emptyStates"
1641
+ />
1642
+ </div>
1632
1643
  </td>
1633
1644
  </tr>
1634
1645
  </tbody>
@@ -211,7 +211,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
211
211
  };
212
212
  return run.target.type === "modal"
213
213
  ? openModal({ ...options, size: run.target.size })
214
- : openDrawer(options);
214
+ : openDrawer({ ...options, direction: run.target.direction });
215
215
  };
216
216
 
217
217
  // A container opened on a listed row steps through the rows shown (its
@@ -19,6 +19,10 @@ export interface UseBlockItemsOptions<T> {
19
19
  */
20
20
  fetchUrl?: string;
21
21
  fetchUrlMethod?: string;
22
+ /** Id of the PeriodSelector whose period `fetchUrl` is requested with. */
23
+ periodScope?: string;
24
+ /** Topics whose events make the block read `fetchUrl` again. */
25
+ realtimeTopic?: string | string[];
22
26
  /** The parameters of the page route (the block's `routeParams` prop). */
23
27
  routeParams?: () => Record<string, string> | undefined;
24
28
  watchActions?: WatchAction[];
@@ -35,11 +39,12 @@ export interface UseBlockItemsReturn<T> {
35
39
  }
36
40
 
37
41
  /**
38
- * The items a list block (StatGroup, KeyValueList, NavCardGrid) renders:
39
- * either the static ones of its options, or the `items` its `fetchUrl`
40
- * answers with — refetched when a watched event changes the block's state or
41
- * the page asks its blocks to refresh (refreshPageBlocks), like the KPI and
42
- * top-list cards.
42
+ * The items a list block (StatGroup, KeyValueList, NavCardGrid, ActivityFeed)
43
+ * renders: either the static ones of its options, or the `items` its
44
+ * `fetchUrl` answers with — refetched when its period scope changes, a
45
+ * watched event changes the block's state, one of its realtime topics fires,
46
+ * or the page asks its blocks to refresh (refreshPageBlocks), like the KPI
47
+ * and top-list cards.
43
48
  */
44
49
  export function useBlockItems<T>(
45
50
  options: UseBlockItemsOptions<T>,
@@ -56,6 +61,8 @@ export function useBlockItems<T>(
56
61
  >({
57
62
  fetchUrl: options.fetchUrl,
58
63
  fetchUrlMethod: options.fetchUrlMethod,
64
+ periodScope: options.periodScope,
65
+ realtimeTopic: options.realtimeTopic,
59
66
  routeParams: options.routeParams,
60
67
  watchSource: () => watchKey.value,
61
68
  });
@@ -0,0 +1,40 @@
1
+ import { onBeforeUnmount, onMounted } from "vue";
2
+ import { useRealtimeTopic } from "../../../../../dms-core/app/composables/realtime/useRealtimeTopic";
3
+ import { onPageBlocksRefresh } from "../../../utils/blockRefresh";
4
+
5
+ /**
6
+ * Calls `onRefresh` whenever the data behind a block moved without its inputs
7
+ * changing: on each event of one of its realtime topics, and each time the
8
+ * page asks its blocks to refresh (refreshPageBlocks).
9
+ */
10
+ export function useRefreshTriggers(
11
+ realtimeTopic: string | string[] | undefined,
12
+ onRefresh: () => void,
13
+ ): void {
14
+ subscribeRealtimeTopics(realtimeTopic, onRefresh);
15
+ subscribePageRefresh(onRefresh);
16
+ }
17
+
18
+ function subscribeRealtimeTopics(
19
+ topic: string | string[] | undefined,
20
+ onEvent: () => void,
21
+ ): void {
22
+ if (!topic) return;
23
+ const topics = Array.isArray(topic) ? topic : [topic];
24
+ for (const entry of topics) {
25
+ useRealtimeTopic(entry, (event) => {
26
+ if (!("type" in event)) return;
27
+ onEvent();
28
+ });
29
+ }
30
+ }
31
+
32
+ // Mounted only: the page refresh is a browser event, and a block the server
33
+ // renders has nothing to refetch.
34
+ function subscribePageRefresh(onRefresh: () => void): void {
35
+ let unsubscribe: (() => void) | undefined;
36
+ onMounted(() => {
37
+ unsubscribe = onPageBlocksRefresh(onRefresh);
38
+ });
39
+ onBeforeUnmount(() => unsubscribe?.());
40
+ }
@@ -3,6 +3,7 @@ import type { DataType } from "#dms-core/app/composables/data-types/useDataType"
3
3
  import StatusPill from "../../../components/status-pill/StatusPill.vue";
4
4
  import MonoCell from "../../components/table/MonoCell.vue";
5
5
  import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
6
+ import { isDmsTone } from "../../utils/tone";
6
7
  import { readRowField, readSubline, toneClass, type Row } from "./cellHelpers";
7
8
 
8
9
  const Sparkline = defineAsyncComponent(
@@ -21,6 +22,8 @@ interface SelectItemOption {
21
22
 
22
23
  interface StatusPillOptions {
23
24
  tones?: Record<string, string>;
25
+ /** Row field holding the pill's tone, read before `tones`. */
26
+ toneField?: string;
24
27
  subField?: string;
25
28
  /** Tone of the line under the pill when the row gives none. */
26
29
  subTone?: string;
@@ -29,10 +32,18 @@ interface StatusPillOptions {
29
32
  typeOptions?: { items?: SelectItemOption[] };
30
33
  }
31
34
 
35
+ // A row field that holds no known tone gives way to the column's map.
36
+ function statusPillTone(opts: StatusPillOptions, key: string, row: Row) {
37
+ const rowTone = readRowField(row, opts.toneField);
38
+ if (typeof rowTone === "string" && isDmsTone(rowTone)) return rowTone;
39
+ return opts.tones?.[key] ?? "neutral";
40
+ }
41
+
32
42
  /**
33
- * `status_pill`: a tinted pill led by a dot, in the tone the value maps to,
34
- * with an optional line under it (a failure's cause, a composed "retry Oct
35
- * 10") in the row's tone, else `subTone`, else the pill's.
43
+ * `status_pill`: a tinted pill led by a dot, in the tone the row gives
44
+ * (`toneField`) or the value maps to, with an optional line under it (a
45
+ * failure's cause, a composed "retry Oct 10") in the row's tone, else
46
+ * `subTone`, else the pill's.
36
47
  */
37
48
  function renderStatusPill(value: unknown, options: unknown, row: Row) {
38
49
  const { processI18n } = useTranslation();
@@ -42,7 +53,7 @@ function renderStatusPill(value: unknown, options: unknown, row: Row) {
42
53
  const item = opts.typeOptions?.items?.find(
43
54
  (candidate) => String(candidate.value) === key,
44
55
  );
45
- const tone = opts.tones?.[key] ?? "neutral";
56
+ const tone = statusPillTone(opts, key, row);
46
57
  const pill = h(StatusPill, {
47
58
  tone,
48
59
  label: processI18n(item?.label ?? key),
@@ -18,7 +18,10 @@ import {
18
18
  import { registerMetricCellTypes } from "./metricCells";
19
19
  import { registerTwoLineCellType } from "./twoLineCell";
20
20
  import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
21
- import StatusPill from "../../../components/status-pill/StatusPill.vue";
21
+ import StatusPill, {
22
+ type StatusPillVariant,
23
+ } from "../../../components/status-pill/StatusPill.vue";
24
+ import { isDmsTone } from "../../utils/tone";
22
25
  import IdentityCell from "../../components/table-view/IdentityCell.vue";
23
26
  import {
24
27
  firstNameOf,
@@ -453,9 +456,76 @@ const PILL_FILLED_CLASS =
453
456
  "shrink-0 border-(--dms-accent-fill) bg-(--dms-accent-fill) font-[650] text-(--dms-accent-on-fill)";
454
457
 
455
458
  /**
456
- * `pills`: a list (relation rows, select values, strings) as v2 role pills,
457
- * or a single filled pill when the row's `exclusive.field` is set (an owner's
458
- * crown). A bare id a relation could not resolve (a deleted row) is skipped.
459
+ * A pill the server styles per row. Mirror of `DefaultDisplays.PillItem` from
460
+ * `@antelopejs/interface-dms/base/table-view`.
461
+ */
462
+ interface PillItem {
463
+ label: string;
464
+ tone?: string;
465
+ variant?: string;
466
+ }
467
+
468
+ const PILL_VARIANTS = new Set<string>(["soft", "outline"]);
469
+ const DEFAULT_PILL_VARIANT: StatusPillVariant = "soft";
470
+
471
+ const pillVariantOf = (variant: string | undefined): StatusPillVariant =>
472
+ variant && PILL_VARIANTS.has(variant)
473
+ ? (variant as StatusPillVariant)
474
+ : DEFAULT_PILL_VARIANT;
475
+ const DEFAULT_PILL_TONE = "neutral";
476
+
477
+ // An object with a `label` but no `labelKey` field: a related row carries
478
+ // that field, which tells the two apart.
479
+ const isPillItem = (item: unknown, labelKey: string): item is PillItem =>
480
+ isObject(item) && typeof item.label === "string" && !(labelKey in item);
481
+
482
+ /**
483
+ * The pills of a list. A bare id is skipped next to the related rows it
484
+ * could not be resolved into; next to pill items it is a pill.
485
+ */
486
+ function pillsOf(
487
+ items: unknown[],
488
+ labelKey: string,
489
+ processI18n: (text: string) => string,
490
+ ): PillItem[] {
491
+ const hasRelatedRows = items.some(
492
+ (item) => isObject(item) && !isPillItem(item, labelKey),
493
+ );
494
+ return items.flatMap((item): PillItem[] => {
495
+ if (isPillItem(item, labelKey)) {
496
+ return [{ ...item, label: processI18n(item.label) }];
497
+ }
498
+ if (isObject(item)) {
499
+ const label = (item as Record<string, unknown>)[labelKey];
500
+ return label === undefined ? [] : [{ label: processI18n(String(label)) }];
501
+ }
502
+ return hasRelatedRows ? [] : [{ label: processI18n(String(item)) }];
503
+ });
504
+ }
505
+
506
+ /** A plain pill in the role-pill outline, a styled one as a status pill. */
507
+ function renderPill(pill: PillItem) {
508
+ if (!pill.tone && !pill.variant) {
509
+ return h(
510
+ "span",
511
+ { class: [PILL_CLASS, PILL_OUTLINE_CLASS, PILL_SHRINK_CLASS] },
512
+ [h("span", { class: "truncate" }, pill.label)],
513
+ );
514
+ }
515
+ return h(StatusPill, {
516
+ label: pill.label,
517
+ tone: pill.tone && isDmsTone(pill.tone) ? pill.tone : DEFAULT_PILL_TONE,
518
+ variant: pillVariantOf(pill.variant),
519
+ dot: "none",
520
+ class: PILL_SHRINK_CLASS,
521
+ });
522
+ }
523
+
524
+ /**
525
+ * `pills`: a list (relation rows, select values, strings, pill items) as v2
526
+ * role pills, a pill item in its own tone and variant, or a single filled
527
+ * pill when the row's `exclusive.field` is set (an owner's crown). A bare id
528
+ * a relation could not resolve (a deleted row) is skipped.
459
529
  */
460
530
  function renderPills(value: unknown, options: unknown, row: Row) {
461
531
  const { processI18n } = useTranslation();
@@ -468,17 +538,13 @@ function renderPills(value: unknown, options: unknown, row: Row) {
468
538
  processI18n(opts.exclusive.label),
469
539
  ]);
470
540
  }
471
- const labelKey = opts.labelKey ?? DEFAULT_RELATION_LABEL_KEY;
472
541
  const items = Array.isArray(value) ? value : value == null ? [] : [value];
473
- const hasObjects = items.some((item) => isObject(item));
474
- const labels = items.flatMap((item) => {
475
- if (isObject(item)) {
476
- const label = (item as Record<string, unknown>)[labelKey];
477
- return label === undefined ? [] : [processI18n(String(label))];
478
- }
479
- return hasObjects ? [] : [processI18n(String(item))];
480
- });
481
- if (labels.length === 0) {
542
+ const pills = pillsOf(
543
+ items,
544
+ opts.labelKey ?? DEFAULT_RELATION_LABEL_KEY,
545
+ processI18n,
546
+ );
547
+ if (pills.length === 0) {
482
548
  return opts.emptyLabel
483
549
  ? h(
484
550
  "span",
@@ -491,14 +557,11 @@ function renderPills(value: unknown, options: unknown, row: Row) {
491
557
  // few letters); the list's tooltip names them all.
492
558
  return h(
493
559
  "span",
494
- { class: "flex min-w-0 gap-1 overflow-hidden", title: labels.join(", ") },
495
- labels.map((label) =>
496
- h(
497
- "span",
498
- { class: [PILL_CLASS, PILL_OUTLINE_CLASS, PILL_SHRINK_CLASS] },
499
- [h("span", { class: "truncate" }, label)],
500
- ),
501
- ),
560
+ {
561
+ class: "flex min-w-0 gap-1 overflow-hidden",
562
+ title: pills.map((pill) => pill.label).join(", "),
563
+ },
564
+ pills.map(renderPill),
502
565
  );
503
566
  }
504
567