Skip to content

Commit

Permalink
feat(OnyxDatePicker): implement min and max property (#2196)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
larsrickert authored Dec 5, 2024
1 parent 0350cdf commit c8beae4
Show file tree
Hide file tree
Showing 7 changed files with 239 additions and 20 deletions.
7 changes: 7 additions & 0 deletions .changeset/stupid-bottles-clap.md
Original file line number Diff line number Diff line change
@@ -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"`
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<OnyxDatePicker
label="Label"
min={new Date(2024, 11, 10)}
modelValue={new Date(2024, 11, 5)}
/>,
);

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(
<OnyxDatePicker
label="Label"
max={new Date(2024, 11, 6)}
modelValue={new Date(2024, 11, 20)}
/>,
);

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");
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof OnyxDatePicker> = {
title: "Form Elements/DatePicker",
component: OnyxDatePicker,
Expand All @@ -13,13 +17,16 @@ const meta: Meta<typeof OnyxDatePicker> = {
],
argTypes: {
...withNativeEventLogging(["onInput", "onChange", "onFocusin", "onFocusout"]),
modelValue: { control: { type: "text" } },
min: { control: { type: "date" } },
max: { control: { type: "date" } },
},
};

export default meta;
type Story = StoryObj<typeof OnyxDatePicker>;

export const Date = {
export const Default = {
args: {
label: "Date",
},
Expand All @@ -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;
}
20 changes: 13 additions & 7 deletions packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
</script>

<template>
Expand All @@ -94,8 +99,8 @@ const handleInput = (event: Event) => {
<input
:id="inputId"
:key="props.type"
v-model="value"
v-custom-validity
:value="getNormalizedDate(props.modelValue)"
class="onyx-datepicker__native"
:class="{ 'onyx-datepicker__native--success': successMessages }"
:type="props.type"
Expand All @@ -106,7 +111,8 @@ const handleInput = (event: Event) => {
:disabled="disabled || props.loading"
:aria-label="props.hideLabel ? props.label : undefined"
:title="props.hideLabel ? props.label : undefined"
@input="handleInput"
:min="getNormalizedDate(props.min)"
:max="getNormalizedDate(props.max)"
/>
</div>
</template>
Expand Down
10 changes: 10 additions & 0 deletions packages/sit-onyx/src/components/OnyxDatePicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`. */
Expand Down
123 changes: 117 additions & 6 deletions packages/sit-onyx/src/composables/useCustomValidity.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,6 +11,7 @@ const tFunctionMock = vi.fn();
vi.mock("../i18n", () => ({
injectI18n: () => ({
t: { value: tFunctionMock },
locale: ref("en-US"),
}),
}));

Expand All @@ -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,
Expand All @@ -46,6 +49,7 @@ describe("useCustomValidity", () => {
} satisfies InputValidationElement;

const props = reactive<UseCustomValidityOptions["props"]>({
label: "Label",
customError: "Test error",
});

Expand Down Expand Up @@ -101,9 +105,8 @@ describe("useCustomValidity", () => {
[cause]: true,
valid: false,
};
const props = reactive<UseCustomValidityOptions["props"]>({});
const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit: (_, __) => {} });
tFunctionMock.mockReset();
const props = reactive<UseCustomValidityOptions["props"]>({ label: "Label" });
const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit: () => ({}) });
tFunctionMock.mockReturnValueOnce("Test");
tFunctionMock.mockReturnValueOnce("This is a test");
const mockInput = {
Expand All @@ -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<UseCustomValidityOptions["props"]>({
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<UseCustomValidityOptions["props"]>({
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",
});
});
});
33 changes: 27 additions & 6 deletions packages/sit-onyx/src/composables/useCustomValidity.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<BaseSelectOption, "hideLabel" | "label">;
/**
Expand Down Expand Up @@ -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<Record<keyof ValidityState, boolean>>();
const isDirty = ref(false);
Expand Down Expand Up @@ -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,
};

Expand All @@ -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);
};

0 comments on commit c8beae4

Please sign in to comment.