@antelopejs/dms 0.7.6 → 0.7.8

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 (75) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
  3. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  4. package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
  5. package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
  6. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
  7. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  8. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  9. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
  10. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
  11. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
  12. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
  13. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
  14. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
  15. package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
  16. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  17. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  18. package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +30 -15
  19. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
  20. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  21. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  22. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  23. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  24. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  26. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  27. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  29. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  30. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  32. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  33. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  35. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  36. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  37. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  38. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  40. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  41. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  42. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  43. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  44. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
  45. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +1 -1
  46. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  49. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  50. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  51. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  52. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  53. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
  54. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  55. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  56. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  57. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  58. package/frontend-vue/tests/field-row.test.ts +14 -0
  59. package/frontend-vue/tests/form-section-nav-groups.test.ts +75 -14
  60. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  61. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  62. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  63. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  64. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  65. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  66. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  67. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  68. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  69. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  70. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  71. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  72. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  73. package/package.json +2 -2
  74. package/skills/dms/SKILL.md +11 -3
  75. package/skills/dms-pages/REFERENCE.md +5 -0
@@ -25,21 +25,36 @@ const emit = defineEmits<{ select: [sectionId: string] }>();
25
25
  const { t } = useI18n();
26
26
  const { processI18n } = useTranslation();
27
27
 
