@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.
- package/CHANGELOG.md +23 -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/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/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/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 +9 -2
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import {
|
|
2
|
+
END_OF_DAY_HOURS,
|
|
3
|
+
END_OF_DAY_MINUTES,
|
|
4
|
+
END_OF_DAY_MS,
|
|
5
|
+
END_OF_DAY_SECONDS,
|
|
6
|
+
type PeriodComparison,
|
|
7
|
+
type PeriodPreset,
|
|
8
|
+
type PeriodRange,
|
|
9
|
+
} from "#dms-core/app/composables/period/types";
|
|
10
|
+
|
|
11
|
+
/** The range half of a selector's choice: a preset, or a custom range. */
|
|
12
|
+
export interface PeriodRangeChoice {
|
|
13
|
+
preset: PeriodPreset;
|
|
14
|
+
customRange: PeriodRange | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The comparison half of a selector's choice. */
|
|
18
|
+
export interface PeriodComparisonChoice {
|
|
19
|
+
comparison: PeriodComparison;
|
|
20
|
+
customCompareRange: PeriodRange | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What a selector keeps in the URL and in the user's preferences. */
|
|
24
|
+
export interface PeriodSelection
|
|
25
|
+
extends PeriodRangeChoice,
|
|
26
|
+
PeriodComparisonChoice {}
|
|
27
|
+
|
|
28
|
+
/** The menus a selection has to fit in. */
|
|
29
|
+
export interface PeriodChoices {
|
|
30
|
+
presets: readonly PeriodPreset[];
|
|
31
|
+
comparisons: readonly PeriodComparison[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A page URL query, or a selection kept in the preferences. */
|
|
35
|
+
export type PeriodQuery = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
/** The keys one selector reads and writes. */
|
|
38
|
+
export interface PeriodQueryKeys {
|
|
39
|
+
period: string;
|
|
40
|
+
compare: string;
|
|
41
|
+
from: string;
|
|
42
|
+
to: string;
|
|
43
|
+
compareFrom: string;
|
|
44
|
+
compareTo: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A source the halves of a selection are read from, in order. */
|
|
48
|
+
export interface PeriodSource {
|
|
49
|
+
query: PeriodQuery;
|
|
50
|
+
keys: PeriodQueryKeys;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CUSTOM_PRESET: PeriodPreset = "custom";
|
|
54
|
+
const CUSTOM_COMPARISON: PeriodComparison = "custom";
|
|
55
|
+
const KEY_NAMES: Array<keyof PeriodQueryKeys> = [
|
|
56
|
+
"period",
|
|
57
|
+
"compare",
|
|
58
|
+
"from",
|
|
59
|
+
"to",
|
|
60
|
+
"compareFrom",
|
|
61
|
+
"compareTo",
|
|
62
|
+
];
|
|
63
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
64
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
65
|
+
const MONTH_OFFSET = 1;
|
|
66
|
+
const DATE_PART_LENGTH = 2;
|
|
67
|
+
const YEAR_PART_LENGTH = 4;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The keys of selector `id` in the page URL (`<id>.period`), or the bare keys
|
|
71
|
+
* (`period`) of a selection kept on its own.
|
|
72
|
+
*/
|
|
73
|
+
export function periodQueryKeys(id?: string): PeriodQueryKeys {
|
|
74
|
+
const prefix = id ? `${id}.` : "";
|
|
75
|
+
return Object.fromEntries(
|
|
76
|
+
KEY_NAMES.map((name) => [name, `${prefix}${name}`]),
|
|
77
|
+
) as unknown as PeriodQueryKeys;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readText(query: PeriodQuery, key: string): string | undefined {
|
|
81
|
+
const raw = query[key];
|
|
82
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
83
|
+
return typeof value === "string" && value ? value : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type DayBoundary = "start" | "end";
|
|
87
|
+
|
|
88
|
+
function dayBoundary(day: Date, boundary: DayBoundary): Date {
|
|
89
|
+
if (boundary === "end") {
|
|
90
|
+
day.setHours(
|
|
91
|
+
END_OF_DAY_HOURS,
|
|
92
|
+
END_OF_DAY_MINUTES,
|
|
93
|
+
END_OF_DAY_SECONDS,
|
|
94
|
+
END_OF_DAY_MS,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return day;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A local calendar day, refused when it does not exist (`2026-02-30`). */
|
|
101
|
+
function parseIsoDay(match: RegExpExecArray, boundary: DayBoundary) {
|
|
102
|
+
const [year, month, day] = match.slice(1).map(Number);
|
|
103
|
+
const date = new Date(year!, month! - MONTH_OFFSET, day);
|
|
104
|
+
const exists =
|
|
105
|
+
date.getFullYear() === year &&
|
|
106
|
+
date.getMonth() === month! - MONTH_OFFSET &&
|
|
107
|
+
date.getDate() === day;
|
|
108
|
+
return exists ? dayBoundary(date, boundary) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A bound read from the URL: an ISO date is the start (`from`) or the end
|
|
113
|
+
* (`to`) of that local day, an ISO date-time is that instant. Anything else
|
|
114
|
+
* is `null`.
|
|
115
|
+
*/
|
|
116
|
+
export function parsePeriodDate(
|
|
117
|
+
text: string | undefined,
|
|
118
|
+
boundary: DayBoundary,
|
|
119
|
+
): Date | null {
|
|
120
|
+
if (!text) return null;
|
|
121
|
+
const day = ISO_DATE.exec(text);
|
|
122
|
+
if (day) return parseIsoDay(day, boundary);
|
|
123
|
+
if (!ISO_DATE_TIME.test(text)) return null;
|
|
124
|
+
const date = new Date(text);
|
|
125
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pad(value: number, length = DATE_PART_LENGTH): string {
|
|
129
|
+
return String(value).padStart(length, "0");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isDayBoundary(date: Date, boundary: DayBoundary): boolean {
|
|
133
|
+
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
134
|
+
return dayBoundary(day, boundary).getTime() === date.getTime();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A bound as the URL writes it: the bare local day when it falls on that
|
|
139
|
+
* day's boundary (a range picked in the calendar), the ISO instant otherwise.
|
|
140
|
+
*/
|
|
141
|
+
export function formatPeriodDate(date: Date, boundary: DayBoundary): string {
|
|
142
|
+
if (!isDayBoundary(date, boundary)) return date.toISOString();
|
|
143
|
+
return [
|
|
144
|
+
pad(date.getFullYear(), YEAR_PART_LENGTH),
|
|
145
|
+
pad(date.getMonth() + MONTH_OFFSET),
|
|
146
|
+
pad(date.getDate()),
|
|
147
|
+
].join("-");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readRange(
|
|
151
|
+
query: PeriodQuery,
|
|
152
|
+
fromKey: string,
|
|
153
|
+
toKey: string,
|
|
154
|
+
): PeriodRange | null {
|
|
155
|
+
const from = parsePeriodDate(readText(query, fromKey), "start");
|
|
156
|
+
const to = parsePeriodDate(readText(query, toKey), "end");
|
|
157
|
+
if (!from || !to || from.getTime() > to.getTime()) return null;
|
|
158
|
+
return { from, to };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The range a source names, when the selector offers it: a custom range
|
|
163
|
+
* (`from` and `to`) first, then a preset (`period`).
|
|
164
|
+
*/
|
|
165
|
+
export function readRangeChoice(
|
|
166
|
+
source: PeriodSource,
|
|
167
|
+
choices: PeriodChoices,
|
|
168
|
+
): PeriodRangeChoice | null {
|
|
169
|
+
const { query, keys } = source;
|
|
170
|
+
const customRange = readRange(query, keys.from, keys.to);
|
|
171
|
+
if (customRange && choices.presets.includes(CUSTOM_PRESET)) {
|
|
172
|
+
return { preset: CUSTOM_PRESET, customRange };
|
|
173
|
+
}
|
|
174
|
+
const preset = choices.presets.find(
|
|
175
|
+
(entry) => entry === readText(query, keys.period),
|
|
176
|
+
);
|
|
177
|
+
if (!preset || preset === CUSTOM_PRESET) return null;
|
|
178
|
+
return { preset, customRange: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The comparison a source names, when the selector offers it; a custom one
|
|
183
|
+
* needs its `compareFrom` and `compareTo`.
|
|
184
|
+
*/
|
|
185
|
+
export function readComparisonChoice(
|
|
186
|
+
source: PeriodSource,
|
|
187
|
+
choices: PeriodChoices,
|
|
188
|
+
): PeriodComparisonChoice | null {
|
|
189
|
+
const { query, keys } = source;
|
|
190
|
+
const comparison = choices.comparisons.find(
|
|
191
|
+
(entry) => entry === readText(query, keys.compare),
|
|
192
|
+
);
|
|
193
|
+
if (!comparison) return null;
|
|
194
|
+
if (comparison !== CUSTOM_COMPARISON) {
|
|
195
|
+
return { comparison, customCompareRange: null };
|
|
196
|
+
}
|
|
197
|
+
const customCompareRange = readRange(query, keys.compareFrom, keys.compareTo);
|
|
198
|
+
return customCompareRange ? { comparison, customCompareRange } : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function firstChoice<C>(
|
|
202
|
+
sources: PeriodSource[],
|
|
203
|
+
read: (source: PeriodSource) => C | null,
|
|
204
|
+
): C | undefined {
|
|
205
|
+
for (const source of sources) {
|
|
206
|
+
const choice = read(source);
|
|
207
|
+
if (choice) return choice;
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The selection the sources give, read in order (the URL, then the user's
|
|
214
|
+
* last choice), each half on its own: a half no source names validly comes
|
|
215
|
+
* from `fallback`, silently.
|
|
216
|
+
*/
|
|
217
|
+
export function resolvePeriodSelection(
|
|
218
|
+
sources: PeriodSource[],
|
|
219
|
+
choices: PeriodChoices,
|
|
220
|
+
fallback: PeriodSelection,
|
|
221
|
+
): PeriodSelection {
|
|
222
|
+
const range =
|
|
223
|
+
firstChoice(sources, (source) => readRangeChoice(source, choices)) ??
|
|
224
|
+
fallback;
|
|
225
|
+
const comparison =
|
|
226
|
+
firstChoice(sources, (source) => readComparisonChoice(source, choices)) ??
|
|
227
|
+
fallback;
|
|
228
|
+
return {
|
|
229
|
+
preset: range.preset,
|
|
230
|
+
customRange: range.customRange,
|
|
231
|
+
comparison: comparison.comparison,
|
|
232
|
+
customCompareRange: comparison.customCompareRange,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function rangeEntries(
|
|
237
|
+
range: PeriodRange | null,
|
|
238
|
+
fromKey: string,
|
|
239
|
+
toKey: string,
|
|
240
|
+
): Array<[string, string]> {
|
|
241
|
+
if (!range) return [];
|
|
242
|
+
return [
|
|
243
|
+
[fromKey, formatPeriodDate(range.from, "start")],
|
|
244
|
+
[toKey, formatPeriodDate(range.to, "end")],
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A selection as the keys `keys` name. */
|
|
249
|
+
export function writePeriodQuery(
|
|
250
|
+
selection: PeriodSelection,
|
|
251
|
+
keys: PeriodQueryKeys,
|
|
252
|
+
): Record<string, string> {
|
|
253
|
+
const isCustom = selection.preset === CUSTOM_PRESET;
|
|
254
|
+
const isCustomCompare = selection.comparison === CUSTOM_COMPARISON;
|
|
255
|
+
return Object.fromEntries([
|
|
256
|
+
...(isCustom
|
|
257
|
+
? rangeEntries(selection.customRange, keys.from, keys.to)
|
|
258
|
+
: [[keys.period, selection.preset]]),
|
|
259
|
+
[keys.compare, selection.comparison],
|
|
260
|
+
...(isCustomCompare
|
|
261
|
+
? rangeEntries(
|
|
262
|
+
selection.customCompareRange,
|
|
263
|
+
keys.compareFrom,
|
|
264
|
+
keys.compareTo,
|
|
265
|
+
)
|
|
266
|
+
: []),
|
|
267
|
+
]);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Whether `query` already holds what `keys` would write for `selection`. */
|
|
271
|
+
export function isPeriodQueryCurrent(
|
|
272
|
+
query: PeriodQuery,
|
|
273
|
+
keys: PeriodQueryKeys,
|
|
274
|
+
selection: PeriodSelection,
|
|
275
|
+
): boolean {
|
|
276
|
+
const written = writePeriodQuery(selection, keys);
|
|
277
|
+
return Object.values(keys).every(
|
|
278
|
+
(key) => readText(query, key) === written[key],
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The search part of a URL with `selection` written under `keys`, and the
|
|
284
|
+
* keys it no longer uses (a preset's `period` once a custom range is picked)
|
|
285
|
+
* removed. Every other parameter, another selector's included, is kept as it
|
|
286
|
+
* was, in its place.
|
|
287
|
+
*/
|
|
288
|
+
export function mergePeriodSearch(
|
|
289
|
+
search: string,
|
|
290
|
+
keys: PeriodQueryKeys,
|
|
291
|
+
selection: PeriodSelection,
|
|
292
|
+
): string {
|
|
293
|
+
const params = new URLSearchParams(search);
|
|
294
|
+
const written = writePeriodQuery(selection, keys);
|
|
295
|
+
for (const key of Object.values(keys)) {
|
|
296
|
+
const value = written[key];
|
|
297
|
+
if (value === undefined) params.delete(key);
|
|
298
|
+
else params.set(key, value);
|
|
299
|
+
}
|
|
300
|
+
const text = params.toString();
|
|
301
|
+
return text ? `?${text}` : "";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The last choices of the selectors, by `rememberedPeriodKey`, oldest first. */
|
|
305
|
+
export type RememberedPeriods = Record<string, Record<string, string>>;
|
|
306
|
+
|
|
307
|
+
/** How many selectors' last choices the browser keeps. */
|
|
308
|
+
export const REMEMBERED_PERIODS_LIMIT = 20;
|
|
309
|
+
|
|
310
|
+
/** The entry of selector `id` of page `pageId` among the last choices. */
|
|
311
|
+
export function rememberedPeriodKey(pageId: string, id: string): string {
|
|
312
|
+
return `${pageId}#${id}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The last choices with `key`'s set to `query`, moved last as the most
|
|
317
|
+
* recent, and only the `limit` most recent kept: the store lives in a
|
|
318
|
+
* cookie, which must not grow with every page a user ever opened.
|
|
319
|
+
*/
|
|
320
|
+
export function rememberPeriod(
|
|
321
|
+
store: unknown,
|
|
322
|
+
key: string,
|
|
323
|
+
query: Record<string, string>,
|
|
324
|
+
limit = REMEMBERED_PERIODS_LIMIT,
|
|
325
|
+
): RememberedPeriods {
|
|
326
|
+
const entries = Object.entries(
|
|
327
|
+
store && typeof store === "object" ? (store as RememberedPeriods) : {},
|
|
328
|
+
).filter(([entry]) => entry !== key);
|
|
329
|
+
return Object.fromEntries([...entries, [key, query]].slice(-limit));
|
|
330
|
+
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|