@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
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
3
+ import DmsSegmented from "../segmented/Segmented.vue";
4
+ import { usePageLive } from "../../composables/live/usePageLive";
5
+ import { formatElapsed } from "../../build/composables/live/formatElapsed";
6
+ import { useLiveToggleShortcut } from "../../build/composables/live/useLiveToggleShortcut";
7
+ import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
8
+
9
+ interface LiveToggleBlockProps extends DefaultComponentProps {
10
+ align?: "left" | "center" | "right";
11
+ /** Shows "updated N s ago". */
12
+ showUpdatedAt?: boolean;
13
+ }
14
+
15
+ const props = withDefaults(defineProps<LiveToggleBlockProps>(), {
16
+ align: "right",
17
+ showUpdatedAt: true,
18
+ });
19
+
20
+ const DIRECT = "direct";
21
+ const PAUSE = "pause";
22
+ const TICK_MS = 1000;
23
+ const ALIGN_CLASSES: Record<
24
+ NonNullable<LiveToggleBlockProps["align"]>,
25
+ string
26
+ > = {
27
+ left: "justify-start",
28
+ center: "justify-center",
29
+ right: "justify-end",
30
+ };
31
+
32
+ const { t } = useI18n();
33
+ const live = usePageLive();
34
+
35
+ // The state lives in the browser (its storage, its clock): the server and the
36
+ // hydrating render draw nothing, the mounted block draws the real state.
37
+ const isMounted = ref(false);
38
+ const now = ref(Date.now());
39
+ let ticker: ReturnType<typeof setInterval> | undefined;
40
+
41
+ onMounted(() => {
42
+ isMounted.value = true;
43
+ if (props.showUpdatedAt) {
44
+ ticker = setInterval(() => (now.value = Date.now()), TICK_MS);
45
+ }
46
+ });
47
+ onBeforeUnmount(() => clearInterval(ticker));
48
+
49
+ const mode = computed({
50
+ get: () => (live.isPaused.value ? PAUSE : DIRECT),
51
+ set: (next: string | number) => {
52
+ if (next === PAUSE) live.pause();
53
+ else live.resume();
54
+ },
55
+ });
56
+
57
+ const items = computed(() => [
58
+ { value: DIRECT, label: t("dms.live.direct"), icon: "i-ph-broadcast" },
59
+ { value: PAUSE, label: t("dms.live.pause"), icon: "i-ph-pause" },
60
+ ]);
61
+
62
+ const updatedLabel = computed(() => {
63
+ const at = live.lastUpdatedAt.value;
64
+ if (at === null) return "";
65
+ const { unit, count } = formatElapsed(now.value - at);
66
+ return t(`dms.live.updated_${unit}`, { count }, count);
67
+ });
68
+
69
+ useLiveToggleShortcut(() => live.toggle());
70
+ </script>
71
+
72
+ <template>
73
+ <div
74
+ v-if="isMounted && live.isEnabled.value"
75
+ class="dms-live-toggle flex flex-wrap items-center gap-2.5"
76
+ :class="ALIGN_CLASSES[align]"
77
+ >
78
+ <span
79
+ v-if="showUpdatedAt"
80
+ class="text-muted text-[12.5px] tabular-nums"
81
+ aria-live="off"
82
+ >
83
+ {{ updatedLabel }}
84
+ </span>
85
+ <DmsSegmented
86
+ v-model="mode"
87
+ :items="items"
88
+ :aria-label="t('dms.live.aria_label')"
89
+ size="sm"
90
+ />
91
+ </div>
92
+ </template>
@@ -107,15 +107,24 @@ const theme = tv({
107
107
  disabled: {
108
108
  true: { root: "opacity-55" },
109
109
  },
110
+ // Without a label cell, the control is the grid's only child: it spans
111
+ // both columns instead of falling into the form's 240px label column.
112
+ labeled: {
113
+ false: { control: "col-span-full" },
114
+ },
110
115
  },
111
116
  });
112
117
 
118
+ const hasText = computed(
119
+ () => !!(props.label || props.description || slots.label || slots.details),
120
+ );
113
121
  const ui = computed(() =>
114
122
  theme({
115
123
  layout: props.layout,
116
124
  inset: props.inset,
117
125
  spacing: props.spacing,
118
126
  disabled: props.disabled,
127
+ labeled: hasText.value,
119
128
  }),
120
129
  );