28
- // The side list reads its sections in titled runs (`navGroup`); the row of
29
- // chips has no room for headings and lists them all in one run.
30
- const GROUPS_CLASSES: Record<FormSectionNavVariant, string> = {
28
+ /**
29
+ * How the entries are laid out: the side list, the row of chips, or the
30
+ * chips cut into labelled runs once a section names a `navGroup`.
31
+ */
32
+ type FormSectionNavLayout = FormSectionNavVariant | "chipGroups";
33
+
34
+ // A row of chips with no group keeps its one scrolling line; with groups, the
35
+ // runs wrap, each led by its label.
36
+ const GROUPS_CLASSES: Record<FormSectionNavLayout, string> = {
31
37
  list: "flex flex-col gap-4",
32
38
  chips: "",
39
+ chipGroups: "flex flex-wrap items-center gap-x-5 gap-y-2.5",
33
40
  };
34
41
 
35
- const GROUP_CLASSES: Record<FormSectionNavVariant, string> = {
42
+ const GROUP_CLASSES: Record<FormSectionNavLayout, string> = {
36
43
  list: "grid gap-1",
37
44
  chips: "contents",
45
+ chipGroups: "flex flex-wrap items-center gap-x-2 gap-y-1.5",
38
46
  };
39
47
 
40
- const VARIANT_CLASSES: Record<FormSectionNavVariant, string> = {
48
+ const LIST_CLASSES: Record<FormSectionNavLayout, string> = {
41
49
  list: "flex flex-col gap-0.5",
42
50
  chips: "flex gap-1.5 overflow-x-auto pb-1",
51
+ chipGroups: "flex flex-wrap gap-1.5",
52
+ };
53
+
54
+ const GROUP_LABEL_CLASSES: Record<FormSectionNavLayout, string> = {
55
+ list: "px-2.5",
56
+ chips: "",
57
+ chipGroups: "shrink-0",
43
58
  };
44
59
 
45
60
  const ITEM_CLASSES: Record<FormSectionNavVariant, string> = {
@@ -55,9 +70,12 @@ const ACTIVE_CLASSES: Record<FormSectionNavVariant, string> = {
55
70
 
56
71
  const groupIdPrefix = useId();
57
72
  const groups = computed<FormSectionNavGroup[]>(() =>
58
- props.variant === "list"
59
- ? groupSectionsForNav(props.sections)
60
- : [{ sections: props.sections }],
73
+ groupSectionsForNav(props.sections),
74
+ );
75
+ const layout = computed<FormSectionNavLayout>(() =>
76
+ props.variant === "chips" && groups.value.some((group) => group.label)
77
+ ? "chipGroups"
78
+ : props.variant,
61
79
  );
62
80
 
63
81
  function groupHeadingId(index: number): string {
@@ -79,23 +97,20 @@ function onSelect(event: MouseEvent, sectionId: string): void {
79
97
  </script>
80
98
 
81
99
  <template>
82
- <nav
83
- :aria-label="t('dms.form.sections.nav')"
84
- :class="GROUPS_CLASSES[props.variant]"
85
- >
100
+ <nav :aria-label="t('dms.form.sections.nav')" :class="GROUPS_CLASSES[layout]">
86
101
  <div
87
102
  v-for="(group, groupIndex) in groups"
88
103
  :key="group.sections[0]?.id ?? groupIndex"
89
- :class="GROUP_CLASSES[props.variant]"
104
+ :class="GROUP_CLASSES[layout]"
90
105
  >
91
106
  <DmsEyebrow
92
107
  v-if="group.label"
93
108
  :id="groupHeadingId(groupIndex)"
94
109
  :label="processI18n(group.label)"
95
- class="px-2.5"
110
+ :class="GROUP_LABEL_CLASSES[layout]"
96
111
  />
97
112
  <ul
98
- :class="VARIANT_CLASSES[props.variant]"
113
+ :class="LIST_CLASSES[layout]"
99
114
  :aria-labelledby="group.label ? groupHeadingId(groupIndex) : undefined"
100
115
  >
101
116
  <li v-for="section in group.sections" :key="section.id">
@@ -37,6 +37,7 @@ import type {
37
37
  FormPageUrls,
38
38
  } from "../../composables/table-view/useTableViewConfig";
39
39
  import type { CustomRowAction } from "../../../types/row-action";
40
+ import type { TableViewRowClick } from "../../utils/rowClickTrigger";
40
41
  import type { RowActionConfig } from "#dms-core/app/types/row-action";
41
42
  import type { TableTabItem } from "./Tabs.vue";
42
43
  import type { ColumnDisplay } from "../../composables/data-types/useColumnValueRenderer";
@@ -149,6 +150,13 @@ export interface TableProps<T> {
149
150
 
150
151
  rowIdKey?: string;
151
152
  rowActions?: TableRowActionOptions;
153
+ /**
154
+ * What runs a row's click action: a double-click (the default) or a single
155
+ * click (`"single"`). See `isRowClickTrigger`.
156
+ */
157
+ rowClick?: TableViewRowClick;
158
+ /** Id of the row a side panel has open: the grid highlights it. */
159
+ openRowId?: string;
152
160
  customNavItems?: NavItem[];
153
161
  tabs?: TableTabItem[];
154
162
  customButtons?: CustomButton[];
@@ -412,6 +420,7 @@ import {
412
420
  } from "../../composables/table/constants";
413
421
  import { mergeColumnOrder } from "../../composables/table/utils/columnOrder";
414
422
  import { EYEBROW_CLASS } from "../../utils/eyebrow";
423
+ import { isRowClickTrigger } from "../../utils/rowClickTrigger";
415
424
 
416
425
  // Same color as the surrounding card frame (.dms-card) so sticky
417
426
  // rail/pinned cells blend in instead of showing a contrasting block.
@@ -435,6 +444,12 @@ const ROW_SELECTED_BG = "data-[selected=true]:bg-primary/10";
435
444
  const ROW_SELECTED_CELL_BG =
436
445
  "group-data-[selected=true]:bg-[color-mix(in_srgb,var(--ui-primary)_10%,var(--dms-surface-card))]";
437
446
 
447
+ // The row a side panel has open: the hover tint, held, and an accent rule on
448
+ // its leading edge.
449
+ const ROW_OPEN_BG =
450
+ "data-[open=true]:bg-elevated data-[open=true]:shadow-[inset_2px_0_0_0_var(--ui-primary)]";
451
+ const ROW_OPEN_CELL_BG = "group-data-[open=true]:bg-elevated";
452
+
438
453
  // An expanded row keeps the hover tint and hands its bottom rule to the detail
439
454
  // band below it.
440
455
  const ROW_EXPANDED_BG = "data-[expanded=true]:bg-elevated";
@@ -501,7 +516,7 @@ const theme = tv({
501
516
  colResizer:
502
517
  "bg-primary/40 absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize touch-none select-none opacity-0 hover:opacity-100",
503
518
 
504
- row: `group ${ROW_SELECTED_BG} ${ROW_EXPANDED_BG}`,
519
+ row: `group ${ROW_SELECTED_BG} ${ROW_EXPANDED_BG} ${ROW_OPEN_BG}`,
505
520
  rowCell: `${FIRST_ROW_CELL_GUTTER} border-b-muted border-b h-11 px-3.5 py-0 text-[13px] last:pe-2.5 in-[tr:last-child]:border-b-0 group-data-[expanded=true]:border-b-transparent`,
506
521
  rowInternal: "transition-opacity duration-150",
507
522
  rowContainer: "relative",
@@ -560,11 +575,11 @@ const theme = tv({
560
575
  pinned: {
561
576
  left: {
562
577
  headCell: `${HEADER_MATCH_BG} sticky z-10 shadow-[2px_0_0_0_rgba(0,0,0,0.06)]`,
563
- rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[2px_0_0_0_rgba(0,0,0,0.06)]`,
578
+ rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} ${ROW_OPEN_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[2px_0_0_0_rgba(0,0,0,0.06)]`,
564
579
  },
565
580
  right: {
566
581
  headCell: `${HEADER_MATCH_BG} sticky z-10 shadow-[-2px_0_0_0_rgba(0,0,0,0.06)]`,
567
- rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[-2px_0_0_0_rgba(0,0,0,0.06)]`,
582
+ rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} ${ROW_OPEN_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[-2px_0_0_0_rgba(0,0,0,0.06)]`,
568
583
  },
569
584
  },
570
585
  // Dense lists: 36px rows under a 32px header band.
@@ -1205,7 +1220,7 @@ const rowClickAction = (row: T): RowClickAction | undefined =>
1205
1220
  // lights up always opens something.
1206
1221
  const isRowClickable = (row: T): boolean => rowClickAction(row) !== undefined;
1207
1222
 
1208
- const handleRowDoubleClick = (row: T) => {
1223
+ const runRowClickAction = (row: T) => {
1209
1224
  const action = rowClickAction(row);
1210
1225
  if (!action) return;
1211
1226
  if (typeof action === "string") {
@@ -1215,6 +1230,19 @@ const handleRowDoubleClick = (row: T) => {
1215
1230
  props.onCustomRowAction?.(action, row);
1216
1231
  };
1217
1232
 
1233
+ // Both events reach the row: `rowClick` tells which one runs its action.
1234
+ const handleRowPointer = (event: MouseEvent, row: T) => {
1235
+ if (!isRowClickTrigger(props.rowClick, event)) return;
1236
+ runRowClickAction(row);
1237
+ };
1238
+
1239
+ const isOpenRow = (row: T): boolean =>
1240
+ props.openRowId !== undefined &&
1241
+ String(get(row, rowIdKey)) === props.openRowId;
1242
+
1243
+ const isSingleClickRow = (row: T): boolean =>
1244
+ props.rowClick === "single" && isRowClickable(row);
1245
+
1218
1246
  const presenceRailBackground = (row: T): string => {
1219
1247
  if (getRowPresence(row)) return PRESENCE_RAIL_ACTIVE_BG;
1220
1248
  if (props.loading || !isRowClickable(row)) return PANEL_MATCH_BG;
@@ -1339,6 +1367,10 @@ defineShortcuts({
1339
1367
  />
1340
1368
  </template>
1341
1369
 
1370
+ <!-- A strip the table view adds over its rows (the live hold's "N new"),
1371
+ shown whichever chrome the display keeps. -->
1372
+ <slot name="notice" />
1373
+
1342
1374
  <slot name="body" :table="table">
1343
1375
  <section
1344
1376
  ref="tableRoot"
@@ -1472,20 +1504,24 @@ defineShortcuts({
1472
1504
  <tr
1473
1505
  :data-selected="row.getIsSelected()"
1474
1506
  :data-expanded="row.getIsExpanded()"
1507
+ :data-open="isOpenRow(row.original) || undefined"
1508
+ :aria-current="isOpenRow(row.original) || undefined"
1475
1509
  :data-presence="!!getRowPresence(row.original)"
1476
1510
  :title="
1477
1511
  getRowPresence(row.original)
1478
1512
  ? presenceTooltipText(getRowPresence(row.original)!)
1479
1513
  : undefined
1480
1514
  "
1481
- :class="
1515
+ :class="[
1482
1516
  uiTable.row({
1483
1517
  loading,
1484
1518
  rowClickable: isRowClickable(row.original),
1485
1519
  presence: !!getRowPresence(row.original),
1486
- })
1487
- "
1488
- @dblclick="handleRowDoubleClick(row.original)"
1520
+ }),
1521
+ { 'cursor-pointer': isSingleClickRow(row.original) },
1522
+ ]"
1523
+ @click="handleRowPointer($event, row.original)"
1524
+ @dblclick="handleRowPointer($event, row.original)"
1489
1525
  @mouseenter="handleRowHover(row)"
1490
1526
  @mouseleave="handleRowLeave"
1491
1527
  @dragover="reorderHandles.allowDrop"
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The strip a `liveHold` table shows while its page is paused and events came
4
+ * in: "N new · Show". Show reads the rows once; the page stays paused.
5
+ */
6
+ defineProps<{
7
+ /** Events the table received since its rows were held or last shown. */
8
+ count: number;
9
+ }>();
10
+
11
+ const emit = defineEmits<{ show: [] }>();
12
+
13
+ const { t } = useI18n();
14
+ </script>
15
+
16
+ <template>
17
+ <div
18
+ role="status"
19
+ class="dms-live-hold-banner text-highlighted flex items-center gap-2.5 border-b border-(--dms-info-line) bg-(--dms-info-tint) py-[7px] ps-[18px] pe-3.5 text-[13px]"
20
+ >
21
+ <UIcon name="i-ph-pause-circle" class="text-info size-4 shrink-0" />
22
+ <span class="min-w-0">
23
+ {{ t("dms.live.new_rows", { count }, count) }}
24
+ </span>
25
+ <span aria-hidden="true" class="text-muted">·</span>
26
+ <UButton
27
+ :label="t('dms.live.show_new')"
28
+ color="neutral"
29
+ variant="link"
30
+ size="xs"
31
+ class="p-0 font-medium"
32
+ @click="emit('show')"
33
+ />
34
+ </div>
35
+ </template>
@@ -83,3 +83,20 @@ export function listenRowSteps(current: () => RowNavigation | undefined) {
83
83
  window.addEventListener("keydown", onKey);
84
84
  return () => window.removeEventListener("keydown", onKey);
85
85
  }
86
+
87
+ const CLOSE_KEY = "Escape";
88
+
89
+ /**
90
+ * Escape closes a row's side panel, unless the user types in a field or a
91
+ * layer above it (a dialog, a menu) already handled the key. Returns the
92
+ * listener's removal.
93
+ */
94
+ export function listenRowPanelClose(close: () => void) {
95
+ const onKey = (event: KeyboardEvent) => {
96
+ if (event.key !== CLOSE_KEY || event.defaultPrevented) return;
97
+ if (isTypingTarget(event.target)) return;
98
+ close();
99
+ };
100
+ window.addEventListener("keydown", onKey);
101
+ return () => window.removeEventListener("keydown", onKey);
102
+ }
@@ -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({