@antelopejs/dms 0.6.1 → 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 (33) hide show
  1. package/CHANGELOG.md +17 -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/types/page.ts +7 -1
  17. package/frontend-vue/layers/dms-ui/app/build/components/table-view/IdentityCell.vue +36 -7
  18. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +57 -0
  19. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +19 -5
  20. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +22 -2
  21. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/twoLineCell.ts +118 -0
  22. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/useColumnValueRenderer.ts +5 -3
  23. package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
  24. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +8 -5
  25. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +38 -18
  26. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -23
  27. package/frontend-vue/tests/composed-text-blocks.test.ts +205 -0
  28. package/frontend-vue/tests/composed-text.test.ts +265 -0
  29. package/frontend-vue/tests/layout-banners.test.ts +56 -0
  30. package/frontend-vue/tests/table-cell-sublines.test.ts +466 -0
  31. package/frontend-vue/tests/table-metric-cells.test.ts +4 -0
  32. package/package.json +2 -2
  33. package/skills/dms/SKILL.md +7 -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>
@@ -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>
@@ -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
  }
@@ -10,11 +10,14 @@ import { mapRelationBeforeState, sameRelationValue } from "./relationValue";
10
10
  import {
11
11
  isSet,
12
12
  readRowField,
13
+ readSubline,
13
14
  type Row,
14
15
  stringOf,
15
16
  toneClass,
16
17
  } from "./cellHelpers";
17
18
  import { registerMetricCellTypes } from "./metricCells";
19
+ import { registerTwoLineCellType } from "./twoLineCell";
20
+ import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
18
21
  import StatusPill from "../../../components/status-pill/StatusPill.vue";
19
22
  import IdentityCell from "../../components/table-view/IdentityCell.vue";
20
23
  import {
@@ -381,10 +384,15 @@ interface IdentityOptions {
381
384
  avatarField?: string;
382
385
  icon?: string;
383
386
  subtitleField?: string;
387
+ subtitleTone?: string;
384
388
  selfField?: string;
385
389
  selfLabel?: string;
386
390
  badges?: IdentityBadgeOption[];
387
391
  storage?: string;
392
+ /** Name drawn, muted, for a row without a value. */
393
+ emptyLabel?: string;
394
+ /** Icon tile of a row without a value. */
395
+ emptyIcon?: string;
388
396
  }
389
397
 
390
398
  const matchesBadge = (badge: IdentityBadgeOption, value: unknown): boolean =>
@@ -394,17 +402,28 @@ const matchesBadge = (badge: IdentityBadgeOption, value: unknown): boolean =>
394
402
 
395
403
  /**
396
404
  * `identity`: avatar or icon tile, the value as the name, a "You" tag on the
397
- * signed-in user's row, badges and a secondary line, all read off the row.
405
+ * signed-in user's row, badges and a secondary line (an address as written,
406
+ * or a composed text in its tone), all read off the row; `emptyLabel` and
407
+ * `emptyIcon` stand for a row without a value.
398
408
  */
399
409
  function renderIdentity(value: unknown, options: unknown, row: Row) {
400
410
  const { processI18n } = useTranslation();
411
+ const { processText } = useComposedText();
401
412
  const opts = (options ?? {}) as IdentityOptions;
402
413
  const badges = (opts.badges ?? [])
403
414
  .filter((badge) => matchesBadge(badge, readRowField(row, badge.field)))
404
415
  .map((badge) => ({ label: processI18n(badge.label), color: badge.tone }));
416
+ // An address or an id is data: only a composed text is translated.
417
+ const subtitle = readSubline(readRowField(row, opts.subtitleField), {
418
+ processText,
419
+ isStringTranslated: false,
420
+ });
405
421
  return h(IdentityCell, {
406
422
  title: stringOf(value) ?? "",
407
- subtitle: stringOf(readRowField(row, opts.subtitleField)) ?? "",
423
+ subtitle: subtitle?.text ?? "",
424
+ subtitleClass: toneClass(subtitle?.tone ?? opts.subtitleTone, "dimmed"),
425
+ emptyLabel: opts.emptyLabel ? processI18n(opts.emptyLabel) : undefined,
426
+ emptyIcon: opts.emptyIcon,
408
427
  avatar: readRowField(row, opts.avatarField) as
409
428
  | { key: string }
410
429
  | string
@@ -843,5 +862,6 @@ export function registerDefaultDataTypes() {
843
862
  registerDisplayOnlyTypes(registerDataType);
844
863
  registerCellTypes(registerDataType);
845
864
  registerMetricCellTypes(registerDataType);
865
+ registerTwoLineCellType(registerDataType);
846
866
  registerFieldTypes(registerDataType);
847
867
  }