121
130
  const labelTag = computed(() => (props.labelFor ? "label" : "div"));
@@ -127,10 +136,7 @@ const { processI18n } = useTranslation();
127
136
  <template>
128
137
  <div :class="ui.root()">
129
138
  <div :class="ui.body()">
130
- <div
131
- v-if="props.label || props.description || slots.label || slots.details"
132
- :class="ui.text()"
133
- >
139
+ <div v-if="hasText" :class="ui.text()">
134
140
  <component
135
141
  :is="labelTag"
136
142
  v-if="props.label || slots.label"
@@ -12,6 +12,7 @@ import type {
12
12
  } from "#dms-core/app/composables/period/types";
13
13
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
14
14
  import { usePeriodDraft } from "../../build/composables/period/usePeriodDraft";
15
+ import { usePeriodLocation } from "../../build/composables/period/usePeriodLocation";
15
16
  import {
16
17
  type CalendarRange,
17
18
  calendarToRange,
@@ -21,6 +22,7 @@ import {
21
22
  } from "../../build/composables/period/periodDisplay";
22
23
  import PeriodRangeCalendar from "../../build/components/period/PeriodRangeCalendar.vue";
23
24
  import { useUserRegionalPreferences } from "#dms-core/app/composables/user/useUserRegionalPreferences";
25
+ import { usePageLive } from "../../composables/live/usePageLive";
24
26
 
25
27
  interface Props extends DefaultComponentProps {
26
28
  id: string;
@@ -93,11 +95,28 @@ useWatch(props.watchActions || [], props.componentId);
93
95
 
94
96
  const { t, locale } = useI18n();
95
97
 
98
+ // A paused live page keeps its period where it was: a relative preset
99
+ // sliding every minute would refetch every block reading it.
100
+ const { isPaused: isPagePaused } = usePageLive();
96
101
  const period = usePeriod({
97
102
  defaultPreset: props.defaultPreset,
98
103
  defaultComparison: props.defaultComparison,
99
104
  presets: props.presets,
100
105
  comparisons: props.comparisons,
106
+ holdClock: () => isPagePaused.value,
107
+ });
108
+
109
+ // Before the scope registers, so the cards' first request already carries
110
+ // the period of the URL (or of the user's last choice), never the default.
111
+ usePeriodLocation(period, {
112
+ id: props.id,
113
+ pageId: props.pageId,
114
+ fallback: {
115
+ preset: period.preset.value,
116
+ comparison: period.comparison.value,
117
+ customRange: null,
118
+ customCompareRange: null,
119
+ },
101
120
  });
102
121
 
103
122
  const cleanup = registerPeriodScope(props.id, period.state);
@@ -8,7 +8,12 @@ import type {
8
8
  Table,
9
9
  VisibilityState,
10
10
  } from "@tanstack/vue-table";
11
- import { refDebounced, useActiveElement, watchDebounced } from "@vueuse/core";
11
+ import {
12
+ refDebounced,
13
+ until,
14
+ useActiveElement,
15
+ watchDebounced,
16
+ } from "@vueuse/core";
12
17
  import {
13
18
  QUICK_ACTION_BUTTON_KEY,
14
19
  QUICK_ACTION_COMPONENT_KEY,
@@ -71,7 +76,15 @@ import {
71
76
  import { selectedRowIds } from "../../build/composables/table-view/utils/bulkActions";
72
77
  import { isBulkAction } from "../../build/composables/actions/bulkSelection";
73
78
  import { selectionScopeKey } from "../../build/utils/selectionScope";
74
- import { readRecordId } from "../../build/composables/table-view/utils/recordLink";
79
+ import {
80
+ isDeepLinkAction,
81
+ readRecordId,
82
+ resolveDeepLinkedRow,
83
+ } from "../../build/composables/table-view/utils/recordLink";
84
+ import {
85
+ rowSidePanelOwnerKey,
86
+ useRowSidePanelContent,
87
+ } from "#dms-layout/app/build/composables/layout/rowSidePanel";
75
88
  import type { CustomRowAction } from "../../types/row-action";
76
89
  import {
77
90
  type QuickFilterItem,
@@ -82,7 +95,10 @@ import {
82
95
  staticQuickFilterItems,
83
96
  } from "../../build/composables/table-view/utils/quickFilters";
84
97
  import { useNavBadges } from "../../build/composables/navigation/useNavBadges";
85
- import { resolveRowClickAction } from "../../utils/rowClickAction";
98
+ import {
99
+ resolveRowClickAction,
100
+ rowCustomActions,
101
+ } from "../../utils/rowClickAction";
86
102
  import { usePermissionPreview } from "#dms-core/app/build/composables/auth/usePermissionPreview";
87
103
 
88
104
  import UTable from "../../build/components/table/Table.vue";
@@ -110,6 +126,9 @@ import { useAccumulatedPages } from "../../build/composables/table-view/useAccum
110
126
  import { usePersistedPagination } from "../../build/composables/table-view/usePersistedPagination";
111
127
  import { useTableRows } from "../../build/composables/table-view/useTableRows";
112
128
  import { useRefreshTriggers } from "../../build/composables/blocks/useRefreshTriggers";
129
+ import { useTableLiveHold } from "../../build/composables/table-view/useTableLiveHold";
130
+ import { usePageLive } from "../../composables/live/usePageLive";
131
+ import LiveHoldBanner from "../../build/components/table-view/LiveHoldBanner.vue";
113
132
  import { useTableReorder } from "../../build/composables/table-view/useTableReorder";
114
133
  import {
115
134
  groupedOptionsFor,
@@ -179,6 +198,8 @@ interface TableViewProps<T extends Data> extends TableViewConfig<T> {
179
198
  isSoleTableView?: boolean;
180
199
  /** Topics whose events make the table read its rows again. */
181
200
  realtimeTopic?: string | string[];
201
+ /** Keeps the rows while the live page is paused (backend `liveHold`). */
202
+ liveHold?: boolean;
182
203
  }
183
204
 
184
205
  const props = defineProps<TableViewProps<T>>();
@@ -801,6 +822,14 @@ const archiveQuery = computed(() =>
801
822
  archiveMode ? { showArchived: showArchived.value } : {},
802
823
  );
803
824
 
825
+ // The gate is set up with the other refresh triggers below; the hold only
826
+ // reads it once the table is on screen.
827
+ const liveHold = useTableLiveHold({
828
+ isEnabled: !!props.liveHold,
829
+ isSource: tableRows.isSource,
830
+ gate: () => liveGate,
831
+ });
832
+
804
833
  const { data, status, error, refresh } = await useTableListData<T>({
805
834
  componentId,
806
835
  pageId,
@@ -808,6 +837,7 @@ const { data, status, error, refresh } = await useTableListData<T>({
808
837
  query: queryRequest,
809
838
  archiveQuery,
810
839
  isSelfManaged: isActiveDisplaySelfManaged,
840
+ heldQuery: liveHold.heldQuery,
811
841
  });
812
842
 
813
843
  // Nothing listed yet (a client navigation paints before the first page
@@ -949,6 +979,7 @@ const { data: tabCountsData, refresh: refreshTabCounts } =
949
979
  rows: tableRows,
950
980
  queries: tabCountsQuery,
951
981
  archiveQuery,
982
+ heldQuery: liveHold.heldQuery,
952
983
  });
953
984
 
954
985
  const viewItems = computed<TableViewItem[]>(() =>
@@ -1125,8 +1156,19 @@ const refreshAll = async () => {
1125
1156
 
1126
1157
  // The rows can move without the table seeing it (a batch, a job, another
1127
1158
  // block's action): a topic of the backend or a page refresh reads them again,
1128
- // keeping the current rows on screen until the answer lands.
1129
- useRefreshTriggers(props.realtimeTopic, () => void refreshAll());
1159
+ // keeping the current rows on screen until the answer lands. On a live page
1160
+ // the row events below go through the same gate.
1161
+ const liveGate = useRefreshTriggers(
1162
+ props.realtimeTopic,
1163
+ () => void refreshAll(),
1164
+ );
1165
+ // What the user asks for (a row action, the refresh button, ⌘R, a display's
1166
+ // own action) reads at once, even while the live page is paused, and brings
1167
+ // a held table up to date.
1168
+ const refreshByUser = () => {
1169
+ liveGate.acknowledge();
1170
+ return refreshAll();
1171
+ };
1130
1172
 
1131
1173
  // "Preview as role": a link tab to a page the previewed role could not open
1132
1174
  // is drawn locked, one to a page it opens partially is drawn partially
@@ -1203,7 +1245,7 @@ const {
1203
1245
  caption,
1204
1246
  labelKey,
1205
1247
  rowIdKey: props.rowIdKey,
1206
- refreshCallback: refreshAll,
1248
+ refreshCallback: refreshByUser,
1207
1249
  formComponents,
1208
1250
  formContainer,
1209
1251
  formPages,
@@ -1395,12 +1437,28 @@ const realtimeEventHandlers: Record<
1395
1437
  },
1396
1438
  };
1397
1439
 
1440
+ // On a live page a row event is one more event behind the table: in Direct
1441
+ // it reads the rows again (at most once per `maxRate`), paused it is counted
1442
+ // and the rows stay as they are. Rows gone elsewhere still leave a selection
1443
+ // the page shows moving.
1444
+ const { isEnabled: isPageLive, isPaused: isPagePaused } = usePageLive();
1445
+ const gateRowEvent = (type: string, ids: string[], event: object) => {
1446
+ if (type === REALTIME_EVENT_TYPE.DELETED && !isPagePaused.value) {
1447
+ deselectRows(ids);
1448
+ }
1449
+ liveGate.trigger(event);
1450
+ };
1451
+
1398
1452
  useRealtimeTopic(realtimeRowTopic, (event) => {
1399
1453
  const payload = event as { type: string; payload?: { ids?: string[] } };
1400
1454
  const handler = realtimeEventHandlers[payload.type];
1401
1455
  if (!handler) return;
1402
1456
  const ids = payload.payload?.ids ?? [];
1403
1457
  if (ids.length === 0 && payload.type !== REALTIME_EVENT_TYPE.CREATED) return;
1458
+ if (isPageLive.value) {
1459
+ gateRowEvent(payload.type, ids, event);
1460
+ return;
1461
+ }
1404
1462
  void handler(ids);
1405
1463
  });
1406
1464
 
@@ -1599,20 +1657,36 @@ watch(rowSelect, (selection) => {
1599
1657
  if (Object.keys(selection).length === 0) allMatching.value = false;
1600
1658
  });
1601
1659
 
1602
- // A deep-linked action reopens on the row the URL names, listed or not.
1660
+ // A deep-linked action reopens on the row the URL names, listed or not. A
1661
+ // list still loading is waited for first: a row it lists keeps its position
1662
+ // among the rows shown, and a source table without `fetchRowUrl` has no
1663
+ // route to read one row from.
1603
1664
  const deepLinkAction = (tableProps.value.rowActions?.custom ?? []).find(
1604
- (action) => action.deepLink,
1665
+ isDeepLinkAction,
1605
1666
  );
1606
1667
  const openRecordFromUrl = async () => {
1607
1668
  const id = deepLinkAction ? readRecordId(route.query, urlScope) : undefined;
1608
1669
  if (!deepLinkAction || !id) return;
1609
- const listed = shownResults.value?.find((row) => getRowId(row) === id);
1610
- const row = listed ?? (await tableRows.getRow(id).catch(() => undefined));
1670
+ await until(isListLoading).toBe(false);
1671
+ const row = await resolveDeepLinkedRow(id, {
1672
+ listed: shownResults.value ?? undefined,
1673
+ idOf: getRowId,
1674
+ getRow: tableRows.getRow,
1675
+ });
1611
1676
  if (row) handleCustomRowAction(deepLinkAction, row as Data);
1612
1677
  };
1613
1678
 
1614
1679
  const selectedIds = computed(() => selectedRowIds(rowSelect.value));
1615
1680
 
1681
+ // The row a side panel has open, which the grid highlights.
1682
+ const rowSidePanel = useRowSidePanelContent();
1683
+ const rowSidePanelOwner = rowSidePanelOwnerKey(pageId!, componentId!);
1684
+ const openRowId = computed(() =>
1685
+ rowSidePanel.value?.ownerKey === rowSidePanelOwner
1686
+ ? rowSidePanel.value.rowKey
1687
+ : undefined,
1688
+ );
1689
+
1616
1690
  const isRowActionEnabled = (
1617
1691
  config: boolean | RowActionConfig | undefined,
1618
1692
  item: T,
@@ -1707,6 +1781,11 @@ const displayRowActions = {
1707
1781
  isRowActionEnabled(tableProps.value.rowActions?.edit, item),
1708
1782
  canDeleteRow: (item: T) =>
1709
1783
  isRowActionEnabled(tableProps.value.rowActions?.delete, item),
1784
+ customFor: (item: T) =>
1785
+ rowCustomActions(
1786
+ tableProps.value.rowActions?.custom,
1787
+ item as Record<string, unknown>,
1788
+ ),
1710
1789
  };
1711
1790
 
1712
1791
  // The TanStack instance is only available as a #body slot prop; captured here so
@@ -1746,7 +1825,7 @@ const displayContext = computed(
1746
1825
  componentId,
1747
1826
  pageId,
1748
1827
  options: optionsForDisplay(activeDisplayId.value),
1749
- refresh: refreshAll,
1828
+ refresh: refreshByUser,
1750
1829
  reorder: reorderState.value,
1751
1830
  table: displayTable.value,
1752
1831
  }),
@@ -1833,7 +1912,7 @@ onBeforeUnmount(() => unregisterQuickActionTarget?.());
1833
1912
 
1834
1913
  defineShortcuts(
1835
1914
  buildTableViewShortcuts({
1836
- refresh: refreshAll,
1915
+ refresh: refreshByUser,
1837
1916
  tableProps,
1838
1917
  newRow: () => newRow(queryParamDefaults.value),
1839
1918
  globalFilter,
@@ -2004,8 +2083,9 @@ onMounted(() => {
2004
2083
  :bulk-actions="bulkCustomActions"
2005
2084
  v-model:all-matching="allMatching"
2006
2085
  :presence-by-row="presenceByRowFiltered"
2086
+ :open-row-id="openRowId"
2007
2087
  @add="handleRowAdd"
2008
- @refresh="refreshAll"
2088
+ @refresh="refreshByUser"
2009
2089
  @details="handleRowClick"
2010
2090
  @delete="(e) => deleteRows(e)"
2011
2091
  @archive="(e) => archiveRows(e)"
@@ -2016,6 +2096,12 @@ onMounted(() => {
2016
2096
  @duplicate="handleRowDuplicate"
2017
2097
  @edit="handleRowEdit"
2018
2098
  >
2099
+ <template v-if="liveHold.newCount.value > 0" #notice>
2100
+ <LiveHoldBanner
2101
+ :count="liveHold.newCount.value"
2102
+ @show="liveHold.showNew"
2103
+ />
2104
+ </template>
2019
2105
  <template v-if="hasViews" #views>
2020
2106
  <TableViews
2021
2107
  :items="viewItems"
@@ -0,0 +1,52 @@
1
+ import { computed, type ComputedRef } from "vue";
2
+ import { currentPageLive } from "../../build/composables/live/currentPageLive";
3
+
4
+ /** The Direct / Pause state of the page on screen, for a module's component. */
5
+ export interface PageLiveState {
6
+ /** The page declares `live`: it has a Direct / Pause state at all. */
7
+ isEnabled: ComputedRef<boolean>;
8
+ /** The page's data follows its events: Direct, or a page without `live`. */
9
+ isLive: ComputedRef<boolean>;
10
+ /** The page is paused: its blocks request nothing on their events. */
11
+ isPaused: ComputedRef<boolean>;
12
+ pause: () => void;
13
+ /** Leaves the pause: each block that missed an event refetches once. */
14
+ resume: () => void;
15
+ toggle: () => void;
16
+ /** When the page last read its data (ms since the epoch), `null` without `live`. */
17
+ lastUpdatedAt: ComputedRef<number | null>;
18
+ /** Distinct events the page received since the pause. */
19
+ missedCount: ComputedRef<number>;
20
+ }
21
+
22
+ /**
23
+ * Follows the Direct / Pause state of the page on screen (see the page option
24
+ * `live` and the `LiveToggle()` block), so a custom block stays in step with
25
+ * the built-in ones without reading the DOM: stop a client-side ticker while
26
+ * paused, show what was missed, offer its own switch.
27
+ *
28
+ * On a page without `live`, or while server-rendering, `isEnabled` is false,
29
+ * the page reads as live and never paused, and `pause` / `resume` do nothing.
30
+ *
31
+ * ```ts
32
+ * const { isPaused, missedCount, resume } = usePageLive();
33
+ * ```
34
+ *
35
+ * A component that refetches on its own realtime topic need not use it:
36
+ * built-in blocks with a `realtimeTopic` already follow the state.
37
+ */
38
+ export function usePageLive(): PageLiveState {
39
+ const isPaused = computed(() => currentPageLive()?.isPaused.value ?? false);
40
+ return {
41
+ isEnabled: computed(() => currentPageLive() !== null),
42
+ isLive: computed(() => !isPaused.value),
43
+ isPaused,
44
+ pause: () => currentPageLive()?.pause(),
45
+ resume: () => currentPageLive()?.resume(),
46
+ toggle: () => currentPageLive()?.toggle(),
47
+ lastUpdatedAt: computed(
48
+ () => currentPageLive()?.lastUpdatedAt.value ?? null,
49
+ ),
50
+ missedCount: computed(() => currentPageLive()?.missedCount.value ?? 0),
51
+ };
52
+ }
@@ -23,6 +23,11 @@ import {
23
23
  } from "./tableViewShiftA";
24
24
  import { TABLE_VIEW_DELETE_METADATA } from "./tableViewDelete";
25
25
  import { TABLE_VIEW_SHIFT_F_METADATA } from "./tableViewShiftF";
26
+ import {
27
+ TABLE_VIEW_J_METADATA,
28
+ TABLE_VIEW_K_METADATA,
29
+ TABLE_VIEW_PANEL_ESCAPE_METADATA,
30
+ } from "./tableViewRowPanel";
26
31
 
27
32
  export const TABLE_VIEW_SHORTCUTS_METADATA = [
28
33
  TABLE_VIEW_META_R_METADATA,
@@ -31,6 +36,9 @@ export const TABLE_VIEW_SHORTCUTS_METADATA = [
31
36
  TABLE_VIEW_SHIFT_A_METADATA,
32
37
  TABLE_VIEW_DELETE_METADATA,
33
38
  TABLE_VIEW_SHIFT_F_METADATA,
39
+ TABLE_VIEW_J_METADATA,
40
+ TABLE_VIEW_K_METADATA,
41
+ TABLE_VIEW_PANEL_ESCAPE_METADATA,
34
42
  ];
35
43
 
36
44
  interface BuildTableViewShortcutsParams<T extends Data> {
@@ -0,0 +1,30 @@
1
+ // The keys of a row opened from the table (see `listenRowSteps` and
2
+ // `listenRowPanelClose`): listed in the shortcuts help, handled by the panel,
3
+ // drawer or modal while it is open.
4
+
5
+ const ROW_OPEN_CONDITION = {
6
+ descriptionKey: "$dms.shortcuts.tableview.row_open.condition",
7
+ };
8
+
9
+ export const TABLE_VIEW_J_METADATA: ShortcutMetadata = {
10
+ key: ["j"],
11
+ descriptionKey: "$dms.shortcuts.tableview.j.description",
12
+ component: "$dms.components.tableview",
13
+ condition: ROW_OPEN_CONDITION,
14
+ };
15
+
16
+ export const TABLE_VIEW_K_METADATA: ShortcutMetadata = {
17
+ key: ["k"],
18
+ descriptionKey: "$dms.shortcuts.tableview.k.description",
19
+ component: "$dms.components.tableview",
20
+ condition: ROW_OPEN_CONDITION,
21
+ };
22
+
23
+ export const TABLE_VIEW_PANEL_ESCAPE_METADATA: ShortcutMetadata = {
24
+ key: ["$keyboard.escape"],
25
+ descriptionKey: "$dms.shortcuts.tableview.panel_escape.description",
26
+ component: "$dms.components.tableview",
27
+ condition: {
28
+ descriptionKey: "$dms.shortcuts.tableview.panel_escape.condition",
29
+ },
30
+ };
@@ -1,5 +1,18 @@
1
1
  import type { DrawerDirection } from "../../containers/types";
2
2
 
3
+ /** What a `sidePanel` target sets besides its component (backend `SidePanelTargetOptions`). */
4
+ export interface SidePanelTargetOptions {
5
+ /** Title of the panel header (`$`: an i18n key); the action's label else. */
6
+ title?: string;
7
+ /**
8
+ * The row's position under the title: an i18n key (`$`-prefixed) receiving
9
+ * `{ index }` (from 1) and `{ total }`.
10
+ */
11
+ positionLabel?: string;
12
+ /** The open row is kept in the URL (`?record=`): on unless `false`. */
13
+ deepLink?: boolean;
14
+ }
15
+
3
16
  export type ActionTarget =
4
17
  | {
5
18
  type: "drawer";
@@ -16,6 +29,8 @@ export type ActionTarget =
16
29
  title?: string;
17
30
  description?: string;
18
31
  }
32
+ /** A non-modal panel docked on the right of the page, on a row. */
33
+ | ({ type: "sidePanel"; component: ComponentInfo } & SidePanelTargetOptions)
19
34
  | { type: "page"; url: string }
20
35
  | { type: "external"; url: string; newTab?: boolean }
21
36
  /** Runs a quick action, by its `category:id` key or bare id. */
@@ -219,7 +219,11 @@ export interface TableViewSourceCapabilities {
219
219
  /** Where a `TableView.fromSource` table reads its rows (backend `source`). */
220
220
  export interface TableViewSourceConfig {
221
221
  fetchUrl: string;
222
+ /** Route answering one row, `{id}` its id (backend `fetchRowUrl`). */
223
+ fetchRowUrl?: string;
222
224
  capabilities: TableViewSourceCapabilities;
225
+ /** Id of the `PeriodSelector` whose period is sent to `fetchUrl`. */
226
+ periodScope?: string;
223
227
  }
224
228
 
225
229
  export interface TableViewListResponse<T> {
@@ -92,7 +92,14 @@ export interface TableViewDisplayActions<T> {
92
92
  details: (item: T) => void;
93
93
  /** What a click on the row does in the grid: its default action. */
94
94
  open: (item: T) => void;
95
- custom: (action: CustomRowAction) => void;
95
+ /** Runs a custom row action on the row, as its grid menu entry does. */
96
+ custom: (action: CustomRowAction, item?: T) => void;
97
+ /**
98
+ * The table's custom row actions the row offers: those whose `rule` accepts
99
+ * it, in their declared order. A card drawing its own menu lists these and
100
+ * runs one with `custom`; an action's `id` finds a given one.
101
+ */
102
+ customFor: (item: T) => CustomRowAction[];
96
103
  }
97
104
 
98
105
  /**
@@ -1,4 +1,5 @@
1
1
  import { GLOBAL_SHORTCUTS_METADATA } from "../build/composables/global/shortcuts";
2
+ import { LIVE_TOGGLE_SHORTCUTS_METADATA } from "../build/composables/live/shortcuts";
2
3
  import { TAB_SHORTCUTS_METADATA } from "../composables/tab/shortcuts";
3
4
  import { TABLE_VIEW_SHORTCUTS_METADATA } from "../composables/table-view/shortcuts";
4
5
  import type { ComponentShortcuts } from "../types/shortcuts";
@@ -16,4 +17,8 @@ export default [
16
17
  component: "TableView",
17
18
  shortcuts: TABLE_VIEW_SHORTCUTS_METADATA,
18
19
  },
20
+ {
21
+ component: "LiveToggle",
22
+ shortcuts: LIVE_TOGGLE_SHORTCUTS_METADATA,
23
+ },
19
24
  ] satisfies ComponentShortcuts[];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Mirror of `PageLiveOptions` from `@antelopejs/interface-dms/page`: the
3
+ * Direct / Pause valve a page declares with `live`.
4
+ */
5
+ export interface PageLiveOptions {
6
+ /** Shortest interval, in seconds, between two refetches of one block. */
7
+ maxRate?: number;
8
+ }
@@ -14,6 +14,8 @@ export interface CustomRowActionBulkOptions {
14
14
  export type CustomRowActionBulk = true | CustomRowActionBulkOptions;
15
15
 
16
16
  export interface CustomRowAction {
17
+ /** Names the action for a card or display that places it itself. */
18
+ id?: string;
17
19
  label: string;
18
20
  icon?: string;
19
21
  target: ActionTarget;
@@ -24,7 +26,10 @@ export interface CustomRowAction {
24
26
  isVisible?: boolean;
25
27
  /** Legacy spelling of `isVisible`, still honoured. */
26
28
  visible?: boolean;
27
- /** Preferred row-click target when its rule accepts the row. */
29
+ /**
30
+ * Preferred row-click target when its rule accepts the row: a double-click,
31
+ * or a single click on a table with `rowClick: "single"`.
32
+ */
28
33
  isDefault?: boolean;
29
34
  /** Color of the inline button or menu entry. */
30
35
  color?: ColorValue;
@@ -34,6 +39,6 @@ export interface CustomRowAction {
34
39
  showLabel?: boolean;
35
40
  /** Offered on the selection bar too. */
36
41
  bulk?: CustomRowActionBulk;
37
- /** The drawer or modal it opens is reflected in the URL (`?record=`). */
42
+ /** The drawer, modal or side panel it opens is reflected in the URL (`?record=`). */
38
43
  deepLink?: boolean;
39
44
  }