diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 14f7ad9a..1d312129 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@pinia/plugin-debounce": "^1.0.1", "@types/humanize-duration": "^3.27.4", + "@vuepic/vue-datepicker": "^11.0.1", "@vueuse/core": "^11.0.3", "buffer": "^6.0.3", "humanize-duration": "^3.32.1", @@ -3639,6 +3640,21 @@ "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", "dev": true }, + "node_modules/@vuepic/vue-datepicker": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-11.0.1.tgz", + "integrity": "sha512-xtGbgZAftBiU1H8pwM54vOCutLzEHsHiolRuDn+memTjqpfzT0x1Ml1tykJ53PLvdkCTyb6sB+1muv5Gsd4nQA==", + "license": "MIT", + "dependencies": { + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "vue": ">=3.3.0" + } + }, "node_modules/@vueuse/core": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.0.3.tgz", @@ -4578,6 +4594,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -13716,6 +13742,14 @@ "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", "dev": true }, + "@vuepic/vue-datepicker": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-11.0.1.tgz", + "integrity": "sha512-xtGbgZAftBiU1H8pwM54vOCutLzEHsHiolRuDn+memTjqpfzT0x1Ml1tykJ53PLvdkCTyb6sB+1muv5Gsd4nQA==", + "requires": { + "date-fns": "^4.1.0" + } + }, "@vueuse/core": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.0.3.tgz", @@ -14306,6 +14340,11 @@ "is-data-view": "^1.0.1" } }, + "date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==" + }, "de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index b24fea3e..4d376ac0 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -18,6 +18,7 @@ "dependencies": { "@pinia/plugin-debounce": "^1.0.1", "@types/humanize-duration": "^3.27.4", + "@vuepic/vue-datepicker": "^11.0.1", "@vueuse/core": "^11.0.3", "buffer": "^6.0.3", "humanize-duration": "^3.32.1", diff --git a/dashboard/src/components/PreservationActionCollapse.vue b/dashboard/src/components/PreservationActionCollapse.vue index f0392d89..252cda59 100644 --- a/dashboard/src/components/PreservationActionCollapse.vue +++ b/dashboard/src/components/PreservationActionCollapse.vue @@ -70,7 +70,7 @@ function isComplete(task: EnduroPackagePreservationTask) { />
diff --git a/dashboard/src/components/TimeDropdown.vue b/dashboard/src/components/TimeDropdown.vue index 95e1c9cd..10efb408 100644 --- a/dashboard/src/components/TimeDropdown.vue +++ b/dashboard/src/components/TimeDropdown.vue @@ -1,7 +1,14 @@ diff --git a/dashboard/src/components/__tests__/PackageDetailsCard.test.ts b/dashboard/src/components/__tests__/PackageDetailsCard.test.ts index 5e1527dd..0fdf2d2c 100644 --- a/dashboard/src/components/__tests__/PackageDetailsCard.test.ts +++ b/dashboard/src/components/__tests__/PackageDetailsCard.test.ts @@ -44,7 +44,6 @@ describe("PackageDetailsCard.vue", () => { }); it("renders when the package is in pending status", async () => { - const now = new Date(); const { getByText } = render(PackageDetailsCard, { global: { plugins: [ diff --git a/dashboard/src/components/__tests__/TimeDropdown.test.ts b/dashboard/src/components/__tests__/TimeDropdown.test.ts index 8aeac062..e595a149 100644 --- a/dashboard/src/components/__tests__/TimeDropdown.test.ts +++ b/dashboard/src/components/__tests__/TimeDropdown.test.ts @@ -1,114 +1,127 @@ import { mount } from "@vue/test-utils"; +import VueDatePicker from "@vuepic/vue-datepicker"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ref } from "vue"; import TimeDropdown from "../TimeDropdown.vue"; -const changeEvent = ref<{ field: string; value: string } | null>(null); - -const handleChange = (field: string, value: string) => { - changeEvent.value = { field, value }; -}; - describe("TimeDropdown.vue", () => { + let wrapper: ReturnType; + beforeEach(() => { vi.useFakeTimers(); + wrapper = mount(TimeDropdown, { + attachTo: document.body, + props: { + name: "createdAt", + }, + }); }); afterEach(() => { vi.useRealTimers(); - }); - - it("renders correctly", () => { - const wrapper = mount(TimeDropdown, { - props: { - fieldname: "testField", - onChange: handleChange, - }, - }); - expect(wrapper.exists()).toBe(true); + wrapper.unmount(); }); it("initializes with correct default values", () => { - const wrapper = mount(TimeDropdown, { - props: { - fieldname: "testField", - onChange: handleChange, - }, - }); const button = wrapper.find("button"); + const dropdown = wrapper.get("#time-filter-createdAt"); + expect(button.text()).toBe("Started"); + expect(dropdown.isVisible()).toBe(false); + }); + + it("toggles dropdown visibility when button is clicked", async () => { + const dropdown = wrapper.get("#time-filter-createdAt"); + + await wrapper.get("#time-filter-createdAt-toggle").trigger("click"); + expect(dropdown.isVisible()).toBe(true); + + await wrapper.get("#time-filter-createdAt-toggle").trigger("click"); + expect(dropdown.isVisible()).toBe(false); + }); + + it("handles direct date picker input", async () => { + const datePicker = wrapper.findComponent( + "[data-test='startTime']", + ); + + datePicker.vm.$emit( + "update:model-value", + new Date(Date.UTC(2025, 0, 1, 12, 0, 0)), + ); + + expect(wrapper.emitted("change")).toEqual([ + ["createdAt", "2025-01-01T12:00:00Z", ""], + ]); }); it.each([ - [0, ""], // Any time (default). - [1, "2025-01-01T09:00:00Z"], // Last 3 hours. - [2, "2025-01-01T06:00:00Z"], // Last 6 hours. - [3, "2025-01-01T00:00:00Z"], // Last 12 hours. - [4, "2024-12-31T12:00:00Z"], // Last 24 hours. - [5, "2024-12-29T12:00:00Z"], // Last 3 days. - [6, "2024-12-25T12:00:00Z"], // Last 7 days. - ])("emits the correct event when a date is selected", async (index, want) => { + ["3h", "2025-01-01T09:00:00Z"], // Last 3 hours. + ["6h", "2025-01-01T06:00:00Z"], // Last 6 hours. + ["12h", "2025-01-01T00:00:00Z"], // Last 12 hours. + ["24h", "2024-12-31T12:00:00Z"], // Last 24 hours. + ["3d", "2024-12-29T12:00:00Z"], // Last 3 days. + ["7d", "2024-12-25T12:00:00Z"], // Last 7 days. + ])("emits the correct event when a date is selected", async (value, want) => { + wrapper = mount(TimeDropdown, { + attachTo: document.body, + props: { + name: "createdAt", + }, + }); + // set the test time to noon on 2025-01-01 (UTC). const date = new Date(Date.UTC(2025, 0, 1, 12, 0, 0)); vi.setSystemTime(date); - const wrapper = mount(TimeDropdown, { - props: { - fieldname: "testField", - onChange: handleChange, - }, - }); + await wrapper.find("select").setValue(value); - const options = wrapper.findAll(".dropdown-item"); - await options[index].trigger("click"); - expect(changeEvent.value).toEqual({ - field: "testField", - value: want, - }); + expect(wrapper.emitted("change")).toEqual([["createdAt", want, ""]]); }); it.each([ - [0, "Started"], // Any time. - [1, "Started: The last 3 hours"], - [2, "Started: The last 6 hours"], - [3, "Started: The last 12 hours"], - [4, "Started: The last 24 hours"], - [5, "Started: The last 3 days"], - [6, "Started: The last 7 days"], + ["3h", "Started: The last 3 hours"], + ["6h", "Started: The last 6 hours"], + ["12h", "Started: The last 12 hours"], + ["24h", "Started: The last 24 hours"], + ["3d", "Started: The last 3 days"], + ["7d", "Started: The last 7 days"], ])( "sets the button label correctly when a time is selected", - async (index, want) => { - const wrapper = mount(TimeDropdown, { - props: { - fieldname: "testField", - onChange: handleChange, - }, - }); - + async (value, want) => { const button = wrapper.find("button"); - const option = wrapper.findAll(".dropdown-item"); - await option[index].trigger("click"); + await wrapper.find("select").setValue(value); + expect(button.text()).toBe(want); }, ); - it("resets the button label when 'Any time' is selected", async () => { - const wrapper = mount(TimeDropdown, { - props: { - fieldname: "testField", - onChange: handleChange, - }, - }); - + it("clears all values when the clear button is clicked", async () => { const button = wrapper.find("button"); - const option = wrapper.findAll(".dropdown-item"); + const clearButton = wrapper.find("button[type='reset']"); - await option[1].trigger("click"); - expect(button.text()).toBe("Started: The last 3 hours"); + await clearButton.trigger("click"); - await option[0].trigger("click"); expect(button.text()).toBe("Started"); + expect(wrapper.emitted("change")).toEqual([["createdAt", "", ""]]); + }); + + it("emits the correct event when a custom date is selected", async () => { + const button = wrapper.find("button"); + const datePicker = wrapper.findComponent( + '[data-test="startTime"]', + ); + + datePicker.vm.$emit( + "update:model-value", + new Date(Date.UTC(2025, 0, 1, 12, 0, 0)), + ); + await datePicker.vm.$nextTick(); + + expect(button.text()).toBe("Started: Custom"); + expect(wrapper.emitted("change")).toEqual([ + ["createdAt", "2025-01-01T12:00:00Z", ""], + ]); }); }); diff --git a/dashboard/src/pages/packages/[id]/index.vue b/dashboard/src/pages/packages/[id]/index.vue index 63253458..d5b2fd53 100644 --- a/dashboard/src/pages/packages/[id]/index.vue +++ b/dashboard/src/pages/packages/[id]/index.vue @@ -120,6 +120,7 @@ const createAipWorkflow = computed( :index="index" v-for="(action, index) in packageStore.current_preservation_actions ?.actions" + v-bind:key="action.id" />
diff --git a/dashboard/src/pages/packages/index.vue b/dashboard/src/pages/packages/index.vue index 9cce7b9f..c0e87feb 100644 --- a/dashboard/src/pages/packages/index.vue +++ b/dashboard/src/pages/packages/index.vue @@ -113,12 +113,40 @@ const doSearch = () => { }); }; -const updateDateFilter = (name: string, value: LocationQueryValue) => { - let q = { ...route.query }; - if (value === null || value === "") { - delete q[name]; +const updateCreatedAtFilter = ( + q: { [x: string]: LocationQueryValue | LocationQueryValue[] }, + start: LocationQueryValue, + end: LocationQueryValue, +): { [x: string]: LocationQueryValue | LocationQueryValue[] } => { + if (start) { + q.earliestCreatedTime = start; + } else { + delete q.earliestCreatedTime; + } + + if (end) { + q.latestCreatedTime = end; } else { - q[name] = value; + delete q.latestCreatedTime; + } + + return q; +}; + +const updateDateFilter = ( + name: string, + start: LocationQueryValue, + end: LocationQueryValue, +) => { + let q = { ...route.query }; + + switch (name) { + case "createdAt": + q = updateCreatedAtFilter(q, start, end); + break; + default: + // undefined. + return; } router.push({ @@ -139,13 +167,23 @@ const { execute, error } = useAsyncState(() => { route.query.earliestCreatedTime as string, ); } + if (route.query.latestCreatedTime) { + packageStore.filters.latestCreatedTime = new Date( + route.query.latestCreatedTime as string, + ); + } return packageStore.fetchPackages(1); }, null); watch( - () => [route.query.status, route.query.name, route.query.earliestCreatedTime], - ([newStatus, newName, newEarliest]) => { + () => [ + route.query.status, + route.query.name, + route.query.earliestCreatedTime, + route.query.latestCreatedTime, + ], + ([newStatus, newName, newEarliest, newLatest]) => { packageStore.filters.status = newStatus as PackageListStatusEnum; if (newName) { @@ -160,6 +198,12 @@ watch( packageStore.filters.earliestCreatedTime = undefined; } + if (newLatest) { + packageStore.filters.latestCreatedTime = new Date(newLatest as string); + } else { + packageStore.filters.latestCreatedTime = undefined; + } + return packageStore.fetchPackages(1); }, ); @@ -214,10 +258,13 @@ watch(
diff --git a/dashboard/src/stores/package.ts b/dashboard/src/stores/package.ts index f26253c8..2d7815dd 100644 --- a/dashboard/src/stores/package.ts +++ b/dashboard/src/stores/package.ts @@ -53,6 +53,7 @@ export const usePackageStore = defineStore("package", { status: "" as PackageListStatusEnum, name: "", earliestCreatedTime: undefined as Date | undefined, + latestCreatedTime: undefined as Date | undefined, }, }), getters: { @@ -156,6 +157,7 @@ export const usePackageStore = defineStore("package", { status: this.filters.status ?? undefined, name: this.filters.name ?? undefined, earliestCreatedTime: this.filters.earliestCreatedTime, + latestCreatedTime: this.filters.latestCreatedTime, }); this.packages = resp.items; this.page = resp.page; diff --git a/dashboard/tsconfig.vitest.json b/dashboard/tsconfig.vitest.json index d080d611..67705fd2 100644 --- a/dashboard/tsconfig.vitest.json +++ b/dashboard/tsconfig.vitest.json @@ -4,6 +4,6 @@ "compilerOptions": { "composite": true, "lib": [], - "types": ["node", "jsdom"] + "types": ["node", "jsdom", "vitest/globals"] } }