Merge pull request #20666 from davelopez/history_export_wizard

Unify History export UX using wizard
This commit is contained in:
Alireza Heidari
2025-07-22 16:26:50 +02:00
committed by GitHub
11 changed files with 1192 additions and 605 deletions
@@ -1,24 +1,24 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faCheckCircle,
faClock,
faDownload,
faExclamationCircle,
faExclamationTriangle,
faFileImport,
faHourglassEnd,
faLink,
faSpinner,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert } from "bootstrap-vue";
import { computed } from "vue";
import type { ColorVariant } from ".";
import type { ExportRecord } from "./models/exportRecordModel";
import type { ColorVariant } from "@/components/Common";
import type { CardAction, CardBadge } from "@/components/Common/GCard.types";
import type { ExportRecord } from "@/components/Common/models/exportRecordModel";
import GButton from "@/components/BaseComponents/GButton.vue";
import GCard from "@/components/Common/GCard.vue";
import Heading from "@/components/Common/Heading.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
library.add(faCheckCircle, faClock, faExclamationCircle, faExclamationTriangle, faLink);
interface Props {
record: ExportRecord;
@@ -39,22 +39,88 @@ const emit = defineEmits<{
(e: "onCopyDownloadLink", record: ExportRecord): void;
}>();
const title = computed(() => (props.record.isReady ? `Exported` : `Export started`));
const preparingMessage = computed(
() => `Preparing export. This may take some time depending on the size of your ${props.objectType}`
);
const primaryActions = computed<CardAction[]>(() => {
const actions: CardAction[] = [];
async function reimportObject() {
emit("onReimport", props.record);
}
if (props.record.canDownload) {
actions.push({
id: "copy-download-link",
label: "Copy Link",
icon: faLink,
title: "Copy the download link to clipboard",
variant: "outline-primary",
handler: () => emit("onCopyDownloadLink", props.record),
});
actions.push({
id: "download",
label: "Download",
icon: faDownload,
title: "Download the result",
variant: "primary",
handler: () => emit("onDownload", props.record),
});
}
function downloadObject() {
emit("onDownload", props.record);
}
if (props.record.canReimport) {
actions.push({
id: "reimport",
label: "Reimport",
icon: faFileImport,
title: `Reimport the ${props.objectType} from this export record`,
variant: "primary",
handler: () => emit("onReimport", props.record),
});
}
function copyDownloadLink() {
emit("onCopyDownloadLink", props.record);
}
return actions;
});
const badges = computed<CardBadge[]>(() => {
const badges: CardBadge[] = [];
if (props.record.isPreparing) {
badges.push({
id: "in-progress",
title: `${props.objectType} is being prepared for download`,
label: "In Progress",
variant: "info",
});
}
if (props.record.canDownload) {
badges.push({
id: "ready-to-download",
title: `${props.objectType} is ready to download`,
label: "Ready to Download",
variant: "success",
});
}
if (props.record.hasFailed) {
badges.push({
id: "failed",
title: `Failed to prepare ${props.objectType} for download`,
label: "Failed",
variant: "danger",
});
} else if (props.record.canExpire) {
if (props.record.hasExpired) {
badges.push({
id: "expired",
title: `The ${props.objectType} has expired and can no longer be downloaded. Please generate a new export.`,
label: "Expired",
variant: "warning",
icon: faHourglassEnd,
});
} else {
badges.push({
id: "expires-soon",
label: `Expires ${props.record.expirationElapsedTime}`,
title: `The ${props.objectType} was exported ${props.record.elapsedTime} and this download link will expire in ${props.record.expirationElapsedTime}`,
variant: "warning",
icon: faHourglassEnd,
});
}
}
return badges;
});
function onMessageDismissed() {
emit("onActionMessageDismissed");
@@ -62,95 +128,68 @@ function onMessageDismissed() {
</script>
<template>
<div class="export-record-details">
<Heading size="sm">
<b>{{ title }}</b> {{ props.record.elapsedTime }}
</Heading>
<p v-if="!props.record.isPreparing">
Format: <b class="record-archive-format">{{ props.record.modelStoreFormat }}</b>
</p>
<span v-if="props.record.isPreparing">
<LoadingSpan :message="preparingMessage" />
</span>
<div v-else>
<div v-if="props.record.hasFailed">
<GCard id="latest-export-record" class="export-record-details" :primary-actions="primaryActions" :badges="badges">
<template v-slot:title>
<Heading h3 size="sm">
<span v-if="props.record.isPreparing">
<FontAwesomeIcon :icon="faSpinner" spin class="mr-1" />
Preparing {{ props.objectType }} for download...
</span>
<span v-else>
This {{ props.objectType }} was exported <b>{{ props.record.elapsedTime }}</b>
</span>
</Heading>
</template>
<template v-slot:description>
<span v-if="props.record.hasFailed">
<FontAwesomeIcon
:icon="faExclamationCircle"
class="text-danger record-failed-icon"
title="Export failed" />
<span>
Something failed during this export. Please try again and if the problem persist contact your
Something failed during the export process. Please try again and if the problem persist contact your
administrator.
</span>
<BAlert show variant="danger">{{ props.record.errorMessage }}</BAlert>
</div>
<div v-else-if="props.record.isUpToDate" title="Up to date">
<BAlert :show="props.record.errorMessage" variant="danger">{{ props.record.errorMessage }}</BAlert>
</span>
<span v-if="props.record.isUpToDate" title="Up to date">
<FontAwesomeIcon :icon="faCheckCircle" class="text-success record-up-to-date-icon" />
<span> This export record contains the latest changes of the {{ props.objectType }}. </span>
</div>
<div v-else>
<span>
This export record contains the latest changes of the {{ props.objectType }} in
<b class="record-archive-format">{{ props.record.modelStoreFormat }}</b> format.
</span>
</span>
<span v-else>
<FontAwesomeIcon :icon="faExclamationTriangle" class="text-warning record-outdated-icon" />
<span>
This export is outdated and contains the changes of this {{ props.objectType }} from
{{ props.record.elapsedTime }}.
</span>
</div>
</span>
<p v-if="props.record.canExpire" class="mt-3">
<span v-if="props.record.canExpire" class="mt-3">
<span v-if="props.record.hasExpired">
<FontAwesomeIcon :icon="faClock" class="text-danger record-expired-icon" />
This download link has expired.
<FontAwesomeIcon :icon="faHourglassEnd" class="text-danger record-expired-icon" />
This download link has expired, and the result is no longer available. You can generate a new export
before downloading it again.
</span>
<span v-else>
<FontAwesomeIcon :icon="faClock" class="text-warning record-expiration-warning-icon" />
<FontAwesomeIcon :icon="faHourglassEnd" class="text-warning record-expiration-warning-icon" />
This download link expires {{ props.record.expirationElapsedTime }}.
</span>
</p>
</span>
<div v-if="props.record.isReady">
<p class="mt-3">You can do the following actions with this {{ props.objectType }} export:</p>
<BAlert
v-if="props.actionMessage !== undefined"
:variant="props.actionMessageVariant"
show
fade
dismissible
@dismissed="onMessageDismissed">
{{ props.actionMessage }}
</BAlert>
<div v-else class="actions">
<GButton
v-if="props.record.canDownload"
class="record-download-btn"
color="blue"
@click="downloadObject">
Download
</GButton>
<GButton
v-if="props.record.canDownload"
title="Copy Download Link"
size="small"
color="blue"
transparent
@click.stop="copyDownloadLink">
<FontAwesomeIcon :icon="faLink" />
</GButton>
<GButton
v-if="props.record.canReimport"
class="record-reimport-btn"
color="blue"
@click="reimportObject">
Reimport
</GButton>
</div>
</div>
</div>
</div>
<BAlert
v-if="props.actionMessage !== undefined"
:variant="props.actionMessageVariant"
show
fade
dismissible
@dismissed="onMessageDismissed">
{{ props.actionMessage }}
</BAlert>
</template>
</GCard>
</template>
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faCheckCircle,
faDownload,
@@ -9,16 +8,13 @@ import {
faSpinner,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BCard, BCollapse, BLink, BTable } from "bootstrap-vue";
import { computed, ref } from "vue";
import { BTable } from "bootstrap-vue";
import type { ExportRecord } from "./models/exportRecordModel";
import GButton from "@/components/BaseComponents/GButton.vue";
import GButtonGroup from "@/components/BaseComponents/GButtonGroup.vue";
library.add(faCheckCircle, faDownload, faExclamationCircle, faFileImport, faLink, faSpinner);
interface Props {
records: ExportRecord[];
}
@@ -39,10 +35,6 @@ const fields = [
{ key: "actions", label: "Actions" },
];
const isExpanded = ref(false);
const title = computed(() => (isExpanded.value ? `Hide old export records` : `Show old export records`));
async function reimportObject(record: ExportRecord) {
emit("onReimport", record);
}
@@ -57,105 +49,87 @@ function copyDownloadLink(record: ExportRecord) {
</script>
<template>
<div>
<BLink
:class="isExpanded ? null : 'collapsed'"
:aria-expanded="isExpanded ? 'true' : 'false'"
aria-controls="collapse-previous"
@click="isExpanded = !isExpanded">
{{ title }}
</BLink>
<BTable :items="props.records" :fields="fields">
<template v-slot:cell(elapsedTime)="row">
<span :title="row.item.date">{{ row.value }}</span>
</template>
<BCollapse id="collapse-previous" v-model="isExpanded">
<BCard>
<BTable :items="props.records" :fields="fields">
<template v-slot:cell(elapsedTime)="row">
<span :title="row.item.date">{{ row.value }}</span>
</template>
<template v-slot:cell(format)="row">
<span>{{ row.item.modelStoreFormat }}</span>
</template>
<template v-slot:cell(format)="row">
<span>{{ row.item.modelStoreFormat }}</span>
</template>
<template v-slot:cell(expires)="row">
<span v-if="row.item.hasExpired">Expired</span>
<template v-slot:cell(expires)="row">
<span v-if="row.item.hasExpired">Expired</span>
<span v-else-if="row.item.expirationDate" :title="row.item.expirationDate">
{{ row.item.expirationElapsedTime }}
</span>
<span v-else-if="row.item.expirationDate" :title="row.item.expirationDate">
{{ row.item.expirationElapsedTime }}
</span>
<span v-else>No</span>
</template>
<span v-else>No</span>
</template>
<template v-slot:cell(isUpToDate)="row">
<FontAwesomeIcon
v-if="row.item.isUpToDate"
:icon="faCheckCircle"
class="text-success"
title="This export record contains the latest changes." />
<FontAwesomeIcon
v-else
:icon="faExclamationCircle"
class="text-danger"
title="This export record is outdated. Please consider generating a new export if you need the latest changes." />
</template>
<template v-slot:cell(isUpToDate)="row">
<FontAwesomeIcon
v-if="row.item.isUpToDate"
:icon="faCheckCircle"
class="text-success"
title="This export record contains the latest changes." />
<FontAwesomeIcon
v-else
:icon="faExclamationCircle"
class="text-danger"
title="This export record is outdated. Please consider generating a new export if you need the latest changes." />
</template>
<template v-slot:cell(isReady)="row">
<FontAwesomeIcon
v-if="row.item.isReady"
:icon="faCheckCircle"
class="text-success"
title="Ready to download or import." />
<FontAwesomeIcon
v-else-if="row.item.isPreparing"
:icon="faSpinner"
spin
class="text-info"
title="Exporting in progress..." />
<FontAwesomeIcon
v-else-if="row.item.hasExpired"
:icon="faExclamationCircle"
class="text-danger"
title="The export has expired." />
<FontAwesomeIcon v-else :icon="faExclamationCircle" class="text-danger" title="The export failed." />
</template>
<template v-slot:cell(isReady)="row">
<FontAwesomeIcon
v-if="row.item.isReady"
:icon="faCheckCircle"
class="text-success"
title="Ready to download or import." />
<FontAwesomeIcon
v-else-if="row.item.isPreparing"
:icon="faSpinner"
spin
class="text-info"
title="Exporting in progress..." />
<FontAwesomeIcon
v-else-if="row.item.hasExpired"
:icon="faExclamationCircle"
class="text-danger"
title="The export has expired." />
<FontAwesomeIcon
v-else
:icon="faExclamationCircle"
class="text-danger"
title="The export failed." />
</template>
<template v-slot:cell(actions)="row">
<GButtonGroup aria-label="Actions">
<GButton
tooltip
tooltip-placement="bottom"
:disabled="!row.item.canDownload"
title="Download"
@click="downloadObject(row.item)">
<FontAwesomeIcon :icon="faDownload" />
</GButton>
<template v-slot:cell(actions)="row">
<GButtonGroup aria-label="Actions">
<GButton
tooltip
tooltip-placement="bottom"
:disabled="!row.item.canDownload"
title="Download"
@click="downloadObject(row.item)">
<FontAwesomeIcon :icon="faDownload" />
</GButton>
<GButton
v-if="row.item.canDownload"
tooltip
tooltip-placement="bottom"
title="Copy Download Link"
@click.stop="copyDownloadLink(row.item)">
<FontAwesomeIcon :icon="faLink" />
</GButton>
<GButton
v-if="row.item.canDownload"
tooltip
tooltip-placement="bottom"
title="Copy Download Link"
@click.stop="copyDownloadLink(row.item)">
<FontAwesomeIcon :icon="faLink" />
</GButton>
<GButton
tooltip
tooltip-placement="bottom"
:disabled="!row.item.canReimport"
title="Reimport"
@click="reimportObject(row.item)">
<FontAwesomeIcon :icon="faFileImport" />
</GButton>
</GButtonGroup>
</template>
</BTable>
</BCard>
</BCollapse>
</div>
<GButton
tooltip
tooltip-placement="bottom"
:disabled="!row.item.canReimport"
title="Reimport"
@click="reimportObject(row.item)">
<FontAwesomeIcon :icon="faFileImport" />
</GButton>
</GButtonGroup>
</template>
</BTable>
</template>
@@ -9,10 +9,10 @@ import type {
} from "@/api";
export interface ExportParams {
readonly modelStoreFormat: ModelStoreFormat;
readonly includeFiles: boolean;
readonly includeDeleted: boolean;
readonly includeHidden: boolean;
modelStoreFormat: ModelStoreFormat;
includeFiles: boolean;
includeDeleted: boolean;
includeHidden: boolean;
}
export interface ExportRecord {
@@ -30,7 +30,7 @@ export interface ExportRecord {
readonly isStsDownload: boolean;
readonly canDownload: boolean;
readonly modelStoreFormat: ModelStoreFormat;
readonly exportParams?: ExportParams;
readonly exportParams?: Readonly<ExportParams>;
readonly duration?: number | null;
readonly canExpire: boolean;
readonly isPermanent: boolean;
@@ -1,81 +0,0 @@
<script setup lang="ts">
import { BCard, BCollapse, BFormCheckbox, BFormGroup, BFormSelect, BLink } from "bootstrap-vue";
import { computed, reactive, ref } from "vue";
import { AVAILABLE_EXPORT_FORMATS } from "@/api/histories.export";
import type { ExportParams } from "@/components/Common/models/exportRecordModel";
interface Props {
exportParams: ExportParams;
}
const props = defineProps<Props>();
const emit = defineEmits(["onValueChanged"]);
const isExpanded = ref(false);
const title = computed(() => (isExpanded.value ? `Hide advanced export options` : `Show advanced export options`));
const localOptions = reactive({
modelStoreFormat: props.exportParams.modelStoreFormat,
includeFiles: props.exportParams.includeFiles,
includeDeleted: props.exportParams.includeDeleted,
includeHidden: props.exportParams.includeHidden,
});
function onValueChanged() {
emit("onValueChanged", localOptions);
}
</script>
<template>
<div>
<BLink
id="toggle-options-link"
:class="isExpanded ? null : 'collapsed'"
:aria-expanded="isExpanded ? 'true' : 'false'"
aria-controls="collapse-options"
@click="isExpanded = !isExpanded">
{{ title }}
</BLink>
<BCollapse id="collapse-options" v-model="isExpanded">
<BCard>
<BFormGroup label="Export Format:" label-for="format">
<BFormSelect
id="format-selector"
v-model="localOptions.modelStoreFormat"
:options="AVAILABLE_EXPORT_FORMATS"
value-field="id"
text-field="name"
@change="onValueChanged" />
</BFormGroup>
<BFormGroup label="Dataset files included in the package:">
<BFormCheckbox
id="include-files-check"
v-model="localOptions.includeFiles"
switch
@change="onValueChanged">
Include Active
</BFormCheckbox>
<BFormCheckbox
id="include-deleted-check"
v-model="localOptions.includeDeleted"
switch
@change="onValueChanged">
Include Deleted (not purged)
</BFormCheckbox>
<BFormCheckbox
id="include-hidden-check"
v-model="localOptions.includeHidden"
switch
@change="onValueChanged">
Include Hidden
</BFormCheckbox>
</BFormGroup>
</BCard>
</BCollapse>
</div>
</template>
@@ -7,7 +7,6 @@ import { setActivePinia } from "pinia";
import type { HistorySummary } from "@/api";
import { useServerMock } from "@/api/client/__mocks__";
import { fetchHistoryExportRecords } from "@/api/histories.export";
import type { BrowsableFilesSourcePlugin } from "@/api/remoteFiles";
import {
EXPIRED_STS_DOWNLOAD_RECORD,
FILE_SOURCE_STORE_RECORD,
@@ -39,24 +38,12 @@ const FAKE_HISTORY: HistorySummary = {
url: FAKE_HISTORY_URL,
};
const REMOTE_FILES_API_RESPONSE: BrowsableFilesSourcePlugin[] = [
{
id: "test-posix-source",
type: "posix",
label: "TestSource",
doc: "For testing",
writable: true,
browsable: true,
requires_roles: undefined,
requires_groups: undefined,
supports: {
pagination: false,
search: false,
sorting: false,
},
uri_root: "gxfiles://test-posix-source",
},
];
const selectors = {
historyName: "#history-name",
latestExportRecord: "#latest-export-record",
showPreviousExportRecordsButton: "#show-old-records-button",
fatalErrorAlert: "#fatal-error-alert",
} as const;
async function mountHistoryExport() {
const pinia = createTestingPinia({ stubActions: false });
@@ -81,10 +68,6 @@ describe("HistoryExport.vue", () => {
if (historyId === FAKE_HISTORY_ID) {
return response(200).json(FAKE_HISTORY);
}
}),
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json([]);
})
);
});
@@ -92,22 +75,16 @@ describe("HistoryExport.vue", () => {
it("should render the history name", async () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#history-name").text()).toBe(FAKE_HISTORY.name);
expect(wrapper.find(selectors.historyName).text()).toBe(FAKE_HISTORY.name);
});
it("should render export options", async () => {
it("should not display the latest export record if there is no export record", async () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#history-export-options").exists()).toBe(true);
expect(wrapper.find(selectors.latestExportRecord).exists()).toBe(false);
});
it("should display a message indicating there are no exports where there are none", async () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#no-export-records-alert").exists()).toBe(true);
});
it("should render previous records when there is more than one record", async () => {
it("should display the previous records button when there is more than one record", async () => {
mockFetchExportRecords.mockResolvedValue([
RECENT_STS_DOWNLOAD_RECORD,
FILE_SOURCE_STORE_RECORD,
@@ -115,79 +92,14 @@ describe("HistoryExport.vue", () => {
]);
const wrapper = await mountHistoryExport();
expect(wrapper.find("#previous-export-records").exists()).toBe(true);
expect(wrapper.find(selectors.showPreviousExportRecordsButton).exists()).toBe(true);
});
it("should not render previous records when there is one or less records", async () => {
it("should not display the previous records button when there is one or less records", async () => {
mockFetchExportRecords.mockResolvedValue([RECENT_STS_DOWNLOAD_RECORD]);
const wrapper = await mountHistoryExport();
expect(wrapper.find("#previous-export-records").exists()).toBe(false);
});
it("should display file sources tab if there are available", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => response(200).json(REMOTE_FILES_API_RESPONSE))
);
const wrapper = await mountHistoryExport();
expect(wrapper.find("#direct-download-tab").exists()).toBe(true);
expect(wrapper.find("#file-source-tab").exists()).toBe(true);
});
it("should not display file sources tab if there are no file sources available", async () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#direct-download-tab").exists()).toBe(true);
expect(wrapper.find("#file-source-tab").exists()).toBe(false);
});
it("should display the ZENODO tab if the Zenodo plugin is available", async () => {
const zenodoPlugin: BrowsableFilesSourcePlugin = {
id: "zenodo",
type: "rdm",
label: "Zenodo",
doc: "For testing",
writable: true,
browsable: true,
supports: {
pagination: false,
search: false,
sorting: false,
},
uri_root: "zenodo://",
};
server.use(http.get("/api/remote_files/plugins", ({ response }) => response(200).json([zenodoPlugin])));
const wrapper = await mountHistoryExport();
expect(wrapper.find("#zenodo-file-source-tab").exists()).toBe(true);
});
it("should display the ZENODO tab if the user has a user-defined Zenodo file source", async () => {
const userZenodoPlugin: BrowsableFilesSourcePlugin = {
id: "998c5bba-b18f-4223-9c93-0f36fa2fdae8",
type: "zenodo",
label: "ZENODO",
doc: "My integration with Zenodo",
browsable: true,
writable: true,
requires_roles: null,
requires_groups: null,
url: "https://zenodo.org/",
supports: {
pagination: true,
search: true,
sorting: false,
},
uri_root: "gxuserfiles://998c5bba-b18f-4223-9c93-0f36fa2fdae8",
};
server.use(http.get("/api/remote_files/plugins", ({ response }) => response(200).json([userZenodoPlugin])));
const wrapper = await mountHistoryExport();
expect(wrapper.find("#zenodo-file-source-tab").exists()).toBe(true);
expect(wrapper.find(selectors.showPreviousExportRecordsButton).exists()).toBe(false);
});
it("should not display a fatal error alert if the history is found and loaded", async () => {
@@ -195,11 +107,8 @@ describe("HistoryExport.vue", () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#fatal-error-alert").exists()).toBe(false);
expect(wrapper.find("#history-name").exists()).toBe(true);
expect(wrapper.find("#history-export-options").exists()).toBe(true);
expect(wrapper.find("#direct-download-tab").exists()).toBe(true);
expect(wrapper.find(selectors.fatalErrorAlert).exists()).toBe(false);
expect(wrapper.find(selectors.historyName).exists()).toBe(true);
});
it("should not render the UI and display a fatal error message if the history cannot be found or loaded", async () => {
@@ -217,10 +126,7 @@ describe("HistoryExport.vue", () => {
const wrapper = await mountHistoryExport();
expect(wrapper.find("#fatal-error-alert").exists()).toBe(true);
expect(wrapper.find("#history-name").exists()).toBe(false);
expect(wrapper.find("#history-export-options").exists()).toBe(false);
expect(wrapper.find("#direct-download-tab").exists()).toBe(false);
expect(wrapper.find(selectors.fatalErrorAlert).exists()).toBe(true);
expect(wrapper.find(selectors.historyName).exists()).toBe(false);
});
});
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faFileExport } from "@fortawesome/free-solid-svg-icons";
import { faFileExport, faList } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BButton, BCard, BTab, BTabs } from "bootstrap-vue";
import { BAlert } from "bootstrap-vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { AnyHistory } from "@/api";
@@ -12,24 +11,24 @@ import {
reimportHistoryFromRecord,
} from "@/api/histories.export";
import type { ColorVariant } from "@/components/Common";
import { areEqual, type ExportParams, type ExportRecord } from "@/components/Common/models/exportRecordModel";
import type { ExportParams, ExportRecord } from "@/components/Common/models/exportRecordModel";
import { useConfirmDialog } from "@/composables/confirmDialog";
import { useDownloadTracker } from "@/composables/downloadTracker";
import { useFileSources } from "@/composables/fileSources";
import { DEFAULT_EXPORT_PARAMS, useShortTermStorage } from "@/composables/shortTermStorage";
import { useShortTermStorage } from "@/composables/shortTermStorage";
import { useTaskMonitor } from "@/composables/taskMonitor";
import { useHistoryStore } from "@/stores/historyStore";
import { copy as sendToClipboard } from "@/utils/clipboard";
import { absPath } from "@/utils/redirect";
import { errorMessageAsString } from "@/utils/simple-error";
import ExportOptions from "./ExportOptions.vue";
import ExportToFileSourceForm from "@/components/Common/ExportForm.vue";
import ExportToRDMRepositoryForm from "@/components/Common/ExportRDMForm.vue";
import type { HistoryExportData } from "./types";
import HistoryExportWizard from "./HistoryExportWizard.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import GModal from "@/components/BaseComponents/GModal.vue";
import ExportRecordDetails from "@/components/Common/ExportRecordDetails.vue";
import ExportRecordTable from "@/components/Common/ExportRecordTable.vue";
import RDMCredentialsInfo from "@/components/Common/RDMCredentialsInfo.vue";
import ExternalLink from "@/components/ExternalLink.vue";
import Heading from "@/components/Common/Heading.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
const {
@@ -40,14 +39,6 @@ const {
hasFailed: taskHasFailed,
} = useTaskMonitor();
const { hasWritable: hasWritableFileSources } = useFileSources({ exclude: ["rdm"] });
const {
hasWritable: hasWritableRDMFileSources,
getFileSourceById,
getFileSourcesByType,
isPrivateFileSource,
} = useFileSources({ include: ["rdm"] });
const {
isPreparing: isPreparingDownload,
prepareHistoryDownload,
@@ -66,34 +57,18 @@ interface Props {
const props = defineProps<Props>();
library.add(faFileExport);
const POLLING_DELAY = 3000;
const exportParams = ref(DEFAULT_EXPORT_PARAMS);
const isLoadingRecords = ref(true);
const exportRecords = ref<ExportRecord[]>([]);
const history = ref<AnyHistory>();
const isLoadingHistory = ref(true);
const historyName = computed(() => history.value?.name ?? props.historyId);
const defaultFileName = computed(() => `(Galaxy History) ${historyName.value}`);
const latestExportRecord = computed(() => (exportRecords.value?.length ? exportRecords.value.at(0) : null));
const isLatestExportRecordReadyToDownload = computed(
() =>
latestExportRecord.value &&
latestExportRecord.value.isUpToDate &&
latestExportRecord.value.canDownload &&
areEqual(latestExportRecord.value.exportParams, exportParams.value)
);
const canGenerateDownload = computed(() => !isPreparingDownload.value && !isLatestExportRecordReadyToDownload.value);
const previousExportRecords = computed(() => (exportRecords.value ? exportRecords.value.slice(1) : []));
const hasPreviousExports = computed(() => previousExportRecords.value?.length > 0);
const availableRecordsMessage = computed(() =>
isLoadingRecords.value
? "Loading export records..."
: "This history has no export records yet. You can choose one of the export options above."
);
const isBusy = computed(() => isLoadingRecords.value || isPreparingDownload.value || isExportTaskRunning.value);
const historyStore = useHistoryStore();
@@ -101,7 +76,7 @@ const isFatalError = ref(false);
const errorMessage = ref<string>();
const actionMessage = ref<string>();
const actionMessageVariant = ref<ColorVariant>();
const zenodoSource = computed(() => getZenodoSource());
const showOldRecords = ref(false);
onMounted(async () => {
if (await loadHistory()) {
@@ -116,12 +91,6 @@ watch(isExportTaskRunning, (newValue, oldValue) => {
}
});
function getZenodoSource() {
const zenodoSources = getFileSourcesByType("zenodo");
// Prioritize the one provided by the user in case there are multiple
return zenodoSources.find((fs) => isPrivateFileSource(fs) && fs.writable) ?? getFileSourceById("zenodo");
}
async function loadHistory() {
isLoadingHistory.value = true;
try {
@@ -164,15 +133,28 @@ async function updateExports() {
}
}
async function doExportToFileSource(exportDirectory: string, fileName: string) {
await exportHistoryToFileSource(props.historyId, exportDirectory, fileName, exportParams.value);
async function onExportHistory(exportData: HistoryExportData) {
switch (exportData.destination) {
case "download":
await prepareDownload(exportData);
break;
case "remote-source":
case "rdm-repository":
case "zenodo-repository":
await exportToFileSource(exportData);
break;
}
}
async function exportToFileSource(exportData: HistoryExportData) {
await exportHistoryToFileSource(props.historyId, exportData.remoteUri, exportData.outputFileName, exportData);
updateExports();
}
async function prepareDownload() {
async function prepareDownload(exportParams: ExportParams) {
const result = await prepareHistoryDownload(props.historyId, {
pollDelayInMs: POLLING_DELAY,
exportParams: exportParams.value,
exportParams: exportParams,
});
if (result) {
downloadTracker.trackDownloadRequestWithData({
@@ -231,10 +213,8 @@ function onActionMessageDismissedFromRecord() {
actionMessageVariant.value = undefined;
}
function updateExportParams(newParams: ExportParams) {
exportParams.value = {
...newParams,
};
function onShowOldRecords() {
showOldRecords.value = true;
}
onUnmounted(() => {
@@ -243,164 +223,72 @@ onUnmounted(() => {
});
</script>
<template>
<span class="history-export-component">
<FontAwesomeIcon icon="file-export" size="2x" class="text-primary float-left mr-2" />
<h1 class="h-lg">
<div class="history-export-component">
<Heading h1 separator inline>
<FontAwesomeIcon :icon="faFileExport" class="text-primary float-left mr-2" />
Export
<b v-if="isFatalError" class="text-danger">Error</b>
<LoadingSpan v-else-if="!history" spinner-only />
<b v-else id="history-name">{{ historyName }}</b>
</h1>
</Heading>
<BAlert v-if="isFatalError" id="fatal-error-alert" variant="danger" class="mt-3" show>
{{ errorMessage }}
</BAlert>
<div v-if="history">
<ExportOptions
id="history-export-options"
class="mt-3"
:export-params="exportParams"
@onValueChanged="updateExportParams" />
<div v-if="latestExportRecord">
<Heading h2 size="md">Latest Export Record</Heading>
<ExportRecordDetails
v-if="latestExportRecord"
:record="latestExportRecord"
:action-message="actionMessage"
:action-message-variant="actionMessageVariant"
object-type="history"
@onDownload="downloadFromRecord"
@onCopyDownloadLink="copyDownloadLinkFromRecord"
@onReimport="reimportFromRecord"
@onActionMessageDismissed="onActionMessageDismissedFromRecord" />
<h2 class="h-md mt-3">How do you want to export this history?</h2>
<BCard no-body class="mt-3">
<BTabs pills card vertical>
<BTab
id="direct-download-tab"
title="to direct download"
title-link-class="tab-export-to-link"
active>
<p>
Here you can generate a temporal download for your history. When your download link expires
or your history changes you can re-generate it again.
</p>
<GButton
v-if="hasPreviousExports"
id="show-old-records-button"
outline
color="blue"
@click="onShowOldRecords">
<FontAwesomeIcon :icon="faList" class="mr-1" />
Show old export records
</GButton>
<BAlert show variant="warning">
History archive downloads can expire and are removed at regular intervals. For permanent
storage, export to a <b>repository</b> or download and then import the archive on another
Galaxy server.
</BAlert>
<Heading h2 size="md" class="mt-3">Export your history again</Heading>
</div>
<BButton
class="gen-direct-download-btn"
:disabled="!canGenerateDownload"
variant="primary"
@click="prepareDownload">
Generate direct download
</BButton>
<span v-if="isPreparingDownload">
<LoadingSpan message="Galaxy is preparing your download, this will likely take a while" />
</span>
<BAlert v-else-if="isLatestExportRecordReadyToDownload" variant="success" class="mt-3" show>
The latest export record is ready. Use the download button below to download it or change
the advanced export options above to generate a new one.
</BAlert>
</BTab>
<BTab
v-if="hasWritableFileSources"
id="file-source-tab"
title="to repository"
title-link-class="tab-export-to-file">
<p>
If you need a "more permanent" way of storing your history archive you can export it
directly to one of the available repositories. You will be able to re-import it later as
long as it remains available on the remote server.
</p>
<ExportToFileSourceForm
what="history"
:clear-input-after-export="true"
@export="doExportToFileSource" />
</BTab>
<BTab
v-if="hasWritableRDMFileSources"
id="rdm-file-source-tab"
title="to RDM repository"
title-link-class="tab-export-to-rdm-repo">
<p>You can <b>upload your history</b> to one of the available RDM repositories here.</p>
<RDMCredentialsInfo what="history export archive" />
<ExportToRDMRepositoryForm
what="history"
:default-filename="defaultFileName"
:default-record-name="historyName"
:clear-input-after-export="true"
@export="doExportToFileSource" />
</BTab>
<BTab
v-if="zenodoSource"
id="zenodo-file-source-tab"
:title="`to ${zenodoSource.label}`"
title-link-class="tab-export-to-zenodo-repo">
<div class="zenodo-info">
<img
src="https://raw.githubusercontent.com/zenodo/zenodo/master/zenodo/modules/theme/static/img/logos/zenodo-gradient-square.svg"
alt="ZENODO Logo" />
<p>
<ExternalLink href="https://zenodo.org"><b>Zenodo</b></ExternalLink> is a
general-purpose open repository developed under the
<ExternalLink href="https://www.openaire.eu">European OpenAIRE</ExternalLink> program
and operated by <ExternalLink href="https://home.cern">CERN</ExternalLink>. It allows
researchers to deposit research papers, data sets, research software, reports, and any
other research related digital artefacts. For each submission, a persistent
<b>digital object identifier (DOI)</b> is minted, which makes the stored items easily
citeable.
</p>
</div>
<RDMCredentialsInfo
what="history export archive"
:selected-repository="zenodoSource"
:is-private-file-source="isPrivateFileSource(zenodoSource)" />
<ExportToRDMRepositoryForm
what="history"
:default-filename="defaultFileName"
:default-record-name="historyName"
:clear-input-after-export="true"
:file-source="zenodoSource"
@export="doExportToFileSource">
</ExportToRDMRepositoryForm>
</BTab>
</BTabs>
</BCard>
<HistoryExportWizard
:history-id="props.historyId"
:history-name="history.name"
:is-busy="isBusy"
@onExport="onExportHistory" />
<BAlert v-if="errorMessage" id="last-export-record-error-alert" variant="danger" class="mt-3" show>
{{ errorMessage }}
</BAlert>
<div v-else-if="latestExportRecord">
<h2 class="h-md mt-3">Export Records</h2>
<BCard>
<ExportRecordDetails
:record="latestExportRecord"
object-type="history"
:action-message="actionMessage"
:action-message-variant="actionMessageVariant"
@onDownload="downloadFromRecord"
@onCopyDownloadLink="copyDownloadLinkFromRecord"
@onReimport="reimportFromRecord"
@onActionMessageDismissed="onActionMessageDismissedFromRecord" />
<ExportRecordTable
v-if="hasPreviousExports"
id="previous-export-records"
:records="previousExportRecords"
class="mt-3"
@onDownload="downloadFromRecord"
@onReimport="reimportFromRecord" />
</BCard>
</div>
<BAlert v-else id="no-export-records-alert" variant="info" class="mt-3" show>
{{ availableRecordsMessage }}
</BAlert>
</div>
</span>
</template>
<style scoped>
.zenodo-info {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
</style>
<GModal
:show.sync="showOldRecords"
:title="`Previous Export Records for ${historyName}`"
size="medium"
hide-header
fixed-height
class="history-export-old-records-modal"
centered>
<ExportRecordTable
v-if="hasPreviousExports"
id="previous-export-records"
:records="previousExportRecords"
class="mt-3"
@onDownload="downloadFromRecord"
@onReimport="reimportFromRecord" />
</GModal>
</div>
</template>
@@ -0,0 +1,436 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/jest/helpers";
import { mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { setActivePinia } from "pinia";
import { useServerMock } from "@/api/client/__mocks__";
import type { BrowsableFilesSourcePlugin } from "@/api/remoteFiles";
import HistoryExportWizard from "./HistoryExportWizard.vue";
const localVue = getLocalVue(true);
const FAKE_HISTORY_ID = "fake-history-id";
const FAKE_HISTORY_NAME = "Test History";
const REMOTE_FILES_API_RESPONSE: BrowsableFilesSourcePlugin[] = [
{
id: "test-posix-source",
type: "posix",
label: "TestSource",
doc: "For testing",
writable: true,
browsable: true,
requires_roles: undefined,
requires_groups: undefined,
supports: {
pagination: false,
search: false,
sorting: false,
},
uri_root: "gxfiles://test-posix-source",
},
];
const ZENODO_PLUGIN: BrowsableFilesSourcePlugin = {
id: "zenodo",
type: "rdm",
label: "Zenodo",
doc: "For testing",
writable: true,
browsable: true,
supports: {
pagination: false,
search: false,
sorting: false,
},
uri_root: "zenodo://",
};
const USER_ZENODO_PLUGIN: BrowsableFilesSourcePlugin = {
id: "998c5bba-b18f-4223-9c93-0f36fa2fdae8",
type: "zenodo",
label: "My Zenodo",
doc: "My integration with Zenodo",
browsable: true,
writable: true,
requires_roles: null,
requires_groups: null,
url: "https://zenodo.org/",
supports: {
pagination: true,
search: true,
sorting: false,
},
uri_root: "gxuserfiles://998c5bba-b18f-4223-9c93-0f36fa2fdae8",
};
const selectors = {
wizard: ".history-export-wizard",
formatCard: "[data-history-export-format]",
destinationCard: "[data-history-export-destination]",
directoryInput: "#directory",
fileNameInput: "#exported-file-name",
includeFilesCheckbox: 'input[type="checkbox"]',
submitButton: ".go-next-btn",
nextButton: ".go-next-btn",
previousButton: ".go-back-btn",
errorAlert: ".alert-danger",
} as const;
interface MountOptions {
historyId?: string;
historyName?: string;
isBusy?: boolean;
}
async function mountHistoryExportWizard(options: MountOptions = {}) {
const { historyId = FAKE_HISTORY_ID, historyName = FAKE_HISTORY_NAME, isBusy = false } = options;
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
const wrapper = mount(HistoryExportWizard as object, {
propsData: {
historyId,
historyName,
isBusy,
},
localVue,
pinia,
});
await flushPromises();
return wrapper;
}
const { server, http } = useServerMock();
describe("HistoryExportWizard.vue", () => {
beforeEach(() => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json([]);
})
);
});
describe("Component Initialization", () => {
it("should start with format selection step", async () => {
const wrapper = await mountHistoryExportWizard();
expect(wrapper.find(selectors.formatCard).exists()).toBe(true);
});
it("should display available export formats", async () => {
const wrapper = await mountHistoryExportWizard();
const formatCards = wrapper.findAll(selectors.formatCard);
expect(formatCards.length).toBe(2);
});
});
describe("Format Selection", () => {
it("should display format options", async () => {
const formats = [
{ id: "rocrate.zip", label: "RO-Crate" },
{ id: "tar.gz", label: "Compressed TGZ" },
];
const wrapper = await mountHistoryExportWizard();
const formatCards = wrapper.findAll(selectors.formatCard);
expect(formatCards.length).toBe(formats.length);
for (const format of formats) {
const card = wrapper.find(`[data-history-export-format="${format.id}"]`);
expect(card.exists()).toBe(true);
expect(card.text()).toContain(format.label);
}
});
});
describe("Destination Selection", () => {
it("should show download destination by default", async () => {
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
const nextButtonAfterFormat = wrapper.find(selectors.nextButton);
if (nextButtonAfterFormat.exists()) {
await nextButtonAfterFormat.trigger("click");
}
const downloadCard = wrapper.find('[data-history-export-destination="download"]');
expect(downloadCard.exists()).toBe(true);
});
it("should show remote source destination when file sources are available", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json(REMOTE_FILES_API_RESPONSE);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
const nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
const remoteCard = wrapper.find('[data-history-export-destination="remote-source"]');
expect(remoteCard.exists()).toBe(true);
});
it("should show Zenodo destination when Zenodo plugin is available", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json([ZENODO_PLUGIN]);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
const nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
const zenodoCard = wrapper.find('[data-history-export-destination="zenodo-repository"]');
expect(zenodoCard.exists()).toBe(true);
});
it("should prioritize user-defined Zenodo over default Zenodo", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json([ZENODO_PLUGIN, USER_ZENODO_PLUGIN]);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
const nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
const zenodoCard = wrapper.find('[data-history-export-destination="zenodo-repository"]');
if (zenodoCard.exists()) {
// Should show user-defined Zenodo label
expect(zenodoCard.text()).toContain("My Zenodo");
}
});
});
describe("Remote Source Setup", () => {
it("should show directory input when remote source is selected", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json(REMOTE_FILES_API_RESPONSE);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
let nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Select remote source
const remoteCard = wrapper.find('[data-history-export-destination="remote-source"]');
if (remoteCard.exists()) {
await remoteCard.trigger("click");
}
// Navigate to setup step
nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Check that the setup step is rendered with directory selection
expect(wrapper.text()).toContain("Select a 'repository' to export history to.");
});
});
describe("Export Summary", () => {
it("should display default file name placeholder", async () => {
// Set up a remote file source to show file name input
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json(REMOTE_FILES_API_RESPONSE);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate through wizard steps to final step
// Step 1: Select destination
let nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Step 2: Select remote source to require file name
const remoteCard = wrapper.find('[data-history-export-destination="remote-source"]');
if (remoteCard.exists()) {
await remoteCard.trigger("click");
}
// Step 3: Navigate to setup step
nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Simulate selecting a directory by triggering the input
const directoryInput = wrapper.find('input[placeholder="Click to select directory"]');
if (directoryInput.exists()) {
await directoryInput.setValue("gxfiles://test-remote-source/test-directory");
}
// Step 4: Navigate to final step
nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Check if the file name input has the expected placeholder text
const fileNameInput = wrapper.find(selectors.fileNameInput);
expect(fileNameInput.exists()).toBe(true);
expect(fileNameInput.attributes("placeholder")).toContain(FAKE_HISTORY_NAME);
});
});
describe("Event Handling", () => {
it("should emit onExport event when export is triggered", async () => {
const wrapper = await mountHistoryExportWizard();
// Navigate through wizard steps to final step
// Step 1: Select destination
let nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Step 2: Select download destination to skip setup steps
const downloadCard = wrapper.find('[data-history-export-destination="download"]');
if (downloadCard.exists()) {
await downloadCard.trigger("click");
}
// Step 3: Navigate to final step
nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
const exportButton = wrapper.find(selectors.submitButton);
await exportButton.trigger("click");
expect(wrapper.emitted("onExport")).toBeTruthy();
});
});
describe("Validation", () => {
it("should validate remote source selection", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json(REMOTE_FILES_API_RESPONSE);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
let nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Navigate to remote source setup
const remoteCard = wrapper.find('[data-history-export-destination="remote-source"]');
if (remoteCard.exists()) {
await remoteCard.trigger("click");
}
// Navigate to setup step
nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Check for directory input presence
const directoryInput = wrapper.find(selectors.directoryInput);
expect(directoryInput.exists() || wrapper.text().includes("Select a 'repository'")).toBe(true);
});
});
describe("Step Navigation", () => {
it("should show setup steps for remote source selection", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json(REMOTE_FILES_API_RESPONSE);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
let nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Select remote source
const remoteCard = wrapper.find('[data-history-export-destination="remote-source"]');
if (remoteCard.exists()) {
await remoteCard.trigger("click");
}
// Navigate to setup step
nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Should show setup for remote source
expect(wrapper.find(selectors.directoryInput).exists()).toBe(true);
});
it("should show Zenodo setup when Zenodo is selected", async () => {
server.use(
http.get("/api/remote_files/plugins", ({ response }) => {
return response(200).json([ZENODO_PLUGIN]);
})
);
const wrapper = await mountHistoryExportWizard();
// Navigate to destination step
let nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Select Zenodo
const zenodoCard = wrapper.find('[data-history-export-destination="zenodo-repository"]');
if (zenodoCard.exists()) {
await zenodoCard.trigger("click");
}
// Navigate to setup step
nextButton = wrapper.find(selectors.nextButton);
if (nextButton.exists()) {
await nextButton.trigger("click");
}
// Should show Zenodo-specific setup
expect(wrapper.text().includes("Zenodo") || wrapper.text().includes("draft record")).toBe(true);
});
});
});
@@ -0,0 +1,396 @@
<script setup lang="ts">
import { BAlert, BCard, BCardGroup, BCardTitle, BFormCheckbox, BFormGroup, BFormInput } from "bootstrap-vue";
import { computed, reactive, ref, watch } from "vue";
import { AVAILABLE_EXPORT_FORMATS } from "@/api/histories.export";
import { useWizard } from "@/components/Common/Wizard/useWizard";
import { borderVariant } from "@/components/Common/Wizard/utils";
import { useFileSources } from "@/composables/fileSources";
import { useMarkdown } from "@/composables/markdown";
import { DEFAULT_EXPORT_PARAMS } from "@/composables/shortTermStorage";
import type { HistoryExportData, HistoryExportDestination } from "./types";
import RDMCredentialsInfo from "@/components/Common/RDMCredentialsInfo.vue";
import RDMDestinationSelector from "@/components/Common/RDMDestinationSelector.vue";
import GenericWizard from "@/components/Common/Wizard/GenericWizard.vue";
import ExternalLink from "@/components/ExternalLink.vue";
import FilesInput from "@/components/FilesDialog/FilesInput.vue";
import FileSourceNameSpan from "@/components/FileSources/FileSourceNameSpan.vue";
const { renderMarkdown } = useMarkdown({ openLinksInNewPage: true });
const { hasWritable: hasWritableFileSources } = useFileSources({ exclude: ["rdm"] });
const {
hasWritable: hasWritableRDMFileSources,
getFileSourceById,
getFileSourcesByType,
isPrivateFileSource,
} = useFileSources({ include: ["rdm"] });
const resource = "history";
interface ExportDestinationInfo {
destination: HistoryExportDestination;
label: string;
markdownDescription: string;
}
interface Props {
historyId: string;
historyName: string;
isBusy: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "onExport", exportData: HistoryExportData): void;
}>();
const errorMessage = ref<string>();
const exportData: HistoryExportData = reactive(initializeExportData());
const defaultFileName = computed(() => `(Galaxy History) ${props.historyName}`);
const exportButtonLabel = computed(() => {
switch (exportData.destination) {
case "download":
return "Generate Download Link";
case "remote-source":
return "Export to Remote Source";
case "rdm-repository":
return "Export to RDM Repository";
case "zenodo-repository":
return "Export to Zenodo";
default:
return "Export";
}
});
const needsFileName = computed(
() =>
exportData.destination === "remote-source" ||
exportData.destination === "rdm-repository" ||
exportData.destination === "zenodo-repository"
);
const exportDestinationSummary = computed(() => {
const exportDestination = exportDestinationTargets.value.find(
(target) => target.destination === exportData.destination
);
return exportDestination?.label ?? "Unknown Destination";
});
const exportDestinationTargets = computed(initializeExportDestinations);
const exportFormats = computed(() => AVAILABLE_EXPORT_FORMATS);
const exportFormatName = computed(() => {
const format = exportFormats.value.find((f) => f.id === exportData.modelStoreFormat);
return format ? format.name : "";
});
const zenodoSource = computed(() => getZenodoSource());
const wizard = useWizard({
"select-format": {
label: "Select output format",
instructions: computed(() => {
return `Select the format you would like to export the ${resource} to and click Next to continue.`;
}),
isValid: () => true,
isSkippable: () => false,
},
"select-destination": {
label: "Select destination",
instructions: computed(() => {
return `Select where you would like to export the ${resource} ${exportFormatName.value} to and click Next to continue.`;
}),
isValid: () => true,
isSkippable: () => false,
},
"setup-remote": {
label: "Select remote source",
instructions: "Select remote source directory",
isValid: () => Boolean(exportData.remoteUri),
isSkippable: () => exportData.destination !== "remote-source",
},
"setup-rdm": {
label: "Select RDM repository",
instructions: "Select RDM repository and provide a draft record to export to",
isValid: () => Boolean(exportData.remoteUri),
isSkippable: () => exportData.destination !== "rdm-repository",
},
"setup-zenodo": {
label: "Select draft record",
instructions: "Select or create a draft record to export to",
isValid: () => Boolean(exportData.remoteUri),
isSkippable: () => exportData.destination !== "zenodo-repository",
},
"export-summary": {
label: "Export",
instructions: "Summary",
isValid: () => Boolean(exportData.outputFileName) || exportData.destination === "download",
isSkippable: () => false,
},
});
watch(
() => props.isBusy,
(newValue, oldValue) => {
if (oldValue && !newValue) {
resetWizard();
}
}
);
function getZenodoSource() {
const zenodoSources = getFileSourcesByType("zenodo");
// Prioritize the one provided by the user in case there are multiple
return zenodoSources.find((fs) => isPrivateFileSource(fs) && fs.writable) ?? getFileSourceById("zenodo");
}
function onRecordSelected(recordUri: string) {
exportData.remoteUri = recordUri;
}
async function exportHistory() {
emit("onExport", exportData);
}
function initializeExportDestinations(): ExportDestinationInfo[] {
const destinations: ExportDestinationInfo[] = [
{
destination: "download",
label: "Temporary Direct Download",
markdownDescription: `Generate a link to the export file and download it directly to your computer.
**Please note that the link will expire after 24 hours.**`,
},
];
if (hasWritableFileSources.value) {
destinations.push({
destination: "remote-source",
label: "Repository",
markdownDescription: `If you need a **more permanent** way of storing your ${resource} you can export it directly to one of the available repositories. You will be able to re-import it later as long as it remains available on the remote server.
Examples of remote sources include Amazon S3, Azure Storage, Google Drive... and other public or personal file sources that you have setup access to.`,
});
}
if (hasWritableRDMFileSources.value) {
destinations.push({
destination: "rdm-repository",
label: "RDM Repository",
markdownDescription: `You can upload your ${resource} to one of the available **Research Data Management repositories** here.
This will allow you to easily associate your ${resource} with your research project or publication.
Examples of RDM repositories include [Invenio RDM](https://inveniosoftware.org/products/rdm/) instances, [RSpace](https://www.researchspace.com/), and other public or personal repositories that you have setup access to.`,
});
}
if (zenodoSource.value) {
destinations.push({
destination: "zenodo-repository",
label: `${zenodoSource.value.label}`,
markdownDescription: `![Zenodo Logo](https://raw.githubusercontent.com/zenodo/zenodo/master/zenodo/modules/theme/static/img/logos/zenodo-gradient-square.svg)
[Zenodo](https://zenodo.org/) is a general-purpose open repository developed under the [European OpenAIRE](https://www.openaire.eu/) program and operated by [CERN](https://home.cern/). It allows researchers to deposit research papers, data sets, research software, reports, and any other research related digital artefacts. For each submission, a persistent **digital object identifier (DOI)** is minted, which makes the stored items easily citeable.`,
});
}
return destinations;
}
function initializeExportData(): HistoryExportData {
return {
...DEFAULT_EXPORT_PARAMS,
destination: "download",
remoteUri: "",
outputFileName: "",
};
}
function resetWizard() {
const initialExportData = initializeExportData();
Object.assign(exportData, initialExportData);
wizard.goTo("select-format");
}
</script>
<template>
<div>
<BAlert v-if="errorMessage" show dismissible fade variant="danger" @dismissed="errorMessage = undefined">
{{ errorMessage }}
</BAlert>
<GenericWizard
class="history-export-wizard"
:use="wizard"
:submit-button-label="exportButtonLabel"
:is-busy="isBusy"
@submit="exportHistory">
<div v-if="wizard.isCurrent('select-format')">
<BCardGroup deck>
<BCard
v-for="format in exportFormats"
:key="format.id"
:data-history-export-format="format.id"
class="wizard-selection-card"
:border-variant="borderVariant(exportData.modelStoreFormat == format.id)"
@click="exportData.modelStoreFormat = format.id">
<BCardTitle>
<b>{{ format.name }}</b>
</BCardTitle>
<div v-if="format.id === 'rocrate.zip'">
<p>
RO-Crate is a community effort to establish a lightweight approach to packaging research
data with their metadata. It makes use of schema.org annotations in JSON-LD format.
</p>
<img
class="card-img"
src="https://www.researchobject.org/ro-crate/assets/img/ro-crate-wide.svg"
alt="RO-Crate Logo" />
<br />
<ExternalLink href="https://www.researchobject.org/ro-crate/">
<b>Learn more</b>
</ExternalLink>
</div>
<div v-else>
<p>A compressed tar.gz archive containing the history and its metadata.</p>
</div>
</BCard>
</BCardGroup>
</div>
<div v-if="wizard.isCurrent('select-destination')">
<BCardGroup deck>
<BCard
v-for="target in exportDestinationTargets"
:key="target.destination"
:data-history-export-destination="target.destination"
:border-variant="borderVariant(exportData.destination === target.destination)"
:header-bg-variant="exportData.destination === target.destination ? 'primary' : 'default'"
:header-text-variant="exportData.destination === target.destination ? 'white' : 'default'"
:header="target.label"
class="wizard-selection-card"
@click="exportData.destination = target.destination">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="renderMarkdown(target.markdownDescription)" />
</BCard>
</BCardGroup>
</div>
<div v-if="wizard.isCurrent('setup-remote')">
<BFormGroup
id="fieldset-directory"
label-for="directory"
:description="`Select a 'repository' to export ${resource} to.`"
class="mt-3">
<FilesInput
id="directory"
v-model="exportData.remoteUri"
mode="directory"
:require-writable="true"
:filter-options="{ exclude: ['rdm'] }" />
</BFormGroup>
</div>
<div v-if="wizard.isCurrent('setup-rdm')">
<RDMCredentialsInfo what="history export archive" />
<RDMDestinationSelector :what="resource" @onRecordSelected="onRecordSelected" />
</div>
<div v-if="wizard.isCurrent('setup-zenodo')">
<div class="zenodo-info">
<img
class="card-img"
src="https://raw.githubusercontent.com/zenodo/zenodo/master/zenodo/modules/theme/static/img/logos/zenodo-gradient-square.svg"
alt="ZENODO Logo" />
<div>
<RDMCredentialsInfo
what="history export archive"
:selected-repository="zenodoSource"
:is-private-file-source="zenodoSource ? isPrivateFileSource(zenodoSource) : false" />
</div>
</div>
<RDMDestinationSelector
:what="resource"
:file-source="zenodoSource"
@onRecordSelected="onRecordSelected" />
</div>
<div v-if="wizard.isCurrent('export-summary')">
<BFormGroup
v-if="needsFileName"
label-for="exported-file-name"
:description="`Give the exported file a name.`"
class="mt-3">
<BFormInput
id="exported-file-name"
v-model="exportData.outputFileName"
:placeholder="defaultFileName"
required />
</BFormGroup>
<BFormGroup label="Dataset files included in the package:">
<BFormCheckbox v-model="exportData.includeFiles" switch> Include Active Files </BFormCheckbox>
<BFormCheckbox v-model="exportData.includeDeleted" switch>
Include Deleted (not purged)
</BFormCheckbox>
<BFormCheckbox v-model="exportData.includeHidden" switch> Include Hidden </BFormCheckbox>
</BFormGroup>
<br />
<div>
Format <b>{{ exportFormatName }}</b>
</div>
<div>
Destination
<b>{{ exportDestinationSummary }}</b>
<b v-if="exportData.destination !== 'download' && exportData.remoteUri">
<FileSourceNameSpan :uri="exportData.remoteUri" class="text-primary" />
</b>
</div>
<div v-if="needsFileName && exportData.outputFileName">
File Name <b>{{ exportData.outputFileName }}.{{ exportData.modelStoreFormat }}</b>
</div>
</div>
</GenericWizard>
</div>
</template>
<style scoped lang="scss">
.zenodo-info {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.wizard-selection-card {
cursor: pointer;
transition: border-color 0.15s ease-in-out;
&:hover {
border-color: #007bff;
}
}
.card-img {
height: auto;
width: auto;
max-height: 100px;
max-width: 100%;
max-inline-size: -webkit-fill-available;
}
</style>
@@ -0,0 +1,9 @@
import type { ExportParams } from "@/components/Common/models/exportRecordModel";
export type HistoryExportDestination = "download" | "remote-source" | "rdm-repository" | "zenodo-repository";
export interface HistoryExportData extends ExportParams {
destination: HistoryExportDestination;
remoteUri: string;
outputFileName: string;
}
+12 -12
View File
@@ -521,25 +521,25 @@ history_export:
history_export_tasks:
selectors:
direct_download: '.gen-direct-download-btn'
file_source_tab: '.tab-export-to-file'
remote_file_name_input: '#file-source-tab #name'
toggle_options_link: '#toggle-options-link'
export_format_selector: '#format-selector'
select_format:
type: xpath
selector: '//select[@id="format-selector"]/option[@value="${format}"]'
history_export_wizard: '.history-export-wizard'
next_button: '.go-next-btn'
back_button: '.go-back-btn'
export_button: '.go-next-btn.btn-primary'
select_format: '[data-history-export-format="${format}"]'
select_destination: '[data-history-export-destination="${destination}"]'
exported_file_name: '#exported-file-name'
last_export_record:
selectors:
details: '.export-record-details'
preparing_export: '.export-record-details .loading-icon'
export_format: '.record-archive-format'
up_to_date_icon: '.record-up-to-date-icon'
outdated_icon: '.record-outdated-icon'
expiration_warning_icon: '.record-expiration-warning-icon'
download_btn: '.record-download-btn'
reimport_btn: '.record-reimport-btn'
preparing_export_badge: '#g-card-badge-in-progress-latest-export-record'
download_ready_badge: '#g-card-badge-ready-to-download-latest-export-record'
expiration_warning_badge: '#g-card-badge-expires-soon-latest-export-record'
download_btn: '#g-card-action-download-latest-export-record'
reimport_btn: '#g-card-action-reimport-latest-export-record'
history_import:
selectors:
@@ -102,22 +102,42 @@ class TestHistoryImportExportFtpSeleniumIntegrationWithTasks(TestHistoryImportEx
# Export to direct download link
export_format = "rocrate.zip"
destination = "download"
history_export_tasks = self.components.history_export_tasks
history_export_tasks.direct_download.wait_for_and_click()
# Select export format
history_export_tasks.select_format(format=export_format).wait_for_and_click()
history_export_tasks.next_button.wait_for_and_click()
# Select destination
history_export_tasks.select_destination(destination=destination).wait_for_and_click()
history_export_tasks.next_button.wait_for_and_click()
# Confirm export
history_export_tasks.export_button.wait_for_and_click()
self._verify_last_export_record(expected_format=export_format, is_download=True)
# Change export format
export_format = "tar.gz"
history_export_tasks.toggle_options_link.wait_for_and_click()
history_export_tasks.export_format_selector.wait_for_visible()
destination = "remote-source"
history_export_tasks.select_format(format=export_format).wait_for_and_click()
history_export_tasks.toggle_options_link.wait_for_and_click()
history_export_tasks.next_button.wait_for_and_click()
# Select destination
history_export_tasks.select_destination(destination=destination).wait_for_and_click()
history_export_tasks.next_button.wait_for_and_click()
# Export to FTP file source
history_export_tasks.file_source_tab.wait_for_present()
history_export_tasks.file_source_tab.wait_for_and_click()
self._export_to_ftp_with_filename("my_export.tar.gz")
# Select FTP file source
self.components.history_export.directory_input.wait_for_and_click()
self.components.files_dialog.ftp_label.wait_for_and_click()
self.components.upload.file_dialog_ok.wait_for_and_click()
# Go to Summary step
history_export_tasks.next_button.wait_for_and_click()
# Set filename
filename = "my_export.tar.gz"
history_export_tasks.exported_file_name.wait_for_and_send_keys(filename)
# Confirm export
history_export_tasks.export_button.wait_for_and_click()
self._verify_last_export_record(expected_format=export_format)
@@ -125,8 +145,8 @@ class TestHistoryImportExportFtpSeleniumIntegrationWithTasks(TestHistoryImportEx
self, expected_format: str, expect_up_to_date: bool = True, is_download: bool = False
):
last_export_record = self.components.last_export_record
last_export_record.preparing_export.wait_for_visible()
last_export_record.preparing_export.wait_for_absent(wait_type=self.wait_types.DATABASE_OPERATION)
last_export_record.preparing_export_badge.wait_for_visible()
last_export_record.preparing_export_badge.wait_for_absent(wait_type=self.wait_types.DATABASE_OPERATION)
last_export_record.details.wait_for_visible()
format_element = last_export_record.export_format.wait_for_visible()
@@ -138,7 +158,7 @@ class TestHistoryImportExportFtpSeleniumIntegrationWithTasks(TestHistoryImportEx
last_export_record.outdated_icon.wait_for_visible()
if is_download:
last_export_record.expiration_warning_icon.wait_for_visible()
last_export_record.expiration_warning_badge.wait_for_visible()
last_export_record.download_btn.wait_for_visible()
else:
last_export_record.reimport_btn.wait_for_visible()