@antelopejs/dms 0.7.14 → 0.7.16
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 +42 -0
- package/dist/db/models/userNotifications.model.js +3 -27
- package/dist/db/models/userNotifications.model.js.map +1 -1
- package/dist/implementations/dms/frontend-modules.d.ts +4 -2
- package/dist/implementations/dms/frontend-modules.js +4 -0
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
- package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
- package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
- package/dist/implementations/dms/manifest.d.ts +1 -0
- package/dist/implementations/dms/manifest.js +3 -1
- package/dist/implementations/dms/manifest.js.map +1 -1
- package/dist/test/integration/notifications.test.js +6 -3
- package/dist/test/integration/notifications.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
- 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 +66 -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-layout/app/emails/components/EmailLayout.vue +35 -12
- package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
- package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/build/components/period/PeriodRangeCalendar.vue +17 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +39 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formControlErrors.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
- 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/composables/table-view/utils/recordLink.ts +34 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
- 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 +75 -6
- package/frontend-vue/layers/dms-ui/app/composables/form/useControlError.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -0
- package/frontend-vue/tests/control-error-path.test.ts +11 -0
- package/frontend-vue/tests/email-layout.test.ts +124 -0
- package/frontend-vue/tests/header-action-page-query.test.ts +96 -1
- package/frontend-vue/tests/input-number-range.test.ts +1 -1
- package/frontend-vue/tests/input-tags.test.ts +1 -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/row-side-panel.test.ts +2 -2
- package/frontend-vue/tests/table-url-filters.test.ts +158 -0
- package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
- package/frontend-vue/tests/table-view-url-tab.test.ts +115 -0
- package/package.json +2 -2
- package/skills/dms-pages/REFERENCE.md +6 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { emailStyles } from "./index";
|
|
2
|
+
import { emailLogoUrl, emailStyles } from "./index";
|
|
3
3
|
|
|
4
4
|
interface EmailAppConfig {
|
|
5
5
|
branding?: {
|
|
@@ -11,38 +11,61 @@ interface EmailAppConfig {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
/** Shown as the email's heading; no heading when omitted. */
|
|
17
|
+
title?: string;
|
|
18
|
+
lang?: string;
|
|
19
|
+
/** Inbox preview text, hidden in the message body. */
|
|
20
|
+
preheader?: string;
|
|
21
|
+
/** Draws the separator between the content and the `footer` slot. */
|
|
22
|
+
divider?: boolean;
|
|
23
|
+
logoAlt?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Leaves the logo out unless a mail client can load it: an absolute
|
|
26
|
+
* public http(s) URL, not an SVG (see `emailLogoUrl`).
|
|
27
|
+
*/
|
|
28
|
+
publicLogoOnly?: boolean;
|
|
29
|
+
}>(),
|
|
30
|
+
{ divider: true, logoAlt: "Logo", publicLogoOnly: false },
|
|
31
|
+
);
|
|
18
32
|
|
|
19
33
|
const appConfig = useDmsAppConfig() as EmailAppConfig;
|
|
20
34
|
const runtimeConfig = useDmsRuntimeConfig();
|
|
21
35
|
|
|
22
36
|
const logoPath = appConfig.branding?.logo?.default?.light;
|
|
23
37
|
const clientBaseUrl = runtimeConfig.public.dms?.clientBaseUrl ?? "";
|
|
24
|
-
const logoUrl =
|
|
25
|
-
?
|
|
26
|
-
: logoPath
|
|
38
|
+
const logoUrl = props.publicLogoOnly
|
|
39
|
+
? emailLogoUrl(logoPath, clientBaseUrl)
|
|
40
|
+
: logoPath?.startsWith("/")
|
|
41
|
+
? `${clientBaseUrl}${logoPath}`
|
|
42
|
+
: logoPath;
|
|
27
43
|
</script>
|
|
28
44
|
|
|
29
45
|
<template>
|
|
30
46
|
<EHtml :lang="lang ?? 'en'">
|
|
31
|
-
<EHead
|
|
47
|
+
<EHead>
|
|
48
|
+
<slot name="head" />
|
|
49
|
+
</EHead>
|
|
32
50
|
<EBody :style="emailStyles.main">
|
|
51
|
+
<EPreview v-if="preheader">{{ preheader }}</EPreview>
|
|
33
52
|
<EContainer :style="emailStyles.container">
|
|
34
53
|
<ESection :style="emailStyles.card">
|
|
35
54
|
<ESection v-if="logoUrl" :style="emailStyles.logo">
|
|
36
|
-
<EImg
|
|
55
|
+
<EImg
|
|
56
|
+
:src="logoUrl"
|
|
57
|
+
:alt="logoAlt"
|
|
58
|
+
:style="emailStyles.logoImage"
|
|
59
|
+
/>
|
|
37
60
|
</ESection>
|
|
38
61
|
|
|
39
|
-
<EHeading as="h1" :style="emailStyles.heading">
|
|
62
|
+
<EHeading v-if="title" as="h1" :style="emailStyles.heading">
|
|
40
63
|
{{ title }}
|
|
41
64
|
</EHeading>
|
|
42
65
|
|
|
43
66
|
<slot />
|
|
44
67
|
|
|
45
|
-
<EHr :style="emailStyles.divider" />
|
|
68
|
+
<EHr v-if="divider" :style="emailStyles.divider" />
|
|
46
69
|
|
|
47
70
|
<slot name="footer" />
|
|
48
71
|
</ESection>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which logo URL an email may carry. A recipient's mail client fetches the
|
|
3
|
+
* image from the internet, so it needs an absolute public http(s) URL; Gmail
|
|
4
|
+
* and Outlook also refuse SVG. A logo that fails either test is left out
|
|
5
|
+
* rather than drawn as a broken image.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ROOT_PATH = "/";
|
|
9
|
+
const WEB_PROTOCOLS = ["http:", "https:"];
|
|
10
|
+
const SVG_PATH = /\.svg(?:[?#]|$)/i;
|
|
11
|
+
const LOCAL_HOSTS = ["localhost", "0.0.0.0", "[::]", "[::1]"];
|
|
12
|
+
const LOCAL_SUFFIXES = [".localhost", ".local", ".internal"];
|
|
13
|
+
const PRIVATE_ADDRESSES = [
|
|
14
|
+
/^127\./,
|
|
15
|
+
/^10\./,
|
|
16
|
+
/^192\.168\./,
|
|
17
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
18
|
+
/^169\.254\./,
|
|
19
|
+
/^\[f[cd][0-9a-f]{0,2}:/i,
|
|
20
|
+
/^\[fe[89ab][0-9a-f]:/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function absoluteUrl(path: string, clientBaseUrl: string): URL | null {
|
|
24
|
+
const source = path.startsWith(ROOT_PATH) ? `${clientBaseUrl}${path}` : path;
|
|
25
|
+
try {
|
|
26
|
+
return new URL(source);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a mail client out on the internet can reach `hostname`. */
|
|
33
|
+
export function isPublicEmailHost(hostname: string): boolean {
|
|
34
|
+
const name = hostname.toLowerCase();
|
|
35
|
+
if (LOCAL_HOSTS.includes(name)) return false;
|
|
36
|
+
if (LOCAL_SUFFIXES.some((suffix) => name.endsWith(suffix))) return false;
|
|
37
|
+
return !PRIVATE_ADDRESSES.some((pattern) => pattern.test(name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The logo URL an email may use: `logoPath` made absolute against
|
|
42
|
+
* `clientBaseUrl` when it starts with `/`, or `""` when it is an SVG, not
|
|
43
|
+
* http(s), or served from a host a mail client cannot reach (localhost, a
|
|
44
|
+
* private address).
|
|
45
|
+
*/
|
|
46
|
+
export function emailLogoUrl(
|
|
47
|
+
logoPath: string | undefined,
|
|
48
|
+
clientBaseUrl: string,
|
|
49
|
+
): string {
|
|
50
|
+
if (!logoPath || SVG_PATH.test(logoPath)) return "";
|
|
51
|
+
const url = absoluteUrl(logoPath, clientBaseUrl);
|
|
52
|
+
if (!url || !WEB_PROTOCOLS.includes(url.protocol)) return "";
|
|
53
|
+
return isPublicEmailHost(url.hostname) ? url.href : "";
|
|
54
|
+
}
|
|
@@ -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"
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
TableColumn,
|
|
10
10
|
TableFilter,
|
|
11
11
|
TableSharedData,
|
|
12
|
+
TableUrlFilter,
|
|
12
13
|
} from "./Table.vue";
|
|
13
14
|
import TableMenuFilter from "./MenuFilter.vue";
|
|
14
15
|
import { formatFilterValue } from "../../composables/table/utils/formatFilterValue";
|
|
@@ -126,6 +127,17 @@ const deleteFilter = (index: number) => {
|
|
|
126
127
|
tableSharedData?.value?.deleteFilter(index);
|
|
127
128
|
};
|
|
128
129
|
|
|
130
|
+
const urlFilters = computed(() => tableSharedData?.value?.urlFilters ?? []);
|
|
131
|
+
|
|
132
|
+
const urlFilterLabel = (filter: TableUrlFilter): string =>
|
|
133
|
+
(filter.label && processI18n(filter.label)) ||
|
|
134
|
+
getColumn(filter.accessorKey)?.label ||
|
|
135
|
+
filter.param;
|
|
136
|
+
|
|
137
|
+
const removeUrlFilter = (filter: TableUrlFilter) => {
|
|
138
|
+
tableSharedData?.value?.emits("removeUrlFilter", filter.param);
|
|
139
|
+
};
|
|
140
|
+
|
|
129
141
|
// The "clear all" action: offered while something narrows the rows, named
|
|
130
142
|
// after what it clears (filters, search or both).
|
|
131
143
|
const clearable = computed(
|
|
@@ -171,9 +183,9 @@ const displayValue = (filter: TableFilter): string => {
|
|
|
171
183
|
};
|
|
172
184
|
|
|
173
185
|
// The whole filter in words, for a chip shrunk to an ellipsis.
|
|
174
|
-
const chipTitle = (filter: TableFilter): string =>
|
|
186
|
+
const chipTitle = (filter: TableFilter, label?: string): string =>
|
|
175
187
|
[
|
|
176
|
-
getColumn(filter.accessorKey)?.label,
|
|
188
|
+
label ?? getColumn(filter.accessorKey)?.label,
|
|
177
189
|
t(`dms.table.compare_mode.${filter.mode}`),
|
|
178
190
|
displayValue(filter),
|
|
179
191
|
]
|
|
@@ -184,6 +196,31 @@ const chipTitle = (filter: TableFilter): string =>
|
|
|
184
196
|
<template>
|
|
185
197
|
<div :class="ui.root()">
|
|
186
198
|
<ul :class="ui.list()">
|
|
199
|
+
<li
|
|
200
|
+
v-for="filter in urlFilters"
|
|
201
|
+
:key="`url-${filter.param}`"
|
|
202
|
+
:class="ui.item()"
|
|
203
|
+
data-dms-url-filter
|
|
204
|
+
>
|
|
205
|
+
<span
|
|
206
|
+
:class="ui.chip()"
|
|
207
|
+
:title="chipTitle(filter, urlFilterLabel(filter))"
|
|
208
|
+
>
|
|
209
|
+
<span :class="ui.label()">{{ urlFilterLabel(filter) }}</span>
|
|
210
|
+
<span :class="ui.mode()">
|
|
211
|
+
{{ t(`dms.table.compare_mode.${filter.mode}`) }}
|
|
212
|
+
</span>
|
|
213
|
+
<span :class="ui.value()">{{ displayValue(filter) }}</span>
|
|
214
|
+
</span>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
:class="ui.remove()"
|
|
218
|
+
:aria-label="t('dms.button.delete')"
|
|
219
|
+
@click="removeUrlFilter(filter)"
|
|
220
|
+
>
|
|
221
|
+
<UIcon :name="appConfig.ui.icons.close" />
|
|
222
|
+
</button>
|
|
223
|
+
</li>
|
|
187
224
|
<li
|
|
188
225
|
v-for="(filter, index) in filters"
|
|
189
226
|
:key="`${filter.accessorKey}-${index}`"
|
|
@@ -247,6 +247,11 @@ export interface TableProps<T> {
|
|
|
247
247
|
* says no row matches rather than that there are none yet.
|
|
248
248
|
*/
|
|
249
249
|
hiddenFiltersApply?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* The page URL's filters declared `visible`: drawn as chips of the filters
|
|
252
|
+
* row, each removable (`removeUrlFilter`).
|
|
253
|
+
*/
|
|
254
|
+
urlFilters?: TableUrlFilter[];
|
|
250
255
|
|
|
251
256
|
data?: T[] | null;
|
|
252
257
|
columns?: TableColumn<T>[];
|
|
@@ -307,6 +312,7 @@ export interface TableEmits<T> {
|
|
|
307
312
|
(e: "details" | "edit", item: T): void;
|
|
308
313
|
(e: "delete" | "archive" | "restore" | "export", itemIds: string[]): void;
|
|
309
314
|
(e: "duplicate", itemId: string): void;
|
|
315
|
+
(e: "removeUrlFilter", param: string): void;
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
export interface TableFilter {
|
|
@@ -317,6 +323,14 @@ export interface TableFilter {
|
|
|
317
323
|
initialValue?: unknown | unknown[];
|
|
318
324
|
}
|
|
319
325
|
|
|
326
|
+
/** A filter the page URL applies and shows (see `TableProps.urlFilters`). */
|
|
327
|
+
export interface TableUrlFilter extends TableFilter {
|
|
328
|
+
/** The query parameter carrying it, removed with the chip. */
|
|
329
|
+
param: string;
|
|
330
|
+
/** The chip's name, before the column's label. */
|
|
331
|
+
label?: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
320
334
|
export interface TableSharedData<T> {
|
|
321
335
|
table: Table<T>;
|
|
322
336
|
emits: TableEmits<T>;
|
|
@@ -343,6 +357,8 @@ export interface TableSharedData<T> {
|
|
|
343
357
|
resetSorting: () => void;
|
|
344
358
|
hasCustomColumns: ComputedRef<boolean>;
|
|
345
359
|
deleteFilter: (index: number) => void;
|
|
360
|
+
/** The URL filters shown as chips (see `TableProps.urlFilters`). */
|
|
361
|
+
urlFilters: TableUrlFilter[];
|
|
346
362
|
/**
|
|
347
363
|
* Clears everything that narrows the rows — filter chips (quick filters
|
|
348
364
|
* included) and the toolbar search — and goes back to the first page.
|
|
@@ -1183,6 +1199,7 @@ watchEffect(() => {
|
|
|
1183
1199
|
resetSorting,
|
|
1184
1200
|
hasCustomColumns,
|
|
1185
1201
|
deleteFilter,
|
|
1202
|
+
urlFilters: props.urlFilters ?? [],
|
|
1186
1203
|
resetFilters,
|
|
1187
1204
|
clearSearch,
|
|
1188
1205
|
clearFilters,
|
|
@@ -1348,7 +1365,7 @@ defineShortcuts({
|
|
|
1348
1365
|
|
|
1349
1366
|
<TableFiltersRow
|
|
1350
1367
|
v-if="
|
|
1351
|
-
filtersRowOpen &&
|
|
1368
|
+
(filtersRowOpen || !!urlFilters?.length) &&
|
|
1352
1369
|
resolvedCapabilities.filters &&
|
|
1353
1370
|
resolvedChrome.filters
|
|
1354
1371
|
"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InjectionKey } from "vue";
|
|
2
|
+
|
|
3
|
+
/** Where a form collects the errors its controls report, by field id. */
|
|
4
|
+
export interface FormControlErrors {
|
|
5
|
+
report: (fieldId: string, message: string | undefined) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const FORM_CONTROL_ERRORS_KEY: InjectionKey<FormControlErrors> = Symbol(
|
|
9
|
+
"dms-form-control-errors",
|
|
10
|
+
);
|
|
@@ -1,34 +1,10 @@
|
|
|
1
|
-
import { formFieldInjectionKey } from "@nuxt/ui/composables/useFormField";
|
|
2
|
-
import { type InjectionKey, inject, onBeforeUnmount, ref } from "vue";
|
|
3
|
-
|
|
4
|
-
/** Where a form collects the errors its controls report, by field id. */
|
|
5
|
-
export interface FormControlErrors {
|
|
6
|
-
report: (fieldId: string, message: string | undefined) => void;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const FORM_CONTROL_ERRORS_KEY: InjectionKey<FormControlErrors> = Symbol(
|
|
10
|
-
"dms-form-control-errors",
|
|
11
|
-
);
|
|
12
|
-
|
|
13
1
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
2
|
+
* Former home of `useControlError`, kept importable for the modules that
|
|
3
|
+
* reached it here.
|
|
4
|
+
*
|
|
5
|
+
* @deprecated Import `useControlError` from
|
|
6
|
+
* `#dms-ui/app/composables/form/useControlError` (or rely on its auto-import).
|
|
7
|
+
* This path is removed in the next breaking release.
|
|
18
8
|
*/
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
const errors = inject(FORM_CONTROL_ERRORS_KEY, null);
|
|
22
|
-
const message = ref<string>();
|
|
23
|
-
|
|
24
|
-
function report(next: string | undefined): void {
|
|
25
|
-
if (message.value === next) return;
|
|
26
|
-
message.value = next;
|
|
27
|
-
const name = field?.value?.name;
|
|
28
|
-
if (name) errors?.report(name, next);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
onBeforeUnmount(() => report(undefined));
|
|
32
|
-
|
|
33
|
-
return { message, report };
|
|
34
|
-
}
|
|
9
|
+
export * from "../../../composables/form/useControlError";
|
|
10
|
+
export * from "./formControlErrors";
|
|
@@ -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
|
+
}
|
|
@@ -82,12 +82,18 @@ export interface DeepLinkedRow<T> {
|
|
|
82
82
|
row?: T;
|
|
83
83
|
/** The route answered 404: the row the link names no longer exists. */
|
|
84
84
|
isMissing: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The read failed for another reason (a refusal, the network, a server
|
|
87
|
+
* error): the row may still exist, the link just could not be followed.
|
|
88
|
+
*/
|
|
89
|
+
isFailed?: boolean;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/**
|
|
88
93
|
* The row a deep link names: the listed one, else the one read by id — a row
|
|
89
|
-
* beyond the first page. A route answering 404 marks it missing
|
|
90
|
-
* failure (a refusal, the network)
|
|
94
|
+
* beyond the first page. A route answering 404 marks it missing, any other
|
|
95
|
+
* failure (a refusal, the network) marks it failed, and no route at all finds
|
|
96
|
+
* nothing.
|
|
91
97
|
*/
|
|
92
98
|
export async function resolveDeepLinkedRow<T>(
|
|
93
99
|
id: string,
|
|
@@ -99,6 +105,31 @@ export async function resolveDeepLinkedRow<T>(
|
|
|
99
105
|
const row = await source.getRow(id);
|
|
100
106
|
return { row: row ?? undefined, isMissing: false };
|
|
101
107
|
} catch (error: unknown) {
|
|
102
|
-
|
|
108
|
+
if (readFetchNotFound(error) !== null) return { isMissing: true };
|
|
109
|
+
return { isMissing: false, isFailed: true };
|
|
103
110
|
}
|
|
104
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What a table does with the row a deep link resolved to: open it, let a side
|
|
115
|
+
* panel show its own "no longer available" state, tell the user the link led
|
|
116
|
+
* nowhere (a drawer or a modal has no such state, and a table with no route to
|
|
117
|
+
* read one row finds nothing either), or tell them the row could not be read
|
|
118
|
+
* (a refusal, the network) without claiming it is gone.
|
|
119
|
+
*/
|
|
120
|
+
export type DeepLinkOutcome =
|
|
121
|
+
| "open"
|
|
122
|
+
| "missingPanel"
|
|
123
|
+
| "notify"
|
|
124
|
+
| "notifyFailed";
|
|
125
|
+
|
|
126
|
+
export function deepLinkOutcome(
|
|
127
|
+
found: DeepLinkedRow<unknown>,
|
|
128
|
+
action: CustomRowAction,
|
|
129
|
+
): DeepLinkOutcome {
|
|
130
|
+
if (found.row) return "open";
|
|
131
|
+
if (found.isFailed) return "notifyFailed";
|
|
132
|
+
if (found.isMissing && action.target.type === "sidePanel")
|
|
133
|
+
return "missingPanel";
|
|
134
|
+
return "notify";
|
|
135
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TableUrlFilter } from "../../../components/table/Table.vue";
|
|
2
|
+
import type { QueryParamFilters } from "../../../../composables/table-view/types/config";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FILTER_MODE = "is";
|
|
5
|
+
|
|
6
|
+
/** The `queryParamFilters` declared `visible` that the URL applies now. */
|
|
7
|
+
export function visibleUrlFilters(
|
|
8
|
+
filters: QueryParamFilters | undefined,
|
|
9
|
+
query: Record<string, unknown>,
|
|
10
|
+
): TableUrlFilter[] {
|
|
11
|
+
return Object.entries(filters ?? {})
|
|
12
|
+
.filter(([param, config]) => config.visible && query[param] !== undefined)
|
|
13
|
+
.map(([param, config]) => ({
|
|
14
|
+
param,
|
|
15
|
+
label: config.label,
|
|
16
|
+
accessorKey: config.field,
|
|
17
|
+
mode: config.mode || DEFAULT_FILTER_MODE,
|
|
18
|
+
value: query[param],
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The URL query without `param`, for a chip the user removed. */
|
|
23
|
+
export function withoutQueryParam<Query extends Record<string, unknown>>(
|
|
24
|
+
query: Query,
|
|
25
|
+
param: string,
|
|
26
|
+
): Query {
|
|
27
|
+
const { [param]: _removed, ...rest } = query;
|
|
28
|
+
return rest as Query;
|
|
29
|
+
}
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
resolveSectionNav,
|
|
57
57
|
sectionState,
|
|
58
58
|
} from "../../build/composables/form/formSections";
|
|
59
|
-
import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/
|
|
59
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/formControlErrors";
|
|
60
60
|
import { useInstantForm } from "../../build/composables/form/useInstantForm";
|
|
61
61
|
import { useInstantSaveHeader } from "#dms-layout/app/composables/layout/useInstantSaveHeader";
|
|
62
62
|
import DmsFormEntries from "../../build/components/form/FormEntries.vue";
|