@antelopejs/dms 0.7.7 → 0.7.9
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 +39 -0
- package/dist/test/attachment-host/capabilities.d.ts +25 -0
- package/dist/test/attachment-host/capabilities.js +53 -1
- package/dist/test/attachment-host/capabilities.js.map +1 -1
- package/dist/test/integration/table-view-capabilities.test.js +13 -0
- package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +6 -1
- package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
- package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
- package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
- package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
- package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +99 -13
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/types/row-action.ts +7 -2
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
- package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
- package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
- package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
- package/frontend-vue/tests/component-id.test.ts +18 -0
- package/frontend-vue/tests/field-row.test.ts +14 -0
- package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
- package/frontend-vue/tests/live-toggle.test.ts +162 -0
- package/frontend-vue/tests/page-live-gate.test.ts +241 -0
- package/frontend-vue/tests/period-live-pause.test.ts +120 -0
- package/frontend-vue/tests/period-selector-url.test.ts +268 -0
- package/frontend-vue/tests/period-url-query.test.ts +351 -0
- package/frontend-vue/tests/row-side-panel.test.ts +653 -0
- package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
- package/frontend-vue/tests/table-live-hold.test.ts +204 -0
- package/frontend-vue/tests/table-row-hover.test.ts +32 -0
- package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
- package/frontend-vue/tests/table-view-sources.test.ts +40 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +9 -2
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
isConfirmFrom,
|
|
5
5
|
} from "#dms-core/app/types/confirm-dialog";
|
|
6
6
|
import { get } from "@nuxt/ui/runtime/utils/index.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type Component, getCurrentScope, onScopeDispose } from "vue";
|
|
8
8
|
import type { ActionTarget } from "../../../composables/table-view/types/action-target";
|
|
9
9
|
import type { CustomButton } from "../../../composables/table-view/types/custom-button";
|
|
10
10
|
import type { CustomRowAction } from "../../../types/row-action";
|
|
@@ -26,7 +26,18 @@ import {
|
|
|
26
26
|
type RowNavigationSource,
|
|
27
27
|
rowNavigation,
|
|
28
28
|
} from "./rowNavigation";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
clearWrittenRecord,
|
|
31
|
+
isDeepLinkAction,
|
|
32
|
+
writeRecordId,
|
|
33
|
+
type WrittenRecord,
|
|
34
|
+
} from "../table-view/utils/recordLink";
|
|
35
|
+
import {
|
|
36
|
+
closeRowSidePanel,
|
|
37
|
+
closeRowSidePanelOf,
|
|
38
|
+
openRowSidePanel,
|
|
39
|
+
rowSidePanelOwnerKey,
|
|
40
|
+
} from "#dms-layout/app/build/composables/layout/rowSidePanel";
|
|
30
41
|
import type { TableUrlScope } from "../table-view/utils/views";
|
|
31
42
|
import { useActionConfirm } from "../confirm/useActionConfirm";
|
|
32
43
|
import {
|
|
@@ -72,6 +83,16 @@ interface TargetRunExtras {
|
|
|
72
83
|
deepLink?: boolean;
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
type SidePanelTarget = ActionTarget & { type: "sidePanel" };
|
|
87
|
+
|
|
88
|
+
/** A side panel to open, and on what. */
|
|
89
|
+
interface SidePanelTargetRun extends TargetRunExtras {
|
|
90
|
+
target: SidePanelTarget;
|
|
91
|
+
title: string;
|
|
92
|
+
containerId: string;
|
|
93
|
+
rowData?: Data;
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/** A drawer or modal to open, and on what. */
|
|
76
97
|
interface ComponentTargetRun extends TargetRunExtras {
|
|
77
98
|
target: ActionTarget & { type: "modal" | "drawer" };
|
|
@@ -214,14 +235,84 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
214
235
|
: openDrawer({ ...options, direction: run.target.direction });
|
|
215
236
|
};
|
|
216
237
|
|
|
238
|
+
const resolveTargetComponent = (componentName: string): Component =>
|
|
239
|
+
(resolveDmsComponent(componentName) || componentName) as Component;
|
|
240
|
+
|
|
241
|
+
const rowIdOf = (row?: Data): string | undefined => {
|
|
242
|
+
if (!row) return undefined;
|
|
243
|
+
const idKey =
|
|
244
|
+
config.rowNavigation?.rowIdKey ?? config.rowIdKey ?? DEFAULT_ROW_ID_KEY;
|
|
245
|
+
const id = get(row, idKey);
|
|
246
|
+
return id === undefined || id === null ? undefined : String(id);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// A deep-linked action keeps the row it has open in the URL; `undefined`
|
|
250
|
+
// removes it, but only the row this run wrote, on the page it wrote it:
|
|
251
|
+
// closing as the user navigates leaves the next page's `?record=` alone.
|
|
252
|
+
const rowRecorder = (deepLink?: boolean) => {
|
|
253
|
+
let written: WrittenRecord | undefined;
|
|
254
|
+
return (row?: Data): void => {
|
|
255
|
+
if (!deepLink || !config.recordScope || !config.rowNavigation) return;
|
|
256
|
+
if (!row) {
|
|
257
|
+
clearWrittenRecord(written);
|
|
258
|
+
written = undefined;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
written = writeRecordId(config.recordScope, rowIdOf(row));
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const ownerKey = rowSidePanelOwnerKey(config.pageId, config.componentId);
|
|
266
|
+
|
|
267
|
+
// The panel belongs to the table that opened it: it closes with it.
|
|
268
|
+
if (getCurrentScope()) onScopeDispose(() => closeRowSidePanelOf(ownerKey));
|
|
269
|
+
|
|
270
|
+
// A side panel opened on a listed row steps through the rows shown in place
|
|
271
|
+
// (the panel computes its position); a deep-linked one keeps the row in the
|
|
272
|
+
// URL while it is open. A bulk action's panel receives the selection.
|
|
273
|
+
const handleSidePanelTarget = (run: SidePanelTargetRun) => {
|
|
274
|
+
const componentName = run.target.component?.componentName;
|
|
275
|
+
if (!componentName) return;
|
|
276
|
+
const session = Symbol(componentName);
|
|
277
|
+
const component = resolveTargetComponent(componentName);
|
|
278
|
+
const recordRow = rowRecorder(run.deepLink && !run.selection);
|
|
279
|
+
const componentOptions = {
|
|
280
|
+
...run.target.component?.options,
|
|
281
|
+
pageId: config.pageId,
|
|
282
|
+
componentId: config.componentId,
|
|
283
|
+
containerId: run.containerId,
|
|
284
|
+
selection: run.selection,
|
|
285
|
+
onSuccessCallback: () => {
|
|
286
|
+
closeRowSidePanel(session);
|
|
287
|
+
config.refreshCallback?.();
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
const showRow = (row?: Data) => {
|
|
291
|
+
openRowSidePanel({
|
|
292
|
+
session,
|
|
293
|
+
ownerKey,
|
|
294
|
+
title: run.title,
|
|
295
|
+
positionLabel: run.target.positionLabel,
|
|
296
|
+
component,
|
|
297
|
+
componentOptions,
|
|
298
|
+
row,
|
|
299
|
+
rowKey: rowIdOf(row),
|
|
300
|
+
source: row ? config.rowNavigation : undefined,
|
|
301
|
+
showRow,
|
|
302
|
+
onClose: () => recordRow(undefined),
|
|
303
|
+
});
|
|
304
|
+
recordRow(row);
|
|
305
|
+
};
|
|
306
|
+
showRow(run.rowData);
|
|
307
|
+
};
|
|
308
|
+
|
|
217
309
|
// A container opened on a listed row steps through the rows shown (its
|
|
218
310
|
// `navigation` prop, J and K), mounting its component anew on each; a
|
|
219
311
|
// deep-linked one keeps the row in the URL while it is open.
|
|
220
312
|
const handleComponentTarget = (run: ComponentTargetRun) => {
|
|
221
313
|
const componentName = run.target.component?.componentName;
|
|
222
314
|
if (!componentName) return;
|
|
223
|
-
const component = (
|
|
224
|
-
componentName) as Component;
|
|
315
|
+
const component = resolveTargetComponent(componentName);
|
|
225
316
|
const source = config.rowNavigation;
|
|
226
317
|
let instance: ContainerInstance | undefined;
|
|
227
318
|
let navigation: RowNavigation | undefined;
|
|
@@ -242,13 +333,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
242
333
|
},
|
|
243
334
|
};
|
|
244
335
|
};
|
|
245
|
-
const recordRow = (
|
|
246
|
-
if (!run.deepLink || !config.recordScope || !source) return;
|
|
247
|
-
writeRecordId(
|
|
248
|
-
config.recordScope,
|
|
249
|
-
row ? String(get(row, source.rowIdKey)) : undefined,
|
|
250
|
-
);
|
|
251
|
-
};
|
|
336
|
+
const recordRow = rowRecorder(run.deepLink);
|
|
252
337
|
function showRow(row: Data) {
|
|
253
338
|
instance?.patch({
|
|
254
339
|
componentOptions: optionsFor(row),
|
|
@@ -286,6 +371,15 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
286
371
|
...extras,
|
|
287
372
|
});
|
|
288
373
|
|
|
374
|
+
const sidePanelHandler: TargetHandler = (target, label, rowData, extras) =>
|
|
375
|
+
handleSidePanelTarget({
|
|
376
|
+
target: target as SidePanelTarget,
|
|
377
|
+
title: processI18n((target as SidePanelTarget).title || label),
|
|
378
|
+
containerId: `${config.pageId}-${config.componentId}-${target.type}`,
|
|
379
|
+
rowData: extras?.selection ? undefined : rowData,
|
|
380
|
+
...extras,
|
|
381
|
+
});
|
|
382
|
+
|
|
289
383
|
const targetHandlers: Record<string, TargetHandler> = {
|
|
290
384
|
page: (target, _label, rowData) => {
|
|
291
385
|
const pageTarget = target as ActionTarget & { type: "page" };
|
|
@@ -332,6 +426,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
332
426
|
if (quickAction) dispatchQuickActionTarget(quickAction.target);
|
|
333
427
|
},
|
|
334
428
|
drawer: componentHandler,
|
|
429
|
+
sidePanel: sidePanelHandler,
|
|
335
430
|
};
|
|
336
431
|
|
|
337
432
|
const targetUrl = (url: string, rowData?: Data): string =>
|
|
@@ -421,7 +516,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
421
516
|
action.label,
|
|
422
517
|
rowData,
|
|
423
518
|
action.confirm,
|
|
424
|
-
{ deepLink: action
|
|
519
|
+
{ deepLink: isDeepLinkAction(action) },
|
|
425
520
|
);
|
|
426
521
|
};
|
|
427
522
|
|
|
@@ -1,40 +1,55 @@
|
|
|
1
1
|
import { onBeforeUnmount, onMounted } from "vue";
|
|
2
2
|
import { useRealtimeTopic } from "../../../../../dms-core/app/composables/realtime/useRealtimeTopic";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
onLivePageBlocksRefresh,
|
|
5
|
+
onPageBlocksRefresh,
|
|
6
|
+
} from "../../../utils/blockRefresh";
|
|
7
|
+
import { type LiveGate, useLiveGate } from "../live/currentPageLive";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Calls `onRefresh` whenever the data behind a block moved without its inputs
|
|
7
11
|
* changing: on each event of one of its realtime topics, and each time the
|
|
8
|
-
* page asks its blocks to refresh (refreshPageBlocks).
|
|
12
|
+
* page asks its blocks to refresh (refreshPageBlocks, refreshLivePageBlocks).
|
|
13
|
+
*
|
|
14
|
+
* On a page declared with `live`, the realtime events and the live refreshes
|
|
15
|
+
* go through the page's gate: in Direct at most once per `maxRate`, in Pause
|
|
16
|
+
* not at all — the block counts what it missed and refetches once on resume.
|
|
17
|
+
* A refresh the user asked for (refreshPageBlocks) reads at once, paused or
|
|
18
|
+
* not. The returned gate says what was missed.
|
|
9
19
|
*/
|
|
10
20
|
export function useRefreshTriggers(
|
|
11
21
|
realtimeTopic: string | string[] | undefined,
|
|
12
22
|
onRefresh: () => void,
|
|
13
|
-
):
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
): LiveGate {
|
|
24
|
+
const gate = useLiveGate(onRefresh);
|
|
25
|
+
subscribeRealtimeTopics(realtimeTopic, gate.trigger);
|
|
26
|
+
subscribePageRefresh(gate);
|
|
27
|
+
return gate;
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
function subscribeRealtimeTopics(
|
|
19
31
|
topic: string | string[] | undefined,
|
|
20
|
-
onEvent: () => void,
|
|
32
|
+
onEvent: (event: object) => void,
|
|
21
33
|
): void {
|
|
22
34
|
if (!topic) return;
|
|
23
35
|
const topics = Array.isArray(topic) ? topic : [topic];
|
|
24
36
|
for (const entry of topics) {
|
|
25
37
|
useRealtimeTopic(entry, (event) => {
|
|
26
38
|
if (!("type" in event)) return;
|
|
27
|
-
onEvent();
|
|
39
|
+
onEvent(event);
|
|
28
40
|
});
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
// Mounted only: the page refresh is a browser event, and a block the server
|
|
33
45
|
// renders has nothing to refetch.
|
|
34
|
-
function subscribePageRefresh(
|
|
35
|
-
|
|
46
|
+
function subscribePageRefresh(gate: LiveGate): void {
|
|
47
|
+
const unsubscribes: Array<() => void> = [];
|
|
36
48
|
onMounted(() => {
|
|
37
|
-
|
|
49
|
+
unsubscribes.push(
|
|
50
|
+
onPageBlocksRefresh(() => gate.catchUp()),
|
|
51
|
+
onLivePageBlocksRefresh(gate.trigger),
|
|
52
|
+
);
|
|
38
53
|
});
|
|
39
|
-
onBeforeUnmount(() =>
|
|
54
|
+
onBeforeUnmount(() => unsubscribes.splice(0).forEach((stop) => stop()));
|
|
40
55
|
}
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type ComposableText,
|
|
5
5
|
isComposedText,
|
|
6
6
|
} from "../../../../../dms-core/app/utils/composedText";
|
|
7
|
+
import type { Tone } from "../../../types/tone";
|
|
8
|
+
import { DMS_TONE_TEXT, isDmsTone } from "../../utils/tone";
|
|
7
9
|
|
|
8
10
|
// What the cell data types share: reading a sibling field off the row, and
|
|
9
11
|
// the text tones a cell takes.
|
|
@@ -13,6 +15,28 @@ export type Row = Record<string, unknown> | undefined;
|
|
|
13
15
|
export const readRowField = (row: Row, field: string | undefined): unknown =>
|
|
14
16
|
field && row ? get(row, field) : undefined;
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The `Tone` a row field holds (`toneField` of a display), or nothing when the
|
|
20
|
+
* field is unset or holds no known tone.
|
|
21
|
+
*/
|
|
22
|
+
export const readRowTone = (
|
|
23
|
+
row: Row,
|
|
24
|
+
field: string | undefined,
|
|
25
|
+
): Tone | undefined => {
|
|
26
|
+
const tone = readRowField(row, field);
|
|
27
|
+
return typeof tone === "string" && isDmsTone(tone) ? tone : undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The text colour of the row's tone, else `fallback` (the display's own). */
|
|
31
|
+
export const rowToneTextClass = (
|
|
32
|
+
row: Row,
|
|
33
|
+
field: string | undefined,
|
|
34
|
+
fallback: string,
|
|
35
|
+
): string => {
|
|
36
|
+
const tone = readRowTone(row, field);
|
|
37
|
+
return tone ? DMS_TONE_TEXT[tone] : fallback;
|
|
38
|
+
};
|
|
39
|
+
|
|
16
40
|
export const stringOf = (value: unknown): string | undefined =>
|
|
17
41
|
value === null || value === undefined || value === ""
|
|
18
42
|
? undefined
|
|
@@ -3,8 +3,14 @@ import type { DataType } from "#dms-core/app/composables/data-types/useDataType"
|
|
|
3
3
|
import StatusPill from "../../../components/status-pill/StatusPill.vue";
|
|
4
4
|
import MonoCell from "../../components/table/MonoCell.vue";
|
|
5
5
|
import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
readRowField,
|
|
8
|
+
readRowTone,
|
|
9
|
+
readSubline,
|
|
10
|
+
rowToneTextClass,
|
|
11
|
+
toneClass,
|
|
12
|
+
type Row,
|
|
13
|
+
} from "./cellHelpers";
|
|
8
14
|
|
|
9
15
|
const Sparkline = defineAsyncComponent(
|
|
10
16
|
() => import("../../../components/chart/internal/Sparkline.vue"),
|
|
@@ -34,9 +40,7 @@ interface StatusPillOptions {
|
|
|
34
40
|
|
|
35
41
|
// A row field that holds no known tone gives way to the column's map.
|
|
36
42
|
function statusPillTone(opts: StatusPillOptions, key: string, row: Row) {
|
|
37
|
-
|
|
38
|
-
if (typeof rowTone === "string" && isDmsTone(rowTone)) return rowTone;
|
|
39
|
-
return opts.tones?.[key] ?? "neutral";
|
|
43
|
+
return readRowTone(row, opts.toneField) ?? opts.tones?.[key] ?? "neutral";
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -186,6 +190,17 @@ const withSymbol = (
|
|
|
186
190
|
): string =>
|
|
187
191
|
`${new Intl.NumberFormat(locale, { maximumFractionDigits }).format(value)} ${symbol}`;
|
|
188
192
|
|
|
193
|
+
/** A duration always in milliseconds, its digits grouped: "2,149 ms". */
|
|
194
|
+
export function formatMilliseconds(ms: number, locale: string): string {
|
|
195
|
+
const figure = new Intl.NumberFormat(locale, {
|
|
196
|
+
maximumFractionDigits: 0,
|
|
197
|
+
// `true` groups a four-digit figure too, where a locale's default (es,
|
|
198
|
+
// pl) would leave "2149" against "12.149" further down the column.
|
|
199
|
+
useGrouping: true,
|
|
200
|
+
}).format(ms);
|
|
201
|
+
return `${figure} ms`;
|
|
202
|
+
}
|
|
203
|
+
|
|
189
204
|
/**
|
|
190
205
|
* A duration in its most readable unit: "910 ms", "1.8 s", "4 min 12 s",
|
|
191
206
|
* "2 h 5 min".
|
|
@@ -225,8 +240,42 @@ export function formatBytes(bytes: number, locale: string): string {
|
|
|
225
240
|
);
|
|
226
241
|
}
|
|
227
242
|
|
|
228
|
-
const
|
|
229
|
-
|
|
243
|
+
const FIGURE_TONE_CLASS = "text-toned";
|
|
244
|
+
|
|
245
|
+
const figure = (text: string, toneClass = FIGURE_TONE_CLASS) =>
|
|
246
|
+
h("span", { class: [FIGURE_CLASS, "block text-right", toneClass] }, text);
|
|
247
|
+
|
|
248
|
+
interface DurationOptions {
|
|
249
|
+
unit?: string;
|
|
250
|
+
/** `readable` (the default) or `ms`. */
|
|
251
|
+
format?: string;
|
|
252
|
+
/** Row field holding the figure's tone. */
|
|
253
|
+
toneField?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const DURATION_FORMATS: Record<string, typeof formatDuration> = {
|
|
257
|
+
readable: formatDuration,
|
|
258
|
+
ms: formatMilliseconds,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `duration`: the figure in its most readable unit, or in milliseconds
|
|
263
|
+
* (`format: "ms"`), in the tone the row gives (`toneField`).
|
|
264
|
+
*/
|
|
265
|
+
function renderDuration(
|
|
266
|
+
value: unknown,
|
|
267
|
+
locale: string,
|
|
268
|
+
options: unknown,
|
|
269
|
+
row: Row,
|
|
270
|
+
) {
|
|
271
|
+
const opts = (options ?? {}) as DurationOptions;
|
|
272
|
+
const ms = numberOf(value) * (DURATION_UNIT_MS[opts.unit ?? "ms"] ?? 1);
|
|
273
|
+
const format = DURATION_FORMATS[opts.format ?? "readable"] ?? formatDuration;
|
|
274
|
+
return figure(
|
|
275
|
+
format(ms, locale),
|
|
276
|
+
rowToneTextClass(row, opts.toneField, FIGURE_TONE_CLASS),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
230
279
|
|
|
231
280
|
/**
|
|
232
281
|
* Cell data types a column picks through its `display` option to draw a
|
|
@@ -259,15 +308,8 @@ export const registerMetricCellTypes = (
|
|
|
259
308
|
registerDataType({
|
|
260
309
|
id: "duration",
|
|
261
310
|
formatter: {
|
|
262
|
-
default: (value, locale, options) =>
|
|
263
|
-
|
|
264
|
-
return figure(
|
|
265
|
-
formatDuration(
|
|
266
|
-
numberOf(value) * (DURATION_UNIT_MS[unit] ?? 1),
|
|
267
|
-
locale,
|
|
268
|
-
),
|
|
269
|
-
);
|
|
270
|
-
},
|
|
311
|
+
default: (value, locale, options, row) =>
|
|
312
|
+
renderDuration(value, locale, options, row),
|
|
271
313
|
},
|
|
272
314
|
});
|
|
273
315
|
registerDataType({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerFieldTypes } from "./registerFieldTypes";
|
|
2
|
-
import { defineAsyncComponent, type VNode } from "vue";
|
|
2
|
+
import { defineAsyncComponent, type VNode, type VNodeChild } from "vue";
|
|
3
3
|
import type { DataType } from "#dms-core/app/composables/data-types/useDataType";
|
|
4
4
|
import UAvatar from "@nuxt/ui/components/Avatar.vue";
|
|
5
5
|
import UBadge from "@nuxt/ui/components/Badge.vue";
|
|
@@ -10,6 +10,7 @@ import { mapRelationBeforeState, sameRelationValue } from "./relationValue";
|
|
|
10
10
|
import {
|
|
11
11
|
isSet,
|
|
12
12
|
readRowField,
|
|
13
|
+
readRowTone,
|
|
13
14
|
readSubline,
|
|
14
15
|
type Row,
|
|
15
16
|
stringOf,
|
|
@@ -21,7 +22,7 @@ import { useComposedText } from "../../../../../dms-core/app/composables/transla
|
|
|
21
22
|
import StatusPill, {
|
|
22
23
|
type StatusPillVariant,
|
|
23
24
|
} from "../../../components/status-pill/StatusPill.vue";
|
|
24
|
-
import { isDmsTone } from "../../utils/tone";
|
|
25
|
+
import { DMS_TONE_TEXT, isDmsTone } from "../../utils/tone";
|
|
25
26
|
import IdentityCell from "../../components/table-view/IdentityCell.vue";
|
|
26
27
|
import {
|
|
27
28
|
firstNameOf,
|
|
@@ -738,6 +739,32 @@ const registerCellTypes = (registerDataType: (dataType: DataType) => void) => {
|
|
|
738
739
|
});
|
|
739
740
|
};
|
|
740
741
|
|
|
742
|
+
interface TextOptions {
|
|
743
|
+
/** Row field holding the text's tone. */
|
|
744
|
+
toneField?: string;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* `string`: the value as written, in the tone the row gives (`toneField`) when
|
|
749
|
+
* it gives one; a row without one keeps the bare value, as a string column
|
|
750
|
+
* always drew it.
|
|
751
|
+
*/
|
|
752
|
+
function renderText(
|
|
753
|
+
value: unknown,
|
|
754
|
+
_locale: string,
|
|
755
|
+
options?: unknown,
|
|
756
|
+
row?: Row,
|
|
757
|
+
): unknown {
|
|
758
|
+
const tone = readRowTone(
|
|
759
|
+
row,
|
|
760
|
+
(options as TextOptions | undefined)?.toneField,
|
|
761
|
+
);
|
|
762
|
+
if (!tone) return value;
|
|
763
|
+
// The value stays the child, as the untoned cell returns it: a number or a
|
|
764
|
+
// list renders as it did, never as "[object Object]".
|
|
765
|
+
return h("span", { class: DMS_TONE_TEXT[tone] }, [value as VNodeChild]);
|
|
766
|
+
}
|
|
767
|
+
|
|
741
768
|
const registerPrimitiveTypes = (
|
|
742
769
|
registerDataType: (dataType: DataType) => void,
|
|
743
770
|
) => {
|
|
@@ -747,7 +774,7 @@ const registerPrimitiveTypes = (
|
|
|
747
774
|
});
|
|
748
775
|
registerDataType({
|
|
749
776
|
id: "string",
|
|
750
|
-
formatter: { default:
|
|
777
|
+
formatter: { default: renderText },
|
|
751
778
|
});
|
|
752
779
|
registerDataType({
|
|
753
780
|
id: "price",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
getCurrentScope,
|
|
4
|
+
onScopeDispose,
|
|
5
|
+
shallowRef,
|
|
6
|
+
type ComputedRef,
|
|
7
|
+
} from "vue";
|
|
8
|
+
import type {
|
|
9
|
+
LiveSubscription,
|
|
10
|
+
PageLiveController,
|
|
11
|
+
} from "./pageLiveController";
|
|
12
|
+
|
|
13
|
+
// Client-only state: the page renderer sets it in the browser, where one app
|
|
14
|
+
// shows one page at a time. The server renders no event and no refetch, so it
|
|
15
|
+
// never needs one, and a module-level value there would be shared between
|
|
16
|
+
// requests.
|
|
17
|
+
const currentController = shallowRef<PageLiveController | null>(null);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The live controller of the page on screen, `null` when the page has no
|
|
21
|
+
* `live` option (or while server-rendering).
|
|
22
|
+
*
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export function currentPageLive(): PageLiveController | null {
|
|
26
|
+
return currentController.value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Installs the controller of the page on screen. The page renderer calls it.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function setCurrentPageLive(controller: PageLiveController | null) {
|
|
35
|
+
if (typeof window === "undefined") return;
|
|
36
|
+
currentController.value = controller;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @internal */
|
|
40
|
+
export interface LiveGate {
|
|
41
|
+
/** An event behind the block arrived: refetch, now or later, or count it. */
|
|
42
|
+
trigger(event?: object): void;
|
|
43
|
+
/** Events the block missed while the page is paused. */
|
|
44
|
+
missed: ComputedRef<number>;
|
|
45
|
+
/** When the block last read through the gate (ms), 0 before or without `live`. */
|
|
46
|
+
lastReadAt: ComputedRef<number>;
|
|
47
|
+
/** Refetches now and forgets what was missed. */
|
|
48
|
+
catchUp(): void;
|
|
49
|
+
/** Records a read the caller makes itself (see LiveSubscription). */
|
|
50
|
+
acknowledge(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BoundSubscription {
|
|
54
|
+
controller: PageLiveController;
|
|
55
|
+
subscription: LiveSubscription;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The gate a block's refetch goes through. On a page without `live` it calls
|
|
60
|
+
* `refresh` at once, as before. Bound lazily, on the first event, to the
|
|
61
|
+
* controller of the page on screen: a block of the page layout sets up before
|
|
62
|
+
* the page installs its controller.
|
|
63
|
+
*
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
export function useLiveGate(refresh: () => void): LiveGate {
|
|
67
|
+
const bound = shallowRef<BoundSubscription | null>(null);
|
|
68
|
+
|
|
69
|
+
const unbind = () => {
|
|
70
|
+
bound.value?.subscription.unsubscribe();
|
|
71
|
+
bound.value = null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const resolve = (): LiveSubscription | null => {
|
|
75
|
+
const controller = currentController.value;
|
|
76
|
+
if (bound.value?.controller === controller) {
|
|
77
|
+
return bound.value?.subscription ?? null;
|
|
78
|
+
}
|
|
79
|
+
unbind();
|
|
80
|
+
if (!controller) return null;
|
|
81
|
+
bound.value = { controller, subscription: controller.subscribe(refresh) };
|
|
82
|
+
return bound.value.subscription;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (getCurrentScope()) onScopeDispose(unbind);
|
|
86
|
+
|
|
87
|
+
const current = () =>
|
|
88
|
+
bound.value?.controller === currentController.value
|
|
89
|
+
? (bound.value?.subscription ?? null)
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
trigger: (event) => {
|
|
94
|
+
const subscription = resolve();
|
|
95
|
+
if (subscription) subscription.trigger(event);
|
|
96
|
+
else refresh();
|
|
97
|
+
},
|
|
98
|
+
missed: computed(() => current()?.missed.value ?? 0),
|
|
99
|
+
lastReadAt: computed(() => current()?.lastReadAt.value ?? 0),
|
|
100
|
+
catchUp: () => {
|
|
101
|
+
const subscription = resolve();
|
|
102
|
+
if (subscription) subscription.catchUp();
|
|
103
|
+
else refresh();
|
|
104
|
+
},
|
|
105
|
+
acknowledge: () => resolve()?.acknowledge(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MS_PER_SECOND = 1000;
|
|
2
|
+
const SECONDS_PER_MINUTE = 60;
|
|
3
|
+
const MINUTES_PER_HOUR = 60;
|
|
4
|
+
|
|
5
|
+
/** The unit an elapsed time reads in: seconds, then minutes, then hours. */
|
|
6
|
+
export type ElapsedUnit = "seconds" | "minutes" | "hours";
|
|
7
|
+
|
|
8
|
+
export interface Elapsed {
|
|
9
|
+
unit: ElapsedUnit;
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A duration as "N s", "N min" or "N h": the largest unit it fills once,
|
|
15
|
+
* rounded down. A negative duration (a clock set back) reads as 0 s.
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export function formatElapsed(elapsedMs: number): Elapsed {
|
|
20
|
+
const seconds = Math.max(0, Math.floor(elapsedMs / MS_PER_SECOND));
|
|
21
|
+
if (seconds < SECONDS_PER_MINUTE) return { unit: "seconds", count: seconds };
|
|
22
|
+
const minutes = Math.floor(seconds / SECONDS_PER_MINUTE);
|
|
23
|
+
if (minutes < MINUTES_PER_HOUR) return { unit: "minutes", count: minutes };
|
|
24
|
+
return { unit: "hours", count: Math.floor(minutes / MINUTES_PER_HOUR) };
|
|
25
|
+
}
|