Skip to content

Commit

Permalink
Add PersistentTaskProgressMonitorAlert component
Browse files Browse the repository at this point in the history
The PersistentTaskProgressMonitorAlert component is added to provide a visual representation of the progress of a long-running operation. It includes different alert messages for different states of the task, such as in progress, completed, failed, and request failed. The component also supports automatic download of the task result when it is completed, if enabled.
  • Loading branch information
davelopez committed Jul 8, 2024
1 parent 7c8adb0 commit 696a674
Show file tree
Hide file tree
Showing 2 changed files with 295 additions and 0 deletions.
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 client/src/components/Common/PersistentTaskProgressMonitorAlert.vue
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>

0 comments on commit 696a674

Please sign in to comment.