diff --git a/client/.eslintrc.js b/client/.eslintrc.js index d496b9f13b74..af760fcaeb53 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -91,6 +91,7 @@ module.exports = { rules: { ...baseRules, "@typescript-eslint/no-throw-literal": "error", + "@typescript-eslint/ban-ts-comment": "warn", }, parser: "@typescript-eslint/parser", parserOptions: { diff --git a/client/src/components/Common/models/exportRecordModel.ts b/client/src/components/Common/models/exportRecordModel.ts index 6c9f6c3f0e3f..578072022980 100644 --- a/client/src/components/Common/models/exportRecordModel.ts +++ b/client/src/components/Common/models/exportRecordModel.ts @@ -6,26 +6,58 @@ type ExportObjectRequestMetadata = components["schemas"]["ExportObjectRequestMet export type StoreExportPayload = components["schemas"]["StoreExportPayload"]; export type ObjectExportTaskResponse = components["schemas"]["ObjectExportTaskResponse"]; -export class ExportParamsModel { +export interface ExportParams { + readonly modelStoreFormat: string; + readonly includeFiles: boolean; + readonly includeDeleted: boolean; + readonly includeHidden: boolean; +} + +export interface ExportRecord { + readonly id: string; + readonly isReady: boolean; + readonly isPreparing: boolean; + readonly isUpToDate: boolean; + readonly hasFailed: boolean; + readonly date: Date; + readonly elapsedTime: string; + readonly taskUUID: string; + readonly importUri?: string; + readonly canReimport: boolean; + readonly stsDownloadId?: string; + readonly isStsDownload: boolean; + readonly canDownload: boolean; + readonly modelStoreFormat: string; + readonly exportParams?: ExportParams; + readonly duration?: number; + readonly canExpire: boolean; + readonly isPermanent: boolean; + readonly expirationDate?: Date; + readonly expirationElapsedTime?: string; + readonly hasExpired: boolean; + readonly errorMessage?: string; +} + +export class ExportParamsModel implements ExportParams { private _params: StoreExportPayload; constructor(data: StoreExportPayload = {}) { this._params = data; } get modelStoreFormat() { - return this._params?.model_store_format; + return this._params?.model_store_format ?? "tgz"; } get includeFiles() { - return this._params?.include_files; + return Boolean(this._params?.include_files); } get includeDeleted() { - return this._params?.include_deleted; + return Boolean(this._params?.include_deleted); } get includeHidden() { - return this._params?.include_hidden; + return Boolean(this._params?.include_hidden); } public equals(otherExportParams?: ExportParamsModel) { @@ -41,9 +73,9 @@ export class ExportParamsModel { } } -export class ExportRecordModel { +export class ExportRecordModel implements ExportRecord { private _data: ObjectExportTaskResponse; - private _expirationDate?: Date | null; + private _expirationDate?: Date; private _requestMetadata?: ExportObjectRequestMetadata; private _exportParameters?: ExportParamsModel; @@ -56,6 +88,10 @@ export class ExportRecordModel { : undefined; } + get id() { + return this._data.id; + } + get isReady() { return (this._data.ready && !this.hasExpired) ?? false; } @@ -109,7 +145,7 @@ export class ExportRecordModel { } get modelStoreFormat() { - return this.exportParams?.modelStoreFormat; + return this.exportParams?.modelStoreFormat ?? "tgz"; } get exportParams() { @@ -125,9 +161,13 @@ export class ExportRecordModel { return this.isStsDownload && Boolean(this.duration); } + get isPermanent() { + return !this.canExpire; + } + get expirationDate() { if (this._expirationDate === undefined) { - this._expirationDate = this.duration ? new Date(this.date.getTime() + this.duration * 1000) : null; + this._expirationDate = this.duration ? new Date(this.date.getTime() + this.duration * 1000) : undefined; } return this._expirationDate; } @@ -135,11 +175,11 @@ export class ExportRecordModel { get expirationElapsedTime() { return this.canExpire && this.expirationDate ? formatDistanceToNow(this.expirationDate, { addSuffix: true }) - : null; + : undefined; } get hasExpired() { - return this.canExpire && this.expirationDate && Date.now() > this.expirationDate.getTime(); + return Boolean(this.canExpire && this.expirationDate && Date.now() > this.expirationDate.getTime()); } get errorMessage() { diff --git a/client/src/components/Common/models/testData/exportData.ts b/client/src/components/Common/models/testData/exportData.ts index 419af7290d36..8253bff39751 100644 --- a/client/src/components/Common/models/testData/exportData.ts +++ b/client/src/components/Common/models/testData/exportData.ts @@ -91,10 +91,10 @@ export const FAILED_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = { }; export const FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = { - id: "FAKE_RECENT_DOWNLOAD_ID", + id: "FAKE_FILE_SOURCE_EXPORT_ID", ready: true, preparing: false, - up_to_date: true, + up_to_date: false, task_uuid: "35563335-e275-4520-80e8-885793279095", create_time: RECENT_EXPORT_DATE, export_metadata: { @@ -103,6 +103,37 @@ export const FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = { }, }; +export const RECENT_FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = { + ...FILE_SOURCE_STORE_RESPONSE, + id: "FAKE_RECENT_FILE_SOURCE_EXPORT_ID", + up_to_date: true, +}; + +export const FAILED_FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = { + ...FILE_SOURCE_STORE_RESPONSE, + id: "FAKE_FAILED_FILE_SOURCE_EXPORT_ID", + export_metadata: { + request_data: FAKE_FILE_SOURCE_REQUEST_DATA, + result_data: FAILED_EXPORT_RESULT_DATA, + }, +}; + +export const IN_PROGRESS_FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = { + ...FILE_SOURCE_STORE_RESPONSE, + id: "FAKE_IN_PROGRESS_FILE_SOURCE_EXPORT_ID", + ready: false, + preparing: true, + export_metadata: { + request_data: FAKE_FILE_SOURCE_REQUEST_DATA, + result_data: undefined, + }, +}; + export const EXPIRED_STS_DOWNLOAD_RECORD = new ExportRecordModel(EXPIRED_STS_DOWNLOAD_RESPONSE); -export const FILE_SOURCE_STORE_RECORD = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE); export const RECENT_STS_DOWNLOAD_RECORD = new ExportRecordModel(RECENT_STS_DOWNLOAD_RESPONSE); +export const FAILED_DOWNLOAD_RECORD = new ExportRecordModel(FAILED_DOWNLOAD_RESPONSE); + +export const FILE_SOURCE_STORE_RECORD = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE); +export const RECENT_FILE_SOURCE_STORE_RECORD = new ExportRecordModel(RECENT_FILE_SOURCE_STORE_RESPONSE); +export const FAILED_FILE_SOURCE_STORE_RECORD = new ExportRecordModel(FAILED_FILE_SOURCE_STORE_RESPONSE); +export const IN_PROGRESS_FILE_SOURCE_STORE_RECORD = new ExportRecordModel(IN_PROGRESS_FILE_SOURCE_STORE_RESPONSE); diff --git a/client/src/components/History/Archiving/ExportRecordCard.vue b/client/src/components/History/Archiving/ExportRecordCard.vue new file mode 100644 index 000000000000..d4efe297f3e0 --- /dev/null +++ b/client/src/components/History/Archiving/ExportRecordCard.vue @@ -0,0 +1,24 @@ + + + diff --git a/client/src/components/History/Archiving/HistoryArchive.vue b/client/src/components/History/Archiving/HistoryArchive.vue new file mode 100644 index 000000000000..78ff133caa79 --- /dev/null +++ b/client/src/components/History/Archiving/HistoryArchive.vue @@ -0,0 +1,245 @@ + + + + diff --git a/client/src/components/History/Archiving/HistoryArchiveExportSelector.test.ts b/client/src/components/History/Archiving/HistoryArchiveExportSelector.test.ts new file mode 100644 index 000000000000..7bcb299b2c87 --- /dev/null +++ b/client/src/components/History/Archiving/HistoryArchiveExportSelector.test.ts @@ -0,0 +1,156 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import flushPromises from "flush-promises"; +import { BFormCheckbox } from "bootstrap-vue"; +import HistoryArchiveExportSelector from "./HistoryArchiveExportSelector.vue"; +import type { HistorySummary } from "@/stores/historyStore"; +import { mockFetcher } from "@/schema/__mocks__"; +import { + FAILED_FILE_SOURCE_STORE_RESPONSE, + FILE_SOURCE_STORE_RESPONSE, + IN_PROGRESS_FILE_SOURCE_STORE_RESPONSE, + RECENT_FILE_SOURCE_STORE_RESPONSE, + RECENT_STS_DOWNLOAD_RESPONSE, +} from "@/components/Common/models/testData/exportData"; + +jest.mock("@/schema"); + +const localVue = getLocalVue(true); + +const TEST_HISTORY_ID = "test-history-id"; +const TEST_HISTORY = { + id: TEST_HISTORY_ID, + name: "fake-history-name", + archived: false, +}; + +const GET_EXPORTS_API_ENDPOINT = "/api/histories/{history_id}/exports"; + +const EXPORT_RECORD_BTN = "#create-export-record-btn"; +const ARCHIVE_HISTORY_BTN = "#archive-history-btn"; +const CONFIRM_DELETE_CHECKBOX = "[type='checkbox']"; + +async function mountComponentWithHistory(history: HistorySummary) { + const wrapper = shallowMount(HistoryArchiveExportSelector, { + propsData: { history }, + localVue, + stubs: { + // Stub with the real component to be able to use setChecked + BFormCheckbox, + }, + }); + await flushPromises(); + return wrapper; +} + +describe("HistoryArchiveExportSelector.vue", () => { + let axiosMock: MockAdapter; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it("should display a button to create an export record if there is no up to date export record", async () => { + mockFetcher.path(GET_EXPORTS_API_ENDPOINT).method("get").mock({ data: [] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const createExportButton = wrapper.find(EXPORT_RECORD_BTN); + expect(createExportButton.exists()).toBe(true); + }); + + it("should display a button to create an export record if the most recent export record is not permanent", async () => { + mockFetcher + .path(GET_EXPORTS_API_ENDPOINT) + .method("get") + .mock({ data: [RECENT_STS_DOWNLOAD_RESPONSE] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const createExportButton = wrapper.find(EXPORT_RECORD_BTN); + expect(createExportButton.exists()).toBe(true); + }); + + it("should display a button to create an export record if there are permanent export records but none are up to date", async () => { + mockFetcher + .path(GET_EXPORTS_API_ENDPOINT) + .method("get") + .mock({ data: [FILE_SOURCE_STORE_RESPONSE, FAILED_FILE_SOURCE_STORE_RESPONSE] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const createExportButton = wrapper.find(EXPORT_RECORD_BTN); + expect(createExportButton.exists()).toBe(true); + }); + + it("should not display a button to create an export record if there is an up to date export record", async () => { + mockFetcher + .path(GET_EXPORTS_API_ENDPOINT) + .method("get") + .mock({ data: [RECENT_FILE_SOURCE_STORE_RESPONSE] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const createExportButton = wrapper.find(EXPORT_RECORD_BTN); + expect(createExportButton.exists()).toBe(false); + }); + + it("should not display a button to create an export record if a record is being created", async () => { + mockFetcher + .path(GET_EXPORTS_API_ENDPOINT) + .method("get") + .mock({ data: [IN_PROGRESS_FILE_SOURCE_STORE_RESPONSE] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const createExportButton = wrapper.find(EXPORT_RECORD_BTN); + expect(createExportButton.exists()).toBe(false); + }); + + it("should disable the Archive button if there is no up to date export record", async () => { + mockFetcher.path(GET_EXPORTS_API_ENDPOINT).method("get").mock({ data: [] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const archiveButton = wrapper.find(ARCHIVE_HISTORY_BTN); + expect(archiveButton.attributes("disabled")).toBeTruthy(); + }); + + it("should disable the Archive button if the confirm delete checkbox is not checked", async () => { + mockFetcher + .path(GET_EXPORTS_API_ENDPOINT) + .method("get") + .mock({ data: [RECENT_FILE_SOURCE_STORE_RESPONSE] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const confirmDeleteCheckbox = wrapper.find(CONFIRM_DELETE_CHECKBOX); + await confirmDeleteCheckbox.setChecked(false); + expect((confirmDeleteCheckbox.element as HTMLInputElement).checked).toBeFalsy(); + + const archiveButton = wrapper.find(ARCHIVE_HISTORY_BTN); + expect(archiveButton.attributes("disabled")).toBeTruthy(); + }); + + it("should enable the Archive button if there is an up to date export record and the confirm delete checkbox is checked", async () => { + mockFetcher + .path(GET_EXPORTS_API_ENDPOINT) + .method("get") + .mock({ data: [RECENT_FILE_SOURCE_STORE_RESPONSE] }); + + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const confirmDeleteCheckbox = wrapper.find(CONFIRM_DELETE_CHECKBOX); + await confirmDeleteCheckbox.setChecked(true); + expect((confirmDeleteCheckbox.element as HTMLInputElement).checked).toBeTruthy(); + + const archiveButton = wrapper.find(ARCHIVE_HISTORY_BTN); + expect(archiveButton.attributes("disabled")).toBeFalsy(); + }); +}); diff --git a/client/src/components/History/Archiving/HistoryArchiveExportSelector.vue b/client/src/components/History/Archiving/HistoryArchiveExportSelector.vue new file mode 100644 index 000000000000..dc39daafcd3a --- /dev/null +++ b/client/src/components/History/Archiving/HistoryArchiveExportSelector.vue @@ -0,0 +1,200 @@ + + + diff --git a/client/src/components/History/Archiving/HistoryArchiveSimple.vue b/client/src/components/History/Archiving/HistoryArchiveSimple.vue new file mode 100644 index 000000000000..6d07ff0b30da --- /dev/null +++ b/client/src/components/History/Archiving/HistoryArchiveSimple.vue @@ -0,0 +1,30 @@ + + + diff --git a/client/src/components/History/Archiving/HistoryArchiveWizard.test.ts b/client/src/components/History/Archiving/HistoryArchiveWizard.test.ts new file mode 100644 index 000000000000..54f74cb779b8 --- /dev/null +++ b/client/src/components/History/Archiving/HistoryArchiveWizard.test.ts @@ -0,0 +1,114 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import flushPromises from "flush-promises"; +import { createTestingPinia } from "@pinia/testing"; +import { setActivePinia } from "pinia"; +import { useHistoryStore, type HistorySummary } from "@/stores/historyStore"; +import HistoryArchiveWizard from "./HistoryArchiveWizard.vue"; + +jest.mock("@/composables/config", () => ({ + useConfig: jest.fn(() => ({ + config: { + value: { + enable_celery_tasks: true, + }, + }, + })), +})); + +const localVue = getLocalVue(true); + +const TEST_HISTORY_ID = "test-history-id"; +const TEST_HISTORY = { + id: TEST_HISTORY_ID, + name: "fake-history-name", + archived: false, +}; + +const ARCHIVED_TEST_HISTORY = { + ...TEST_HISTORY, + archived: true, +}; + +const REMOTE_FILES_API_ENDPOINT = new RegExp("/api/remote_files/plugins"); + +async function mountComponentWithHistory(history?: HistorySummary) { + const pinia = createTestingPinia(); + setActivePinia(pinia); + const historyStore = useHistoryStore(pinia); + + // the mocking method described in the pinia docs does not work in vue2 + // this is a work-around + jest.spyOn(historyStore, "getHistoryById").mockImplementation((_history_id: string) => history as HistorySummary); + + const wrapper = shallowMount(HistoryArchiveWizard, { + propsData: { historyId: TEST_HISTORY_ID }, + localVue, + }); + await flushPromises(); + return wrapper; +} + +describe("HistoryArchiveWizard.vue", () => { + let axiosMock: MockAdapter; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, []); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it("should render the history name in the header", async () => { + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const header = wrapper.find("h1"); + expect(header.text()).toContain(TEST_HISTORY.name); + }); + + it("should render only the simple archival mode when no writeable file sources are available", async () => { + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const optionTabs = wrapper.findAll(".archival-option-tabs"); + expect(optionTabs.exists()).toBe(false); + }); + + it("should render both archival modes when writeable file sources and celery tasks are available", async () => { + axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, [ + { + id: "test-posix-source", + type: "posix", + uri_root: "gxfiles://test-posix-source", + label: "TestSource", + doc: "For testing", + writable: true, + requires_roles: undefined, + requires_groups: undefined, + }, + ]); + const wrapper = await mountComponentWithHistory(TEST_HISTORY as HistorySummary); + + const optionTabs = wrapper.findAll(".archival-option-tabs"); + expect(optionTabs.exists()).toBe(true); + + const keepStorageOption = wrapper.find("#keep-storage-tab"); + expect(keepStorageOption.exists()).toBe(true); + + const freeStorageOption = wrapper.find("#free-storage-tab"); + expect(freeStorageOption.exists()).toBe(true); + }); + + it("should display a success alert when the history is archived instead of the archival options", async () => { + const wrapper = await mountComponentWithHistory(ARCHIVED_TEST_HISTORY as HistorySummary); + + const optionTabs = wrapper.findAll(".archival-option-tabs"); + expect(optionTabs.exists()).toBe(false); + + const successMessage = wrapper.find("#history-archived-alert"); + expect(successMessage.exists()).toBe(true); + }); +}); diff --git a/client/src/components/History/Archiving/HistoryArchiveWizard.vue b/client/src/components/History/Archiving/HistoryArchiveWizard.vue new file mode 100644 index 000000000000..3f7b2f905028 --- /dev/null +++ b/client/src/components/History/Archiving/HistoryArchiveWizard.vue @@ -0,0 +1,102 @@ + + + diff --git a/client/src/components/History/Archiving/IncludedBadge.vue b/client/src/components/History/Archiving/IncludedBadge.vue new file mode 100644 index 000000000000..118f40d4ef3c --- /dev/null +++ b/client/src/components/History/Archiving/IncludedBadge.vue @@ -0,0 +1,28 @@ + + + diff --git a/client/src/components/History/CurrentHistory/HistoryEmpty.vue b/client/src/components/History/CurrentHistory/HistoryEmpty.vue index dbd74b8ef7a5..addac9563877 100644 --- a/client/src/components/History/CurrentHistory/HistoryEmpty.vue +++ b/client/src/components/History/CurrentHistory/HistoryEmpty.vue @@ -4,7 +4,7 @@ {{ message | l }} -

+

You can load your own data or get data from an external source. @@ -18,6 +18,7 @@ import { useGlobalUploadModal } from "composables/globalUploadModal"; export default { props: { message: { type: String, default: "This history is empty." }, + writable: { type: Boolean, default: true }, }, setup() { const { openGlobalUploadModal } = useGlobalUploadModal(); diff --git a/client/src/components/History/CurrentHistory/HistoryNavigation.test.js b/client/src/components/History/CurrentHistory/HistoryNavigation.test.js index 798e7e55b52a..ffa76b2db7b3 100644 --- a/client/src/components/History/CurrentHistory/HistoryNavigation.test.js +++ b/client/src/components/History/CurrentHistory/HistoryNavigation.test.js @@ -14,6 +14,7 @@ const expectedOptions = [ "Delete this History", "Export Tool Citations", "Export History to File", + "Archive History", "Extract Workflow", "Show Invocations", "Share or Publish", diff --git a/client/src/components/History/CurrentHistory/HistoryNavigation.vue b/client/src/components/History/CurrentHistory/HistoryNavigation.vue index 28d7d0ed8a73..f6e97949f9c5 100644 --- a/client/src/components/History/CurrentHistory/HistoryNavigation.vue +++ b/client/src/components/History/CurrentHistory/HistoryNavigation.vue @@ -96,6 +96,15 @@ Export History to File + + + Archive History + +

- + Error in filter: @@ -429,7 +429,7 @@ export default { } }, onError(error) { - Toast.error(error); + Toast.error(`${error}`); }, updateFilterVal(newFilter, newVal) { this.filterText = FilterClass.setFilterValue(this.filterText, newFilter, newVal); diff --git a/client/src/components/History/HistoryView.test.js b/client/src/components/History/HistoryView.test.js index 985280af3488..69ac021af618 100644 --- a/client/src/components/History/HistoryView.test.js +++ b/client/src/components/History/HistoryView.test.js @@ -170,6 +170,6 @@ describe("History center panel View", () => { expect(importButton.exists()).toBe(false); // instead we have an alert - expect(wrapper.find("[data-description='history is purged']").text()).toBe("This history has been purged."); + expect(wrapper.find("[data-description='history state info']").text()).toBe("This history has been purged."); }); }); diff --git a/client/src/components/History/HistoryView.vue b/client/src/components/History/HistoryView.vue index 0ec77bed6274..702afbbdcc11 100644 --- a/client/src/components/History/HistoryView.vue +++ b/client/src/components/History/HistoryView.vue @@ -1,15 +1,15 @@