@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/db/models/userNotifications.model.js +3 -27
  3. package/dist/db/models/userNotifications.model.js.map +1 -1
  4. package/dist/implementations/dms/frontend-modules.d.ts +4 -2
  5. package/dist/implementations/dms/frontend-modules.js +4 -0
  6. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  7. package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
  8. package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
  9. package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
  10. package/dist/implementations/dms/manifest.d.ts +1 -0
  11. package/dist/implementations/dms/manifest.js +3 -1
  12. package/dist/implementations/dms/manifest.js.map +1 -1
  13. package/dist/test/integration/notifications.test.js +6 -3
  14. package/dist/test/integration/notifications.test.js.map +1 -1
  15. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
  16. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
  17. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
  18. package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
  19. package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
  20. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +16 -0
  21. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -1
  22. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +66 -0
  23. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
  24. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +19 -1
  25. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +52 -28
  26. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
  27. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
  28. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +48 -4
  29. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +6 -2
  30. package/frontend-vue/layers/dms-layout/app/composables/layout/usePageHeaderActions.ts +32 -1
  31. package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +35 -12
  32. package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
  33. package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
  34. package/frontend-vue/layers/dms-ui/app/build/components/period/PeriodRangeCalendar.vue +17 -1
  35. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +39 -2
  36. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/form/formControlErrors.ts +10 -0
  38. package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
  39. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +49 -0
  40. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +34 -11
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +47 -0
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +34 -3
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
  44. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
  45. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
  46. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
  47. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +1 -1
  48. package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
  49. package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
  50. package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
  51. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +32 -6
  52. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +75 -6
  53. package/frontend-vue/layers/dms-ui/app/composables/form/useControlError.ts +30 -0
  54. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  55. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
  56. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -0
  57. package/frontend-vue/tests/control-error-path.test.ts +11 -0
  58. package/frontend-vue/tests/email-layout.test.ts +124 -0
  59. package/frontend-vue/tests/header-action-page-query.test.ts +96 -1
  60. package/frontend-vue/tests/input-number-range.test.ts +1 -1
  61. package/frontend-vue/tests/input-tags.test.ts +1 -1
  62. package/frontend-vue/tests/period-day-rollover.test.ts +80 -0
  63. package/frontend-vue/tests/period-max-days.test.ts +128 -0
  64. package/frontend-vue/tests/period-selector-url.test.ts +44 -8
  65. package/frontend-vue/tests/row-side-panel.test.ts +2 -2
  66. package/frontend-vue/tests/table-url-filters.test.ts +158 -0
  67. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
  68. package/frontend-vue/tests/table-view-url-tab.test.ts +115 -0
  69. package/package.json +2 -2
  70. 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
- defineProps<{
15
- title: string;
16
- lang?: string;
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 = logoPath?.startsWith("/")
25
- ? `${clientBaseUrl}${logoPath}`
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 :src="logoUrl" alt="Logo" :style="emailStyles.logoImage" />
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
+ }
@@ -1,3 +1,5 @@
1
+ export * from "./email-logo";
2
+
1
3
  export const emailFontFamily =
2
4
  '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
3
5
 
@@ -20,7 +20,10 @@ import {
20
20
  RangeCalendarRoot,
21
21
  type DateRange,
22
22
  } from "reka-ui";
23
- import type { CalendarRange } from "../../composables/period/periodDisplay";
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
- * Lets a control refuse what was typed into it (a duration or JSON it cannot
15
- * read): inside a form, the message becomes the field's error, which blocks
16
- * the submit and joins the form's summary like any other. `message` holds it
17
- * for a control used outside a form.
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 function useControlError() {
20
- const field = inject(formFieldInjectionKey, undefined);
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; Inertia's
70
- * client-side replace then moves its page URL, which the DMS route follows,
71
- * so a component writing the query from the route (a tab, a quick action)
72
- * carries the period on instead of the one the page was opened with.
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: `${url.pathname}${url.search}${url.hash}`,
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. A route changed from outside (Back to the page with another
146
- * period, a link) moves the period.
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; any other
90
- * failure (a refusal, the network) or no route at all finds nothing.
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
- return { isMissing: readFetchNotFound(error) !== null };
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/useControlError";
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";