@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,189 @@
1
+ import { router as inertiaRouter } from "@inertiajs/vue3";
2
+ import { onMounted, watch } from "vue";
3
+ import type { UsePeriodReturn } from "#dms-core/app/composables/period/usePeriod";
4
+ import {
5
+ isPeriodQueryCurrent,
6
+ mergePeriodSearch,
7
+ periodQueryKeys,
8
+ rememberedPeriodKey,
9
+ rememberPeriod,
10
+ type RememberedPeriods,
11
+ type PeriodQuery,
12
+ type PeriodQueryKeys,
13
+ type PeriodSelection,
14
+ type PeriodSource,
15
+ resolvePeriodSelection,
16
+ writePeriodQuery,
17
+ } from "./periodQuery";
18
+
19
+ export interface PeriodLocationOptions {
20
+ /** The selector's id: the prefix of its URL keys. */
21
+ id: string;
22
+ /** The page it is on: its last choice is kept per page and per id. */
23
+ pageId: string;
24
+ /** What the period falls back to when no source names a valid one. */
25
+ fallback: PeriodSelection;
26
+ }
27
+
28
+ /**
29
+ * The preference holding the user's last choice of each selector
30
+ * (`usePreferences`), capped to the most recent ones.
31
+ */
32
+ export const PERIOD_PREFERENCE_KEY = "periods";
33
+ const STORED_KEYS = periodQueryKeys();
34
+
35
+ function readSelection(period: UsePeriodReturn): PeriodSelection {
36
+ return {
37
+ preset: period.preset.value,
38
+ comparison: period.comparison.value,
39
+ customRange: period.customRange.value,
40
+ customCompareRange: period.customCompareRange.value,
41
+ };
42
+ }
43
+
44
+ function applySelection(
45
+ period: UsePeriodReturn,
46
+ selection: PeriodSelection,
47
+ ): void {
48
+ period.customRange.value = selection.customRange;
49
+ period.customCompareRange.value = selection.customCompareRange;
50
+ period.preset.value = selection.preset;
51
+ period.comparison.value = selection.comparison;
52
+ }
53
+
54
+ function urlKeysText(query: PeriodQuery, keys: PeriodQueryKeys): string {
55
+ return JSON.stringify(Object.values(keys).map((key) => query[key] ?? null));
56
+ }
57
+
58
+ function selectionText(selection: PeriodSelection): string {
59
+ return JSON.stringify(writePeriodQuery(selection, STORED_KEYS));
60
+ }
61
+
62
+ /**
63
+ * Writes the period into the URL: the history entry is replaced, and no
64
+ * request reloads the page. The browser's URL is written at once, so a
65
+ * second selector writing in the same tick merges into it; Inertia's
66
+ * client-side replace then moves its page URL, which the DMS route follows,
67
+ * so a component writing the query from the route (a tab, a quick action)
68
+ * carries the period on instead of the one the page was opened with.
69
+ */
70
+ function replacePeriodInUrl(
71
+ keys: PeriodQueryKeys,
72
+ selection: PeriodSelection,
73
+ ): string | null {
74
+ if (typeof window === "undefined") return null;
75
+ const url = new URL(window.location.href);
76
+ const query = Object.fromEntries(url.searchParams);
77
+ if (isPeriodQueryCurrent(query, keys, selection)) return null;
78
+ url.search = mergePeriodSearch(url.search, keys, selection);
79
+ const written = urlKeysText(Object.fromEntries(url.searchParams), keys);
80
+ window.history.replaceState(window.history.state, "", url);
81
+ inertiaRouter.replace({
82
+ url: `${url.pathname}${url.search}${url.hash}`,
83
+ preserveState: true,
84
+ preserveScroll: true,
85
+ });
86
+ return written;
87
+ }
88
+
89
+ /** The user's last choice of one selector, kept in the preferences. */
90
+ function useRememberedPeriod(pageId: string, id: string) {
91
+ const { getPreference, setPreference } = usePreferences();
92
+ const key = rememberedPeriodKey(pageId, id);
93
+ const all = () => getPreference<RememberedPeriods>(PERIOD_PREFERENCE_KEY, {});
94
+ return {
95
+ source: (): PeriodSource => ({
96
+ query: all()[key] ?? {},
97
+ keys: STORED_KEYS,
98
+ }),
99
+ remember: (selection: PeriodSelection) =>
100
+ setPreference(
101
+ PERIOD_PREFERENCE_KEY,
102
+ rememberPeriod(all(), key, writePeriodQuery(selection, STORED_KEYS)),
103
+ ),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The URL writes of one selector. The route catches up with each write
109
+ * later, and a pick made meanwhile must not be undone by the URL of the one
110
+ * before: `isEcho` tells a route naming a URL written here from a change.
111
+ */
112
+ function usePeriodUrlWriter(keys: PeriodQueryKeys) {
113
+ const pending: string[] = [];
114
+ return {
115
+ write: (selection: PeriodSelection) => {
116
+ const written = replacePeriodInUrl(keys, selection);
117
+ if (written !== null) pending.push(written);
118
+ },
119
+ isEcho: (text: string) => {
120
+ const index = pending.indexOf(text);
121
+ if (index === -1) return false;
122
+ pending.splice(0, index + 1);
123
+ return true;
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Keeps a selector's period in the page URL (`?<id>.period=`, see
130
+ * `periodQuery`) and its last choice in the user's preferences. The period
131
+ * starts from the URL, then from that last choice, then from `fallback`. Both
132
+ * are read in setup, on the server too: the preferences are a cookie, so the
133
+ * server renders the period the browser will show.
134
+ *
135
+ * The URL is written once mounted, so a copied URL always carries the
136
+ * period, then on every change: the history entry is replaced instead of
137
+ * pushed, so Back leaves the page rather than stepping through every pick,
138
+ * and the page is not requested again, only the bound cards and tables
139
+ * refetch. A route changed from outside (Back to the page with another
140
+ * period, a link) moves the period.
141
+ */
142
+ export function usePeriodLocation(
143
+ period: UsePeriodReturn,
144
+ options: PeriodLocationOptions,
145
+ ): void {
146
+ const route = useDmsRoute();
147
+ const urlKeys = periodQueryKeys(options.id);
148
+ const remembered = useRememberedPeriod(options.pageId, options.id);
149
+ const writer = usePeriodUrlWriter(urlKeys);
150
+ const choices = {
151
+ presets: period.presets.value,
152
+ comparisons: period.comparisons.value,
153
+ };
154
+ const urlSource = (): PeriodSource => ({
155
+ query: route.query as PeriodQuery,
156
+ keys: urlKeys,
157
+ });
158
+
159
+ applySelection(
160
+ period,
161
+ resolvePeriodSelection(
162
+ [urlSource(), remembered.source()],
163
+ choices,
164
+ options.fallback,
165
+ ),
166
+ );
167
+ onMounted(() => writer.write(readSelection(period)));
168
+
169
+ watch(
170
+ () => selectionText(readSelection(period)),
171
+ () => {
172
+ const selection = readSelection(period);
173
+ remembered.remember(selection);
174
+ writer.write(selection);
175
+ },
176
+ );
177
+
178
+ // Only the route is followed here: the preferences move with the period.
179
+ watch(
180
+ () => urlKeysText(route.query as PeriodQuery, urlKeys),
181
+ (text) => {
182
+ if (writer.isEcho(text)) return;
183
+ const current = readSelection(period);
184
+ const next = resolvePeriodSelection([urlSource()], choices, current);
185
+ if (selectionText(next) === selectionText(current)) return;
186
+ applySelection(period, next);
187
+ },
188
+ );
189
+ }
@@ -18,6 +18,11 @@ export interface TableListDataOptions<T extends Record<string, unknown>> {
18
18
  archiveQuery: ComputedRef<Query>;
19
19
  /** A self-managed display (the kanban) reads its own rows. */
20
20
  isSelfManaged: ComputedRef<boolean>;
21
+ /**
22
+ * Added to each request when it leaves, never watched: the live hold's
23
+ * bound must not list the rows again by changing (see useTableLiveHold).
24
+ */
25
+ heldQuery?: () => Query;
21
26
  }
22
27
 
23
28
  /** One counter: its id and its list query, as the table view builds it. */
@@ -32,8 +37,12 @@ export interface TableCountsDataOptions<T extends Record<string, unknown>> {
32
37
  rows: TableRows<T>;
33
38
  queries: ComputedRef<TableCountRequest[]>;
34
39
  archiveQuery: ComputedRef<Query>;
40
+ /** Added to each request when it leaves, never watched (see the list's). */
41
+ heldQuery?: () => Query;
35
42
  }
36
43
 
44
+ const NO_HELD_QUERY = (): Query => ({});
45
+
37
46
  const EMPTY_LIST_RESULT: TableViewListResponse<never> = {
38
47
  results: [],
39
48
  total: 0,
@@ -51,6 +60,7 @@ export function useTableListData<T extends Record<string, unknown>>(
51
60
  options: TableListDataOptions<T>,
52
61
  ) {
53
62
  const { rows, query, archiveQuery, isSelfManaged } = options;
63
+ const heldQuery = options.heldQuery ?? NO_HELD_QUERY;
54
64
  const key = buildTableDataKey({
55
65
  componentId: options.componentId,
56
66
  pageId: options.pageId,
@@ -61,10 +71,11 @@ export function useTableListData<T extends Record<string, unknown>>(
61
71
  });
62
72
  return useServerRenderedAsyncData(
63
73
  key,
64
- (): Promise<TableViewListResponse<T>> =>
74
+ // `null` while a source waits on its period scope: the table's skeleton.
75
+ (): Promise<TableViewListResponse<T> | null> =>
65
76
  isSelfManaged.value
66
77
  ? Promise.resolve(EMPTY_LIST_RESULT)
67
- : rows.list({ ...query.value, ...archiveQuery.value }),
78
+ : rows.list({ ...query.value, ...archiveQuery.value, ...heldQuery() }),
68
79
  { watch: [query, archiveQuery, isSelfManaged, rows.sourceUrl] },
69
80
  );
70
81
  }
@@ -77,6 +88,7 @@ export function useTableCountsData<T extends Record<string, unknown>>(
77
88
  options: TableCountsDataOptions<T>,
78
89
  ) {
79
90
  const { rows, queries, archiveQuery } = options;
91
+ const heldQuery = options.heldQuery ?? NO_HELD_QUERY;
80
92
  const sourceSuffix = rows.sourceUrl.value ? `-${rows.sourceUrl.value}` : "";
81
93
  return useServerRenderedAsyncData<Record<string, number>>(
82
94
  `table-view-${options.componentId}-${options.pageId}-tab-counts${sourceSuffix}`,
@@ -85,7 +97,7 @@ export function useTableCountsData<T extends Record<string, unknown>>(
85
97
  return await rows.countBatch(
86
98
  queries.value.map(({ id, query }) => ({
87
99
  id,
88
- query: { ...query, ...archiveQuery.value },
100
+ query: { ...query, ...archiveQuery.value, ...heldQuery() },
89
101
  })),
90
102
  );
91
103
  },
@@ -0,0 +1,77 @@
1
+ import { computed, ref, watch, type ComputedRef } from "vue";
2
+ import { usePageLive } from "../../../composables/live/usePageLive";
3
+ import type { LiveGate } from "../live/currentPageLive";
4
+ import { LIVE_HELD_AT_QUERY_KEY } from "./useTableRows";
5
+
6
+ type Query = Record<string, unknown>;
7
+
8
+ export interface TableLiveHoldOptions {
9
+ /** The table declares `liveHold`. */
10
+ isEnabled: boolean;
11
+ /** A `TableView.fromSource` table: its route receives the hold's bound. */
12
+ isSource: boolean;
13
+ /**
14
+ * The table's refresh gate (useRefreshTriggers), read when used: the table
15
+ * sets it up after the list this hold bounds.
16
+ */
17
+ gate: () => LiveGate;
18
+ }
19
+
20
+ export interface TableLiveHold {
21
+ /** The rows are held: the table declares `liveHold` and the page is paused. */
22
+ isHolding: ComputedRef<boolean>;
23
+ /** Events the table received since its rows were held or last shown. */
24
+ newCount: ComputedRef<number>;
25
+ /** Reads the rows once, staying paused, and holds them from now. */
26
+ showNew: () => void;
27
+ /** The bound a source route receives with each request while held. */
28
+ heldQuery: () => Query;
29
+ }
30
+
31
+ const NO_QUERY: Query = {};
32
+
33
+ /**
34
+ * The `liveHold` of a table view: while its live page is paused, the table
35
+ * keeps its rows — the gate already requests nothing on its events — and a
36
+ * source route is told the moment they were frozen, so the user's own search,
37
+ * sort and pagination list the rows as they stood. Show reads them once more
38
+ * without leaving the pause.
39
+ *
40
+ * @internal
41
+ */
42
+ export function useTableLiveHold(options: TableLiveHoldOptions): TableLiveHold {
43
+ const { isEnabled, isPaused } = usePageLive();
44
+ const pausedAt = ref<number | null>(null);
45
+ const isHolding = computed(
46
+ () => options.isEnabled && isEnabled.value && isPaused.value,
47
+ );
48
+
49
+ watch(
50
+ isHolding,
51
+ (holding) => {
52
+ pausedAt.value = holding ? Date.now() : null;
53
+ },
54
+ { immediate: true },
55
+ );
56
+
57
+ // The rows are as fresh as the pause, or as the last read the user asked
58
+ // for since (Show, an action of theirs).
59
+ const heldAt = () =>
60
+ pausedAt.value === null
61
+ ? null
62
+ : Math.max(pausedAt.value, options.gate().lastReadAt.value);
63
+
64
+ return {
65
+ isHolding,
66
+ newCount: computed(() =>
67
+ isHolding.value ? options.gate().missed.value : 0,
68
+ ),
69
+ showNew: () => options.gate().catchUp(),
70
+ heldQuery: () => {
71
+ const at = heldAt();
72
+ return options.isSource && at !== null
73
+ ? { [LIVE_HELD_AT_QUERY_KEY]: new Date(at).toISOString() }
74
+ : NO_QUERY;
75
+ },
76
+ };
77
+ }
@@ -9,6 +9,10 @@ import {
9
9
  type ReplaceUrlVariablesContext,
10
10
  resolveUrlVariables,
11
11
  } from "../../utils/urlVariables";
12
+ import {
13
+ appendPeriodToUrl,
14
+ usePeriodScope,
15
+ } from "#dms-core/app/composables/period/usePeriodScope";
12
16
 
13
17
  type Query = Record<string, unknown>;
14
18
 
@@ -37,7 +41,13 @@ const SEARCH_KEY = "search";
37
41
  const SORT_KEYS = ["sortKey", "sortDirection"];
38
42
  const PAGE_KEYS = ["offset", "limit"];
39
43
  const DESCENDING = "desc";
44
+ /**
45
+ * Sent while a `liveHold` table is paused: the moment its rows were frozen,
46
+ * for a source route to list the rows as they stood then.
47
+ */
48
+ export const LIVE_HELD_AT_QUERY_KEY = "liveHeldAt";
40
49
  const NO_ROUTE_VALUES: ReplaceUrlVariablesContext = { routeQuery: {} };
50
+ const ROW_ID_TOKEN = "{id}";
41
51
 
42
52
  type SourceCapabilities = TableViewSourceConfig["capabilities"];
43
53
 
@@ -54,6 +64,7 @@ const QUERY_KEY_CAPABILITIES: Array<
54
64
  /**
55
65
  * The part of the list query a source route handles, by its capabilities: a
56
66
  * key no capability names (a controller's `showArchived`…) never reaches it.
67
+ * The live hold's bound always does: a route not reading it lists as usual.
57
68
  */
58
69
  export function sourceRouteQuery(
59
70
  query: Query,
@@ -61,6 +72,7 @@ export function sourceRouteQuery(
61
72
  ): Query {
62
73
  return Object.fromEntries(
63
74
  Object.entries(query).filter(([key]) => {
75
+ if (key === LIVE_HELD_AT_QUERY_KEY) return true;
64
76
  const entry = QUERY_KEY_CAPABILITIES.find(([matches]) => matches(key));
65
77
  return !!entry && !!capabilities[entry[1]];
66
78
  }),
@@ -135,28 +147,35 @@ function emptySourcePage<T>(query: Query): TableViewListResponse<T> {
135
147
  * the rest to the browser; a route answering one page is taken as it is.
136
148
  *
137
149
  * A source's `{{params.X}}` and `{{query.X}}` tokens are filled from `route`;
138
- * while one has no value nothing is requested and the list is empty.
139
- * `sourceUrl` is the URL requested, for the caller to list again when it
140
- * changes.
150
+ * while one has no value nothing is requested and the list is empty. A
151
+ * source's `periodScope` adds the period of its selector to the URL, like a
152
+ * chart's; until that selector registers, nothing is requested and the list
153
+ * answers `null`, which the table draws as its skeleton. `sourceUrl` is the
154
+ * URL requested, for the caller to list again when it changes.
141
155
  */
142
156
  export function useTableRows<T extends Record<string, unknown>>(
143
157
  options: TableRowsOptions,
144
158
  ) {
145
159
  const { api, location, source, columns } = options;
146
-
147
- const sourceUrl = computed(() =>
148
- source
149
- ? resolveUrlVariables(
150
- source.fetchUrl,
151
- options.route?.() ?? NO_ROUTE_VALUES,
152
- )
153
- : undefined,
160
+ const periodScope = usePeriodScope(source?.periodScope);
161
+ const isPeriodPending = computed(
162
+ () => !!source?.periodScope && !periodScope.value,
154
163
  );
155
164
 
165
+ const sourceUrl = computed(() => {
166
+ if (!source) return undefined;
167
+ const url = resolveUrlVariables(
168
+ source.fetchUrl,
169
+ options.route?.() ?? NO_ROUTE_VALUES,
170
+ );
171
+ return source.periodScope ? appendPeriodToUrl(url, periodScope.value) : url;
172
+ });
173
+
156
174
  const listFromSource = async (
157
175
  sourceConfig: TableViewSourceConfig,
158
176
  query: Query,
159
- ): Promise<TableViewListResponse<T>> => {
177
+ ): Promise<TableViewListResponse<T> | null> => {
178
+ if (isPeriodPending.value) return null;
160
179
  const url = sourceUrl.value;
161
180
  if (!url) return emptySourcePage(query);
162
181
  const { capabilities } = sourceConfig;
@@ -173,7 +192,7 @@ export function useTableRows<T extends Record<string, unknown>>(
173
192
  return processSourceRows(response.results, query, capabilities, columns);
174
193
  };
175
194
 
176
- const list = (query: Query): Promise<TableViewListResponse<T>> =>
195
+ const list = (query: Query): Promise<TableViewListResponse<T> | null> =>
177
196
  source
178
197
  ? listFromSource(source, query)
179
198
  : api<TableViewListResponse<T>>(`${location}/list`, { query });
@@ -190,21 +209,37 @@ export function useTableRows<T extends Record<string, unknown>>(
190
209
  }
191
210
  const totals = await Promise.all(
192
211
  queries.map(async ({ id, query }) => {
193
- const { total } = await listFromSource(source, {
212
+ const page = await listFromSource(source, {
194
213
  ...query,
195
214
  offset: 0,
196
215
  limit: 1,
197
216
  });
198
- return [id, total] as const;
217
+ return page ? [[id, page.total] as const] : [];
199
218
  }),
200
219
  );
201
- return Object.fromEntries(totals);
220
+ return Object.fromEntries(totals.flat());
221
+ };
222
+
223
+ // A source reads one row through its `fetchRowUrl`, if it declares one. It
224
+ // sends no list query, the period included: one row by id depends on none.
225
+ const getSourceRow = (
226
+ sourceConfig: TableViewSourceConfig,
227
+ id: string,
228
+ ): Promise<T | undefined> => {
229
+ const url = resolveUrlVariables(
230
+ sourceConfig.fetchRowUrl?.replaceAll(
231
+ ROW_ID_TOKEN,
232
+ encodeURIComponent(id),
233
+ ),
234
+ options.route?.() ?? NO_ROUTE_VALUES,
235
+ );
236
+ return url ? api<T>(url) : Promise.resolve(undefined);
202
237
  };
203
238
 
204
- /** One row by id: a source has no route for it. */
239
+ /** One row by id: a source's `fetchRowUrl`, a controller's `get` route. */
205
240
  const getRow = (id: string): Promise<T | undefined> =>
206
241
  source
207
- ? Promise.resolve(undefined)
242
+ ? getSourceRow(source, id)
208
243
  : api<T>(`${location}/get`, { query: { id } });
209
244
 
210
245
  return { isSource: !!source, sourceUrl, list, countBatch, getRow };
@@ -123,6 +123,7 @@ export const useTableViewConfig = <T extends Data>(
123
123
  maxHeight: config.maxHeight,
124
124
  rowIdKey: config.rowIdKey,
125
125
  rowActions: config.rowActions,
126
+ rowClick: config.rowClick,
126
127
  customNavItems: config.customNavItems,
127
128
  componentId: config.componentId,
128
129
  formContainer: config.formContainer,
@@ -1,3 +1,4 @@
1
+ import type { CustomRowAction } from "../../../../types/row-action";
1
2
  import { readTableUrlKey, type TableUrlScope } from "./views";
2
3
 
3
4
  /** Query key of the row a `deepLink` action has open. */
@@ -21,12 +22,70 @@ export function readRecordId(
21
22
  /**
22
23
  * Writes (or, without an id, removes) the open row in the browser's URL in
23
24
  * place: no visit, no history entry, the link and a reload open it again.
25
+ * Returns what it wrote, for {@link clearWrittenRecord}.
24
26
  */
25
- export function writeRecordId(scope: TableUrlScope, id?: string): void {
26
- if (typeof window === "undefined") return;
27
+ export function writeRecordId(
28
+ scope: TableUrlScope,
29
+ id?: string,
30
+ ): WrittenRecord | undefined {
31
+ if (typeof window === "undefined") return undefined;
27
32
  const url = new URL(window.location.href);
28
33
  const key = recordUrlKey(scope);
29
34
  if (id) url.searchParams.set(key, id);
30
35
  else url.searchParams.delete(key);
31
36
  window.history.replaceState(window.history.state, "", url);
37
+ return id ? { pathname: url.pathname, key, id } : undefined;
38
+ }
39
+
40
+ /**
41
+ * Whether an action keeps the row it opens in the URL: a side panel does
42
+ * unless its target or the action sets `deepLink: false`, a drawer or a modal
43
+ * only with the action's `deepLink: true`.
44
+ */
45
+ export function isDeepLinkAction(action: CustomRowAction): boolean {
46
+ if (action.target.type !== "sidePanel") return !!action.deepLink;
47
+ return (action.deepLink ?? action.target.deepLink) !== false;
48
+ }
49
+
50
+ /** The row a table wrote in the URL, and the page it wrote it on. */
51
+ export interface WrittenRecord {
52
+ pathname: string;
53
+ key: string;
54
+ id: string;
55
+ }
56
+
57
+ /**
58
+ * Removes the row a table wrote in the URL, only while the URL still holds
59
+ * it: a container closing as the user leaves the page must not erase the
60
+ * `?record=` of the page reached.
61
+ */
62
+ export function clearWrittenRecord(written: WrittenRecord | undefined): void {
63
+ if (typeof window === "undefined" || !written) return;
64
+ const url = new URL(window.location.href);
65
+ if (url.pathname !== written.pathname) return;
66
+ if (url.searchParams.get(written.key) !== written.id) return;
67
+ url.searchParams.delete(written.key);
68
+ window.history.replaceState(window.history.state, "", url);
69
+ }
70
+
71
+ /** Where a deep link finds its row: among the rows listed, or by id. */
72
+ export interface DeepLinkedRowSource<T> {
73
+ listed: T[] | undefined;
74
+ idOf: (row: T) => string | undefined;
75
+ /** Reads one row: a controller's `get` route, a source's `fetchRowUrl`. */
76
+ getRow: (id: string) => Promise<T | undefined>;
77
+ }
78
+
79
+ /**
80
+ * The row a deep link names: the listed one, else the one read by id — a row
81
+ * beyond the first page. A row the route cannot answer (gone, a 404) opens
82
+ * nothing.
83
+ */
84
+ export async function resolveDeepLinkedRow<T>(
85
+ id: string,
86
+ source: DeepLinkedRowSource<T>,
87
+ ): Promise<T | undefined> {
88
+ const listed = source.listed?.find((row) => source.idOf(row) === id);
89
+ if (listed) return listed;
90
+ return source.getRow(id).catch(() => undefined);
32
91
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What runs a row's click action (backend `TableViewRowClick`): a double-click
3
+ * by default, or a single click.
4
+ */
5
+ export type TableViewRowClick = "single" | "double";
6
+
7
+ /** A reason a pointer event on a row must not run its click action. */
8
+ type RowClickGuard = (event: MouseEvent) => boolean;
9
+
10
+ interface RowClickTrigger {
11
+ eventType: string;
12
+ guards: RowClickGuard[];
13
+ }
14
+
15
+ const DEFAULT_ROW_CLICK: TableViewRowClick = "double";
16
+
17
+ // The controls a row may hold: a click on one is that control's, not the
18
+ // row's.
19
+ const ROW_CONTROL_SELECTOR = [
20
+ "a",
21
+ "button",
22
+ "input",
23
+ "select",
24
+ "textarea",
25
+ "label",
26
+ "summary",
27
+ "[role='button']",
28
+ "[role='checkbox']",
29
+ "[role='menuitem']",
30
+ "[role='switch']",
31
+ "[contenteditable='true']",
32
+ ].join(", ");
33
+
34
+ // The second click of a double-click: the first one already ran the action.
35
+ const isRepeatClick: RowClickGuard = (event) => event.detail > 1;
36
+
37
+ const isOnRowControl: RowClickGuard = (event) => {
38
+ const target = event.target as Element | null;
39
+ const control = target?.closest?.(ROW_CONTROL_SELECTOR);
40
+ if (!control) return false;
41
+ const row = event.currentTarget as Element | null;
42
+ return !row || row.contains(control);
43
+ };
44
+
45
+ // A click ending a drag over the cells selects their text: the user copies a
46
+ // value, not opens the row.
47
+ const endsTextSelection: RowClickGuard = () => {
48
+ if (typeof window === "undefined") return false;
49
+ const selection = window.getSelection();
50
+ return !!selection && !selection.isCollapsed && selection.toString() !== "";
51
+ };
52
+
53
+ const ROW_CLICK_TRIGGERS: Record<TableViewRowClick, RowClickTrigger> = {
54
+ // Unchanged from before `rowClick` existed: any double-click on the row.
55
+ double: { eventType: "dblclick", guards: [] },
56
+ single: {
57
+ eventType: "click",
58
+ guards: [isRepeatClick, isOnRowControl, endsTextSelection],
59
+ },
60
+ };
61
+
62
+ /**
63
+ * Whether `event`, a click or a double-click on a row, runs the row's click
64
+ * action under the table's `rowClick` mode.
65
+ */
66
+ export function isRowClickTrigger(
67
+ mode: TableViewRowClick | undefined,
68
+ event: MouseEvent,
69
+ ): boolean {
70
+ const trigger = ROW_CLICK_TRIGGERS[mode ?? DEFAULT_ROW_CLICK];
71
+ if (!trigger || event.type !== trigger.eventType) return false;
72
+ return !trigger.guards.some((guard) => guard(event));
73
+ }
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { usePermissionPreview } from "#dms-core/app/build/composables/auth/usePermissionPreview";
3
3
  import { childLayoutPath } from "#dms-core/app/build/utils/permission-preview";
4
+ import { childComponentId } from "../utils/componentId";
4
5
  import {
5
6
  GRID_DECLARED_COLUMNS,
6
7
  type GridDeclaredColumns,
@@ -111,7 +112,7 @@ provide<GridDeclaredColumns>(GRID_DECLARED_COLUMNS, {
111
112
  <DmsRecursiveComponent
112
113
  :component="child"
113
114
  :page-id="pageId"
114
- :component-id="`${componentId}-child-${child.id}`"
115
+ :component-id="childComponentId(componentId, child.id)"
115
116
  :route-params="routeParams"
116
117
  :layout-path="childLayoutPath(layoutPath, child.id)"
117
118
  />
@@ -120,7 +121,7 @@ provide<GridDeclaredColumns>(GRID_DECLARED_COLUMNS, {
120
121
  v-else
121
122
  :component="child"
122
123
  :page-id="pageId"
123
- :component-id="`${componentId}-child-${child.id}`"
124
+ :component-id="childComponentId(componentId, child.id)"
124
125
  :route-params="routeParams"
125
126
  :layout-path="childLayoutPath(layoutPath, child.id)"
126
127
  />
@@ -135,7 +136,7 @@ provide<GridDeclaredColumns>(GRID_DECLARED_COLUMNS, {
135
136
  <DmsRecursiveComponent
136
137
  :component="child"
137
138
  :page-id="pageId"
138
- :component-id="`${componentId}-child-${child.id}`"
139
+ :component-id="childComponentId(componentId, child.id)"
139
140
  :route-params="routeParams"
140
141
  :layout-path="childLayoutPath(layoutPath, child.id)"
141
142
  />
@@ -144,7 +145,7 @@ provide<GridDeclaredColumns>(GRID_DECLARED_COLUMNS, {
144
145
  v-else
145
146
  :component="child"
146
147
  :page-id="pageId"
147
- :component-id="`${componentId}-child-${child.id}`"
148
+ :component-id="childComponentId(componentId, child.id)"
148
149
  :route-params="routeParams"
149
150
  :layout-path="childLayoutPath(layoutPath, child.id)"
150
151
  />