@antelopejs/dms 0.7.7 → 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 (72) hide show
  1. package/CHANGELOG.md +23 -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/table/Table.vue +44 -8
  19. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  20. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  21. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  22. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  23. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  24. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  25. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  26. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  29. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  30. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  32. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  33. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  35. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  36. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  37. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  38. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  39. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  40. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  41. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  42. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  43. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
  44. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  45. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  46. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  49. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  50. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  51. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
  52. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  53. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  54. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  55. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  56. package/frontend-vue/tests/field-row.test.ts +14 -0
  57. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  58. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  59. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  60. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  61. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  62. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  63. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  64. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  65. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  66. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  67. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  68. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  69. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  70. package/package.json +2 -2
  71. package/skills/dms/SKILL.md +9 -2
  72. package/skills/dms-pages/REFERENCE.md +5 -0
@@ -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,
@@ -110,6 +123,9 @@ import { useAccumulatedPages } from "../../build/composables/table-view/useAccum
110
123
  import { usePersistedPagination } from "../../build/composables/table-view/usePersistedPagination";
111
124
  import { useTableRows } from "../../build/composables/table-view/useTableRows";
112
125
  import { useRefreshTriggers } from "../../build/composables/blocks/useRefreshTriggers";
126
+ import { useTableLiveHold } from "../../build/composables/table-view/useTableLiveHold";
127
+ import { usePageLive } from "../../composables/live/usePageLive";
128
+ import LiveHoldBanner from "../../build/components/table-view/LiveHoldBanner.vue";
113
129
  import { useTableReorder } from "../../build/composables/table-view/useTableReorder";
