@antelopejs/dms 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
- package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
- package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +30 -15
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
- package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
- package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
- package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
- package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
- package/frontend-vue/tests/field-row.test.ts +14 -0
- package/frontend-vue/tests/form-section-nav-groups.test.ts +75 -14
- package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
- package/frontend-vue/tests/live-toggle.test.ts +162 -0
- package/frontend-vue/tests/page-live-gate.test.ts +241 -0
- package/frontend-vue/tests/period-live-pause.test.ts +120 -0
- package/frontend-vue/tests/period-selector-url.test.ts +268 -0
- package/frontend-vue/tests/period-url-query.test.ts +351 -0
- package/frontend-vue/tests/row-side-panel.test.ts +653 -0
- package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
- package/frontend-vue/tests/table-live-hold.test.ts +204 -0
- package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
- package/frontend-vue/tests/table-view-sources.test.ts +40 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +11 -3
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -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
|
-
* `
|
|
140
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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(
|
|
26
|
-
|
|
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
|
+
}
|
|
@@ -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);
|