@antelopejs/dms 0.4.2 → 0.4.4
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 +43 -0
- package/dist/pages/settings/users/invites.js +6 -5
- package/dist/pages/settings/users/invites.js.map +1 -1
- package/dist/pages/settings/users/members.d.ts +1 -0
- package/dist/pages/settings/users/members.js +12 -3
- package/dist/pages/settings/users/members.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.js +62 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +36 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js +117 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js +62 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js.map +1 -0
- package/frontend-vue/dms.frontend.ts +2 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +4 -2
- package/frontend-vue/layers/dms-layout/app/composables/general/types/index.ts +7 -0
- package/frontend-vue/layers/dms-layout/app/composables/general/useColorModePreference.ts +17 -0
- package/frontend-vue/layers/dms-layout/app/custom-pages/settings/appearance.vue +6 -6
- package/frontend-vue/layers/dms-layout/app/plugins/color-mode.ts +100 -0
- package/frontend-vue/layers/dms-ui/app/components/Placeholder.vue +17 -5
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +14 -9
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +49 -6
- package/frontend-vue/layers/dms-ui/app/components/form/components/Calendar.vue +49 -26
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +77 -32
- package/frontend-vue/layers/dms-ui/app/components/grid/Grid.vue +15 -5
- package/frontend-vue/layers/dms-ui/app/components/grid/GridRow.vue +7 -4
- package/frontend-vue/layers/dms-ui/app/components/grid/constants.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +14 -9
- package/frontend-vue/layers/dms-ui/app/composables/chart/formatValue.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +4 -2
- package/frontend-vue/layers/dms-ui/app/composables/chart/useThemeRevision.ts +0 -22
- package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +76 -4
- package/frontend-vue/layers/dms-ui/app/composables/tree/useTree.ts +5 -7
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +2 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +2 -0
- package/frontend-vue/package.json +2 -2
- package/frontend-vue/pnpm-lock.yaml +7 -33
- package/frontend-vue/tests/chart-absent-value.test.ts +78 -0
- package/frontend-vue/tests/color-mode-server-render.test.ts +84 -0
- package/frontend-vue/tests/color-mode-sync.test.ts +320 -0
- package/frontend-vue/tests/form-actions.test.ts +27 -0
- package/frontend-vue/tests/form-required-fields.test.ts +44 -0
- package/frontend-vue/tests/form-submit-target.test.ts +28 -0
- package/frontend-vue/tests/tree-without-source.test.ts +79 -0
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import ThemePreview from "../../build/components/pages/settings/theme/ThemePrevi
|
|
|
3
3
|
import ScalePreview from "../../build/components/pages/settings/theme/ScalePreview.vue";
|
|
4
4
|
import CssVariablesList from "../../build/components/pages/settings/theme/CssVariablesList.vue";
|
|
5
5
|
|
|
6
|
-
const colorOptions = [
|
|
6
|
+
const colorOptions: ColorModeOption[] = [
|
|
7
7
|
{
|
|
8
8
|
value: "system",
|
|
9
9
|
label: "$page.settings.appearance.system",
|
|
@@ -36,7 +36,7 @@ const scaleOptions: ScaleOption[] = [
|
|
|
36
36
|
},
|
|
37
37
|
];
|
|
38
38
|
|
|
39
|
-
const
|
|
39
|
+
const colorModePreference = useColorModePreference();
|
|
40
40
|
const interfaceScale = useInterfaceScale();
|
|
41
41
|
const { processI18n } = useTranslation();
|
|
42
42
|
</script>
|
|
@@ -60,13 +60,13 @@ const { processI18n } = useTranslation();
|
|
|
60
60
|
:key="`color-option-${index}`"
|
|
61
61
|
class="group bg-elevated relative flex cursor-pointer flex-col overflow-hidden rounded-xl transition-all"
|
|
62
62
|
:class="
|
|
63
|
-
|
|
63
|
+
colorModePreference === option.value
|
|
64
64
|
? 'outline-primary outline-2 outline-offset-2'
|
|
65
65
|
: 'ring-default hover:ring-accented ring hover:-translate-y-0.5'
|
|
66
66
|
"
|
|
67
67
|
>
|
|
68
68
|
<input
|
|
69
|
-
v-model="
|
|
69
|
+
v-model="colorModePreference"
|
|
70
70
|
type="radio"
|
|
71
71
|
:value="option.value"
|
|
72
72
|
class="sr-only"
|
|
@@ -85,13 +85,13 @@ const { processI18n } = useTranslation();
|
|
|
85
85
|
<span
|
|
86
86
|
class="ring-accented grid size-4 shrink-0 place-items-center rounded-full ring ring-inset"
|
|
87
87
|
:class="
|
|
88
|
-
|
|
88
|
+
colorModePreference === option.value
|
|
89
89
|
? 'bg-primary ring-primary'
|
|
90
90
|
: ''
|
|
91
91
|
"
|
|
92
92
|
>
|
|
93
93
|
<span
|
|
94
|
-
v-if="
|
|
94
|
+
v-if="colorModePreference === option.value"
|
|
95
95
|
class="bg-default size-1.5 rounded-full"
|
|
96
96
|
/>
|
|
97
97
|
</span>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BasicColorSchema,
|
|
3
|
+
useColorMode as useVueUseColorMode,
|
|
4
|
+
} from "@vueuse/core";
|
|
5
|
+
import { type Ref, watch } from "vue";
|
|
6
|
+
import type { ColorModePreference } from "../composables/general/types";
|
|
7
|
+
import {
|
|
8
|
+
COLOR_MODE_COOKIE,
|
|
9
|
+
useColorModePreference,
|
|
10
|
+
} from "../composables/general/useColorModePreference";
|
|
11
|
+
|
|
12
|
+
type ExplicitColorMode = Exclude<ColorModePreference, "system">;
|
|
13
|
+
|
|
14
|
+
/** The preferences that pin their class on `<html>`; any other follows the system. */
|
|
15
|
+
const EXPLICIT_COLOR_MODES: ExplicitColorMode[] = ["light", "dark"];
|
|
16
|
+
const SYSTEM_PREFERENCE: ColorModePreference = "system";
|
|
17
|
+
const VUEUSE_SYSTEM_MODE: BasicColorSchema = "auto";
|
|
18
|
+
/**
|
|
19
|
+
* vueuse's default key. Nuxt UI's `useDark()`, installed with the app, and its
|
|
20
|
+
* color-mode components (`UDashboardSearch`'s theme group…) all read it.
|
|
21
|
+
*/
|
|
22
|
+
const VUEUSE_STORAGE_KEY = "vueuse-color-scheme";
|
|
23
|
+
const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
|
|
24
|
+
const PRE_PAINT_SCRIPT_ID = "dms-color-mode";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs in `<head>` before first paint, and so before the entry module installs
|
|
28
|
+
* Nuxt UI: it hands vueuse the cookie's mode, which `useDark()` reads on boot,
|
|
29
|
+
* and sets the `<html>` class, resolving `system` itself. The cookie is read
|
|
30
|
+
* the way `useDmsCookie` writes it (URI-encoded JSON), falling back to the raw
|
|
31
|
+
* value. Source text rather than a serialized function, which a bundler could
|
|
32
|
+
* fill with helpers the page does not have.
|
|
33
|
+
*/
|
|
34
|
+
const PRE_PAINT_SCRIPT = `(() => {
|
|
35
|
+
const modes = ${JSON.stringify(EXPLICIT_COLOR_MODES)};
|
|
36
|
+
const prefix = ${JSON.stringify(`${COLOR_MODE_COOKIE}=`)};
|
|
37
|
+
const entry = document.cookie.split("; ").find((cookie) => cookie.startsWith(prefix)) ?? "";
|
|
38
|
+
let preference = "";
|
|
39
|
+
try {
|
|
40
|
+
preference = decodeURIComponent(entry.slice(prefix.length));
|
|
41
|
+
preference = JSON.parse(preference);
|
|
42
|
+
} catch {}
|
|
43
|
+
const mode = modes.includes(preference) ? preference : ${JSON.stringify(VUEUSE_SYSTEM_MODE)};
|
|
44
|
+
try {
|
|
45
|
+
localStorage.setItem(${JSON.stringify(VUEUSE_STORAGE_KEY)}, mode);
|
|
46
|
+
} catch {}
|
|
47
|
+
const system = matchMedia(${JSON.stringify(DARK_SCHEME_QUERY)}).matches ? "dark" : "light";
|
|
48
|
+
document.documentElement.classList.remove(...modes);
|
|
49
|
+
document.documentElement.classList.add(modes.includes(mode) ? mode : system);
|
|
50
|
+
})();`;
|
|
51
|
+
|
|
52
|
+
function explicitColorMode(value: string): ExplicitColorMode | undefined {
|
|
53
|
+
return EXPLICIT_COLOR_MODES.find((mode) => mode === value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mirrors the cookie, the source of truth, into the vueuse store Nuxt UI
|
|
58
|
+
* shares, and a change made from a Nuxt UI component back into the cookie.
|
|
59
|
+
* Each side is written only when it would change, so neither echoes the other.
|
|
60
|
+
*/
|
|
61
|
+
function syncWithVueUse(preference: Ref<ColorModePreference>): void {
|
|
62
|
+
const { store } = useVueUseColorMode({ storageKey: VUEUSE_STORAGE_KEY });
|
|
63
|
+
watch(
|
|
64
|
+
preference,
|
|
65
|
+
(next) => {
|
|
66
|
+
const mode = explicitColorMode(next) ?? VUEUSE_SYSTEM_MODE;
|
|
67
|
+
if (store.value !== mode) store.value = mode;
|
|
68
|
+
},
|
|
69
|
+
{ immediate: true },
|
|
70
|
+
);
|
|
71
|
+
watch(store, (next) => {
|
|
72
|
+
const nextPreference = explicitColorMode(next) ?? SYSTEM_PREFERENCE;
|
|
73
|
+
if (preference.value !== nextPreference) preference.value = nextPreference;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The server renders an explicit preference's class, so the first paint is
|
|
79
|
+
* right before any script runs, and the pre-paint script, which only matters
|
|
80
|
+
* before that paint: inserted later by the browser head it would run after
|
|
81
|
+
* boot and redo what the sync already did. In the browser the class belongs to
|
|
82
|
+
* vueuse, which Nuxt UI already drives it with: were unhead tracking it as
|
|
83
|
+
* well, it would remove it when `dark` gives way to a `system` that still
|
|
84
|
+
* resolves dark, and vueuse, seeing no change, would not put it back. A page
|
|
85
|
+
* rendered without the script (client-side fallback) is corrected by the sync,
|
|
86
|
+
* which applies the cookie on boot.
|
|
87
|
+
*/
|
|
88
|
+
export default defineDmsPlugin(() => {
|
|
89
|
+
const preference = useColorModePreference();
|
|
90
|
+
|
|
91
|
+
if (!import.meta.env.SSR) {
|
|
92
|
+
syncWithVueUse(preference);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const mode = explicitColorMode(preference.value);
|
|
96
|
+
useHead({
|
|
97
|
+
htmlAttrs: mode ? { class: mode } : {},
|
|
98
|
+
script: [{ id: PRE_PAINT_SCRIPT_ID, innerHTML: PRE_PAINT_SCRIPT }],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -5,19 +5,31 @@ interface PlaceholderProps {
|
|
|
5
5
|
width?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** What a box nobody sized is worth showing at, when nothing stretches it. */
|
|
9
|
+
const MIN_HEIGHT = "120px";
|
|
10
|
+
|
|
8
11
|
const props = withDefaults(defineProps<PlaceholderProps>(), {
|
|
9
12
|
label: undefined,
|
|
10
|
-
height:
|
|
13
|
+
height: undefined,
|
|
11
14
|
width: undefined,
|
|
12
15
|
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A placeholder stands in for content still to come, so it takes the room it is
|
|
19
|
+
* given: a height of its own would keep it short in a row sized by its tallest
|
|
20
|
+
* cell, and a layout laid out with these could not be read for what it will be.
|
|
21
|
+
* A given height still wins, and a floor keeps a lone one visible.
|
|
22
|
+
*/
|
|
23
|
+
const box = computed(() => ({
|
|
24
|
+
height: props.height,
|
|
25
|
+
minHeight: props.height ? undefined : MIN_HEIGHT,
|
|
26
|
+
width: props.width,
|
|
27
|
+
}));
|
|
13
28
|
</script>
|
|
14
29
|
|
|
15
30
|
<template>
|
|
16
31
|
<div
|
|
17
|
-
:style="
|
|
18
|
-
height: props.height,
|
|
19
|
-
width: props.width,
|
|
20
|
-
}"
|
|
32
|
+
:style="box"
|
|
21
33
|
class="border-accented relative flex items-center justify-center overflow-hidden rounded-md border border-dashed px-4 opacity-75"
|
|
22
34
|
>
|
|
23
35
|
<svg
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, provide, reactive, watchEffect } from "vue";
|
|
3
3
|
import { useChartFetch } from "../../composables/chart/useChartFetch";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ABSENT_VALUE_TEXT,
|
|
6
|
+
formatValue,
|
|
7
|
+
} from "../../composables/chart/formatValue";
|
|
5
8
|
import { resolveChartColor } from "../../composables/chart/useChartTheme";
|
|
6
9
|
import { useThemeRevision } from "../../composables/chart/useThemeRevision";
|
|
7
10
|
import {
|
|
@@ -64,7 +67,7 @@ const { data, isLoading } = useChartFetch<ChartCardResponse>({
|
|
|
64
67
|
watchSource: () => watchKey.value,
|
|
65
68
|
});
|
|
66
69
|
|
|
67
|
-
const value = computed(() => data.value?.value
|
|
70
|
+
const value = computed(() => data.value?.value);
|
|
68
71
|
const previousValue = computed(() => data.value?.previousValue);
|
|
69
72
|
const delta = computed(() => data.value?.delta ?? null);
|
|
70
73
|
|
|
@@ -87,13 +90,15 @@ const cardComparisonSeries = computed<ChartSeries[]>(() =>
|
|
|
87
90
|
);
|
|
88
91
|
|
|
89
92
|
const formatted = computed(() =>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
value.value === undefined
|
|
94
|
+
? ABSENT_VALUE_TEXT
|
|
95
|
+
: formatValue(
|
|
96
|
+
value.value,
|
|
97
|
+
props.valueFormat,
|
|
98
|
+
locale.value,
|
|
99
|
+
props.currencyCode,
|
|
100
|
+
props.valuePrecision,
|
|
101
|
+
),
|
|
97
102
|
);
|
|
98
103
|
|
|
99
104
|
const previousFormatted = computed(() =>
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { FormProps } from "../../composables/form/types";
|
|
3
3
|
import type { FormFieldValue } from "../../composables/form/types/value";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
cloneFormValue,
|
|
6
|
+
formShowsActions,
|
|
7
|
+
isFieldMarkedRequired,
|
|
8
|
+
} from "../../composables/form/useForm";
|
|
5
9
|
import {
|
|
6
10
|
FORM_VALIDATOR_KEY,
|
|
7
11
|
type FormValidator,
|
|
@@ -17,7 +21,12 @@ const REALTIME_ROW_TOPIC_PREFIX = "tableview:row:";
|
|
|
17
21
|
const REALTIME_GET_SEGMENT = "/get";
|
|
18
22
|
const REALTIME_EVENT_UPDATED = "updated";
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
// `showActions` left out has to read as left out: Vue casts an absent boolean
|
|
25
|
+
// prop to `false`, which would hide the buttons of every form not asking for
|
|
26
|
+
// them.
|
|
27
|
+
const props = withDefaults(defineProps<FormProps>(), {
|
|
28
|
+
showActions: undefined,
|
|
29
|
+
});
|
|
21
30
|
const form = useTemplateRef("form");
|
|
22
31
|
|
|
23
32
|
// Inside a DynamicModal/DynamicDrawer the container already provides the
|
|
@@ -38,15 +47,13 @@ const {
|
|
|
38
47
|
allFields,
|
|
39
48
|
disabledFields,
|
|
40
49
|
hiddenFields,
|
|
50
|
+
requiredFields,
|
|
41
51
|
isFieldGroup,
|
|
42
52
|
submitSucceeded,
|
|
43
53
|
resolvedFetchUrl,
|
|
44
54
|
} = useForm(props);
|
|
45
55
|
|
|
46
|
-
const showActions = computed(
|
|
47
|
-
() =>
|
|
48
|
-
!!props.submitUrl && !toValue(allFields).every((field) => field.disabled),
|
|
49
|
-
);
|
|
56
|
+
const showActions = computed(() => formShowsActions(props, toValue(allFields)));
|
|
50
57
|
|
|
51
58
|
const { addGuard } = useLeaveGuard();
|
|
52
59
|
const { confirm } = useConfirm();
|
|
@@ -200,6 +207,23 @@ function isGroupVisible(group: { fields: FormField[] }): boolean {
|
|
|
200
207
|
return group.fields.some((field) => !isFieldHidden(field));
|
|
201
208
|
}
|
|
202
209
|
|
|
210
|
+
function isFieldRequired(field: FormField): boolean {
|
|
211
|
+
return isFieldMarkedRequired(
|
|
212
|
+
field,
|
|
213
|
+
disabledFields.value,
|
|
214
|
+
hiddenFields.value,
|
|
215
|
+
requiredFields.value,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isGroupRequired(group: { fields: FormField[] }): boolean {
|
|
220
|
+
return group.fields.some(isFieldRequired);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const hasRequiredFields = computed(() =>
|
|
224
|
+
toValue(allFields).some(isFieldRequired),
|
|
225
|
+
);
|
|
226
|
+
|
|
203
227
|
function shouldShowDisplay(field: FormField): boolean {
|
|
204
228
|
return isFieldDisabled(field) && !!field.type;
|
|
205
229
|
}
|
|
@@ -379,6 +403,13 @@ onUnmounted(async () => {
|
|
|
379
403
|
class="text-default block text-base font-semibold sm:text-sm"
|
|
380
404
|
>
|
|
381
405
|
{{ processI18n(item.label || "") }}
|
|
406
|
+
<span
|
|
407
|
+
v-if="isGroupRequired(item)"
|
|
408
|
+
class="text-error ms-0.5"
|
|
409
|
+
aria-hidden="true"
|
|
410
|
+
>
|
|
411
|
+
*
|
|
412
|
+
</span>
|
|
382
413
|
</label>
|
|
383
414
|
<p v-if="item.description" class="text-dimmed text-xs">
|
|
384
415
|
{{ processI18n(item.description || "") }}
|
|
@@ -453,6 +484,13 @@ onUnmounted(async () => {
|
|
|
453
484
|
class="text-default block text-base font-semibold sm:text-sm"
|
|
454
485
|
>
|
|
455
486
|
{{ processI18n(item.label || "") }}
|
|
487
|
+
<span
|
|
488
|
+
v-if="isFieldRequired(item)"
|
|
489
|
+
class="text-error ms-0.5"
|
|
490
|
+
aria-hidden="true"
|
|
491
|
+
>
|
|
492
|
+
*
|
|
493
|
+
</span>
|
|
456
494
|
</label>
|
|
457
495
|
<p v-if="item.description" class="text-dimmed text-xs">
|
|
458
496
|
{{ processI18n(item.description || "") }}
|
|
@@ -509,6 +547,11 @@ onUnmounted(async () => {
|
|
|
509
547
|
</template>
|
|
510
548
|
</div>
|
|
511
549
|
|
|
550
|
+
<p v-if="hasRequiredFields" class="text-dimmed mt-6 text-xs">
|
|
551
|
+
<span class="text-error" aria-hidden="true">*</span>
|
|
552
|
+
{{ $t("dms.form.required_legend") }}
|
|
553
|
+
</p>
|
|
554
|
+
|
|
512
555
|
<template v-if="showActions">
|
|
513
556
|
<section class="mt-6 flex justify-end gap-2">
|
|
514
557
|
<UButton
|
|
@@ -11,27 +11,39 @@ import {
|
|
|
11
11
|
type CalendarDate,
|
|
12
12
|
} from "@internationalized/date";
|
|
13
13
|
|
|
14
|
+
// Mid-selection, the range calendar emits a range whose end is still unset.
|
|
14
15
|
interface StrictDateRange {
|
|
15
|
-
start: CalendarDate;
|
|
16
|
-
end: CalendarDate;
|
|
16
|
+
start: CalendarDate | undefined;
|
|
17
|
+
end: CalendarDate | undefined;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
interface StringDateRange {
|
|
20
|
-
start: string;
|
|
21
|
-
end: string;
|
|
21
|
+
start: string | undefined;
|
|
22
|
+
end: string | undefined;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
interface NativeDateRange {
|
|
25
|
-
start: Date;
|
|
26
|
-
end: Date;
|
|
26
|
+
start: Date | undefined;
|
|
27
|
+
end: Date | undefined;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
type DateValue =
|
|
30
|
+
type DateValue =
|
|
31
|
+
| CalendarDate
|
|
32
|
+
| StrictDateRange
|
|
33
|
+
| string
|
|
34
|
+
| (string | Date | CalendarDate)[]
|
|
35
|
+
| null
|
|
36
|
+
| undefined;
|
|
30
37
|
|
|
31
38
|
const props = defineProps<CalendarProps<R, M>>();
|
|
32
39
|
const emits = defineEmits<{
|
|
33
40
|
"update:modelValue": [
|
|
34
|
-
value:
|
|
41
|
+
value:
|
|
42
|
+
| string
|
|
43
|
+
| { start: string | undefined; end: string | undefined }
|
|
44
|
+
| string[]
|
|
45
|
+
| null
|
|
46
|
+
| undefined,
|
|
35
47
|
];
|
|
36
48
|
"update:placeholder": [date: CalendarDate];
|
|
37
49
|
"update:startValue": [date: CalendarDate | undefined];
|
|
@@ -42,22 +54,25 @@ function convertIsoStringToCalendarDate(value: string): CalendarDate {
|
|
|
42
54
|
return parseDate(value.split("T")[0]!);
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
):
|
|
48
|
-
if (isString(
|
|
49
|
-
return
|
|
50
|
-
start: convertIsoStringToCalendarDate(rangeValue.start),
|
|
51
|
-
end: convertIsoStringToCalendarDate(rangeValue.end),
|
|
52
|
-
};
|
|
57
|
+
function convertToCalendarDate(
|
|
58
|
+
value: string | Date | CalendarDate | undefined,
|
|
59
|
+
): CalendarDate | undefined {
|
|
60
|
+
if (isString(value)) {
|
|
61
|
+
return convertIsoStringToCalendarDate(value);
|
|
53
62
|
}
|
|
54
|
-
if (
|
|
55
|
-
return
|
|
56
|
-
start: toCalendarDate(fromDate(rangeValue.start, "UTC")),
|
|
57
|
-
end: toCalendarDate(fromDate(rangeValue.end, "UTC")),
|
|
58
|
-
};
|
|
63
|
+
if (value instanceof Date) {
|
|
64
|
+
return toCalendarDate(fromDate(value, "UTC"));
|
|
59
65
|
}
|
|
60
|
-
return
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function convertDateRangeValue(
|
|
70
|
+
rangeValue: StringDateRange | StrictDateRange | NativeDateRange,
|
|
71
|
+
): StrictDateRange {
|
|
72
|
+
return {
|
|
73
|
+
start: convertToCalendarDate(rangeValue.start),
|
|
74
|
+
end: convertToCalendarDate(rangeValue.end),
|
|
75
|
+
};
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
function isDateRangeValue(
|
|
@@ -73,7 +88,10 @@ function convertModelValue(value: DateValue): DateValue {
|
|
|
73
88
|
return convertIsoStringToCalendarDate(value);
|
|
74
89
|
}
|
|
75
90
|
if (isDateRangeValue(value)) {
|
|
76
|
-
return convertDateRangeValue(value)
|
|
91
|
+
return convertDateRangeValue(value);
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
return value.map(convertToCalendarDate) as CalendarDate[];
|
|
77
95
|
}
|
|
78
96
|
return value;
|
|
79
97
|
}
|
|
@@ -88,7 +106,12 @@ type EmitValue = unknown;
|
|
|
88
106
|
|
|
89
107
|
const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
|
|
90
108
|
if (event === "update:modelValue" && value) {
|
|
91
|
-
if (
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
const isoStrings = (value as CalendarDate[]).map((date) =>
|
|
111
|
+
date.toString(),
|
|
112
|
+
);
|
|
113
|
+
emits("update:modelValue", isoStrings);
|
|
114
|
+
} else if (
|
|
92
115
|
isObject(value) &&
|
|
93
116
|
"year" in value &&
|
|
94
117
|
"month" in value &&
|
|
@@ -98,8 +121,8 @@ const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
|
|
|
98
121
|
emits("update:modelValue", isoString);
|
|
99
122
|
} else if (isObject(value) && "start" in value && "end" in value) {
|
|
100
123
|
const dateRange = value as unknown as StrictDateRange;
|
|
101
|
-
const startIso = dateRange.start
|
|
102
|
-
const endIso = dateRange.end
|
|
124
|
+
const startIso = dateRange.start?.toString();
|
|
125
|
+
const endIso = dateRange.end?.toString();
|
|
103
126
|
emits("update:modelValue", { start: startIso, end: endIso });
|
|
104
127
|
} else {
|
|
105
128
|
emits("update:modelValue", value as string | null | undefined);
|
|
@@ -13,29 +13,41 @@ import {
|
|
|
13
13
|
} from "@internationalized/date";
|
|
14
14
|
import { reactivePick } from "@vueuse/core";
|
|
15
15
|
|
|
16
|
+
// Mid-selection, the range calendar emits a range whose end is still unset.
|
|
16
17
|
interface StrictDateRange {
|
|
17
|
-
start: CalendarDate;
|
|
18
|
-
end: CalendarDate;
|
|
18
|
+
start: CalendarDate | undefined;
|
|
19
|
+
end: CalendarDate | undefined;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
interface StringDateRange {
|
|
22
|
-
start: string;
|
|
23
|
-
end: string;
|
|
23
|
+
start: string | undefined;
|
|
24
|
+
end: string | undefined;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
interface NativeDateRange {
|
|
27
|
-
start: Date;
|
|
28
|
-
end: Date;
|
|
28
|
+
start: Date | undefined;
|
|
29
|
+
end: Date | undefined;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
type DateValue =
|
|
32
|
+
type DateValue =
|
|
33
|
+
| CalendarDate
|
|
34
|
+
| StrictDateRange
|
|
35
|
+
| string
|
|
36
|
+
| (string | Date | CalendarDate)[]
|
|
37
|
+
| null
|
|
38
|
+
| undefined;
|
|
32
39
|
|
|
33
40
|
type DatePickerProps = Omit<CalendarProps<R, M>, "modelValue">;
|
|
34
41
|
|
|
35
42
|
const props = defineProps<DatePickerProps>();
|
|
36
43
|
const emits = defineEmits<{
|
|
37
44
|
"update:modelValue": [
|
|
38
|
-
value:
|
|
45
|
+
value:
|
|
46
|
+
| string
|
|
47
|
+
| { start: string | undefined; end: string | undefined }
|
|
48
|
+
| string[]
|
|
49
|
+
| null
|
|
50
|
+
| undefined,
|
|
39
51
|
];
|
|
40
52
|
"update:placeholder": [date: CalendarDate];
|
|
41
53
|
"update:startValue": [date: CalendarDate | undefined];
|
|
@@ -50,22 +62,25 @@ function convertIsoStringToCalendarDate(value: string): CalendarDate {
|
|
|
50
62
|
return parseDate(value.split("T")[0]!);
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
):
|
|
56
|
-
if (isString(
|
|
57
|
-
return
|
|
58
|
-
start: convertIsoStringToCalendarDate(rangeValue.start),
|
|
59
|
-
end: convertIsoStringToCalendarDate(rangeValue.end),
|
|
60
|
-
};
|
|
65
|
+
function convertToCalendarDate(
|
|
66
|
+
value: string | Date | CalendarDate | undefined,
|
|
67
|
+
): CalendarDate | undefined {
|
|
68
|
+
if (isString(value)) {
|
|
69
|
+
return convertIsoStringToCalendarDate(value);
|
|
61
70
|
}
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
64
|
-
start: toCalendarDate(fromDate(rangeValue.start, "UTC")),
|
|
65
|
-
end: toCalendarDate(fromDate(rangeValue.end, "UTC")),
|
|
66
|
-
};
|
|
71
|
+
if (value instanceof Date) {
|
|
72
|
+
return toCalendarDate(fromDate(value, "UTC"));
|
|
67
73
|
}
|
|
68
|
-
return
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function convertDateRangeValue(
|
|
78
|
+
rangeValue: StringDateRange | StrictDateRange | NativeDateRange,
|
|
79
|
+
): StrictDateRange {
|
|
80
|
+
return {
|
|
81
|
+
start: convertToCalendarDate(rangeValue.start),
|
|
82
|
+
end: convertToCalendarDate(rangeValue.end),
|
|
83
|
+
};
|
|
69
84
|
}
|
|
70
85
|
|
|
71
86
|
function isDateRangeValue(
|
|
@@ -81,7 +96,10 @@ function convertModelValue(value: DateValue): DateValue {
|
|
|
81
96
|
return convertIsoStringToCalendarDate(value);
|
|
82
97
|
}
|
|
83
98
|
if (isDateRangeValue(value)) {
|
|
84
|
-
return convertDateRangeValue(value)
|
|
99
|
+
return convertDateRangeValue(value);
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
return value.map(convertToCalendarDate) as CalendarDate[];
|
|
85
103
|
}
|
|
86
104
|
return value;
|
|
87
105
|
}
|
|
@@ -96,7 +114,12 @@ type EmitValue = unknown;
|
|
|
96
114
|
|
|
97
115
|
const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
|
|
98
116
|
if (event === "update:modelValue" && value) {
|
|
99
|
-
if (
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
const isoStrings = (value as CalendarDate[]).map((date) =>
|
|
119
|
+
date.toString(),
|
|
120
|
+
);
|
|
121
|
+
emits("update:modelValue", isoStrings);
|
|
122
|
+
} else if (
|
|
100
123
|
isObject(value) &&
|
|
101
124
|
"year" in value &&
|
|
102
125
|
"month" in value &&
|
|
@@ -106,8 +129,8 @@ const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
|
|
|
106
129
|
emits("update:modelValue", isoString);
|
|
107
130
|
} else if (isObject(value) && "start" in value && "end" in value) {
|
|
108
131
|
const dateRange = value as unknown as StrictDateRange;
|
|
109
|
-
const startIso = dateRange.start
|
|
110
|
-
const endIso = dateRange.end
|
|
132
|
+
const startIso = dateRange.start?.toString();
|
|
133
|
+
const endIso = dateRange.end?.toString();
|
|
111
134
|
emits("update:modelValue", { start: startIso, end: endIso });
|
|
112
135
|
} else {
|
|
113
136
|
emits("update:modelValue", value as string | null | undefined);
|
|
@@ -123,18 +146,40 @@ const forwarded = useForwardPropsEmits(
|
|
|
123
146
|
wrappedEmits as typeof emits,
|
|
124
147
|
);
|
|
125
148
|
const buttonProps = reactivePick(props, "disabled");
|
|
149
|
+
|
|
150
|
+
function formatDateLabel(value: unknown): string | undefined {
|
|
151
|
+
if (value === undefined || value === null) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
// A CalendarDate stringifies to its ISO date.
|
|
155
|
+
const date = value instanceof Date ? value : String(value);
|
|
156
|
+
return formatDate(date, locale.value) ?? undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Range mode holds a { start, end } object and multiple mode an array, which
|
|
160
|
+
// formatDate cannot read as a single date.
|
|
161
|
+
const label = computed(() => {
|
|
162
|
+
const value: unknown = modelValue.value;
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
return value.map(formatDateLabel).filter(Boolean).join(", ") || undefined;
|
|
165
|
+
}
|
|
166
|
+
if (isDateRangeValue(value)) {
|
|
167
|
+
return (
|
|
168
|
+
[formatDateLabel(value.start), formatDateLabel(value.end)]
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.join(" – ") || undefined
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return formatDateLabel(value);
|
|
174
|
+
});
|
|
126
175
|
</script>
|
|
127
176
|
|
|
128
177
|
<template>
|
|
129
178
|
<UPopover>
|
|
130
179
|
<UButton
|
|
131
|
-
:label="
|
|
132
|
-
modelValue
|
|
133
|
-
? (formatDate(modelValue, locale) ?? undefined)
|
|
134
|
-
: t('dms.form.select_date')
|
|
135
|
-
"
|
|
180
|
+
:label="label ?? t('dms.form.select_date')"
|
|
136
181
|
:ui="{
|
|
137
|
-
label:
|
|
182
|
+
label: label ? 'text-default' : 'text-dimmed',
|
|
138
183
|
}"
|
|
139
184
|
v-bind="buttonProps"
|
|
140
185
|
variant="outline"
|
|
@@ -13,19 +13,29 @@ const props = withDefaults(defineProps<GridProps>(), {
|
|
|
13
13
|
minColumnWidth: GRID_DEFAULT_MIN_COLUMN_WIDTH,
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Keyed by row rather than appended to: a list that only grows keeps counting
|
|
18
|
+
* rows that have gone and widths that were never asked for, so the grid ends up
|
|
19
|
+
* reserving columns nothing fills.
|
|
20
|
+
*/
|
|
21
|
+
const rowColumnCounts = ref(new Map<symbol, number>());
|
|
17
22
|
|
|
18
23
|
const maxColumns = computed(() => {
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
const counts = [...rowColumnCounts.value.values()].filter(
|
|
25
|
+
(count) => count > 0,
|
|
26
|
+
);
|
|
27
|
+
return counts.length === 0 ? 1 : Math.max(...counts);
|
|
21
28
|
});
|
|
22
29
|
|
|
23
30
|
const gapRef = computed(() => props.gap);
|
|
24
31
|
const minColumnWidthRef = computed(() => props.minColumnWidth);
|
|
25
32
|
|
|
26
33
|
provide<GridContext>(GRID_CONTEXT, {
|
|
27
|
-
|
|
28
|
-
rowColumnCounts.value.
|
|
34
|
+
setRowColumnCount: (row: symbol, columnCount: number) => {
|
|
35
|
+
rowColumnCounts.value.set(row, columnCount);
|
|
36
|
+
},
|
|
37
|
+
dropRow: (row: symbol) => {
|
|
38
|
+
rowColumnCounts.value.delete(row);
|
|
29
39
|
},
|
|
30
40
|
maxColumns,
|
|
31
41
|
gap: gapRef,
|