@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.
- package/CHANGELOG.md +17 -0
- package/dist/implementations/dms/layout-banners.d.ts +4 -1
- package/dist/implementations/dms/layout-banners.js +33 -18
- package/dist/implementations/dms/layout-banners.js.map +1 -1
- package/dist/test/unit/implementations/dms/layout-banners.test.js +76 -0
- package/dist/test/unit/implementations/dms/layout-banners.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +70 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +38 -0
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/translation/useComposedText.ts +25 -0
- package/frontend-vue/layers/dms-core/app/types/composed-text.ts +64 -0
- package/frontend-vue/layers/dms-core/app/utils/composedText.ts +227 -0
- package/frontend-vue/layers/dms-core/app/utils/formatter.ts +49 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardBanners.vue +17 -3
- package/frontend-vue/layers/dms-layout/app/types/page.ts +7 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/IdentityCell.vue +36 -7
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +57 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +19 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +22 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/twoLineCell.ts +118 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/useColumnValueRenderer.ts +5 -3
- package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
- package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +8 -5
- package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +38 -18
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -23
- package/frontend-vue/tests/composed-text-blocks.test.ts +205 -0
- package/frontend-vue/tests/composed-text.test.ts +265 -0
- package/frontend-vue/tests/layout-banners.test.ts +56 -0
- package/frontend-vue/tests/table-cell-sublines.test.ts +466 -0
- package/frontend-vue/tests/table-metric-cells.test.ts +4 -0
- package/package.json +2 -2
- 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 {
|
|
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>{{
|
|
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
|
|
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
|
-
*
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
120
|
-
class="
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
-
{
|
|
54
|
-
|
|
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
|
|
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:
|
|
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
|
}
|