@antelopejs/dms 0.6.0 → 0.6.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 (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/implementations/dms/layout-banners.d.ts +4 -1
  3. package/dist/implementations/dms/layout-banners.js +33 -18
  4. package/dist/implementations/dms/layout-banners.js.map +1 -1
  5. package/dist/test/unit/implementations/dms/layout-banners.test.js +76 -0
  6. package/dist/test/unit/implementations/dms/layout-banners.test.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms-base/column-display.test.js +70 -0
  8. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +38 -0
  10. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  11. package/frontend-vue/layers/dms-core/app/composables/translation/useComposedText.ts +25 -0
  12. package/frontend-vue/layers/dms-core/app/types/composed-text.ts +64 -0
  13. package/frontend-vue/layers/dms-core/app/utils/composedText.ts +227 -0
  14. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +49 -0
  15. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardBanners.vue +17 -3
  16. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +5 -1
  17. package/frontend-vue/layers/dms-layout/app/types/page.ts +7 -1
  18. package/frontend-vue/layers/dms-ui/app/build/components/table-view/IdentityCell.vue +36 -7
  19. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/registerBlockFunctions.ts +7 -0
  20. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +11 -3
  21. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +57 -0
  22. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +19 -5
  23. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +22 -2
  24. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/twoLineCell.ts +118 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/useColumnValueRenderer.ts +5 -3
  26. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +1 -1
  27. package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
  28. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +63 -0
  29. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +1 -0
  30. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +8 -5
  31. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +38 -17
  32. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +1 -0
  33. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -22
  34. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +1 -0
  35. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +1 -0
  36. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +1 -0
  37. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +1 -0
  38. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +1 -0
  39. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +1 -0
  40. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +78 -16
  41. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +21 -53
  42. package/frontend-vue/layers/dms-ui/app/plugins/register.ts +2 -0
  43. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +28 -0
  44. package/frontend-vue/tests/block-page-refresh.test.ts +128 -0
  45. package/frontend-vue/tests/block-route-params.test.ts +190 -0
  46. package/frontend-vue/tests/composed-text-blocks.test.ts +205 -0
  47. package/frontend-vue/tests/composed-text.test.ts +265 -0
  48. package/frontend-vue/tests/layout-banners.test.ts +56 -0
  49. package/frontend-vue/tests/table-cell-sublines.test.ts +466 -0
  50. package/frontend-vue/tests/table-metric-cells.test.ts +4 -0
  51. package/package.json +2 -2
  52. package/skills/dms/SKILL.md +10 -0
@@ -0,0 +1,227 @@
1
+ import type {
2
+ BlockText,
3
+ ComposedText,
4
+ ComposedTextDateFormat,
5
+ ComposedTextDateParam,
6
+ ComposedTextMoneyParam,
7
+ ComposedTextNumberFormat,
8
+ ComposedTextNumberParam,
9
+ ComposedTextParam,
10
+ ComposedTextParamType,
11
+ ComposedTextRelativeDateParam,
12
+ ComposedTextTypedParam,
13
+ } from "../types/composed-text";
14
+ import {
15
+ readTranslationKey,
16
+ resolveI18nKey,
17
+ } from "../composables/translation/useTranslation";
18
+ import { formatDate, formatNumber, formatRelativeDistance } from "./formatter";
19
+
20
+ /** `t` as vue-i18n takes it: the key, the named values, the plural count. */
21
+ export type ComposedTextTranslate = (
22
+ key: string,
23
+ named: Record<string, unknown>,
24
+ plural?: number,
25
+ ) => string;
26
+
27
+ /** What a composed text is written with. */
28
+ export interface ComposedTextContext {
29
+ translate: ComposedTextTranslate;
30
+ /** The language the values are written in. */
31
+ locale: string;
32
+ /** The instant relative dates are measured from; defaults to now. */
33
+ now?: number;
34
+ }
35
+
36
+ /** Any value a text slot of a block may receive, fetched data included. */
37
+ export type ComposableText = BlockText | number | null | undefined;
38
+
39
+ type ParamFormatter<Param extends ComposedTextTypedParam> = (
40
+ param: Param,
41
+ context: ComposedTextContext,
42
+ ) => string;
43
+
44
+ type ParamFormatters = {
45
+ [Type in ComposedTextParamType]: ParamFormatter<
46
+ Extract<ComposedTextTypedParam, { type: Type }>
47
+ >;
48
+ };
49
+
50
+ // What a currency without a known minor unit is assumed to have, as most do.
51
+ const DEFAULT_MINOR_DIGITS = 2;
52
+ const DECIMAL_BASE = 10;
53
+ const DEFAULT_DATE_FORMAT: ComposedTextDateFormat = "medium";
54
+
55
+ const DATE_FORMATS: Record<ComposedTextDateFormat, Intl.DateTimeFormatOptions> =
56
+ {
57
+ day: { month: "short", day: "numeric" },
58
+ short: { dateStyle: "short" },
59
+ medium: { dateStyle: "medium" },
60
+ long: { dateStyle: "long" },
61
+ };
62
+
63
+ // Intl refuses `dateStyle` next to `hour`, so each format names its time the
64
+ // way its date is written.
65
+ const DATETIME_FORMATS: Record<
66
+ ComposedTextDateFormat,
67
+ Intl.DateTimeFormatOptions
68
+ > = {
69
+ day: { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" },
70
+ short: { dateStyle: "short", timeStyle: "short" },
71
+ medium: { dateStyle: "medium", timeStyle: "short" },
72
+ long: { dateStyle: "long", timeStyle: "short" },
73
+ };
74
+
75
+ const NUMBER_FORMATS: Record<
76
+ ComposedTextNumberFormat,
77
+ Intl.NumberFormatOptions
78
+ > = {
79
+ decimal: {},
80
+ percent: { style: "percent", maximumFractionDigits: 2 },
81
+ compact: { notation: "compact" },
82
+ };
83
+
84
+ /** Whether a value is a composed text rather than a plain string. */
85
+ export function isComposedText(value: unknown): value is ComposedText {
86
+ return (
87
+ typeof value === "object" &&
88
+ value !== null &&
89
+ typeof (value as ComposedText).key === "string"
90
+ );
91
+ }
92
+
93
+ function isTypedParam(value: unknown): value is ComposedTextTypedParam {
94
+ return (
95
+ typeof value === "object" &&
96
+ value !== null &&
97
+ typeof (value as ComposedTextTypedParam).type === "string"
98
+ );
99
+ }
100
+
101
+ function formatMoney(
102
+ param: ComposedTextMoneyParam,
103
+ { locale }: ComposedTextContext,
104
+ ): string {
105
+ try {
106
+ const format = new Intl.NumberFormat(locale, {
107
+ style: "currency",
108
+ currency: param.currency,
109
+ });
110
+ const digits =
111
+ format.resolvedOptions().maximumFractionDigits ?? DEFAULT_MINOR_DIGITS;
112
+ return format.format(param.value / DECIMAL_BASE ** digits);
113
+ } catch {
114
+ // A malformed currency code: the amount is still worth showing.
115
+ return String(
116
+ formatNumber(param.value / DECIMAL_BASE ** DEFAULT_MINOR_DIGITS, locale),
117
+ );
118
+ }
119
+ }
120
+
121
+ function dateFormatter(
122
+ formats: Record<ComposedTextDateFormat, Intl.DateTimeFormatOptions>,
123
+ ): ParamFormatter<ComposedTextDateParam> {
124
+ return (param, { locale }) =>
125
+ formatDate(
126
+ param.value,
127
+ locale,
128
+ formats[param.format ?? DEFAULT_DATE_FORMAT] ?? formats.medium,
129
+ ) ?? String(param.value);
130
+ }
131
+
132
+ function formatNumberParam(
133
+ param: ComposedTextNumberParam,
134
+ { locale }: ComposedTextContext,
135
+ ): string {
136
+ const options = NUMBER_FORMATS[param.format ?? "decimal"] ?? {};
137
+ return String(formatNumber(param.value, locale, options));
138
+ }
139
+
140
+ function formatRelativeParam(
141
+ param: ComposedTextRelativeDateParam,
142
+ { locale, now }: ComposedTextContext,
143
+ ): string {
144
+ // Intl throws on an invalid date: the raw value beats a broken page.
145
+ if (Number.isNaN(new Date(param.value).getTime())) return String(param.value);
146
+ return formatRelativeDistance(param.value, locale, { now });
147
+ }
148
+
149
+ const PARAM_FORMATTERS: ParamFormatters = {
150
+ money: formatMoney,
151
+ date: dateFormatter(DATE_FORMATS),
152
+ datetime: dateFormatter(DATETIME_FORMATS),
153
+ relative: formatRelativeParam,
154
+ number: formatNumberParam,
155
+ count: (param, { locale }) => String(formatNumber(param.value, locale)),
156
+ };
157
+
158
+ function formatParam(
159
+ param: ComposedTextParam,
160
+ context: ComposedTextContext,
161
+ ): string {
162
+ if (typeof param === "string") return param;
163
+ if (typeof param === "number") {
164
+ return String(formatNumber(param, context.locale));
165
+ }
166
+ if (isComposedText(param)) return composeText(param, context);
167
+ const formatter = PARAM_FORMATTERS[param.type] as
168
+ | ParamFormatter<ComposedTextTypedParam>
169
+ | undefined;
170
+ // A type from a newer backend: its raw value beats a hole in the sentence.
171
+ return formatter ? formatter(param, context) : String(param.value ?? "");
172
+ }
173
+
174
+ function numberOf(param: ComposedTextParam | undefined): number | undefined {
175
+ if (typeof param === "number") return param;
176
+ if (isTypedParam(param) && typeof param.value === "number") {
177
+ return param.value;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ function pluralCount(text: ComposedText): number | undefined {
183
+ const params = text.params ?? {};
184
+ const name =
185
+ text.plural ??
186
+ Object.keys(params).find((candidate) => {
187
+ const param = params[candidate];
188
+ return isTypedParam(param) && param.type === "count";
189
+ });
190
+ return name === undefined ? undefined : numberOf(params[name]);
191
+ }
192
+
193
+ function composeText(text: ComposedText, context: ComposedTextContext): string {
194
+ const { key } = readTranslationKey(text.key);
195
+ const named = Object.fromEntries(
196
+ Object.entries(text.params ?? {}).map(([name, param]) => [
197
+ name,
198
+ formatParam(param, context),
199
+ ]),
200
+ );
201
+ const plural = pluralCount(text);
202
+ return plural === undefined
203
+ ? context.translate(key, named)
204
+ : context.translate(key, named, plural);
205
+ }
206
+
207
+ /**
208
+ * Writes any text a block receives in the reader's language: a
209
+ * `ComposedText` is translated with its parameters formatted for `locale`
210
+ * (amounts, dates, counts and their plural), a `$`-prefixed string is
211
+ * translated, any other string is returned as written, a number is written
212
+ * for `locale`, and nothing is the empty string.
213
+ *
214
+ * A key the active language lacks falls back as vue-i18n does: to the
215
+ * fallback language, then to the bare key.
216
+ */
217
+ export function resolveComposedText(
218
+ text: ComposableText,
219
+ context: ComposedTextContext,
220
+ ): string {
221
+ if (text === null || text === undefined) return "";
222
+ if (typeof text === "number") {
223
+ return String(formatNumber(text, context.locale));
224
+ }
225
+ if (isComposedText(text)) return composeText(text, context);
226
+ return resolveI18nKey(context.translate, String(text));
227
+ }
@@ -119,6 +119,55 @@ export function formatRelativeTime(
119
119
  });
120
120
  }
121
121
 
122
+ interface RelativeUnit {
123
+ unit: Intl.RelativeTimeFormatUnit;
124
+ ms: number;
125
+ /** The unit applies below this distance. */
126
+ below: number;
127
+ }
128
+
129
+ // Months and years by their average length: a distance, not a calendar span.
130
+ const MONTH_MS = 30 * TIME_UNITS.d;
131
+ const YEAR_MS = 365 * TIME_UNITS.d;
132
+
133
+ const RELATIVE_UNITS: RelativeUnit[] = [
134
+ { unit: "minute", ms: TIME_UNITS.m, below: TIME_UNITS.h },
135
+ { unit: "hour", ms: TIME_UNITS.h, below: TIME_UNITS.d },
136
+ { unit: "day", ms: TIME_UNITS.d, below: TIME_UNITS.w },
137
+ { unit: "week", ms: TIME_UNITS.w, below: MONTH_MS },
138
+ { unit: "month", ms: MONTH_MS, below: YEAR_MS },
139
+ { unit: "year", ms: YEAR_MS, below: Number.POSITIVE_INFINITY },
140
+ ];
141
+
142
+ export interface RelativeDistanceOptions {
143
+ /** `long` ("in 3 hours") by default; `short` ("in 3 hr.") for tight cells. */
144
+ style?: Intl.RelativeTimeFormatStyle;
145
+ /** The instant the distance is measured from; defaults to now. */
146
+ now?: number;
147
+ }
148
+
149
+ /**
150
+ * "in 3 days", "tomorrow", "2 hours ago": the distance between `date` and
151
+ * now, past or future, in the largest unit that keeps it a whole number. Not
152
+ * capitalised, so it reads inside a sentence.
153
+ */
154
+ export function formatRelativeDistance(
155
+ date: string | number | Date,
156
+ locale: string,
157
+ { style = "long", now = Date.now() }: RelativeDistanceOptions = {},
158
+ ): string {
159
+ const deltaMs = new Date(date).getTime() - now;
160
+ const distance = Math.abs(deltaMs);
161
+ const unit =
162
+ RELATIVE_UNITS.find((candidate) => distance < candidate.below) ??
163
+ RELATIVE_UNITS[RELATIVE_UNITS.length - 1]!;
164
+ const value = Math.round(deltaMs / unit.ms);
165
+ return new Intl.RelativeTimeFormat(locale, { numeric: "auto", style }).format(
166
+ value,
167
+ unit.unit,
168
+ );
169
+ }
170
+
122
171
  const defaultDateFormatterOptions: Intl.DateTimeFormatOptions = {
123
172
  year: "numeric",
124
173
  month: "long",
@@ -1,11 +1,16 @@
1
1
  <script setup lang="ts">
2
+ import DmsBlockActions from "#dms-ui/app/build/components/blocks/BlockActions.vue";
3
+ import { useComposedText } from "#dms-core/app/composables/translation/useComposedText";
4
+
2
5
  /**
3
6
  * Stacks the global layout banners under the dashboard header, on every page
4
7
  * of the dashboard layout. Each banner carries its own live-region role, so a
5
- * banner appearing after a navigation is announced as well.
8
+ * banner appearing after a navigation is announced as well. A text is a
9
+ * string or a composed text, written in the reader's language, followed by
10
+ * the banner's link buttons.
6
11
  */
7
12
  const { banners, dismiss } = useLayoutBanners();
8
- const { processI18n } = useTranslation();
13
+ const { processText } = useComposedText();
9
14
 
10
15
  // Rows read as tinted bands (design .banner strip): no ring, the colour kept
11
16
  // for the icon and the hairline between stacked rows.
@@ -63,7 +68,16 @@ const entries = computed(() =>
63
68
  v-if="banner.component"
64
69
  v-bind="banner.props"
65
70
  />
66
- <template v-else>{{ processI18n(banner.text ?? "") }}</template>
71
+ <template v-else>{{ processText(banner.text) }}</template>
72
+ </template>
73
+ <template v-if="!banner.component && banner.actions?.length" #actions>
74
+ <DmsBlockActions
75
+ :actions="banner.actions"
76
+ size="xs"
77
+ lead-position="last"
78
+ :lead-color="presentation.color"
79
+ rest-variant="ghost"
80
+ />
67
81
  </template>
68
82
  </UAlert>
69
83
  </div>
@@ -14,6 +14,7 @@ import { usePermissionPreview } from "#dms-core/app/build/composables/auth/usePe
14
14
  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
+ import { refreshPageBlocks } from "#dms-ui/app/utils/blockRefresh";
17
18
  import {
18
19
  dispatchQuickActionTarget,
19
20
  findServedQuickAction,
@@ -60,11 +61,14 @@ const router = useDmsRouter();
60
61
  const { $authFetch } = useAuthFetch();
61
62
 
62
63
  // The page header runs a declared button's target as a table runs its
63
- // toolbar buttons: confirmation, drawer, modal, API call, export, link.
64
+ // toolbar buttons: confirmation, drawer, modal, API call, export, link. A
65
+ // button that changed something (an API call, a form it opened) changes the
66
+ // record the page shows: its blocks read their data again.
64
67
  const { handleCustomButton } = useActionTargets({
65
68
  api: $authFetch,
66
69
  componentId: "page-header",
67
70
  pageId: siteLayout.findMatchingRoute(route.path)?.metadata?.fullId ?? "page",
71
+ refreshCallback: refreshPageBlocks,
68
72
  handleApiError: (error, title) => useApiError(error, { title }),
69
73
  });
70
74
 
@@ -1,3 +1,6 @@
1
+ import type { BlockText } from "#dms-core/app/types/composed-text";
2
+ import type { BlockAction } from "#dms-ui/app/build/components/blocks/BlockActions.vue";
3
+
1
4
  export type MenuItemType = "label" | "link";
2
5
  // MenuItemVariant / MenuItemStatus / MenuItemQuery live in dms-core, the layer
3
6
  // that renders them: this one only consumes them.
@@ -101,7 +104,10 @@ export interface LayoutBanner {
101
104
  order: number;
102
105
  dismissible: boolean;
103
106
  icon?: string;
104
- text?: string;
107
+ /** A string (`$` for an i18n key) or a composed text. */
108
+ text?: BlockText;
109
+ /** Link buttons after the text; the last one leads. */
110
+ actions?: BlockAction[];
105
111
  component?: string;
106
112
  props?: Record<string, unknown>;
107
113
  }
@@ -5,7 +5,9 @@ import { useFileReadUrls } from "../../../composables/useFileReadUrls";
5
5
  /**
6
6
  * The `identity` cell of a table: an avatar (or an icon tile), a name with an
7
7
  * optional "You" tag and status badges, and a secondary line (an address).
8
- * Rendered by the `identity` data type, which reads the extras off the row.
8
+ * A row without a name may stand as a muted `emptyLabel` and an `emptyIcon`
9
+ * tile ("Automatic" and a robot). Rendered by the `identity` data type, which
10
+ * reads the extras off the row.
9
11
  */
10
12
  interface IdentityCellBadge {
11
13
  label: string;
@@ -18,6 +20,8 @@ interface IdentityCellProps {
18
20
  title?: string;
19
21
  /** Secondary line under the title. */
20
22
  subtitle?: string;
23
+ /** Text classes of the secondary line (its tone). */
24
+ subtitleClass?: string;
21
25
  /** Image value (`{ key }`, resolved through the file API) or a URL. */
22
26
  avatar?: { key: string; alt?: string } | string | null;
23
27
  /** Icon drawn in a tile instead of an avatar. */
@@ -30,17 +34,24 @@ interface IdentityCellProps {
30
34
  badges?: IdentityCellBadge[];
31
35
  /** Storage the avatar's file key lives in. */
32
36
  storage?: string;
37
+ /** Name drawn, muted, when there is no title. */
38
+ emptyLabel?: string;
39
+ /** Icon tile drawn when there is no title; `icon` otherwise. */
40
+ emptyIcon?: string;
33
41
  }
34
42
 
35
43
  const props = withDefaults(defineProps<IdentityCellProps>(), {
36
44
  title: "",
37
45
  subtitle: "",
46
+ subtitleClass: "text-dimmed",
38
47
  avatar: null,
39
48
  icon: undefined,
40
49
  selfId: undefined,
41
50
  selfLabel: undefined,
42
51
  badges: () => [],
43
52
  storage: undefined,
53
+ emptyLabel: undefined,
54
+ emptyIcon: undefined,
44
55
  });
45
56
 
46
57
  const { user } = useCurrentUser();
@@ -49,7 +60,22 @@ const { getUrl, resolve } = useFileReadUrls(props.storage);
49
60
  const MAX_INITIALS = 2;
50
61
  const INITIALS_SPLIT = /[\s.@_-]+/;
51
62
 
63
+ const isEmpty = computed(
64
+ () => !props.title && !!(props.emptyLabel || props.emptyIcon),
65
+ );
52
66
  const label = computed(() => props.title || props.subtitle);
67
+ const name = computed(() =>
68
+ isEmpty.value && props.emptyLabel ? props.emptyLabel : label.value,
69
+ );
70
+ const tileIcon = computed(() =>
71
+ isEmpty.value ? (props.emptyIcon ?? props.icon) : props.icon,
72
+ );
73
+ // Without a title the subtitle stands as the name, unless an empty label does.
74
+ const hasSubtitleLine = computed(
75
+ () =>
76
+ !!props.subtitle &&
77
+ (!!props.title || (isEmpty.value && !!props.emptyLabel)),
78
+ );
53
79
  const isSelf = computed(
54
80
  () => !!props.selfId && props.selfId === user.value?._id,
55
81
  );
@@ -79,10 +105,10 @@ const initials = computed(() =>
79
105
  ellipsis, the full label in their tooltip), then the name. -->
80
106
  <div class="flex min-w-0 items-center gap-2.5">
81
107
  <span
82
- v-if="props.icon"
108
+ v-if="tileIcon"
83
109
  class="bg-accented text-toned grid size-7 shrink-0 place-items-center rounded-[7px]"
84
110
  >
85
- <UIcon :name="props.icon" class="size-3.5" />
111
+ <UIcon :name="tileIcon" class="size-3.5" />
86
112
  </span>
87
113
  <UAvatar
88
114
  v-else
@@ -94,9 +120,10 @@ const initials = computed(() =>
94
120
  />
95
121
  <div class="min-w-0">
96
122
  <span
97
- class="text-highlighted flex min-w-0 items-center gap-1.5 leading-[1.3] font-semibold"
123
+ class="flex min-w-0 items-center gap-1.5 leading-[1.3]"
124
+ :class="isEmpty ? 'text-muted' : 'text-highlighted font-semibold'"
98
125
  >
99
- <span class="truncate" :title="label">{{ label }}</span>
126
+ <span class="truncate" :title="name">{{ name }}</span>
100
127
  <span
101
128
  v-if="isSelf && props.selfLabel"
102
129
  class="text-dimmed shrink-0 font-mono text-[10px] font-semibold tracking-[0.06em] uppercase"
@@ -116,8 +143,10 @@ const initials = computed(() =>
116
143
  />
117
144
  </span>
118
145
  <span
119
- v-if="props.title && props.subtitle"
120
- class="text-dimmed block truncate text-xs leading-[1.2]"
146
+ v-if="hasSubtitleLine"
147
+ class="block truncate text-xs leading-[1.2]"
148
+ :class="props.subtitleClass"
149
+ :title="props.subtitle"
121
150
  >
122
151
  {{ props.subtitle }}
123
152
  </span>
@@ -0,0 +1,7 @@
1
+ import { BlockFunctions, refreshPageBlocks } from "../../../utils/blockRefresh";
2
+
3
+ export function registerBlockFunctions() {
4
+ const { registerFunction } = useDefinedFunctions();
5
+
6
+ registerFunction(BlockFunctions.REFRESH_PAGE, () => refreshPageBlocks());
7
+ }
@@ -12,9 +12,15 @@ export interface BlockItemsResponse<T> {
12
12
  export interface UseBlockItemsOptions<T> {
13
13
  /** The items written in the block's options (read reactively). */
14
14
  items: () => T[] | undefined;
15
- /** Route answering `{ items }`; when set it replaces the static items. */
15
+ /**
16
+ * Route answering `{ items }`; when set it replaces the static items. Its
17
+ * `{{params.X}}` and `{{query.X}}` tokens are filled from the page URL, and
18
+ * nothing is requested while one has no value (see useChartFetch).
19
+ */
16
20
  fetchUrl?: string;
17
21
  fetchUrlMethod?: string;
22
+ /** The parameters of the page route (the block's `routeParams` prop). */
23
+ routeParams?: () => Record<string, string> | undefined;
18
24
  watchActions?: WatchAction[];
19
25
  componentId?: string;
20
26
  }
@@ -31,8 +37,9 @@ export interface UseBlockItemsReturn<T> {
31
37
  /**
32
38
  * The items a list block (StatGroup, KeyValueList, NavCardGrid) renders:
33
39
  * either the static ones of its options, or the `items` its `fetchUrl`
34
- * answers with — refetched when a watched event changes the block's state,
35
- * like the KPI and top-list cards.
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.
36
43
  */
37
44
  export function useBlockItems<T>(
38
45
  options: UseBlockItemsOptions<T>,
@@ -49,6 +56,7 @@ export function useBlockItems<T>(
49
56
  >({
50
57
  fetchUrl: options.fetchUrl,
51
58
  fetchUrlMethod: options.fetchUrlMethod,
59
+ routeParams: options.routeParams,
52
60
  watchSource: () => watchKey.value,
53
61
  });
54
62
 
@@ -1,4 +1,9 @@
1
1
  import { get } from "@nuxt/ui/runtime/utils/index.js";
2
+ import type { BlockText } from "../../../../../dms-core/app/types/composed-text";
3
+ import {
4
+ type ComposableText,
5
+ isComposedText,
6
+ } from "../../../../../dms-core/app/utils/composedText";
2
7
 
3
8
  // What the cell data types share: reading a sibling field off the row, and
4
9
  // the text tones a cell takes.
@@ -37,3 +42,55 @@ export const toneClass = (
37
42
  tone: string | undefined,
38
43
  fallback = "default",
39
44
  ): string => CELL_TONES[tone ?? fallback] ?? CELL_TONES[fallback]!;
45
+
46
+ /**
47
+ * A cell's secondary line with its tone, as a row field holds it. Mirror of
48
+ * `CellSubline` from `@antelopejs/interface-dms/base/table-view`.
49
+ */
50
+ export interface CellSubline {
51
+ text: BlockText;
52
+ tone?: string;
53
+ }
54
+
55
+ /** A secondary line written in the reader's language, ready to draw. */
56
+ export interface ResolvedSubline {
57
+ text: string;
58
+ /** The row's own tone, when it gives one. */
59
+ tone?: string;
60
+ }
61
+
62
+ /** How a display reads the row field of its secondary line. */
63
+ export interface SublineReading {
64
+ processText: (text: ComposableText) => string;
65
+ /**
66
+ * Whether a bare string is a block text (`$`-prefixed for an i18n key)
67
+ * rather than data drawn as written (an address under a name).
68
+ */
69
+ isStringTranslated: boolean;
70
+ }
71
+
72
+ const isCellSubline = (value: unknown): value is CellSubline =>
73
+ typeof value === "object" &&
74
+ value !== null &&
75
+ "text" in value &&
76
+ !isComposedText(value);
77
+
78
+ const toSubline = (text: string, tone?: string): ResolvedSubline | undefined =>
79
+ text ? { text, tone } : undefined;
80
+
81
+ /**
82
+ * The secondary line a row field holds — a string, a composed text, or a text
83
+ * and its tone — or nothing for an empty field.
84
+ */
85
+ export function readSubline(
86
+ value: unknown,
87
+ { processText, isStringTranslated }: SublineReading,
88
+ ): ResolvedSubline | undefined {
89
+ if (isCellSubline(value)) {
90
+ return toSubline(processText(value.text), value.tone);
91
+ }
92
+ if (isComposedText(value)) return toSubline(processText(value));
93
+ const plain = stringOf(value);
94
+ if (!plain) return undefined;
95
+ return toSubline(isStringTranslated ? processText(plain) : plain);
96
+ }
@@ -2,7 +2,8 @@ import { defineAsyncComponent, h } from "vue";
2
2
  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
- import { readRowField, stringOf, toneClass, type Row } from "./cellHelpers";
5
+ import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
6
+ import { readRowField, readSubline, toneClass, type Row } from "./cellHelpers";
6
7
 
7
8
  const Sparkline = defineAsyncComponent(
8
9
  () => import("../../../components/chart/internal/Sparkline.vue"),
@@ -21,6 +22,8 @@ interface SelectItemOption {
21
22
  interface StatusPillOptions {
22
23
  tones?: Record<string, string>;
23
24
  subField?: string;
25
+ /** Tone of the line under the pill when the row gives none. */
26
+ subTone?: string;
24
27
  liveValues?: string[];
25
28
  /** The column's own type options: a select's items name the pill. */
26
29
  typeOptions?: { items?: SelectItemOption[] };
@@ -28,10 +31,12 @@ interface StatusPillOptions {
28
31
 
29
32
  /**
30
33
  * `status_pill`: a tinted pill led by a dot, in the tone the value maps to,
31
- * with an optional line under it in the same tone (a failure's cause).
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.
32
36
  */
33
37
  function renderStatusPill(value: unknown, options: unknown, row: Row) {
34
38
  const { processI18n } = useTranslation();
39
+ const { processText } = useComposedText();
35
40
  const opts = (options ?? {}) as StatusPillOptions;
36
41
  const key = String(value);
37
42
  const item = opts.typeOptions?.items?.find(
@@ -44,14 +49,23 @@ function renderStatusPill(value: unknown, options: unknown, row: Row) {
44
49
  icon: item?.icon,
45
50
  dot: opts.liveValues?.includes(key) ? "live" : "static",
46
51
  });
47
- const sub = stringOf(readRowField(row, opts.subField));
52
+ const sub = readSubline(readRowField(row, opts.subField), {
53
+ processText,
54
+ isStringTranslated: true,
55
+ });
48
56
  if (!sub) return pill;
49
57
  return h("span", { class: "flex min-w-0 flex-col items-start gap-0.5" }, [
50
58
  pill,
51
59
  h(
52
60
  "span",
53
- { class: ["max-w-full truncate text-xs", toneClass(tone, "muted")] },
54
- processI18n(sub),
61
+ {
62
+ class: [
63
+ "max-w-full truncate text-xs",
64
+ toneClass(sub.tone ?? opts.subTone ?? tone, "muted"),
65
+ ],
66
+ title: sub.text,
67
+ },
68
+ sub.text,
55
69
  ),
56
70
  ]);
57
71
  }