Skip to content

Commit

Permalink
Merge pull request #18512 from davelopez/improve_invocation_export_ui
Browse files Browse the repository at this point in the history
Improve invocation export UI
  • Loading branch information
dannon authored Jul 10, 2024
2 parents 4c42548 + 286e805 commit 4b72c22
Show file tree
Hide file tree
Showing 21 changed files with 1,006 additions and 344 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>
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<script setup>
<script setup lang="ts">
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ref } from "vue";
import { InvocationExportPluginAction } from "./model";
const modal = ref(null);
const modal = ref();
const props = defineProps({
action: { type: InvocationExportPluginAction, required: true },
});
interface Props {
action: InvocationExportPluginAction;
}
const props = defineProps<Props>();
</script>

<template>
Expand Down
32 changes: 32 additions & 0 deletions client/src/components/Workflow/Invocation/Export/ExportButton.vue
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>

This file was deleted.

Loading

0 comments on commit 4b72c22

Please sign in to comment.