-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #18512 from davelopez/improve_invocation_export_ui
Improve invocation export UI
- Loading branch information
Showing
21 changed files
with
1,006 additions
and
344 deletions.
There are no files selected for viewing
183 changes: 183 additions & 0 deletions
183
client/src/components/Common/PersistentTaskProgressMonitorAlert.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
import { shallowMount } from "@vue/test-utils"; | ||
import { type PropType, ref } from "vue"; | ||
|
||
import { TaskMonitor } from "@/composables/genericTaskMonitor"; | ||
import { | ||
type MonitoringData, | ||
MonitoringRequest, | ||
usePersistentProgressTaskMonitor, | ||
} from "@/composables/persistentProgressMonitor"; | ||
|
||
import PersistentTaskProgressMonitorAlert from "@/components/Common/PersistentTaskProgressMonitorAlert.vue"; | ||
|
||
type ComponentUnderTestProps = Partial<PropType<typeof PersistentTaskProgressMonitorAlert>>; | ||
|
||
const FAKE_MONITOR_REQUEST: MonitoringRequest = { | ||
source: "test", | ||
action: "testing", | ||
taskType: "task", | ||
object: { id: "1", type: "dataset" }, | ||
description: "Test description", | ||
}; | ||
|
||
const FAKE_MONITOR: TaskMonitor = { | ||
waitForTask: jest.fn(), | ||
isRunning: ref(false), | ||
isCompleted: ref(false), | ||
hasFailed: ref(false), | ||
requestHasFailed: ref(false), | ||
status: ref(""), | ||
}; | ||
|
||
const mountComponent = ( | ||
props: ComponentUnderTestProps = { | ||
monitorRequest: FAKE_MONITOR_REQUEST, | ||
useMonitor: FAKE_MONITOR, | ||
} | ||
) => { | ||
return shallowMount(PersistentTaskProgressMonitorAlert as object, { | ||
propsData: { | ||
...props, | ||
}, | ||
}); | ||
}; | ||
|
||
describe("PersistentTaskProgressMonitorAlert.vue", () => { | ||
beforeEach(() => { | ||
usePersistentProgressTaskMonitor(FAKE_MONITOR_REQUEST, FAKE_MONITOR).reset(); | ||
}); | ||
|
||
it("does not render when no monitoring data is available", () => { | ||
const wrapper = mountComponent(); | ||
expect(wrapper.find(".d-flex").exists()).toBe(false); | ||
}); | ||
|
||
it("renders in progress when monitoring data is available and in progress", () => { | ||
const useMonitor = { | ||
...FAKE_MONITOR, | ||
isRunning: ref(true), | ||
}; | ||
const existingMonitoringData: MonitoringData = { | ||
taskId: "1", | ||
taskType: "task", | ||
request: FAKE_MONITOR_REQUEST, | ||
}; | ||
usePersistentProgressTaskMonitor(FAKE_MONITOR_REQUEST, useMonitor, existingMonitoringData); | ||
|
||
const wrapper = mountComponent({ | ||
monitorRequest: FAKE_MONITOR_REQUEST, | ||
useMonitor, | ||
}); | ||
|
||
expect(wrapper.find(".d-flex").exists()).toBe(true); | ||
|
||
const inProgressAlert = wrapper.find('[variant="info"]'); | ||
expect(inProgressAlert.exists()).toBe(true); | ||
expect(inProgressAlert.text()).toContain("Task is in progress"); | ||
}); | ||
|
||
it("renders completed when monitoring data is available and completed", () => { | ||
const useMonitor = { | ||
...FAKE_MONITOR, | ||
isCompleted: ref(true), | ||
}; | ||
const existingMonitoringData: MonitoringData = { | ||
taskId: "1", | ||
taskType: "task", | ||
request: FAKE_MONITOR_REQUEST, | ||
}; | ||
usePersistentProgressTaskMonitor(FAKE_MONITOR_REQUEST, useMonitor, existingMonitoringData); | ||
|
||
const wrapper = mountComponent({ | ||
monitorRequest: FAKE_MONITOR_REQUEST, | ||
useMonitor, | ||
}); | ||
|
||
expect(wrapper.find(".d-flex").exists()).toBe(true); | ||
|
||
const completedAlert = wrapper.find('[variant="success"]'); | ||
expect(completedAlert.exists()).toBe(true); | ||
expect(completedAlert.text()).toContain("Task completed"); | ||
}); | ||
|
||
it("renders failed when monitoring data is available and failed", () => { | ||
const useMonitor = { | ||
...FAKE_MONITOR, | ||
hasFailed: ref(true), | ||
}; | ||
const existingMonitoringData: MonitoringData = { | ||
taskId: "1", | ||
taskType: "task", | ||
request: FAKE_MONITOR_REQUEST, | ||
}; | ||
usePersistentProgressTaskMonitor(FAKE_MONITOR_REQUEST, useMonitor, existingMonitoringData); | ||
|
||
const wrapper = mountComponent({ | ||
monitorRequest: FAKE_MONITOR_REQUEST, | ||
useMonitor, | ||
}); | ||
|
||
expect(wrapper.find(".d-flex").exists()).toBe(true); | ||
|
||
const failedAlert = wrapper.find('[variant="danger"]'); | ||
expect(failedAlert.exists()).toBe(true); | ||
expect(failedAlert.text()).toContain("Task failed"); | ||
}); | ||
|
||
it("renders a link to download the task result when completed and task type is 'short_term_storage'", () => { | ||
const taskId = "fake-task-id"; | ||
const monitoringRequest: MonitoringRequest = { | ||
...FAKE_MONITOR_REQUEST, | ||
taskType: "short_term_storage", | ||
}; | ||
const useMonitor = { | ||
...FAKE_MONITOR, | ||
isCompleted: ref(true), | ||
}; | ||
const existingMonitoringData: MonitoringData = { | ||
taskId: taskId, | ||
taskType: "short_term_storage", | ||
request: monitoringRequest, | ||
}; | ||
usePersistentProgressTaskMonitor(monitoringRequest, useMonitor, existingMonitoringData); | ||
|
||
const wrapper = mountComponent({ | ||
monitorRequest: monitoringRequest, | ||
useMonitor, | ||
}); | ||
|
||
expect(wrapper.find(".d-flex").exists()).toBe(true); | ||
|
||
const completedAlert = wrapper.find('[variant="success"]'); | ||
expect(completedAlert.exists()).toBe(true); | ||
|
||
const downloadLink = wrapper.find(".download-link"); | ||
expect(downloadLink.exists()).toBe(true); | ||
expect(downloadLink.text()).toContain("Download here"); | ||
expect(downloadLink.attributes("href")).toBe(`/api/short_term_storage/${taskId}`); | ||
}); | ||
|
||
it("does not render a link to download the task result when completed and task type is 'task'", () => { | ||
const useMonitor = { | ||
...FAKE_MONITOR, | ||
isCompleted: ref(true), | ||
}; | ||
const existingMonitoringData: MonitoringData = { | ||
taskId: "1", | ||
taskType: "task", | ||
request: FAKE_MONITOR_REQUEST, | ||
}; | ||
usePersistentProgressTaskMonitor(FAKE_MONITOR_REQUEST, useMonitor, existingMonitoringData); | ||
|
||
const wrapper = mountComponent({ | ||
monitorRequest: FAKE_MONITOR_REQUEST, | ||
useMonitor, | ||
}); | ||
|
||
expect(wrapper.find(".d-flex").exists()).toBe(true); | ||
|
||
const completedAlert = wrapper.find('[variant="success"]'); | ||
expect(completedAlert.exists()).toBe(true); | ||
expect(completedAlert.text()).not.toContain("Download here"); | ||
}); | ||
}); |
112 changes: 112 additions & 0 deletions
112
client/src/components/Common/PersistentTaskProgressMonitorAlert.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
<script setup lang="ts"> | ||
import { library } from "@fortawesome/fontawesome-svg-core"; | ||
import { faSpinner } from "@fortawesome/free-solid-svg-icons"; | ||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; | ||
import { BAlert, BLink } from "bootstrap-vue"; | ||
import { computed, watch } from "vue"; | ||
import { TaskMonitor } from "@/composables/genericTaskMonitor"; | ||
import { MonitoringRequest, usePersistentProgressTaskMonitor } from "@/composables/persistentProgressMonitor"; | ||
import { useShortTermStorage } from "@/composables/shortTermStorage"; | ||
library.add(faSpinner); | ||
interface Props { | ||
monitorRequest: MonitoringRequest; | ||
useMonitor: TaskMonitor; | ||
/** | ||
* The task ID to monitor. Can be a task ID or a short-term storage request ID. | ||
* If provided, the component will start monitoring the task with the given ID. | ||
*/ | ||
taskId?: string; | ||
/** | ||
* If true, the download link will be automatically opened when the task is completed if the user | ||
* remains on the page. | ||
* | ||
* Automatic download will only be possible if the task is completed and the task type is `short_term_storage`. | ||
*/ | ||
enableAutoDownload?: boolean; | ||
inProgressMessage?: string; | ||
completedMessage?: string; | ||
failedMessage?: string; | ||
requestFailedMessage?: string; | ||
} | ||
const props = withDefaults(defineProps<Props>(), { | ||
taskId: undefined, | ||
enableAutoDownload: false, | ||
inProgressMessage: `Task is in progress. Please wait...`, | ||
completedMessage: "Task completed successfully.", | ||
failedMessage: "Task failed.", | ||
requestFailedMessage: "Request failed.", | ||
}); | ||
const { getDownloadObjectUrl } = useShortTermStorage(); | ||
const { hasMonitoringData, isRunning, isCompleted, hasFailed, requestHasFailed, storedTaskId, status, start, reset } = | ||
usePersistentProgressTaskMonitor(props.monitorRequest, props.useMonitor); | ||
const downloadUrl = computed(() => { | ||
// We can only download the result if the task is completed and the task type is short_term_storage. | ||
const requestId = props.taskId || storedTaskId; | ||
if (requestId && props.monitorRequest.taskType === "short_term_storage") { | ||
return getDownloadObjectUrl(requestId); | ||
} | ||
return undefined; | ||
}); | ||
if (hasMonitoringData.value) { | ||
start(); | ||
} | ||
watch( | ||
() => props.taskId, | ||
(newTaskId, oldTaskId) => { | ||
if (newTaskId && newTaskId !== oldTaskId) { | ||
start({ taskId: newTaskId, taskType: props.monitorRequest.taskType, request: props.monitorRequest }); | ||
} | ||
} | ||
); | ||
watch( | ||
() => isCompleted.value, | ||
(completed) => { | ||
// We check for props.taskId to be defined to avoid auto-downloading when the task is completed and | ||
// the component is first mounted like when refreshing the page. | ||
if (completed && props.enableAutoDownload && downloadUrl.value && props.taskId) { | ||
window.open(downloadUrl.value, "_blank"); | ||
} | ||
} | ||
); | ||
function dismissAlert() { | ||
reset(); | ||
} | ||
</script> | ||
|
||
<template> | ||
<div v-if="hasMonitoringData" class="d-flex justify-content-end"> | ||
<BAlert v-if="isRunning" variant="info" show> | ||
<b>{{ inProgressMessage }}</b> | ||
<FontAwesomeIcon :icon="faSpinner" class="mr-2" spin /> | ||
</BAlert> | ||
<BAlert v-else-if="isCompleted" variant="success" show dismissible @dismissed="dismissAlert"> | ||
<span>{{ completedMessage }}</span> | ||
<BLink v-if="downloadUrl" class="download-link" :href="downloadUrl"> | ||
<b>Download here</b> | ||
</BLink> | ||
</BAlert> | ||
<BAlert v-else-if="hasFailed" variant="danger" show dismissible @dismissed="dismissAlert"> | ||
<span>{{ failedMessage }}</span> | ||
<span v-if="status"> | ||
Reason: <b>{{ status }}</b> | ||
</span> | ||
</BAlert> | ||
<BAlert v-else-if="requestHasFailed" variant="danger" show dismissible @dismissed="dismissAlert"> | ||
<b>{{ requestFailedMessage }}</b> | ||
</BAlert> | ||
</div> | ||
</template> |
12 changes: 7 additions & 5 deletions
12
client/src/components/Workflow/Invocation/Export/ActionButton.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
client/src/components/Workflow/Invocation/Export/ExportButton.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<script setup lang="ts"> | ||
import { IconDefinition, library } from "@fortawesome/fontawesome-svg-core"; | ||
import { faSpinner } from "@fortawesome/free-solid-svg-icons"; | ||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; | ||
import { BButton } from "bootstrap-vue"; | ||
import { computed } from "vue"; | ||
library.add(faSpinner); | ||
interface Props { | ||
title: string; | ||
idleIcon: IconDefinition; | ||
isBusy?: boolean; | ||
} | ||
const props = withDefaults(defineProps<Props>(), { | ||
isBusy: false, | ||
}); | ||
const disabled = computed(() => props.isBusy); | ||
const emit = defineEmits(["onClick"]); | ||
</script> | ||
|
||
<template> | ||
<span v-b-tooltip.hover.bottom :title="title"> | ||
<BButton :disabled="disabled" @click="() => emit('onClick')"> | ||
<FontAwesomeIcon v-if="isBusy" :icon="faSpinner" spin /> | ||
<FontAwesomeIcon v-else :icon="idleIcon" /> | ||
</BButton> | ||
</span> | ||
</template> |
46 changes: 0 additions & 46 deletions
46
client/src/components/Workflow/Invocation/Export/ExportToRemoteButton.vue
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.