@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/test/attachment-host/capabilities.d.ts +25 -0
  3. package/dist/test/attachment-host/capabilities.js +53 -1
  4. package/dist/test/attachment-host/capabilities.js.map +1 -1
  5. package/dist/test/integration/table-view-capabilities.test.js +13 -0
  6. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
  8. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
  10. package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
  11. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
  12. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +6 -1
  13. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  14. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  15. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  16. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
  17. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
  18. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
  19. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
  20. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
  21. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
  22. package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
  23. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  24. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  25. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
  26. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  29. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  30. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  32. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  33. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  35. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  36. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  37. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  38. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  40. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  46. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  47. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
  48. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  49. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  50. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  51. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +99 -13
  52. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  53. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  54. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  55. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  56. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  57. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
  58. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  59. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  60. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +7 -2
  61. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  62. package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
  63. package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
  64. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  65. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  66. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  67. package/frontend-vue/tests/component-id.test.ts +18 -0
  68. package/frontend-vue/tests/field-row.test.ts +14 -0
  69. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  70. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  71. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  72. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  73. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  74. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  75. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  76. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  77. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  78. package/frontend-vue/tests/table-row-hover.test.ts +32 -0
  79. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  80. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  81. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  82. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  83. package/package.json +2 -2
  84. package/skills/dms/SKILL.md +9 -2
  85. 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 { Component } from "vue";
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 { writeRecordId } from "../table-view/utils/recordLink";
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 = (resolveDmsComponent(componentName) ||
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 = (row?: Data) => {
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.deepLink },
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 { onPageBlocksRefresh } from "../../../utils/blockRefresh";
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
- ): void {
14
- subscribeRealtimeTopics(realtimeTopic, onRefresh);
15
- subscribePageRefresh(onRefresh);
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(onRefresh: () => void): void {
35
- let unsubscribe: (() => void) | undefined;
46
+ function subscribePageRefresh(gate: LiveGate): void {
47
+ const unsubscribes: Array<() => void> = [];
36
48
  onMounted(() => {
37
- unsubscribe = onPageBlocksRefresh(onRefresh);
49
+ unsubscribes.push(
50
+ onPageBlocksRefresh(() => gate.catchUp()),
51
+ onLivePageBlocksRefresh(gate.trigger),
52
+ );
38
53
  });
39
- onBeforeUnmount(() => unsubscribe?.());
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 { isDmsTone } from "../../utils/tone";
7
- import { readRowField, readSubline, toneClass, type Row } from "./cellHelpers";
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
- const rowTone = readRowField(row, opts.toneField);
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 figure = (text: string) =>
229
- h("span", { class: [FIGURE_CLASS, "block text-right text-toned"] }, text);
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
- const unit = (options as { unit?: string } | undefined)?.unit ?? "ms";
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: (value: unknown, _locale: string) => value },
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
+ }