@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
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "@internationalized/date";
|
|
15
15
|
import { reactivePick } from "@vueuse/core";
|
|
16
16
|
import { useFormField } from "@nuxt/ui/composables/useFormField";
|
|
17
|
-
import { useControlError } from "../../../
|
|
17
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
18
18
|
import {
|
|
19
19
|
FIELD_TRIGGER_CLASS,
|
|
20
20
|
FIELD_TRIGGER_INVALID_CLASS,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { getCurrentInstance, h, render } from "vue";
|
|
3
3
|
import { useFormField } from "@nuxt/ui/composables/useFormField";
|
|
4
4
|
import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
|
|
5
|
-
import { useControlError } from "../../../
|
|
5
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
6
6
|
import { codeLanguageLabel } from "../../../build/utils/codeLanguages";
|
|
7
7
|
import type {
|
|
8
8
|
CodeEditor,
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
} from "@nuxt/ui/components/InputNumber.vue";
|
|
7
7
|
import { computed, nextTick, watch } from "vue";
|
|
8
8
|
import { useForwardPropsEmits } from "reka-ui";
|
|
9
|
-
import { useControlError } from "../../../
|
|
9
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
10
10
|
import {
|
|
11
11
|
BELOW_MIN_MESSAGE,
|
|
12
12
|
numberRangeError,
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
formErrorsInjectionKey,
|
|
4
4
|
formFieldInjectionKey,
|
|
5
5
|
} from "@nuxt/ui/composables/useFormField";
|
|
6
|
-
import { useControlError } from "../../../
|
|
6
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
7
7
|
import {
|
|
8
8
|
addTags,
|
|
9
9
|
invalidTags,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { InputProps } from "@nuxt/ui/components/Input.vue";
|
|
3
|
-
import { useControlError } from "../../../
|
|
3
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
4
4
|
|
|
5
5
|
interface InputTimeProps extends Omit<InputProps, "modelValue" | "type"> {
|
|
6
6
|
modelValue?: number | null;
|
|
@@ -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"
|
|
@@ -78,9 +78,12 @@ import { selectedRowIds } from "../../build/composables/table-view/utils/bulkAct
|
|
|
78
78
|
import { isBulkAction } from "../../build/composables/actions/bulkSelection";
|
|
79
79
|
import { selectionScopeKey } from "../../build/utils/selectionScope";
|
|
80
80
|
import {
|
|
81
|
+
type DeepLinkOutcome,
|
|
82
|
+
deepLinkOutcome,
|
|
81
83
|
isDeepLinkAction,
|
|
82
84
|
readRecordId,
|
|
83
85
|
resolveDeepLinkedRow,
|
|
86
|
+
writeRecordId,
|
|
84
87
|
} from "../../build/composables/table-view/utils/recordLink";
|
|
85
88
|
import {
|
|
86
89
|
rowSidePanelOwnerKey,
|
|
@@ -112,8 +115,13 @@ import {
|
|
|
112
115
|
} from "../../build/composables/table-view/useExpandedRowDetails";
|
|
113
116
|
import type {
|
|
114
117
|
TableAccumulation,
|
|
118
|
+
TableUrlFilter,
|
|
115
119
|
TableViewSwitcherItem,
|
|
116
120
|
} from "../../build/components/table/Table.vue";
|
|
121
|
+
import {
|
|
122
|
+
visibleUrlFilters,
|
|
123
|
+
withoutQueryParam,
|
|
124
|
+
} from "../../build/composables/table-view/utils/urlFilters";
|
|
117
125
|
import {
|
|
118
126
|
defineAsyncComponent,
|
|
119
127
|
useTemplateRef,
|
|
@@ -143,7 +151,10 @@ import {
|
|
|
143
151
|
groupedSorting,
|
|
144
152
|
isGroupableColumn,
|
|
145
153
|
} from "../../build/composables/table-view/utils/groupedRows";
|
|
146
|
-
import {
|
|
154
|
+
import {
|
|
155
|
+
readTableUrlTab,
|
|
156
|
+
useTableUrlTab,
|
|
157
|
+
} from "../../build/composables/table-view/useTableUrlTab";
|
|
147
158
|
import TableViews, {
|
|
148
159
|
type TableViewItem,
|
|
149
160
|
} from "../../build/components/table/Views.vue";
|
|
@@ -396,6 +407,15 @@ const queryParamHiddenFilters = computed<TableFilter[]>(() => {
|
|
|
396
407
|
}));
|
|
397
408
|
});
|
|
398
409
|
|
|
410
|
+
const shownUrlFilters = computed<TableUrlFilter[]>(() =>
|
|
411
|
+
visibleUrlFilters(queryParamFilters, route.query as Record<string, unknown>),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const removeUrlFilter = (param: string) =>
|
|
415
|
+
router.replace({
|
|
416
|
+
query: withoutQueryParam(route.query, param),
|
|
417
|
+
});
|
|
418
|
+
|
|
399
419
|
const routeParamHiddenFilters = computed<TableFilter[]>(() => {
|
|
400
420
|
if (!routeParamFilters || !routeParams) return [];
|
|
401
421
|
|
|
@@ -589,11 +609,10 @@ const ALL_TAB_ID = "all";
|
|
|
589
609
|
|
|
590
610
|
const { t, te } = useI18n();
|
|
591
611
|
|
|
592
|
-
const TAB_URL_KEY = "tab";
|
|
593
|
-
const urlTab = readTableUrlKey(useDmsRoute().query, urlScope, TAB_URL_KEY);
|
|
594
612
|
// A tab named by the URL wins over the one kept from the last visit.
|
|
595
613
|
const activeTabId = ref<string>(
|
|
596
|
-
|
|
614
|
+
readTableUrlTab(route.query, urlScope) ??
|
|
615
|
+
tablePreferences.get<string>("activeTab", ALL_TAB_ID),
|
|
597
616
|
);
|
|
598
617
|
|
|
599
618
|
interface ResolvedTab {
|
|
@@ -644,6 +663,14 @@ const activeTabFilters = computed<TableFilter[]>(() => {
|
|
|
644
663
|
return tab?.filters || [];
|
|
645
664
|
});
|
|
646
665
|
|
|
666
|
+
useTableUrlTab({
|
|
667
|
+
route,
|
|
668
|
+
urlScope,
|
|
669
|
+
activeTabId,
|
|
670
|
+
isOpenable: (tabId) =>
|
|
671
|
+
resolvedTabs.value.some((tab) => !tab.to && tab.id === tabId),
|
|
672
|
+
});
|
|
673
|
+
|
|
647
674
|
watch(resolvedTabs, (next) => {
|
|
648
675
|
if (next.length === 0) return;
|
|
649
676
|
if (!next.some((tab) => !tab.to && tab.id === activeTabId.value)) {
|
|
@@ -1686,10 +1713,50 @@ const openRecordFromUrl = async () => {
|
|
|
1686
1713
|
idOf: getRowId,
|
|
1687
1714
|
getRow: tableRows.getRow,
|
|
1688
1715
|
});
|
|
1689
|
-
|
|
1690
|
-
|
|
1716
|
+
const outcome = deepLinkOutcome({ row, isMissing }, deepLinkAction);
|
|
1717
|
+
deepLinkOutcomes[outcome](deepLinkAction, id, row as Data);
|
|
1691
1718
|
};
|
|
1692
1719
|
|
|
1720
|
+
const notifyUnresolvedRecordLink = () => {
|
|
1721
|
+
writeRecordId(urlScope);
|
|
1722
|
+
toast.add({
|
|
1723
|
+
title: t("dms.record_link.not_found_title"),
|
|
1724
|
+
description: t("dms.record_link.not_found_description"),
|
|
1725
|
+
color: Color.warning,
|
|
1726
|
+
icon: "i-ph-link-break",
|
|
1727
|
+
});
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
const notifyFailedRecordLink = () => {
|
|
1731
|
+
writeRecordId(urlScope);
|
|
1732
|
+
toast.add({
|
|
1733
|
+
title: t("dms.record_link.failed_title"),
|
|
1734
|
+
description: t("dms.record_link.failed_description"),
|
|
1735
|
+
color: Color.error,
|
|
1736
|
+
icon: "i-ph-warning-circle",
|
|
1737
|
+
});
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
const deepLinkOutcomes: Record<
|
|
1741
|
+
DeepLinkOutcome,
|
|
1742
|
+
(action: CustomRowAction, id: string, row: Data) => void
|
|
1743
|
+
> = {
|
|
1744
|
+
open: (action, _id, row) => handleCustomRowAction(action, row),
|
|
1745
|
+
missingPanel: (action, id) => handleMissingRow(action, id),
|
|
1746
|
+
notify: notifyUnresolvedRecordLink,
|
|
1747
|
+
notifyFailed: notifyFailedRecordLink,
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
// A visit that keeps the page mounted (a link to the same page with another
|
|
1751
|
+
// `?record=`) opens the row it names, as a fresh load would. The table's own
|
|
1752
|
+
// writes replace the URL in place and never reach the route.
|
|
1753
|
+
watch(
|
|
1754
|
+
() => readRecordId(route.query, urlScope),
|
|
1755
|
+
(id, previous) => {
|
|
1756
|
+
if (id && id !== previous) void openRecordFromUrl();
|
|
1757
|
+
},
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1693
1760
|
const selectedIds = computed(() => selectedRowIds(rowSelect.value));
|
|
1694
1761
|
|
|
1695
1762
|
const isRowActionEnabled = (
|
|
@@ -2065,6 +2132,7 @@ onMounted(() => {
|
|
|
2065
2132
|
:footer="resolvedFooter"
|
|
2066
2133
|
:empty-states="props.emptyStates"
|
|
2067
2134
|
:hidden-filters-apply="queryParamHiddenFilters.length > 0"
|
|
2135
|
+
:url-filters="shownUrlFilters"
|
|
2068
2136
|
:default-page-size="props.pageSize"
|
|
2069
2137
|
:archive-toggle="canToggleArchived"
|
|
2070
2138
|
:displays="availableDisplays"
|
|
@@ -2102,6 +2170,7 @@ onMounted(() => {
|
|
|
2102
2170
|
@export-all="handleExportTable"
|
|
2103
2171
|
@bulk-action="runBulkAction"
|
|
2104
2172
|
@duplicate="handleRowDuplicate"
|
|
2173
|
+
@remove-url-filter="removeUrlFilter"
|
|
2105
2174
|
@edit="handleRowEdit"
|
|
2106
2175
|
>
|
|
2107
2176
|
<template
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { formFieldInjectionKey } from "@nuxt/ui/composables/useFormField";
|
|
2
|
+
import { inject, onBeforeUnmount, ref } from "vue";
|
|
3
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/formControlErrors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lets a control refuse what was typed into it (a duration or JSON it cannot
|
|
7
|
+
* read): inside a form, the message becomes the field's error, which blocks
|
|
8
|
+
* the submit and joins the form's summary like any other. `message` holds it
|
|
9
|
+
* for a control used outside a form.
|
|
10
|
+
*
|
|
11
|
+
* Call it in the setup of a field's input component. `report(message)` sets
|
|
12
|
+
* the error, `report(undefined)` clears it; the error is cleared when the
|
|
13
|
+
* component unmounts.
|
|
14
|
+
*/
|
|
15
|
+
export function useControlError() {
|
|
16
|
+
const field = inject(formFieldInjectionKey, undefined);
|
|
17
|
+
const errors = inject(FORM_CONTROL_ERRORS_KEY, null);
|
|
18
|
+
const message = ref<string>();
|
|
19
|
+
|
|
20
|
+
function report(next: string | undefined): void {
|
|
21
|
+
if (message.value === next) return;
|
|
22
|
+
message.value = next;
|
|
23
|
+
const name = field?.value?.name;
|
|
24
|
+
if (name) errors?.report(name, next);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onBeforeUnmount(() => report(undefined));
|
|
28
|
+
|
|
29
|
+
return { message, report };
|
|
30
|
+
}
|
|
@@ -14,6 +14,10 @@ import type { BlockAction } from "../../../build/components/blocks/BlockActions.
|
|
|
14
14
|
export interface QueryParamFilter {
|
|
15
15
|
field: string;
|
|
16
16
|
mode?: string;
|
|
17
|
+
/** Shown as a removable chip while the URL applies it. */
|
|
18
|
+
visible?: boolean;
|
|
19
|
+
/** The chip's name; the field's column label by default. */
|
|
20
|
+
label?: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export type QueryParamFilters = Record<string, QueryParamFilter>;
|
|
@@ -753,6 +753,12 @@
|
|
|
753
753
|
"held_searching": "search active",
|
|
754
754
|
"held_sorted": "sorted otherwise than by default",
|
|
755
755
|
"held_scrolled": "scrolled down"
|
|
756
|
+
},
|
|
757
|
+
"record_link": {
|
|
758
|
+
"not_found_title": "This entry is no longer available",
|
|
759
|
+
"not_found_description": "The link points to an entry that was deleted, or that this list cannot show.",
|
|
760
|
+
"failed_title": "This entry could not be opened",
|
|
761
|
+
"failed_description": "Loading it failed. Try again in a moment."
|
|
756
762
|
}
|
|
757
763
|
}
|
|
758
764
|
}
|
|
@@ -754,6 +754,12 @@
|
|
|
754
754
|
"held_searching": "recherche active",
|
|
755
755
|
"held_sorted": "tri autre que celui par défaut",
|
|
756
756
|
"held_scrolled": "défilé vers le bas"
|
|
757
|
+
},
|
|
758
|
+
"record_link": {
|
|
759
|
+
"not_found_title": "Cette entrée n’est plus disponible",
|
|
760
|
+
"not_found_description": "Le lien mène à une entrée supprimée, ou que cette liste ne peut pas afficher.",
|
|
761
|
+
"failed_title": "Impossible d’ouvrir cette entrée",
|
|
762
|
+
"failed_description": "Son chargement a échoué. Réessayez dans un instant."
|
|
757
763
|
}
|
|
758
764
|
}
|
|
759
765
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as legacy from "../layers/dms-ui/app/build/composables/form/useControlError";
|
|
3
|
+
import { useControlError } from "../layers/dms-ui/app/composables/form/useControlError";
|
|
4
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/formControlErrors";
|
|
5
|
+
|
|
6
|
+
describe("useControlError location", () => {
|
|
7
|
+
it("stays importable from its former build/ path", () => {
|
|
8
|
+
expect(legacy.useControlError).toBe(useControlError);
|
|
9
|
+
expect(legacy.FORM_CONTROL_ERRORS_KEY).toBe(FORM_CONTROL_ERRORS_KEY);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createSSRApp, defineComponent, h } from "vue";
|
|
3
|
+
import { renderToString } from "vue/server-renderer";
|
|
4
|
+
import EmailLayout from "../layers/dms-layout/app/emails/components/EmailLayout.vue";
|
|
5
|
+
import { emailLogoUrl } from "../layers/dms-layout/app/emails/components/index";
|
|
6
|
+
|
|
7
|
+
const PUBLIC_BASE = "https://admin.example.com";
|
|
8
|
+
const ELEMENTS: Record<string, string> = {
|
|
9
|
+
EHtml: "html",
|
|
10
|
+
EHead: "head",
|
|
11
|
+
EBody: "body",
|
|
12
|
+
EContainer: "div",
|
|
13
|
+
ESection: "section",
|
|
14
|
+
EHeading: "h1",
|
|
15
|
+
EImg: "img",
|
|
16
|
+
EHr: "hr",
|
|
17
|
+
EPreview: "span",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function element(tag: string) {
|
|
21
|
+
return defineComponent({
|
|
22
|
+
inheritAttrs: false,
|
|
23
|
+
setup:
|
|
24
|
+
(_, { attrs, slots }) =>
|
|
25
|
+
() =>
|
|
26
|
+
h(tag, { src: attrs.src, alt: attrs.alt }, slots.default?.()),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function renderLayout(
|
|
31
|
+
props: Record<string, unknown>,
|
|
32
|
+
logo = "/images/logo.png",
|
|
33
|
+
clientBaseUrl = PUBLIC_BASE,
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
vi.stubGlobal("useDmsAppConfig", () => ({
|
|
36
|
+
branding: { logo: { default: { light: logo } } },
|
|
37
|
+
}));
|
|
38
|
+
vi.stubGlobal("useDmsRuntimeConfig", () => ({
|
|
39
|
+
public: { dms: { clientBaseUrl } },
|
|
40
|
+
}));
|
|
41
|
+
const app = createSSRApp({
|
|
42
|
+
render: () =>
|
|
43
|
+
h(EmailLayout, props, {
|
|
44
|
+
default: () => h("p", "Body"),
|
|
45
|
+
footer: () => h("small", "Footer"),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
Object.entries(ELEMENTS).forEach(([name, tag]) =>
|
|
49
|
+
app.component(name, element(tag)),
|
|
50
|
+
);
|
|
51
|
+
const html = await renderToString(app);
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
return html;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("EmailLayout", () => {
|
|
57
|
+
it("keeps the heading, the separator and the logo by default", async () => {
|
|
58
|
+
const html = await renderLayout({ title: "Welcome" });
|
|
59
|
+
expect(html).toContain("<h1>Welcome</h1>");
|
|
60
|
+
expect(html).toContain("<hr>");
|
|
61
|
+
expect(html).toContain(
|
|
62
|
+
`<img src="${PUBLIC_BASE}/images/logo.png" alt="Logo">`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("drops the heading without a title and the separator on request", async () => {
|
|
67
|
+
const html = await renderLayout({ divider: false, preheader: "Preview" });
|
|
68
|
+
expect(html).not.toContain("<h1>");
|
|
69
|
+
expect(html).not.toContain("<hr>");
|
|
70
|
+
expect(html).toContain("<span>Preview</span>");
|
|
71
|
+
expect(html).toContain("<p>Body</p>");
|
|
72
|
+
expect(html).toContain("<small>Footer</small>");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("keeps any logo by default", async () => {
|
|
76
|
+
expect(
|
|
77
|
+
await renderLayout({ title: "Hi" }, "/images/logo.svg", ""),
|
|
78
|
+
).toContain('<img src="/images/logo.svg" alt="Logo">');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("leaves out a logo a mail client cannot load with publicLogoOnly", async () => {
|
|
82
|
+
const guarded = { title: "Hi", publicLogoOnly: true };
|
|
83
|
+
expect(await renderLayout(guarded, "/images/logo.svg")).not.toContain(
|
|
84
|
+
"<img",
|
|
85
|
+
);
|
|
86
|
+
expect(
|
|
87
|
+
await renderLayout(guarded, "/logo.png", "http://localhost:3001"),
|
|
88
|
+
).not.toContain("<img");
|
|
89
|
+
expect(await renderLayout(guarded)).toContain(
|
|
90
|
+
`<img src="${PUBLIC_BASE}/images/logo.png" alt="Logo">`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("emailLogoUrl", () => {
|
|
96
|
+
it("resolves a root path against the public base URL", () => {
|
|
97
|
+
expect(emailLogoUrl("/logo.png", PUBLIC_BASE)).toBe(
|
|
98
|
+
`${PUBLIC_BASE}/logo.png`,
|
|
99
|
+
);
|
|
100
|
+
expect(emailLogoUrl("https://cdn.example.com/logo.png", "")).toBe(
|
|
101
|
+
"https://cdn.example.com/logo.png",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
[undefined, PUBLIC_BASE],
|
|
107
|
+
["/logo.svg", PUBLIC_BASE],
|
|
108
|
+
["https://cdn.example.com/logo.SVG?v=2", ""],
|
|
109
|
+
["data:image/png;base64,AAAA", ""],
|
|
110
|
+
["/logo.png", ""],
|
|
111
|
+
["/logo.png", "http://localhost:3001"],
|
|
112
|
+
["/logo.png", "http://dms.localhost"],
|
|
113
|
+
["/logo.png", "http://127.0.0.1:3001"],
|
|
114
|
+
["/logo.png", "http://10.0.0.5"],
|
|
115
|
+
["/logo.png", "http://172.20.1.1"],
|
|
116
|
+
["/logo.png", "http://192.168.1.10"],
|
|
117
|
+
["/logo.png", "http://169.254.169.254"],
|
|
118
|
+
["/logo.png", "http://[::1]:3001"],
|
|
119
|
+
["/logo.png", "http://[fd00::1]"],
|
|
120
|
+
["/logo.png", "http://intranet.internal"],
|
|
121
|
+
])("leaves out %s on %s", (logo, base) => {
|
|
122
|
+
expect(emailLogoUrl(logo, base)).toBe("");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* A page header's buttons follow the page URL: a target URL takes the
|
|
4
4
|
* `{{params.X}}` / `{{query.X}}` tokens of a block's `fetchUrl`, and a
|
|
5
5
|
* button whose availability reads the query is asked for again when the
|
|
6
|
-
* query changes.
|
|
6
|
+
* query changes. The served buttons and a page's own actions
|
|
7
|
+
* (`usePageHeaderActions`) share one order.
|
|
7
8
|
*/
|
|
8
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
10
|
import {
|
|
@@ -23,6 +24,11 @@ import {
|
|
|
23
24
|
layoutUrlWithQuery,
|
|
24
25
|
pageQueryValues,
|
|
25
26
|
} from "../layers/dms-layout/app/utils/pageQuery";
|
|
27
|
+
import {
|
|
28
|
+
providePageHeaderActions,
|
|
29
|
+
usePageHeaderActions,
|
|
30
|
+
type PageHeaderActionsOptions,
|
|
31
|
+
} from "../layers/dms-layout/app/composables/layout/usePageHeaderActions";
|
|
26
32
|
|
|
27
33
|
const handleCustomButton = vi.fn();
|
|
28
34
|
vi.mock("#dms-ui/app/build/composables/actions/useActionTargets", () => ({
|
|
@@ -106,6 +112,7 @@ const mountBar = (actions: unknown[]) =>
|
|
|
106
112
|
);
|
|
107
113
|
|
|
108
114
|
const buttons = () => [...host.querySelectorAll("button")];
|
|
115
|
+
const labels = () => buttons().map((button) => button.textContent);
|
|
109
116
|
|
|
110
117
|
describe("resolveLinkVariables", () => {
|
|
111
118
|
const context = {
|
|
@@ -277,6 +284,33 @@ describe("a header button's availability", () => {
|
|
|
277
284
|
expect(buttons()[0]?.disabled).toBe(false);
|
|
278
285
|
});
|
|
279
286
|
|
|
287
|
+
it("shows a button it hides only for the queries it does not hide it for", async () => {
|
|
288
|
+
const addKey = {
|
|
289
|
+
id: "add-key",
|
|
290
|
+
label: "Add key",
|
|
291
|
+
target: download(false).target,
|
|
292
|
+
};
|
|
293
|
+
authFetch.mockResolvedValue({
|
|
294
|
+
components: {},
|
|
295
|
+
layout: { options: { headerActions: [download(false)] } },
|
|
296
|
+
});
|
|
297
|
+
await mount(
|
|
298
|
+
"../layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue",
|
|
299
|
+
{ actions: [download(false), addKey], servedQuery: { workspace: "web" } },
|
|
300
|
+
);
|
|
301
|
+
expect(labels()).toEqual(["Download", "Add key"]);
|
|
302
|
+
|
|
303
|
+
route.query = { workspace: "modules" };
|
|
304
|
+
await nextTick();
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
306
|
+
expect(authFetch).toHaveBeenCalledWith(`${LAYOUT_URL}?workspace=modules`);
|
|
307
|
+
expect(labels()).toEqual(["Download"]);
|
|
308
|
+
|
|
309
|
+
route.query = { workspace: "web" };
|
|
310
|
+
await nextTick();
|
|
311
|
+
expect(labels()).toEqual(["Download", "Add key"]);
|
|
312
|
+
});
|
|
313
|
+
|
|
280
314
|
it("is never asked again when no button declares one", async () => {
|
|
281
315
|
await mountOutlet(undefined);
|
|
282
316
|
route.query = { workspace: "gone" };
|
|
@@ -284,3 +318,64 @@ describe("a header button's availability", () => {
|
|
|
284
318
|
expect(authFetch).not.toHaveBeenCalled();
|
|
285
319
|
});
|
|
286
320
|
});
|
|
321
|
+
|
|
322
|
+
describe("the order of the header's buttons", () => {
|
|
323
|
+
const served = (id: string, order?: number) => ({
|
|
324
|
+
id,
|
|
325
|
+
label: id,
|
|
326
|
+
target: { type: "page", url: `/${id}` },
|
|
327
|
+
order,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// A layout's page body: the outlet, then the page calling
|
|
331
|
+
// usePageHeaderActions, then the marker saying the page has rendered.
|
|
332
|
+
async function mountPage(
|
|
333
|
+
actions: unknown[],
|
|
334
|
+
options?: PageHeaderActionsOptions,
|
|
335
|
+
) {
|
|
336
|
+
const { default: Outlet } = await import(
|
|
337
|
+
"../layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue"
|
|
338
|
+
);
|
|
339
|
+
const Page = defineComponent({
|
|
340
|
+
setup() {
|
|
341
|
+
usePageHeaderActions(() => h("button", "Export"), options);
|
|
342
|
+
return () => null;
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
const Body = defineComponent({
|
|
346
|
+
setup() {
|
|
347
|
+
const pageHost = providePageHeaderActions();
|
|
348
|
+
const Rendered = defineComponent({
|
|
349
|
+
setup() {
|
|
350
|
+
pageHost.markSlotRendered();
|
|
351
|
+
return () => null;
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
return () => [h(Outlet, { actions }), h(Page), h(Rendered)];
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
app = createApp(Body);
|
|
358
|
+
app.component("UButton", ButtonStub);
|
|
359
|
+
app.component("UTooltip", SlotStub);
|
|
360
|
+
app.mount(host);
|
|
361
|
+
await nextTick();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
it("puts the page's actions after the served buttons by default", async () => {
|
|
365
|
+
await mountPage([served("add-key"), served("add-language")]);
|
|
366
|
+
expect(labels()).toEqual(["add-key", "add-language", "Export"]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("puts them before the served buttons of a greater order", async () => {
|
|
370
|
+
await mountPage([served("add-key")], { order: -1 });
|
|
371
|
+
expect(labels()).toEqual(["Export", "add-key"]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("sorts the served buttons by order, the declared order breaking ties", async () => {
|
|
375
|
+
await mountPage(
|
|
376
|
+
[served("last", 2), served("first"), served("second"), served("mid", 1)],
|
|
377
|
+
{ order: 1 },
|
|
378
|
+
);
|
|
379
|
+
expect(labels()).toEqual(["first", "second", "mid", "Export", "last"]);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
nextTick,
|
|
18
18
|
ref,
|
|
19
19
|
} from "vue";
|
|
20
|
-
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/
|
|
20
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/formControlErrors";
|
|
21
21
|
import {
|
|
22
22
|
ABOVE_MAX_MESSAGE,
|
|
23
23
|
BELOW_MIN_MESSAGE,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
formErrorsInjectionKey,
|
|
17
17
|
formFieldInjectionKey,
|
|
18
18
|
} from "@nuxt/ui/composables/useFormField";
|
|
19
|
-
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/
|
|
19
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/formControlErrors";
|
|
20
20
|
|
|
21
21
|
const formErrors = ref<Array<Record<string, unknown>>>([]);
|
|
22
22
|
const model = ref<string[]>([]);
|