114
130
  import {
115
131
  groupedOptionsFor,
@@ -179,6 +195,8 @@ interface TableViewProps<T extends Data> extends TableViewConfig<T> {
179
195
  isSoleTableView?: boolean;
180
196
  /** Topics whose events make the table read its rows again. */
181
197
  realtimeTopic?: string | string[];
198
+ /** Keeps the rows while the live page is paused (backend `liveHold`). */
199
+ liveHold?: boolean;
182
200
  }
183
201
 
184
202
  const props = defineProps<TableViewProps<T>>();
@@ -801,6 +819,14 @@ const archiveQuery = computed(() =>
801
819
  archiveMode ? { showArchived: showArchived.value } : {},
802
820
  );
803
821
 
822
+ // The gate is set up with the other refresh triggers below; the hold only
823
+ // reads it once the table is on screen.
824
+ const liveHold = useTableLiveHold({
825
+ isEnabled: !!props.liveHold,
826
+ isSource: tableRows.isSource,
827
+ gate: () => liveGate,
828
+ });
829
+
804
830
  const { data, status, error, refresh } = await useTableListData<T>({
805
831
  componentId,
806
832
  pageId,
@@ -808,6 +834,7 @@ const { data, status, error, refresh } = await useTableListData<T>({
808
834
  query: queryRequest,
809
835
  archiveQuery,
810
836
  isSelfManaged: isActiveDisplaySelfManaged,
837
+ heldQuery: liveHold.heldQuery,
811
838
  });
812
839
 
813
840
  // Nothing listed yet (a client navigation paints before the first page
@@ -949,6 +976,7 @@ const { data: tabCountsData, refresh: refreshTabCounts } =
949
976
  rows: tableRows,
950
977
  queries: tabCountsQuery,
951
978
  archiveQuery,
979
+ heldQuery: liveHold.heldQuery,
952
980
  });
953
981
 
954
982
  const viewItems = computed<TableViewItem[]>(() =>
@@ -1125,8 +1153,19 @@ const refreshAll = async () => {
1125
1153
 
1126
1154
  // The rows can move without the table seeing it (a batch, a job, another
1127
1155
  // 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());
1156
+ // keeping the current rows on screen until the answer lands. On a live page
1157
+ // the row events below go through the same gate.
1158
+ const liveGate = useRefreshTriggers(
1159
+ props.realtimeTopic,
1160
+ () => void refreshAll(),
1161
+ );
1162
+ // What the user asks for (a row action, the refresh button, ⌘R, a display's
1163
+ // own action) reads at once, even while the live page is paused, and brings
1164
+ // a held table up to date.
1165
+ const refreshByUser = () => {
1166
+ liveGate.acknowledge();
1167
+ return refreshAll();
1168
+ };
1130
1169
 
1131
1170
  // "Preview as role": a link tab to a page the previewed role could not open
1132
1171
  // is drawn locked, one to a page it opens partially is drawn partially
@@ -1203,7 +1242,7 @@ const {
1203
1242
  caption,
1204
1243
  labelKey,
1205
1244
  rowIdKey: props.rowIdKey,
1206
- refreshCallback: refreshAll,
1245
+ refreshCallback: refreshByUser,
1207
1246
  formComponents,
1208
1247
  formContainer,
1209
1248
  formPages,
@@ -1395,12 +1434,28 @@ const realtimeEventHandlers: Record<
1395
1434
  },
1396
1435
  };
1397
1436
 
1437
+ // On a live page a row event is one more event behind the table: in Direct
1438
+ // it reads the rows again (at most once per `maxRate`), paused it is counted
1439
+ // and the rows stay as they are. Rows gone elsewhere still leave a selection
1440
+ // the page shows moving.
1441
+ const { isEnabled: isPageLive, isPaused: isPagePaused } = usePageLive();
1442
+ const gateRowEvent = (type: string, ids: string[], event: object) => {
1443
+ if (type === REALTIME_EVENT_TYPE.DELETED && !isPagePaused.value) {
1444
+ deselectRows(ids);
1445
+ }
1446
+ liveGate.trigger(event);
1447
+ };
1448
+
1398
1449
  useRealtimeTopic(realtimeRowTopic, (event) => {
1399
1450
  const payload = event as { type: string; payload?: { ids?: string[] } };
1400
1451
  const handler = realtimeEventHandlers[payload.type];
1401
1452
  if (!handler) return;
1402
1453
  const ids = payload.payload?.ids ?? [];
1403
1454
  if (ids.length === 0 && payload.type !== REALTIME_EVENT_TYPE.CREATED) return;
1455
+ if (isPageLive.value) {
1456
+ gateRowEvent(payload.type, ids, event);
1457
+ return;
1458
+ }
1404
1459
  void handler(ids);
1405
1460
  });
1406
1461
 
@@ -1599,20 +1654,36 @@ watch(rowSelect, (selection) => {
1599
1654
  if (Object.keys(selection).length === 0) allMatching.value = false;
1600
1655
  });
1601
1656
 
1602
- // A deep-linked action reopens on the row the URL names, listed or not.
1657
+ // A deep-linked action reopens on the row the URL names, listed or not. A
1658
+ // list still loading is waited for first: a row it lists keeps its position
1659
+ // among the rows shown, and a source table without `fetchRowUrl` has no
1660
+ // route to read one row from.
1603
1661
  const deepLinkAction = (tableProps.value.rowActions?.custom ?? []).find(
1604
- (action) => action.deepLink,
1662
+ isDeepLinkAction,
1605
1663
  );
1606
1664
  const openRecordFromUrl = async () => {
1607
1665
  const id = deepLinkAction ? readRecordId(route.query, urlScope) : undefined;
1608
1666
  if (!deepLinkAction || !id) return;
1609
- const listed = shownResults.value?.find((row) => getRowId(row) === id);
1610
- const row = listed ?? (await tableRows.getRow(id).catch(() => undefined));
1667
+ await until(isListLoading).toBe(false);
1668
+ const row = await resolveDeepLinkedRow(id, {
1669
+ listed: shownResults.value ?? undefined,
1670
+ idOf: getRowId,
1671
+ getRow: tableRows.getRow,
1672
+ });
1611
1673
  if (row) handleCustomRowAction(deepLinkAction, row as Data);
1612
1674
  };
1613
1675
 
1614
1676
  const selectedIds = computed(() => selectedRowIds(rowSelect.value));
1615
1677
 
1678
+ // The row a side panel has open, which the grid highlights.
1679
+ const rowSidePanel = useRowSidePanelContent();
1680
+ const rowSidePanelOwner = rowSidePanelOwnerKey(pageId!, componentId!);
1681
+ const openRowId = computed(() =>
1682
+ rowSidePanel.value?.ownerKey === rowSidePanelOwner
1683
+ ? rowSidePanel.value.rowKey
1684
+ : undefined,
1685
+ );
1686
+
1616
1687
  const isRowActionEnabled = (
1617
1688
  config: boolean | RowActionConfig | undefined,
1618
1689
  item: T,
@@ -1746,7 +1817,7 @@ const displayContext = computed(
1746
1817
  componentId,
1747
1818
  pageId,
1748
1819
  options: optionsForDisplay(activeDisplayId.value),
1749
- refresh: refreshAll,
1820
+ refresh: refreshByUser,
1750
1821
  reorder: reorderState.value,
1751
1822
  table: displayTable.value,
1752
1823
  }),
@@ -1833,7 +1904,7 @@ onBeforeUnmount(() => unregisterQuickActionTarget?.());
1833
1904
 
1834
1905
  defineShortcuts(
1835
1906
  buildTableViewShortcuts({
1836
- refresh: refreshAll,
1907
+ refresh: refreshByUser,
1837
1908
  tableProps,
1838
1909
  newRow: () => newRow(queryParamDefaults.value),
1839
1910
  globalFilter,
@@ -2004,8 +2075,9 @@ onMounted(() => {
2004
2075
  :bulk-actions="bulkCustomActions"
2005
2076
  v-model:all-matching="allMatching"
2006
2077
  :presence-by-row="presenceByRowFiltered"
2078
+ :open-row-id="openRowId"
2007
2079
  @add="handleRowAdd"
2008
- @refresh="refreshAll"
2080
+ @refresh="refreshByUser"
2009
2081
  @details="handleRowClick"
2010
2082
  @delete="(e) => deleteRows(e)"
2011
2083
  @archive="(e) => archiveRows(e)"
@@ -2016,6 +2088,12 @@ onMounted(() => {
2016
2088
  @duplicate="handleRowDuplicate"
2017
2089
  @edit="handleRowEdit"
2018
2090
  >
2091
+ <template v-if="liveHold.newCount.value > 0" #notice>
2092
+ <LiveHoldBanner
2093
+ :count="liveHold.newCount.value"
2094
+ @show="liveHold.showNew"
2095
+ />
2096
+ </template>
2019
2097
  <template v-if="hasViews" #views>
2020
2098
  <TableViews
2021
2099
  :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> {
@@ -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
+ }
@@ -24,7 +24,10 @@ export interface CustomRowAction {
24
24
  isVisible?: boolean;
25
25
  /** Legacy spelling of `isVisible`, still honoured. */
26
26
  visible?: boolean;
27
- /** Preferred row-click target when its rule accepts the row. */
27
+ /**
28
+ * Preferred row-click target when its rule accepts the row: a double-click,
29
+ * or a single click on a table with `rowClick: "single"`.
30
+ */
28
31
  isDefault?: boolean;
29
32
  /** Color of the inline button or menu entry. */
30
33
  color?: ColorValue;
@@ -34,6 +37,6 @@ export interface CustomRowAction {
34
37
  showLabel?: boolean;
35
38
  /** Offered on the selection bar too. */
36
39
  bulk?: CustomRowActionBulk;
37
- /** The drawer or modal it opens is reflected in the URL (`?record=`). */
40
+ /** The drawer, modal or side panel it opens is reflected in the URL (`?record=`). */
38
41
  deepLink?: boolean;
39
42
  }
@@ -5,6 +5,12 @@
5
5
  */
6
6
  export const BLOCK_REFRESH_EVENT = "DmsComponent.Block.Refresh";
7
7
 
8
+ /**
9
+ * The window event of a refresh nobody asked for (a timer, a feed): on a page
10
+ * declared with `live` it goes through the Direct / Pause gate.
11
+ */
12
+ export const LIVE_BLOCK_REFRESH_EVENT = "DmsComponent.Block.LiveRefresh";
13
+
8
14
  /** The watch functions every block can name (see interface-dms). */
9
15
  export const BlockFunctions = {
10
16
  REFRESH_PAGE: "DmsComponent.Block.RefreshPage",
@@ -12,17 +18,49 @@ export const BlockFunctions = {
12
18
 
13
19
  /**
14
20
  * Asks every data block mounted on the page to read its data again: what a
15
- * component calls once an action of its own changed the record the page
16
- * shows. The page header calls it after a button changed something.
21
+ * component calls once an action of the user changed the record the page
22
+ * shows. The page header calls it after a button changed something, a form
23
+ * through `BlockFunctions.REFRESH_PAGE` once it saved. A user action always
24
+ * refreshes: on a page declared with `live` it reads even while paused, and
25
+ * the blocks it reads no longer count what they had missed.
17
26
  */
18
27
  export function refreshPageBlocks(): void {
19
28
  if (typeof window === "undefined") return;
20
29
  window.dispatchEvent(new CustomEvent(BLOCK_REFRESH_EVENT));
21
30
  }
22
31
 
23
- /** Calls `handler` on each `refreshPageBlocks()`; returns the unsubscribe. */
24
- export function onPageBlocksRefresh(handler: () => void): () => void {
32
+ /**
33
+ * Asks every data block mounted on the page to read its data again because
34
+ * time passed or a feed moved, not because the user did something: on a page
35
+ * declared with `live` it goes through the page's Direct / Pause gate like a
36
+ * realtime event — capped by `maxRate` in Direct, counted while paused and
37
+ * read once on resume. Without `live`, it is `refreshPageBlocks()`.
38
+ */
39
+ export function refreshLivePageBlocks(): void {
40
+ if (typeof window === "undefined") return;
41
+ window.dispatchEvent(new CustomEvent(LIVE_BLOCK_REFRESH_EVENT));
42
+ }
43
+
44
+ /**
45
+ * Calls `handler` on each `refreshPageBlocks()`; returns the unsubscribe. Every
46
+ * handler receives the same event for one call, which tells one refresh
47
+ * reaching several blocks from several refreshes.
48
+ */
49
+ export function onPageBlocksRefresh(
50
+ handler: (event: Event) => void,
51
+ ): () => void {
52
+ return listen(BLOCK_REFRESH_EVENT, handler);
53
+ }
54
+
55
+ /** Calls `handler` on each `refreshLivePageBlocks()`; returns the unsubscribe. */
56
+ export function onLivePageBlocksRefresh(
57
+ handler: (event: Event) => void,
58
+ ): () => void {
59
+ return listen(LIVE_BLOCK_REFRESH_EVENT, handler);
60
+ }
61
+
62
+ function listen(name: string, handler: (event: Event) => void): () => void {
25
63
  if (typeof window === "undefined") return () => {};
26
- window.addEventListener(BLOCK_REFRESH_EVENT, handler);
27
- return () => window.removeEventListener(BLOCK_REFRESH_EVENT, handler);
64
+ window.addEventListener(name, handler);
65
+ return () => window.removeEventListener(name, handler);
28
66
  }
@@ -469,6 +469,19 @@
469
469
  "delete": {
470
470
  "description": "Delete hovered row",
471
471
  "condition": "Only when enableRowDelete is enabled"
472
+ },
473
+ "j": {
474
+ "description": "Next row"
475
+ },
476
+ "k": {
477
+ "description": "Previous row"
478
+ },
479
+ "row_open": {
480
+ "condition": "While a row is open in a side panel, a drawer or a modal, outside a text field"
481
+ },
482
+ "panel_escape": {
483
+ "description": "Close the row side panel",
484
+ "condition": "While a row is open in a side panel, once the table has no search or selection to clear"
472
485
  }
473
486
  },
474
487
  "tab": {
@@ -492,6 +505,11 @@
492
505
  "escape": {
493
506
  "description": "Close dialogs, menus and the palette"
494
507
  }
508
+ },
509
+ "livetoggle": {
510
+ "l": {
511
+ "description": "Switch the page between Direct and Pause"
512
+ }
495
513
  }
496
514
  },
497
515
  "notifications": {
@@ -709,6 +727,16 @@
709
727
  "action_error": {
710
728
  "forbidden": "You don’t have permission to do this.",
711
729
  "generic": "Something went wrong and the action couldn’t be completed. Try again."
730
+ },
731
+ "live": {
732
+ "direct": "Direct",
733
+ "pause": "Pause",
734
+ "aria_label": "Live updates",
735
+ "updated_seconds": "Updated {count} s ago",
736
+ "updated_minutes": "Updated {count} min ago",
737
+ "updated_hours": "Updated {count} h ago",
738
+ "new_rows": "{count} new",
739
+ "show_new": "Show"
712
740
  }
713
741
  }
714
742
  }
@@ -470,6 +470,19 @@
470
470
  "delete": {
471
471
  "description": "Supprimer la ligne survolée",
472
472
  "condition": "Uniquement quand enableRowDelete est activé"
473
+ },
474
+ "j": {
475
+ "description": "Ligne suivante"
476
+ },
477
+ "k": {
478
+ "description": "Ligne précédente"
479
+ },
480
+ "row_open": {
481
+ "condition": "Quand une ligne est ouverte dans un panneau latéral, un tiroir ou une fenêtre, hors d'un champ de saisie"
482
+ },
483
+ "panel_escape": {
484
+ "description": "Fermer le panneau latéral de la ligne",
485
+ "condition": "Quand une ligne est ouverte dans un panneau latéral, une fois que la table n'a plus de recherche ni de sélection à effacer"
473
486
  }
474
487
  },
475
488
  "tab": {
@@ -493,6 +506,11 @@
493
506
  "escape": {
494
507
  "description": "Fermer les dialogues, les menus et la palette"
495
508
  }
509
+ },
510
+ "livetoggle": {
511
+ "l": {
512
+ "description": "Basculer la page entre Direct et Pause"
513
+ }
496
514
  }
497
515
  },
498
516
  "notifications": {
@@ -710,6 +728,16 @@
710
728
  "action_error": {
711
729
  "forbidden": "Vous n’avez pas l’autorisation d’effectuer cette action.",
712
730
  "generic": "Une erreur est survenue et l’action n’a pas pu aboutir. Réessayez."
731
+ },
732
+ "live": {
733
+ "direct": "Direct",
734
+ "pause": "Pause",
735
+ "aria_label": "Mises à jour en direct",
736
+ "updated_seconds": "Mis à jour il y a {count} s",
737
+ "updated_minutes": "Mis à jour il y a {count} min",
738
+ "updated_hours": "Mis à jour il y a {count} h",
739
+ "new_rows": "{count} nouvelle | {count} nouvelles",
740
+ "show_new": "Afficher"
713
741
  }
714
742
  }
715
743
  }
@@ -0,0 +1,74 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A chart on a paused live page sends no request on its events or on a page
4
+ * refresh, and reads once when the page resumes.
5
+ */
6
+ import { createApp, defineComponent, h, ref } from "vue";
7
+ import { afterEach, expect, it, vi } from "vitest";
8
+ import {
9
+ refreshLivePageBlocks,
10
+ refreshPageBlocks,
11
+ } from "../layers/dms-ui/app/utils/blockRefresh";
12
+ import { createPageLiveController } from "../layers/dms-ui/app/build/composables/live/pageLiveController";
13
+ import { setCurrentPageLive } from "../layers/dms-ui/app/build/composables/live/currentPageLive";
14
+
15
+ const requests: string[] = [];
16
+
17
+ vi.stubGlobal("useAuthFetch", () => ({
18
+ $authFetch: async (url: string) => {
19
+ requests.push(url);
20
+ return { value: requests.length };
21
+ },
22
+ }));
23
+ vi.stubGlobal("useDmsRoute", () => ({ query: {} }));
24
+ vi.stubGlobal("ref", ref);
25
+
26
+ const { useChartFetch } = await import(
27
+ "../layers/dms-ui/app/composables/chart/useChartFetch"
28
+ );
29
+
30
+ // useChartFetch debounces every refresh past the first by 180ms.
31
+ const SETTLE_MS = 250;
32
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
33
+
34
+ const apps: Array<() => void> = [];
35
+
36
+ afterEach(() => {
37
+ apps.splice(0).forEach((unmount) => unmount());
38
+ setCurrentPageLive(null);
39
+ window.localStorage.clear();
40
+ });
41
+
42
+ it("requests nothing while paused and reads once on resume", async () => {
43
+ const live = createPageLiveController("api.overview", {});
44
+ setCurrentPageLive(live);
45
+ const app = createApp(
46
+ defineComponent({
47
+ setup() {
48
+ useChartFetch<{ value: number }>({ fetchUrl: "/api/overview/chart" });
49
+ return () => h("div");
50
+ },
51
+ }),
52
+ );
53
+ app.mount(document.createElement("div"));
54
+ apps.push(() => app.unmount());
55
+ await settle();
56
+ expect(requests).toHaveLength(1);
57
+
58
+ live.pause();
59
+ refreshLivePageBlocks();
60
+ refreshLivePageBlocks();
61
+ await settle();
62
+ expect(requests).toHaveLength(1);
63
+
64
+ live.resume();
65
+ await settle();
66
+ expect(requests).toHaveLength(2);
67
+
68
+ // An action of the user refreshes even while paused.
69
+ live.pause();
70
+ refreshPageBlocks();
71
+ await settle();
72
+ expect(requests).toHaveLength(3);
73
+ live.dispose();
74
+ });
@@ -74,6 +74,20 @@ describe("FieldRow", () => {
74
74
  expect(body().className).toContain("py-4");
75
75
  });
76
76
 
77
+ it("spans both form columns with its control when it has no label", async () => {
78
+ await mount({ layout: "form" }, { default: () => h("input") });
79
+ expect(body().children).toHaveLength(1);
80
+ expect(body().firstElementChild!.className).toContain("col-span-full");
81
+ });
82
+
83
+ it("keeps a labelled control in the form's control column", async () => {
84
+ await mount(
85
+ { label: "Name", layout: "form" },
86
+ { default: () => h("input") },
87
+ );
88
+ expect(body().lastElementChild!.className).not.toContain("col-span-full");
89
+ });
90
+
77
91
  it("leaves the spacing to the list of a stacked form", async () => {
78
92
  await mount({
79
93
  label: "Name",