@antelopejs/dms 0.7.15 → 0.7.17
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 +34 -0
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js +16 -0
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +79 -0
- package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +19 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +52 -28
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +48 -4
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +6 -2
- package/frontend-vue/layers/dms-layout/app/composables/layout/usePageHeaderActions.ts +32 -1
- package/frontend-vue/layers/dms-ui/app/build/components/period/PeriodRangeCalendar.vue +17 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/bulkSelection.ts +7 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +42 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +1 -59
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +49 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +34 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +47 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/fileDownload.ts +69 -0
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +32 -6
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +14 -4
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +13 -0
- package/frontend-vue/tests/confirm-secondary-action.test.ts +2 -4
- package/frontend-vue/tests/download-action-target.test.ts +172 -0
- package/frontend-vue/tests/header-action-page-query.test.ts +120 -1
- package/frontend-vue/tests/period-day-rollover.test.ts +80 -0
- package/frontend-vue/tests/period-max-days.test.ts +128 -0
- package/frontend-vue/tests/period-selector-url.test.ts +44 -8
- package/frontend-vue/tests/table-view-url-tab.test.ts +115 -0
- package/package.json +2 -2
- package/skills/dms-pages/REFERENCE.md +9 -1
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
RangeCalendarRoot,
|
|
21
21
|
type DateRange,
|
|
22
22
|
} from "reka-ui";
|
|
23
|
-
import
|
|
23
|
+
import {
|
|
24
|
+
type CalendarRange,
|
|
25
|
+
isBeyondMaxDays,
|
|
26
|
+
} from "../../composables/period/periodDisplay";
|
|
24
27
|
|
|
25
28
|
// Two-month range calendar of the period selector (design .cal): a
|
|
26
29
|
// continuous accent strip for the selected range with filled endpoints, and
|
|
@@ -32,6 +35,11 @@ interface Props {
|
|
|
32
35
|
weekStartsOn?: number;
|
|
33
36
|
numberOfMonths?: number;
|
|
34
37
|
readonly?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The longest range, in days, that can be picked: once a start day is
|
|
40
|
+
* picked, the days that would exceed it are disabled.
|
|
41
|
+
*/
|
|
42
|
+
maxDays?: number;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
@@ -107,6 +115,13 @@ function onUpdate(value: DateRange | null) {
|
|
|
107
115
|
emit("pending", pendingStart.value);
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
function isDateDisabled(date: DateValue): boolean {
|
|
119
|
+
const picked = pendingStart.value;
|
|
120
|
+
return (
|
|
121
|
+
!!picked && isBeyondMaxDays(date as CalendarDate, picked, props.maxDays)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
function onPlaceholder(value: DateValue) {
|
|
111
126
|
// Picking a start day re-anchors reka's view on it; with two months on
|
|
112
127
|
// screen that would shift the grid under the pointer.
|
|
@@ -210,6 +225,7 @@ const weekStart = computed(() => props.weekStartsOn as WeekStart);
|
|
|
210
225
|
:number-of-months="numberOfMonths"
|
|
211
226
|
:max-value="todayDate"
|
|
212
227
|
:readonly="readonly"
|
|
228
|
+
:is-date-disabled="isDateDisabled"
|
|
213
229
|
weekday-format="short"
|
|
214
230
|
prevent-deselect
|
|
215
231
|
class="flex gap-6"
|
|
@@ -47,7 +47,13 @@ export function withQuery(url: string, query: Record<string, QueryValue>) {
|
|
|
47
47
|
return joined ? `${path}?${joined}` : (path ?? url);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const URL_TARGET_TYPES = new Set([
|
|
50
|
+
const URL_TARGET_TYPES = new Set([
|
|
51
|
+
"page",
|
|
52
|
+
"external",
|
|
53
|
+
"download",
|
|
54
|
+
"api",
|
|
55
|
+
"exportJob",
|
|
56
|
+
]);
|
|
51
57
|
|
|
52
58
|
/** A target sending the selection along: its URL carries the query. */
|
|
53
59
|
export function targetWithSelection(
|
|
@@ -51,7 +51,10 @@ import {
|
|
|
51
51
|
import {
|
|
52
52
|
hasUrlVariables,
|
|
53
53
|
resolveLinkVariables,
|
|
54
|
+
resolveTextVariables,
|
|
54
55
|
} from "../../utils/urlVariables";
|
|
56
|
+
import { interpolateLiteral } from "../../utils/literalText";
|
|
57
|
+
import { type DownloadFetch, saveDownload } from "../../utils/fileDownload";
|
|
55
58
|
|
|
56
59
|
/** Runs a confirmed action from inside its dialog (see `onConfirm`). */
|
|
57
60
|
type ConfirmedRun = NonNullable<ConfirmOptions["onConfirm"]>;
|
|
@@ -70,6 +73,21 @@ function linkTargetUrl(url: string, rowData?: Data): string {
|
|
|
70
73
|
return rowData ? interpolateUrl(withPageValues, rowData) : withPageValues;
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
/**
|
|
77
|
+
* A download target's file name with the page's tokens filled as they are
|
|
78
|
+
* (a name, not a URL: nothing is encoded), then the row's `{field}`. A token
|
|
79
|
+
* the page has no value for leaves no name: the server's one is used.
|
|
80
|
+
*/
|
|
81
|
+
function downloadTargetFilename(
|
|
82
|
+
filename: string | undefined,
|
|
83
|
+
rowData?: Data,
|
|
84
|
+
): string | undefined {
|
|
85
|
+
if (!filename) return undefined;
|
|
86
|
+
const withPageValues = resolveTextVariables(filename, currentPageUrlValues());
|
|
87
|
+
if (!withPageValues || !rowData) return withPageValues;
|
|
88
|
+
return interpolateLiteral(withPageValues, rowData);
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
function currentPageUrlValues() {
|
|
74
92
|
const route = useDmsRoute();
|
|
75
93
|
return {
|
|
@@ -110,6 +128,7 @@ interface TargetRunExtras {
|
|
|
110
128
|
}
|
|
111
129
|
|
|
112
130
|
type SidePanelTarget = ActionTarget & { type: "sidePanel" };
|
|
131
|
+
type DownloadTarget = ActionTarget & { type: "download" };
|
|
113
132
|
|
|
114
133
|
/** A side panel to open, and on what. */
|
|
115
134
|
interface SidePanelTargetRun extends TargetRunExtras {
|
|
@@ -148,6 +167,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
148
167
|
const { copy: copyToClipboard } = useClipboard({ legacy: true });
|
|
149
168
|
const { confirmAction } = useActionConfirm();
|
|
150
169
|
const { handleApiError } = config;
|
|
170
|
+
const fetchRaw = useAuthFetch().$authFetch.raw as unknown as DownloadFetch;
|
|
151
171
|
|
|
152
172
|
/** Calls an `api` target and tells how it went (rejects on a failure). */
|
|
153
173
|
const requestApiTarget = async (
|
|
@@ -249,6 +269,26 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
249
269
|
});
|
|
250
270
|
};
|
|
251
271
|
|
|
272
|
+
// Fetched with the user's session, as a confirmation's download is; a
|
|
273
|
+
// refusal is toasted like a failed API call.
|
|
274
|
+
const handleDownloadTarget = async (
|
|
275
|
+
target: DownloadTarget,
|
|
276
|
+
label: string,
|
|
277
|
+
rowData?: Data,
|
|
278
|
+
) => {
|
|
279
|
+
try {
|
|
280
|
+
await saveDownload(
|
|
281
|
+
{
|
|
282
|
+
url: linkTargetUrl(target.url, rowData),
|
|
283
|
+
filename: downloadTargetFilename(target.filename, rowData),
|
|
284
|
+
},
|
|
285
|
+
fetchRaw,
|
|
286
|
+
);
|
|
287
|
+
} catch (error: unknown) {
|
|
288
|
+
handleApiError(error, label ? processI18n(label, rowData) : undefined);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
252
292
|
const openContainer = (
|
|
253
293
|
run: ComponentTargetRun,
|
|
254
294
|
component: Component,
|
|
@@ -462,6 +502,8 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
462
502
|
: jobTarget.url;
|
|
463
503
|
await handleExportJobTarget(jobTarget, url, rowData);
|
|
464
504
|
},
|
|
505
|
+
download: (target, label, rowData) =>
|
|
506
|
+
handleDownloadTarget(target as DownloadTarget, label, rowData),
|
|
465
507
|
modal: componentHandler,
|
|
466
508
|
// Runs the quick action as the command palette does; one the user is not
|
|
467
509
|
// served runs nothing.
|
|
@@ -1,50 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
ConfirmDialogDownloadTarget,
|
|
3
2
|
ConfirmDialogLinkTarget,
|
|
4
3
|
ConfirmDialogSecondaryTarget,
|
|
5
4
|
} from "#dms-core/app/types/confirm-dialog";
|
|
6
|
-
import {
|
|
5
|
+
import { type DownloadFetch, saveDownload } from "../../utils/fileDownload";
|
|
7
6
|
import type { SecondaryTargetRun } from "./confirmDialogTexts";
|
|
8
7
|
|
|
9
|
-
/** The response a download reads: its body and its headers. */
|
|
10
|
-
export interface DownloadResponse {
|
|
11
|
-
_data?: Blob;
|
|
12
|
-
headers: Headers;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Fetches a download with the user's session (`$authFetch.raw`). */
|
|
16
|
-
export type DownloadFetch = (
|
|
17
|
-
url: string,
|
|
18
|
-
options: { responseType: "blob" },
|
|
19
|
-
) => Promise<DownloadResponse>;
|
|
20
|
-
|
|
21
|
-
const DISPOSITION_NAME = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i;
|
|
22
|
-
const FALLBACK_FILENAME = "download";
|
|
23
|
-
|
|
24
|
-
// A malformed escape is kept as written rather than failing the download.
|
|
25
|
-
function decodeName(name: string): string {
|
|
26
|
-
try {
|
|
27
|
-
return decodeURIComponent(name);
|
|
28
|
-
} catch {
|
|
29
|
-
return name;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* The name a download is saved under: the declared one, else the server's
|
|
35
|
-
* `Content-Disposition` one, else the URL's last path segment.
|
|
36
|
-
*/
|
|
37
|
-
export function downloadFilename(
|
|
38
|
-
target: ConfirmDialogDownloadTarget,
|
|
39
|
-
disposition: string | null,
|
|
40
|
-
): string {
|
|
41
|
-
if (target.filename) return target.filename;
|
|
42
|
-
const served = disposition ? DISPOSITION_NAME.exec(disposition)?.[1] : "";
|
|
43
|
-
if (served) return decodeName(served.trim());
|
|
44
|
-
const path = target.url.split(/[?#]/)[0] ?? "";
|
|
45
|
-
return decodeName(path.split("/").pop() || FALLBACK_FILENAME);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
8
|
function openLink(target: ConfirmDialogLinkTarget): void {
|
|
49
9
|
if (typeof window === "undefined" || !target.url) return;
|
|
50
10
|
if (target.newTab) {
|
|
@@ -54,24 +14,6 @@ function openLink(target: ConfirmDialogLinkTarget): void {
|
|
|
54
14
|
window.location.href = target.url;
|
|
55
15
|
}
|
|
56
16
|
|
|
57
|
-
async function saveDownload(
|
|
58
|
-
target: ConfirmDialogDownloadTarget,
|
|
59
|
-
fetchRaw: DownloadFetch,
|
|
60
|
-
): Promise<void> {
|
|
61
|
-
if (!target.url) return;
|
|
62
|
-
const response = await fetchRaw(target.url, { responseType: "blob" });
|
|
63
|
-
const blob = response._data ?? new Blob([]);
|
|
64
|
-
const objectUrl = URL.createObjectURL(blob);
|
|
65
|
-
try {
|
|
66
|
-
downloadFile(
|
|
67
|
-
objectUrl,
|
|
68
|
-
downloadFilename(target, response.headers.get("content-disposition")),
|
|
69
|
-
);
|
|
70
|
-
} finally {
|
|
71
|
-
URL.revokeObjectURL(objectUrl);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
17
|
/**
|
|
76
18
|
* Runs the target of a confirmation's secondary action, the dialog left
|
|
77
19
|
* open: a link opens, a download is fetched with `fetchRaw` and saved.
|
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
type ConfirmTranslate,
|
|
14
14
|
resolveConfirmDialog,
|
|
15
15
|
} from "./confirmDialogTexts";
|
|
16
|
-
import {
|
|
16
|
+
import type { DownloadFetch } from "../../utils/fileDownload";
|
|
17
|
+
import { secondaryTargetRunner } from "./secondaryTarget";
|
|
17
18
|
|
|
18
19
|
/** What an action asks its confirmation with. */
|
|
19
20
|
export interface ActionConfirmRequest {
|
|
@@ -53,6 +53,55 @@ export function calendarToRange(range: CalendarRange): PeriodRange {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Which bound of a range stays put when the range has to shrink. */
|
|
57
|
+
export type CalendarRangeAnchor = "start" | "end";
|
|
58
|
+
|
|
59
|
+
const RANGE_SHRINKERS: Record<
|
|
60
|
+
CalendarRangeAnchor,
|
|
61
|
+
(range: CalendarRange, span: number) => CalendarRange
|
|
62
|
+
> = {
|
|
63
|
+
start: (range, span) => ({
|
|
64
|
+
start: range.start,
|
|
65
|
+
end: range.start.add({ days: span }),
|
|
66
|
+
}),
|
|
67
|
+
end: (range, span) => ({
|
|
68
|
+
start: range.end.subtract({ days: span }),
|
|
69
|
+
end: range.end,
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The number of days a range covers, both ends included. */
|
|
74
|
+
export function calendarRangeDays(range: CalendarRange): number {
|
|
75
|
+
return range.end.compare(range.start) + 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shrinks a range to at most `maxDays` days, keeping its `anchor` bound and
|
|
80
|
+
* moving the other one. Without a limit, or within it, the range is returned
|
|
81
|
+
* as is.
|
|
82
|
+
*/
|
|
83
|
+
export function limitCalendarRange(
|
|
84
|
+
range: CalendarRange,
|
|
85
|
+
maxDays: number | undefined,
|
|
86
|
+
anchor: CalendarRangeAnchor,
|
|
87
|
+
): CalendarRange {
|
|
88
|
+
if (!maxDays || calendarRangeDays(range) <= maxDays) return range;
|
|
89
|
+
return RANGE_SHRINKERS[anchor](range, maxDays - 1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a range starting (or ending) on `pickedDay` and reaching `date`
|
|
94
|
+
* would cover more than `maxDays` days.
|
|
95
|
+
*/
|
|
96
|
+
export function isBeyondMaxDays(
|
|
97
|
+
date: CalendarDate,
|
|
98
|
+
pickedDay: CalendarDate,
|
|
99
|
+
maxDays: number | undefined,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (!maxDays) return false;
|
|
102
|
+
return Math.abs(date.compare(pickedDay)) >= maxDays;
|
|
103
|
+
}
|
|
104
|
+
|
|
56
105
|
function isStartOfDay(date: Date): boolean {
|
|
57
106
|
return (
|
|
58
107
|
date.getHours() === 0 &&
|
|
@@ -63,15 +63,23 @@ function selectionText(selection: PeriodSelection): string {
|
|
|
63
63
|
return JSON.stringify(writePeriodQuery(selection, STORED_KEYS));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
type DmsRouteState = ReturnType<typeof useDmsRoute>;
|
|
67
|
+
|
|
68
|
+
function browserQuery(): PeriodQuery {
|
|
69
|
+
return Object.fromEntries(new URLSearchParams(window.location.search));
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
/**
|
|
67
73
|
* Writes the period into the URL: the history entry is replaced, and no
|
|
68
74
|
* request reloads the page. The browser's URL is written at once, so a
|
|
69
|
-
* second selector writing in the same tick merges into it
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
75
|
+
* second selector writing in the same tick merges into it, and so is the
|
|
76
|
+
* route's query: a component or a module building a query from the route
|
|
77
|
+
* (a tab, a quick action) carries the new period on at once, instead of the
|
|
78
|
+
* one the page was opened with. Inertia's client-side replace then moves its
|
|
79
|
+
* page URL, which the route follows, a few ticks later, to the same query.
|
|
73
80
|
*/
|
|
74
81
|
function replacePeriodInUrl(
|
|
82
|
+
route: DmsRouteState,
|
|
75
83
|
keys: PeriodQueryKeys,
|
|
76
84
|
selection: PeriodSelection,
|
|
77
85
|
): string | null {
|
|
@@ -81,15 +89,29 @@ function replacePeriodInUrl(
|
|
|
81
89
|
if (isPeriodQueryCurrent(query, keys, selection)) return null;
|
|
82
90
|
url.search = mergePeriodSearch(url.search, keys, selection);
|
|
83
91
|
const written = urlKeysText(Object.fromEntries(url.searchParams), keys);
|
|
92
|
+
const fullPath = `${url.pathname}${url.search}${url.hash}`;
|
|
84
93
|
window.history.replaceState(window.history.state, "", url);
|
|
94
|
+
route.query = Object.fromEntries(url.searchParams);
|
|
95
|
+
route.fullPath = fullPath;
|
|
85
96
|
inertiaRouter.replace({
|
|
86
|
-
url:
|
|
97
|
+
url: fullPath,
|
|
87
98
|
preserveState: true,
|
|
88
99
|
preserveScroll: true,
|
|
89
100
|
});
|
|
90
101
|
return written;
|
|
91
102
|
}
|
|
92
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Until Inertia's page catches up with a URL written in place, the route can
|
|
106
|
+
* still be set back to the page's previous query (a component reading the
|
|
107
|
+
* route meanwhile refreshes it from that page). The address bar is ahead of
|
|
108
|
+
* it, never behind: a route whose period keys differ from it is stale.
|
|
109
|
+
*/
|
|
110
|
+
function isRouteBehindBrowser(text: string, keys: PeriodQueryKeys): boolean {
|
|
111
|
+
if (typeof window === "undefined") return false;
|
|
112
|
+
return text !== urlKeysText(browserQuery(), keys);
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
/** The user's last choice of one selector, kept in the preferences. */
|
|
94
116
|
function useRememberedPeriod(target: RememberedPeriodTarget) {
|
|
95
117
|
const { getPreference, setPreference } = usePreferences();
|
|
@@ -113,11 +135,11 @@ function useRememberedPeriod(target: RememberedPeriodTarget) {
|
|
|
113
135
|
* later, and a pick made meanwhile must not be undone by the URL of the one
|
|
114
136
|
* before: `isEcho` tells a route naming a URL written here from a change.
|
|
115
137
|
*/
|
|
116
|
-
function usePeriodUrlWriter(keys: PeriodQueryKeys) {
|
|
138
|
+
function usePeriodUrlWriter(route: DmsRouteState, keys: PeriodQueryKeys) {
|
|
117
139
|
const pending: string[] = [];
|
|
118
140
|
return {
|
|
119
141
|
write: (selection: PeriodSelection) => {
|
|
120
|
-
const written = replacePeriodInUrl(keys, selection);
|
|
142
|
+
const written = replacePeriodInUrl(route, keys, selection);
|
|
121
143
|
if (written !== null) pending.push(written);
|
|
122
144
|
},
|
|
123
145
|
isEcho: (text: string) => {
|
|
@@ -142,8 +164,9 @@ function usePeriodUrlWriter(keys: PeriodQueryKeys) {
|
|
|
142
164
|
* period, then on every change: the history entry is replaced instead of
|
|
143
165
|
* pushed, so Back leaves the page rather than stepping through every pick,
|
|
144
166
|
* and the page is not requested again, only the bound cards and tables
|
|
145
|
-
* refetch.
|
|
146
|
-
*
|
|
167
|
+
* refetch. The route's query (`useDmsRoute().query`) follows each write at
|
|
168
|
+
* once. A route changed from outside (Back to the page with another period,
|
|
169
|
+
* a link) moves the period.
|
|
147
170
|
*/
|
|
148
171
|
export function usePeriodLocation(
|
|
149
172
|
period: UsePeriodReturn,
|
|
@@ -152,7 +175,7 @@ export function usePeriodLocation(
|
|
|
152
175
|
const route = useDmsRoute();
|
|
153
176
|
const urlKeys = periodQueryKeys(options.id);
|
|
154
177
|
const remembered = useRememberedPeriod(options);
|
|
155
|
-
const writer = usePeriodUrlWriter(urlKeys);
|
|
178
|
+
const writer = usePeriodUrlWriter(route, urlKeys);
|
|
156
179
|
const choices = {
|
|
157
180
|
presets: period.presets.value,
|
|
158
181
|
comparisons: period.comparisons.value,
|
|
@@ -186,7 +209,7 @@ export function usePeriodLocation(
|
|
|
186
209
|
watch(
|
|
187
210
|
() => urlKeysText(route.query as PeriodQuery, urlKeys),
|
|
188
211
|
(text) => {
|
|
189
|
-
if (writer.isEcho(text)) return;
|
|
212
|
+
if (writer.isEcho(text) || isRouteBehindBrowser(text, urlKeys)) return;
|
|
190
213
|
const current = readSelection(period);
|
|
191
214
|
const next = resolvePeriodSelection([urlSource()], choices, current);
|
|
192
215
|
if (selectionText(next) === selectionText(current)) return;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { watch, type Ref } from "vue";
|
|
2
|
+
import { readTableUrlKey, type TableUrlScope } from "./utils/views";
|
|
3
|
+
|
|
4
|
+
const TAB_URL_KEY = "tab";
|
|
5
|
+
|
|
6
|
+
/** The route fields the URL tab is read from. */
|
|
7
|
+
export interface TableUrlTabRoute {
|
|
8
|
+
query: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** What `useTableUrlTab` reads the tab from and drives. */
|
|
12
|
+
export interface TableUrlTabOptions {
|
|
13
|
+
route: TableUrlTabRoute;
|
|
14
|
+
urlScope: TableUrlScope;
|
|
15
|
+
/** The open tab: set from the URL on arrival by the caller, then here. */
|
|
16
|
+
activeTabId: Ref<string>;
|
|
17
|
+
/** Whether the table has a tab of that id it can open in place. */
|
|
18
|
+
isOpenable: (tabId: string) => boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The tab the URL names for a table (`?<tableId>.tab=`, or `?tab=` on a page
|
|
23
|
+
* carrying a single table view), as it reads on arrival.
|
|
24
|
+
*/
|
|
25
|
+
export function readTableUrlTab(
|
|
26
|
+
query: Record<string, unknown>,
|
|
27
|
+
urlScope: TableUrlScope,
|
|
28
|
+
): string | undefined {
|
|
29
|
+
return readTableUrlKey(query, urlScope, TAB_URL_KEY).value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Follows the URL's tab after arrival: a link or a `router.replace` naming
|
|
34
|
+
* another tab of the same page switches to it without remounting the page.
|
|
35
|
+
* A URL dropping the key, or naming a tab the table cannot open in place (an
|
|
36
|
+
* unknown id, a link tab), leaves the open tab alone.
|
|
37
|
+
*/
|
|
38
|
+
export function useTableUrlTab(options: TableUrlTabOptions): void {
|
|
39
|
+
watch(
|
|
40
|
+
() => readTableUrlTab(options.route.query, options.urlScope),
|
|
41
|
+
(tabId) => {
|
|
42
|
+
if (!tabId || tabId === options.activeTabId.value) return;
|
|
43
|
+
if (!options.isOpenable(tabId)) return;
|
|
44
|
+
options.activeTabId.value = tabId;
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { downloadFile } from "#dms-core/app/utils/downloadFile";
|
|
2
|
+
|
|
3
|
+
/** A file to fetch with the user's session, and the name it is saved under. */
|
|
4
|
+
export interface FileDownload {
|
|
5
|
+
url: string;
|
|
6
|
+
/** Saved under; the response's `Content-Disposition` name, else the URL's. */
|
|
7
|
+
filename?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The response a download reads: its body and its headers. */
|
|
11
|
+
export interface DownloadResponse {
|
|
12
|
+
_data?: Blob;
|
|
13
|
+
headers: Headers;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Fetches a download with the user's session (`$authFetch.raw`). */
|
|
17
|
+
export type DownloadFetch = (
|
|
18
|
+
url: string,
|
|
19
|
+
options: { responseType: "blob" },
|
|
20
|
+
) => Promise<DownloadResponse>;
|
|
21
|
+
|
|
22
|
+
const DISPOSITION_NAME = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i;
|
|
23
|
+
const FALLBACK_FILENAME = "download";
|
|
24
|
+
|
|
25
|
+
// A malformed escape is kept as written rather than failing the download.
|
|
26
|
+
function decodeName(name: string): string {
|
|
27
|
+
try {
|
|
28
|
+
return decodeURIComponent(name);
|
|
29
|
+
} catch {
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The name a download is saved under: the declared one, else the server's
|
|
36
|
+
* `Content-Disposition` one, else the URL's last path segment.
|
|
37
|
+
*/
|
|
38
|
+
export function downloadFilename(
|
|
39
|
+
download: FileDownload,
|
|
40
|
+
disposition: string | null,
|
|
41
|
+
): string {
|
|
42
|
+
if (download.filename) return download.filename;
|
|
43
|
+
const served = disposition ? DISPOSITION_NAME.exec(disposition)?.[1] : "";
|
|
44
|
+
if (served) return decodeName(served.trim());
|
|
45
|
+
const path = download.url.split(/[?#]/)[0] ?? "";
|
|
46
|
+
return decodeName(path.split("/").pop() || FALLBACK_FILENAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetches a file with `fetchRaw` (the user's session) and saves it; rejects
|
|
51
|
+
* when the request fails.
|
|
52
|
+
*/
|
|
53
|
+
export async function saveDownload(
|
|
54
|
+
download: FileDownload,
|
|
55
|
+
fetchRaw: DownloadFetch,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
if (!download.url) return;
|
|
58
|
+
const response = await fetchRaw(download.url, { responseType: "blob" });
|
|
59
|
+
const blob = response._data ?? new Blob([]);
|
|
60
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
61
|
+
try {
|
|
62
|
+
downloadFile(
|
|
63
|
+
objectUrl,
|
|
64
|
+
downloadFilename(download, response.headers.get("content-disposition")),
|
|
65
|
+
);
|
|
66
|
+
} finally {
|
|
67
|
+
URL.revokeObjectURL(objectUrl);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -23,9 +23,11 @@ import { isRelativePreset } from "#dms-core/app/composables/period/presetResolve
|
|
|
23
23
|
import { usePeriodLocation } from "../../build/composables/period/usePeriodLocation";
|
|
24
24
|
import {
|
|
25
25
|
type CalendarRange,
|
|
26
|
+
type CalendarRangeAnchor,
|
|
26
27
|
calendarToRange,
|
|
27
28
|
formatPeriodRange,
|
|
28
29
|
formatRelativeInstant,
|
|
30
|
+
limitCalendarRange,
|
|
29
31
|
periodDuration,
|
|
30
32
|
rangeToCalendar,
|
|
31
33
|
} from "../../build/composables/period/periodDisplay";
|
|
@@ -49,6 +51,7 @@ interface Props extends DefaultComponentProps {
|
|
|
49
51
|
comparisonControl?: ComparisonControl;
|
|
50
52
|
rememberKey?: string;
|
|
51
53
|
variant?: "default" | "segmented";
|
|
54
|
+
maxDays?: number;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
const CALENDAR_ICON = "i-ph-calendar-blank-light";
|
|
@@ -232,9 +235,18 @@ function setOpen(open: boolean) {
|
|
|
232
235
|
isOpen.value = open;
|
|
233
236
|
}
|
|
234
237
|
|
|
238
|
+
// A custom range starts from the days on screen, which a long preset can
|
|
239
|
+
// stretch past `maxDays`: it keeps its most recent days.
|
|
240
|
+
function selectDraftPreset(preset: PeriodPreset) {
|
|
241
|
+
draft.selectPreset(preset);
|
|
242
|
+
const custom = draft.customRange.value;
|
|
243
|
+
if (preset !== CUSTOM_PRESET || !custom) return;
|
|
244
|
+
selectCalendarRange(rangeToCalendar(custom), "end");
|
|
245
|
+
}
|
|
246
|
+
|
|
235
247
|
function openCustom() {
|
|
236
248
|
draft.reset();
|
|
237
|
-
|
|
249
|
+
selectDraftPreset(CUSTOM_PRESET);
|
|
238
250
|
pendingStart.value = null;
|
|
239
251
|
isOpen.value = true;
|
|
240
252
|
}
|
|
@@ -253,9 +265,14 @@ const draftCompareCalendar = computed<CalendarRange | null>(() => {
|
|
|
253
265
|
return compare ? rangeToCalendar(compare) : null;
|
|
254
266
|
});
|
|
255
267
|
|
|
256
|
-
function selectCalendarRange(
|
|
268
|
+
function selectCalendarRange(
|
|
269
|
+
range: CalendarRange,
|
|
270
|
+
anchor: CalendarRangeAnchor = "start",
|
|
271
|
+
) {
|
|
257
272
|
if (!isCustomEnabled.value) return;
|
|
258
|
-
draft.selectRange(
|
|
273
|
+
draft.selectRange(
|
|
274
|
+
calendarToRange(limitCalendarRange(range, props.maxDays, anchor)),
|
|
275
|
+
);
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
function updateStart(value: DateValue | null | undefined) {
|
|
@@ -273,6 +290,7 @@ function updateEnd(value: DateValue | null | undefined) {
|
|
|
273
290
|
const start = draftCalendar.value.start;
|
|
274
291
|
selectCalendarRange(
|
|
275
292
|
end.compare(start) < 0 ? { start: end, end } : { start, end },
|
|
293
|
+
"end",
|
|
276
294
|
);
|
|
277
295
|
}
|
|
278
296
|
|
|
@@ -297,6 +315,12 @@ const draftDurationLabel = computed(() => {
|
|
|
297
315
|
return t(`dms.period.popover.${unit}`, { count }, count);
|
|
298
316
|
});
|
|
299
317
|
|
|
318
|
+
const maxDaysHint = computed(() =>
|
|
319
|
+
props.maxDays && isCustomEnabled.value
|
|
320
|
+
? t("dms.period.popover.max_days", { count: props.maxDays }, props.maxDays)
|
|
321
|
+
: null,
|
|
322
|
+
);
|
|
323
|
+
|
|
300
324
|
const draftCompareLabel = computed(() => {
|
|
301
325
|
const compare = draft.compareRange.value;
|
|
302
326
|
return compare ? formatPeriodRange(compare, locale.value) : null;
|
|
@@ -557,7 +581,7 @@ function onSegmentedClick(event: MouseEvent) {
|
|
|
557
581
|
role="option"
|
|
558
582
|
:aria-selected="draft.preset.value === entry.preset"
|
|
559
583
|
:class="presetItemClass(entry.preset)"
|
|
560
|
-
@click="
|
|
584
|
+
@click="selectDraftPreset(entry.preset)"
|
|
561
585
|
>
|
|
562
586
|
<span class="truncate">{{ entry.label }}</span>
|
|
563
587
|
<span
|
|
@@ -583,7 +607,7 @@ function onSegmentedClick(event: MouseEvent) {
|
|
|
583
607
|
role="option"
|
|
584
608
|
:aria-selected="draft.preset.value === CUSTOM_PRESET"
|
|
585
609
|
:class="presetItemClass(CUSTOM_PRESET)"
|
|
586
|
-
@click="
|
|
610
|
+
@click="selectDraftPreset(CUSTOM_PRESET)"
|
|
587
611
|
>
|
|
588
612
|
<UIcon :name="CALENDAR_ICON" class="size-4 shrink-0" />
|
|
589
613
|
{{ $t("dms.period.popover.custom_range", "Custom range") }}
|
|
@@ -662,7 +686,8 @@ function onSegmentedClick(event: MouseEvent) {
|
|
|
662
686
|
:week-starts-on="weekStartsOn"
|
|
663
687
|
:number-of-months="isWide ? 2 : 1"
|
|
664
688
|
:readonly="!isCustomEnabled"
|
|
665
|
-
|
|
689
|
+
:max-days="maxDays"
|
|
690
|
+
@update:model-value="selectCalendarRange($event)"
|
|
666
691
|
@pending="pendingStart = $event"
|
|
667
692
|
/>
|
|
668
693
|
|
|
@@ -680,6 +705,7 @@ function onSegmentedClick(event: MouseEvent) {
|
|
|
680
705
|
</span>
|
|
681
706
|
{{ draftCompareLabel }}
|
|
682
707
|
</template>
|
|
708
|
+
<template v-if="maxDaysHint">· {{ maxDaysHint }}</template>
|
|
683
709
|
</span>
|
|
684
710
|
<UButton
|
|
685
711
|
color="neutral"
|
|
@@ -151,7 +151,10 @@ import {
|
|
|
151
151
|
groupedSorting,
|
|
152
152
|
isGroupableColumn,
|
|
153
153
|
} from "../../build/composables/table-view/utils/groupedRows";
|
|
154
|
-
import {
|
|
154
|
+
import {
|
|
155
|
+
readTableUrlTab,
|
|
156
|
+
useTableUrlTab,
|
|
157
|
+
} from "../../build/composables/table-view/useTableUrlTab";
|
|
155
158
|
import TableViews, {
|
|
156
159
|
type TableViewItem,
|
|
157
160
|
} from "../../build/components/table/Views.vue";
|
|
@@ -606,11 +609,10 @@ const ALL_TAB_ID = "all";
|
|
|
606
609
|
|
|
607
610
|
const { t, te } = useI18n();
|
|
608
611
|
|
|
609
|
-
const TAB_URL_KEY = "tab";
|
|
610
|
-
const urlTab = readTableUrlKey(useDmsRoute().query, urlScope, TAB_URL_KEY);
|
|
611
612
|
// A tab named by the URL wins over the one kept from the last visit.
|
|
612
613
|
const activeTabId = ref<string>(
|
|
613
|
-
|
|
614
|
+
readTableUrlTab(route.query, urlScope) ??
|
|
615
|
+
tablePreferences.get<string>("activeTab", ALL_TAB_ID),
|
|
614
616
|
);
|
|
615
617
|
|
|
616
618
|
interface ResolvedTab {
|
|
@@ -661,6 +663,14 @@ const activeTabFilters = computed<TableFilter[]>(() => {
|
|
|
661
663
|
return tab?.filters || [];
|
|
662
664
|
});
|
|
663
665
|
|
|
666
|
+
useTableUrlTab({
|
|
667
|
+
route,
|
|
668
|
+
urlScope,
|
|
669
|
+
activeTabId,
|
|
670
|
+
isOpenable: (tabId) =>
|
|
671
|
+
resolvedTabs.value.some((tab) => !tab.to && tab.id === tabId),
|
|
672
|
+
});
|
|
673
|
+
|
|
664
674
|
watch(resolvedTabs, (next) => {
|
|
665
675
|
if (next.length === 0) return;
|
|
666
676
|
if (!next.some((tab) => !tab.to && tab.id === activeTabId.value)) {
|