@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.
- package/CHANGELOG.md +33 -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/build/components/layout/PageHeaderActionBar.vue +5 -1
- 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/blocks/registerBlockFunctions.ts +7 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +11 -3
- 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/composables/table-view/registerDefaultFunctions.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +63 -0
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +1 -0
- 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 -17
- package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -22
- package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +78 -16
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +21 -53
- package/frontend-vue/layers/dms-ui/app/plugins/register.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +28 -0
- package/frontend-vue/tests/block-page-refresh.test.ts +128 -0
- package/frontend-vue/tests/block-route-params.test.ts +190 -0
- 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 +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 {
|
|
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>
|
|
@@ -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
|
|
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>
|
|
@@ -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
|
-
/**
|
|
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
|
|
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 {
|
|
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
|
}
|