505 lines
16 KiB
JavaScript
505 lines
16 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { defineComponent } from "vue";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { mountWithApp } from "./helpers/mountWithApp.js";
|
|
|
|
const sharedState = vi.hoisted(() => ({
|
|
width: null,
|
|
}));
|
|
|
|
vi.mock("@vueuse/core", async () => {
|
|
const { ref: vueRef } = await import("vue");
|
|
const width = vueRef(1024);
|
|
sharedState.width = width;
|
|
|
|
return {
|
|
useWindowSize: () => ({ width }),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
|
SessionUser: {
|
|
functions: {
|
|
device: {
|
|
isMobile: () => false,
|
|
},
|
|
ucFirst: (value) => String(value).charAt(0).toUpperCase() + String(value).slice(1),
|
|
},
|
|
objects: {
|
|
global: {
|
|
language: {
|
|
text: {
|
|
today: "Today",
|
|
anytime: "Anytime",
|
|
yesterday: "Yesterday",
|
|
last_7_days: "Last 7 days",
|
|
this_week: "This week",
|
|
last_week: "Last week",
|
|
this_month: "This month",
|
|
last_month: "Last month",
|
|
other_month: "Other month",
|
|
same_week_last_year: "Same week last year",
|
|
same_month_last_year: "Same month last year",
|
|
date_from: "From",
|
|
date_to: "To",
|
|
month: "Month",
|
|
year: "Year",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
|
|
|
|
const messages = {
|
|
en: {
|
|
common: {
|
|
to: "To",
|
|
today: "Today",
|
|
yesterday: "Yesterday",
|
|
},
|
|
global: {
|
|
other: "Other",
|
|
text: {
|
|
this_week: "This week",
|
|
last_week: "Last week",
|
|
last_7_days: "Last 7 days",
|
|
this_month: "This month",
|
|
last_month: "Last month",
|
|
other_month: "Other month",
|
|
same_week_last_year: "Same week last year",
|
|
same_month_last_year: "Same month last year",
|
|
},
|
|
anytime: "Anytime",
|
|
},
|
|
date_period: {
|
|
labels: {
|
|
period: "Period",
|
|
select_period: "Select period",
|
|
advanced_period: "Advanced period",
|
|
select_month: "Select month",
|
|
update_selection: "Update selection",
|
|
to: "To",
|
|
no_date_selection: "No date selected",
|
|
},
|
|
messages: {
|
|
invalid_selection: "Invalid",
|
|
multi_month: "Multiple months",
|
|
partial_month_prefix: "Need a full month?",
|
|
partial_month_action: "Use the full selected month",
|
|
partial_month_tooltip: "Set the range to the first and last day of the selected month",
|
|
partial_month_suffix: "for reporting.",
|
|
},
|
|
shortcuts: {
|
|
anytime: "Anytime",
|
|
today: "Today",
|
|
yesterday: "Yesterday",
|
|
this_week: "This week",
|
|
last_week: "Last week",
|
|
last_seven_days: "Last 7 days",
|
|
this_month: "This month",
|
|
last_month: "Last month",
|
|
other_month: "Other month",
|
|
same_week_last_year: "Same week last year",
|
|
same_month_last_year: "Same month last year",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const dropdownStubs = {
|
|
"b-dropdown": defineComponent({
|
|
name: "BDropdownStub",
|
|
template: "<div><slot name='trigger' /><slot /></div>",
|
|
}),
|
|
"b-dropdown-item": defineComponent({
|
|
name: "BDropdownItemStub",
|
|
template: "<div><slot /></div>",
|
|
}),
|
|
BTooltip: defineComponent({
|
|
name: "BTooltipStub",
|
|
template: "<span><slot /></span>",
|
|
}),
|
|
};
|
|
|
|
const parseDateValue = (value) => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const [year, month, day] = String(value).split("-").map(Number);
|
|
return new Date(year, month - 1, day);
|
|
};
|
|
|
|
const formatDateValue = (value) => {
|
|
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
|
return "";
|
|
}
|
|
|
|
return formatLocalDate(value);
|
|
};
|
|
|
|
const BuefyDateFieldStub = defineComponent({
|
|
name: "BuefyDateField",
|
|
props: {
|
|
modelValue: {
|
|
type: Date,
|
|
default: null,
|
|
},
|
|
dataTestid: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
clearable: Boolean,
|
|
},
|
|
emits: ["update:modelValue", "change"],
|
|
methods: {
|
|
formatDateValue,
|
|
update(value) {
|
|
const nextDate = parseDateValue(value);
|
|
this.$emit("update:modelValue", nextDate);
|
|
this.$emit("change", nextDate);
|
|
},
|
|
},
|
|
template:
|
|
"<input :data-testid='dataTestid' :data-clearable='String(clearable)' :value='formatDateValue(modelValue)' @input='update($event.target.value)' />",
|
|
});
|
|
|
|
const BuefyMonthFieldStub = defineComponent({
|
|
name: "BuefyMonthField",
|
|
props: {
|
|
modelValue: {
|
|
type: Date,
|
|
default: null,
|
|
},
|
|
dataTestid: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
clearable: Boolean,
|
|
inline: Boolean,
|
|
},
|
|
emits: ["update:modelValue", "change"],
|
|
methods: {
|
|
update(value) {
|
|
if (!value) {
|
|
this.$emit("update:modelValue", null);
|
|
this.$emit("change", null);
|
|
return;
|
|
}
|
|
const [year, month] = String(value).split("-").map(Number);
|
|
const nextDate = new Date(year, month - 1, 1);
|
|
this.$emit("update:modelValue", nextDate);
|
|
this.$emit("change", nextDate);
|
|
},
|
|
format(value) {
|
|
return value instanceof Date ? `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}` : "";
|
|
},
|
|
},
|
|
template:
|
|
"<input :data-testid='dataTestid' :data-clearable='String(clearable)' :data-inline='String(inline)' :value='format(modelValue)' @input='update($event.target.value)' />",
|
|
});
|
|
|
|
const componentStubs = {
|
|
...dropdownStubs,
|
|
BuefyDateField: BuefyDateFieldStub,
|
|
BuefyMonthField: BuefyMonthFieldStub,
|
|
};
|
|
|
|
const formatLocalDate = (date) => {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
describe("DatePeriodSelector mobile layout", () => {
|
|
beforeEach(() => {
|
|
sharedState.width.value = 480;
|
|
});
|
|
|
|
it("renders stacked mobile controls and hides advanced month and year selectors by default", async () => {
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
|
endDate: new Date("2026-03-03T00:00:00.000Z"),
|
|
},
|
|
onSelectionChange: vi.fn(),
|
|
visibility: {
|
|
showMonthSelector: true,
|
|
showYearSelector: true,
|
|
showUpdateButton: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
expect(wrapper.get("[data-testid='date-period-mobile-layout']").exists()).toBe(true);
|
|
expect(wrapper.get("[data-testid='date-period-shortcuts']").exists()).toBe(true);
|
|
expect(wrapper.get("[data-testid='date-period-start']").exists()).toBe(true);
|
|
expect(wrapper.get("[data-testid='date-period-end']").exists()).toBe(true);
|
|
expect(wrapper.findAll("select")).toHaveLength(1);
|
|
expect(wrapper.find("[data-testid='date-period-month']").exists()).toBe(false);
|
|
|
|
await wrapper.get("[data-testid='date-period-advanced-toggle']").trigger("click");
|
|
|
|
expect(wrapper.findAll("select")).toHaveLength(1);
|
|
expect(wrapper.get("[data-testid='date-period-month']").exists()).toBe(true);
|
|
expect(wrapper.get("[data-testid='date-period-month']").attributes("data-inline")).toBe("false");
|
|
});
|
|
|
|
it("preserves the update:selection and onSelectionChange contract on mobile", async () => {
|
|
const onSelectionChange = vi.fn();
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
|
endDate: new Date("2026-03-03T00:00:00.000Z"),
|
|
},
|
|
onSelectionChange,
|
|
visibility: {
|
|
showMonthSelector: true,
|
|
showYearSelector: true,
|
|
showUpdateButton: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
await wrapper.get("[data-testid='date-period-end']").setValue("2026-03-05");
|
|
|
|
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
|
|
expect(formatLocalDate(emittedSelection.startDate)).toBe("2026-03-01");
|
|
expect(formatLocalDate(emittedSelection.endDate)).toBe("2026-03-05");
|
|
expect(onSelectionChange).toHaveBeenCalledTimes(1);
|
|
expect(formatLocalDate(onSelectionChange.mock.calls[0][0])).toBe("2026-03-01");
|
|
expect(formatLocalDate(onSelectionChange.mock.calls[0][1])).toBe("2026-03-05");
|
|
});
|
|
|
|
it("emits the exact clicked date for start and end inputs", async () => {
|
|
sharedState.width.value = 1024;
|
|
const onSelectionChange = vi.fn();
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: new Date(2026, 2, 1, 0, 0, 0, 0),
|
|
endDate: new Date(2026, 2, 3, 23, 59, 59, 999),
|
|
},
|
|
onSelectionChange,
|
|
visibility: {
|
|
showUpdateButton: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
await wrapper.get("[data-testid='date-period-start']").setValue("2026-03-31");
|
|
await wrapper.get("[data-testid='date-period-end']").setValue("2026-04-02");
|
|
|
|
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
|
|
expect(formatLocalDate(emittedSelection.startDate)).toBe("2026-03-31");
|
|
expect(formatLocalDate(emittedSelection.endDate)).toBe("2026-04-02");
|
|
expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[0])).toBe("2026-03-31");
|
|
expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[1])).toBe("2026-04-02");
|
|
});
|
|
});
|
|
|
|
describe("DatePeriodSelector month warning", () => {
|
|
beforeEach(() => {
|
|
sharedState.width.value = 1024;
|
|
});
|
|
|
|
it("sets the current month from its first day through its last day", async () => {
|
|
const onSelectionChange = vi.fn();
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: new Date(2026, 4, 4, 12, 30, 0, 0),
|
|
endDate: new Date(2026, 4, 5, 15, 45, 0, 0),
|
|
},
|
|
onSelectionChange,
|
|
visibility: {
|
|
showUpdateButton: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
await wrapper.get("[data-testid='date-period-set-entire-month']").trigger("click");
|
|
|
|
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
|
|
expect(emittedSelection).toBeTruthy();
|
|
expect(emittedSelection.startDate.getFullYear()).toBe(2026);
|
|
expect(emittedSelection.startDate.getMonth()).toBe(4);
|
|
expect(emittedSelection.startDate.getDate()).toBe(1);
|
|
expect(emittedSelection.startDate.getHours()).toBe(0);
|
|
expect(emittedSelection.startDate.getMinutes()).toBe(0);
|
|
expect(emittedSelection.startDate.getSeconds()).toBe(0);
|
|
expect(emittedSelection.startDate.getMilliseconds()).toBe(0);
|
|
expect(emittedSelection.endDate.getFullYear()).toBe(2026);
|
|
expect(emittedSelection.endDate.getMonth()).toBe(4);
|
|
expect(emittedSelection.endDate.getDate()).toBe(31);
|
|
expect(emittedSelection.endDate.getHours()).toBe(23);
|
|
expect(emittedSelection.endDate.getMinutes()).toBe(59);
|
|
expect(emittedSelection.endDate.getSeconds()).toBe(59);
|
|
expect(emittedSelection.endDate.getMilliseconds()).toBe(999);
|
|
expect(onSelectionChange).toHaveBeenCalledWith(emittedSelection.startDate, emittedSelection.endDate);
|
|
});
|
|
|
|
it("renders secondary shortcuts behind the Other dropdown", async () => {
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: new Date(2026, 2, 1, 0, 0, 0, 0),
|
|
endDate: new Date(2026, 2, 3, 23, 59, 59, 999),
|
|
},
|
|
onSelectionChange: vi.fn(),
|
|
visibility: {
|
|
showUpdateButton: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
expect(wrapper.get("[data-testid='date-period-other-dropdown-trigger']").text()).toContain("Other");
|
|
expect(wrapper.get("[data-testid='date-period-other-this_month']").text()).toContain("This month");
|
|
expect(wrapper.get("[data-testid='date-period-other-same_month_last_year']").text()).toContain(
|
|
"Same month last year"
|
|
);
|
|
});
|
|
|
|
it("opens the month modal from Other month on mobile", async () => {
|
|
sharedState.width.value = 480;
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: new Date(2026, 4, 4, 12, 30, 0, 0),
|
|
endDate: new Date(2026, 4, 5, 15, 45, 0, 0),
|
|
},
|
|
onSelectionChange: vi.fn(),
|
|
visibility: {
|
|
showUpdateButton: false,
|
|
showMonthSelector: true,
|
|
showYearSelector: true,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
await wrapper.get("[data-testid='date-period-other-dropdown-trigger']").trigger("click");
|
|
|
|
expect(wrapper.get("[data-testid='date-period-other-other_month']").text()).toContain("Other month");
|
|
expect(wrapper.find("[data-testid='date-period-month']").exists()).toBe(false);
|
|
|
|
await wrapper.get("[data-testid='date-period-other-other_month']").trigger("click");
|
|
|
|
expect(wrapper.get("[data-testid='date-period-other-month-modal']").exists()).toBe(true);
|
|
expect(wrapper.get("[data-testid='date-period-other-month-picker']").exists()).toBe(true);
|
|
expect(wrapper.get("[data-testid='date-period-other-month-picker']").attributes("data-inline")).toBe("true");
|
|
});
|
|
|
|
it("selects Anytime for empty ranges without highlighting Today", () => {
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: null,
|
|
endDate: null,
|
|
},
|
|
onSelectionChange: vi.fn(),
|
|
allowEmptySelection: true,
|
|
visibility: {
|
|
showUpdateButton: false,
|
|
showStartDate: false,
|
|
showEndDate: false,
|
|
showToLabel: false,
|
|
showSelectionValidity: false,
|
|
showMonthSelector: false,
|
|
showYearSelector: false,
|
|
showMultipleMonthWarning: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
const buttons = wrapper.findAll("button").filter((button) => ["Anytime", "Today"].includes(button.text()));
|
|
const anytimeButton = buttons.find((button) => button.text() === "Anytime");
|
|
const todayButton = buttons.find((button) => button.text() === "Today");
|
|
|
|
expect(anytimeButton.classes()).toContain("is-info");
|
|
expect(todayButton.classes()).not.toContain("is-info");
|
|
expect(wrapper.find(".level").exists()).toBe(false);
|
|
expect(wrapper.get("[data-testid='date-period-shortcut-anytime']").exists()).toBe(true);
|
|
});
|
|
|
|
it("hides empty range controls unless the selector is optional", () => {
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: null,
|
|
endDate: null,
|
|
},
|
|
onSelectionChange: vi.fn(),
|
|
visibility: {
|
|
showUpdateButton: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).not.toContain("Anytime");
|
|
expect(wrapper.get("[data-testid='date-period-start']").attributes("data-clearable")).toBe("false");
|
|
expect(wrapper.get("[data-testid='date-period-end']").attributes("data-clearable")).toBe("false");
|
|
});
|
|
|
|
it("marks date fields as clearable when optional", () => {
|
|
const wrapper = mountWithApp(DatePeriodSelector, {
|
|
messages,
|
|
props: {
|
|
selection: {
|
|
startDate: new Date(2026, 6, 1),
|
|
endDate: new Date(2026, 6, 5),
|
|
},
|
|
onSelectionChange: vi.fn(),
|
|
allowEmptySelection: true,
|
|
visibility: {
|
|
showUpdateButton: false,
|
|
},
|
|
},
|
|
global: {
|
|
stubs: componentStubs,
|
|
},
|
|
});
|
|
|
|
expect(wrapper.text()).toContain("Anytime");
|
|
expect(wrapper.get("[data-testid='date-period-start']").attributes("data-clearable")).toBe("true");
|
|
expect(wrapper.get("[data-testid='date-period-end']").attributes("data-clearable")).toBe("true");
|
|
});
|
|
});
|