From c8beae423592f6cd1ebd32b889884125fd3ad815 Mon Sep 17 00:00:00 2001 From: Lars Rickert Date: Thu, 5 Dec 2024 08:00:48 +0100 Subject: [PATCH] feat(OnyxDatePicker): implement `min` and `max` property (#2196) Relates to #1818 Also fix the bug where the value is always `undefined`, when using `type="datetime-local"` by removing the usage of `valueAsDate` which does not work with date+time. --- .changeset/stupid-bottles-clap.md | 7 + .../OnyxDatePicker/OnyxDatePicker.ct.tsx | 42 ++++++ .../OnyxDatePicker/OnyxDatePicker.stories.ts | 24 +++- .../OnyxDatePicker/OnyxDatePicker.vue | 20 ++- .../src/components/OnyxDatePicker/types.ts | 10 ++ .../src/composables/useCustomValidity.spec.ts | 123 +++++++++++++++++- .../src/composables/useCustomValidity.ts | 33 ++++- 7 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 .changeset/stupid-bottles-clap.md diff --git a/.changeset/stupid-bottles-clap.md b/.changeset/stupid-bottles-clap.md new file mode 100644 index 0000000000..9adbc47866 --- /dev/null +++ b/.changeset/stupid-bottles-clap.md @@ -0,0 +1,7 @@ +--- +"sit-onyx": minor +--- + +feat(OnyxDatePicker): implement `min` and `max` property + +Also fix the bug where the value is always `undefined`, when using `type="datetime-local"` diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx index 2a0532a768..c79783ad09 100644 --- a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx +++ b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.ct.tsx @@ -64,3 +64,45 @@ test("should emit events", async ({ mount, makeAxeBuilder }) => { updateModelValue: ["2024-11-25T00:00:00.000Z"], }); }); + +test("should show min errors", async ({ mount }) => { + // ARRANGE + const component = await mount( + , + ); + + await expect(component).toBeVisible(); + + // error is only shown after interaction so we need to interact first to see the error + const input = component.getByLabel("Label"); + await input.click(); + await input.blur(); + + await expect(component).toContainText("Too low"); + await expect(component).toContainText("Value must be greater than or equal to 12/10/2024"); +}); + +test("should show max errors", async ({ mount }) => { + // ARRANGE + const component = await mount( + , + ); + + await expect(component).toBeVisible(); + + // error is only shown after interaction so we need to interact first to see the error + const input = component.getByLabel("Label"); + await input.click(); + await input.blur(); + + await expect(component).toContainText("Too high"); + await expect(component).toContainText("Value must be less than or equal to 12/06/2024"); +}); diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts index 3a5437dda3..4bafdf3200 100644 --- a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts +++ b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.stories.ts @@ -2,6 +2,10 @@ import { withNativeEventLogging } from "@sit-onyx/storybook-utils"; import type { Meta, StoryObj } from "@storybook/vue3"; import OnyxDatePicker from "./OnyxDatePicker.vue"; +/** + * The DatePicker component can be used to select a date or date + time. + * **Note**: For now, the calendar flyout will use the native browser calendar. This might be replaced with a custom implementation in the future. + */ const meta: Meta = { title: "Form Elements/DatePicker", component: OnyxDatePicker, @@ -13,13 +17,16 @@ const meta: Meta = { ], argTypes: { ...withNativeEventLogging(["onInput", "onChange", "onFocusin", "onFocusout"]), + modelValue: { control: { type: "text" } }, + min: { control: { type: "date" } }, + max: { control: { type: "date" } }, }, }; export default meta; type Story = StoryObj; -export const Date = { +export const Default = { args: { label: "Date", }, @@ -31,3 +38,18 @@ export const Datetime = { type: "datetime-local", }, } satisfies Story; + +export const MinAndMaxDate = { + args: { + label: "With min. and max. date", + type: "datetime-local", + min: getRelativeDate(-3), + max: getRelativeDate(3), + }, +} satisfies Story; + +function getRelativeDate(offsetDays: number) { + const date = new Date(); + date.setDate(date.getDate() + offsetDays); + return date; +} diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue index f9889df7b8..7c21126894 100644 --- a/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue +++ b/packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue @@ -63,11 +63,16 @@ const getNormalizedDate = computed(() => { }; }); -const handleInput = (event: Event) => { - const input = event.target as HTMLInputElement; - const newValue = input.valueAsDate; - emit("update:modelValue", newValue?.toISOString()); -}; +/** + * Current value (with getter and setter) that can be used as "v-model" for the native input. + */ +const value = computed({ + get: () => getNormalizedDate.value(props.modelValue), + set: (value) => { + const newDate = new Date(value ?? ""); + emit("update:modelValue", isValidDate(newDate) ? newDate.toISOString() : undefined); + }, +}); diff --git a/packages/sit-onyx/src/components/OnyxDatePicker/types.ts b/packages/sit-onyx/src/components/OnyxDatePicker/types.ts index a6c978cf32..b50f5670fd 100644 --- a/packages/sit-onyx/src/components/OnyxDatePicker/types.ts +++ b/packages/sit-onyx/src/components/OnyxDatePicker/types.ts @@ -20,6 +20,16 @@ export type OnyxDatePickerProps = Omit< * Whether the user should be able to select only date or date + time. */ type?: "date" | "datetime-local"; + /** + * Min. / earliest selectable date (inclusive). + * When using `type="datetime-local"`, the user can still select a invalid time but the datepicker will show an error. + */ + min?: DateValue; + /** + * Max. / latest selectable date (inclusive). + * When using `type="datetime-local"`, the user can still select a invalid time but the datepicker will show an error. + */ + max?: DateValue; }; /** Data types that are parsable as date via `new Date()`. */ diff --git a/packages/sit-onyx/src/composables/useCustomValidity.spec.ts b/packages/sit-onyx/src/composables/useCustomValidity.spec.ts index 8ed1acf6ca..2366d3d039 100644 --- a/packages/sit-onyx/src/composables/useCustomValidity.spec.ts +++ b/packages/sit-onyx/src/composables/useCustomValidity.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, test, vi } from "vitest"; -import { nextTick, reactive } from "vue"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { nextTick, reactive, ref } from "vue"; import { useCustomValidity, type InputValidationElement, @@ -11,6 +11,7 @@ const tFunctionMock = vi.fn(); vi.mock("../i18n", () => ({ injectI18n: () => ({ t: { value: tFunctionMock }, + locale: ref("en-US"), }), })); @@ -29,9 +30,11 @@ const getDefaultValidityState = (): ValidityState => ({ }); describe("useCustomValidity", () => { - test("should set custom error", async () => { + beforeEach(() => { tFunctionMock.mockReset(); + }); + test("should set custom error", async () => { const initialValidity: ValidityState = { ...getDefaultValidityState(), customError: true, @@ -46,6 +49,7 @@ describe("useCustomValidity", () => { } satisfies InputValidationElement; const props = reactive({ + label: "Label", customError: "Test error", }); @@ -101,9 +105,8 @@ describe("useCustomValidity", () => { [cause]: true, valid: false, }; - const props = reactive({}); - const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit: (_, __) => {} }); - tFunctionMock.mockReset(); + const props = reactive({ label: "Label" }); + const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit: () => ({}) }); tFunctionMock.mockReturnValueOnce("Test"); tFunctionMock.mockReturnValueOnce("This is a test"); const mockInput = { @@ -120,4 +123,112 @@ describe("useCustomValidity", () => { expect(tFunctionMock).toBeCalledWith(`${key}.preview`); expect(tFunctionMock).toBeCalledWith(`${key}.fullError`, expect.any(Object)); }); + + test("should format date min errors", async () => { + // ARRANGE + const initialValidity: ValidityState = { + ...getDefaultValidityState(), + rangeUnderflow: true, + valid: false, + }; + + const mockInput = { + validity: initialValidity, + setCustomValidity: vi.fn(), + } satisfies InputValidationElement; + + const props = reactive({ + label: "Label", + type: "date", + min: new Date(2024, 11, 10, 14, 42), + }); + + const { vCustomValidity, errorMessages } = useCustomValidity({ + props, + emit: () => ({}), + }); + + tFunctionMock.mockImplementationOnce( + (translationKey, params) => `${translationKey}: ${params.min}`, + ); + tFunctionMock.mockReturnValueOnce("Too low"); + + vCustomValidity.mounted(mockInput); + await nextTick(); // wait for watchers to be called + + // ASSERT + expect(errorMessages.value).toStrictEqual({ + shortMessage: "Too low", + longMessage: "validations.rangeUnderflow.fullError: 12/10/2024", + }); + + // ACT + tFunctionMock.mockImplementationOnce( + (translationKey, params) => `${translationKey}: ${params.min}`, + ); + tFunctionMock.mockReturnValueOnce("Too low"); + + props.type = "datetime-local"; + await nextTick(); + + // ASSERT + expect(errorMessages.value).toStrictEqual({ + shortMessage: "Too low", + longMessage: "validations.rangeUnderflow.fullError: 12/10/2024, 02:42 PM", + }); + }); + + test("should format date max errors", async () => { + // ARRANGE + const initialValidity: ValidityState = { + ...getDefaultValidityState(), + rangeOverflow: true, + valid: false, + }; + + const mockInput = { + validity: initialValidity, + setCustomValidity: vi.fn(), + } satisfies InputValidationElement; + + const props = reactive({ + label: "Label", + type: "date", + max: new Date(2024, 11, 10, 14, 42), + }); + + const { vCustomValidity, errorMessages } = useCustomValidity({ + props, + emit: () => ({}), + }); + + tFunctionMock.mockImplementationOnce( + (translationKey, params) => `${translationKey}: ${params.max}`, + ); + tFunctionMock.mockReturnValueOnce("Too high"); + + vCustomValidity.mounted(mockInput); + await nextTick(); // wait for watchers to be called + + // ASSERT + expect(errorMessages.value).toStrictEqual({ + shortMessage: "Too high", + longMessage: "validations.rangeOverflow.fullError: 12/10/2024", + }); + + // ACT + tFunctionMock.mockImplementationOnce( + (translationKey, params) => `${translationKey}: ${params.max}`, + ); + tFunctionMock.mockReturnValueOnce("Too high"); + + props.type = "datetime-local"; + await nextTick(); + + // ASSERT + expect(errorMessages.value).toStrictEqual({ + shortMessage: "Too high", + longMessage: "validations.rangeOverflow.fullError: 12/10/2024, 02:42 PM", + }); + }); }); diff --git a/packages/sit-onyx/src/composables/useCustomValidity.ts b/packages/sit-onyx/src/composables/useCustomValidity.ts index 7184863661..01309d410f 100644 --- a/packages/sit-onyx/src/composables/useCustomValidity.ts +++ b/packages/sit-onyx/src/composables/useCustomValidity.ts @@ -1,9 +1,10 @@ import { computed, ref, watch, watchEffect, type Directive } from "vue"; -import type { OnyxDatePickerProps } from "../components/OnyxDatePicker/types"; +import type { DateValue, OnyxDatePickerProps } from "../components/OnyxDatePicker/types"; import type { InputType } from "../components/OnyxInput/types"; import { injectI18n } from "../i18n"; import enUS from "../i18n/locales/en-US.json"; import type { BaseSelectOption } from "../types"; +import { isValidDate } from "../utils/date"; import { areObjectsFlatEqual } from "../utils/objects"; import { getFirstInvalidType, transformValidityStateToObject } from "../utils/validity"; @@ -25,8 +26,8 @@ export type UseCustomValidityOptions = { type?: InputType | OnyxDatePickerProps["type"]; maxlength?: number; minlength?: number; - min?: number; - max?: number; + min?: DateValue; + max?: DateValue; precision?: number; } & Pick; /** @@ -115,7 +116,7 @@ export const getFormMessageText = (customError?: CustomMessageType): string | un * ``` */ export const useCustomValidity = (options: UseCustomValidityOptions) => { - const { t } = injectI18n(); + const { t, locale } = injectI18n(); const validityState = ref>(); const isDirty = ref(false); @@ -202,8 +203,8 @@ export const useCustomValidity = (options: UseCustomValidityOptions) => { n: options.props.modelValue?.toString().length ?? 0, minLength: options.props.minlength, maxLength: options.props.maxlength, - min: options.props.min, - max: options.props.max, + min: formatMinMax(locale.value, options.props.type, options.props.min), + max: formatMinMax(locale.value, options.props.type, options.props.max), step: options.props.precision, }; @@ -224,3 +225,23 @@ export const useCustomValidity = (options: UseCustomValidityOptions) => { errorMessages, }; }; + +const formatMinMax = ( + locale: string, + type: UseCustomValidityOptions["props"]["type"], + value?: DateValue, +): string | undefined => { + if (!type || !["date", "datetime-local"].includes(type)) return value?.toString(); + + const date = value != undefined ? new Date(value) : undefined; + if (!isValidDate(date)) return value?.toString(); + + const format: Intl.DateTimeFormatOptions = { + day: "2-digit", + month: "2-digit", + year: "numeric", + ...(type === "datetime-local" ? { hour: "2-digit", minute: "2-digit" } : undefined), + }; + + return date.toLocaleString(locale, format); +};