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(
- updateDateFilter(name, value)
+ (
+ name: string,
+ start: LocationQueryValue,
+ end: LocationQueryValue,
+ ) => updateDateFilter(name, start, end)
"
/>
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"]
}
}