diff --git a/client/src/api/invocations.ts b/client/src/api/invocations.ts index 2196f9a9254e..467267fe19f1 100644 --- a/client/src/api/invocations.ts +++ b/client/src/api/invocations.ts @@ -8,6 +8,7 @@ export type WorkflowInvocationElementView = components["schemas"]["WorkflowInvoc export type WorkflowInvocationCollectionView = components["schemas"]["WorkflowInvocationCollectionView"]; export type InvocationJobsSummary = components["schemas"]["InvocationJobsResponse"]; export type InvocationStep = components["schemas"]["InvocationStep"]; +export type InvocationMessage = components["schemas"]["InvocationMessageResponseUnion"]; export type StepJobSummary = | components["schemas"]["InvocationStepJobsResponseStepModel"] diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 41866690c5cd..f878a162098a 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -8192,6 +8192,18 @@ export interface components { [key: string]: number | undefined; }; }; + InvocationMessageResponseUnion: + | components["schemas"]["InvocationCancellationReviewFailedResponse"] + | components["schemas"]["InvocationCancellationHistoryDeletedResponse"] + | components["schemas"]["InvocationCancellationUserRequestResponse"] + | components["schemas"]["InvocationFailureDatasetFailedResponse"] + | components["schemas"]["InvocationFailureCollectionFailedResponse"] + | components["schemas"]["InvocationFailureJobFailedResponse"] + | components["schemas"]["InvocationFailureOutputNotFoundResponse"] + | components["schemas"]["InvocationFailureExpressionEvaluationFailedResponse"] + | components["schemas"]["InvocationFailureWhenNotBooleanResponse"] + | components["schemas"]["InvocationUnexpectedFailureResponse"] + | components["schemas"]["InvocationEvaluationWarningWorkflowOutputNotFoundResponse"]; /** InvocationOutput */ InvocationOutput: { /** @@ -13459,19 +13471,7 @@ export interface components { * Messages * @description A list of messages about why the invocation did not succeed. */ - messages: ( - | components["schemas"]["InvocationCancellationReviewFailedResponse"] - | components["schemas"]["InvocationCancellationHistoryDeletedResponse"] - | components["schemas"]["InvocationCancellationUserRequestResponse"] - | components["schemas"]["InvocationFailureDatasetFailedResponse"] - | components["schemas"]["InvocationFailureCollectionFailedResponse"] - | components["schemas"]["InvocationFailureJobFailedResponse"] - | components["schemas"]["InvocationFailureOutputNotFoundResponse"] - | components["schemas"]["InvocationFailureExpressionEvaluationFailedResponse"] - | components["schemas"]["InvocationFailureWhenNotBooleanResponse"] - | components["schemas"]["InvocationUnexpectedFailureResponse"] - | components["schemas"]["InvocationEvaluationWarningWorkflowOutputNotFoundResponse"] - )[]; + messages: components["schemas"]["InvocationMessageResponseUnion"][]; /** * Model class * @description The name of the database model class. diff --git a/client/src/components/Help/terms.yml b/client/src/components/Help/terms.yml index a6df4b852a63..4710252b8a56 100644 --- a/client/src/components/Help/terms.yml +++ b/client/src/components/Help/terms.yml @@ -57,7 +57,7 @@ galaxy: invocations: states: scheduled: | - This state indicates the workflow invocation has had all of its job scheduled. This means all the + This state indicates the workflow invocation has had all of its jobs scheduled. This means all the datasets are likely created and Galaxy has created the stubs for the jobs in the workflow. *The jobs themselves might not have been queued or running.* diff --git a/client/src/components/WorkflowInvocationState/InvocationMessage.vue b/client/src/components/WorkflowInvocationState/InvocationMessage.vue index 7c1561576f39..fdf447621b25 100644 --- a/client/src/components/WorkflowInvocationState/InvocationMessage.vue +++ b/client/src/components/WorkflowInvocationState/InvocationMessage.vue @@ -1,10 +1,9 @@ + + diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.test.js b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.test.js index f4e069fb3cc7..ab154ae17813 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.test.js +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.test.js @@ -1,5 +1,6 @@ import { createTestingPinia } from "@pinia/testing"; import { shallowMount } from "@vue/test-utils"; +import flushPromises from "flush-promises"; import { getLocalVue } from "tests/jest/helpers"; import invocationData from "../Workflow/test/json/invocation.json"; @@ -7,6 +8,42 @@ import WorkflowInvocationOverview from "./WorkflowInvocationOverview"; const localVue = getLocalVue(); +// Constants +const workflowData = { + id: "workflow-id", + name: "Test Workflow", + version: 0, +}; +const selectors = { + bAlertStub: "balert-stub", +}; +const alertMessages = { + unOwned: "Workflow is neither importable, nor owned by or shared with current user", + nonExistent: "No workflow found for this invocation.", +}; + +// Mock the workflow store to return the expected workflow data given the stored workflow ID +jest.mock("@/stores/workflowStore", () => { + const originalModule = jest.requireActual("@/stores/workflowStore"); + return { + ...originalModule, + useWorkflowStore: () => ({ + ...originalModule.useWorkflowStore(), + getStoredWorkflowByInstanceId: jest.fn().mockImplementation((workflowId) => { + if (["unowned-workflow", "nonexistant-workflow"].includes(workflowId)) { + return undefined; + } + return workflowData; + }), + fetchWorkflowForInstanceId: jest.fn().mockImplementation((workflowId) => { + if (workflowId === "unowned-workflow") { + throw new Error(alertMessages.unOwned); + } + }), + }), + }; +}); + describe("WorkflowInvocationOverview.vue with terminal invocation", () => { let wrapper; let propsData; @@ -61,3 +98,39 @@ describe("WorkflowInvocationOverview.vue with invocation scheduling running", () expect(wrapper.find(".cancel-workflow-scheduling").exists()).toBeTruthy(); }); }); + +describe("WorkflowInvocationOverview.vue for a valid/invalid workflow", () => { + async function loadWrapper(invocationData) { + const propsData = { + invocation: invocationData, + invocationAndJobTerminal: true, + invocationSchedulingTerminal: true, + jobStatesSummary: {}, + }; + const wrapper = shallowMount(WorkflowInvocationOverview, { + propsData, + localVue, + }); + await flushPromises(); + return wrapper; + } + + it("displays the workflow invocation graph for a valid workflow", async () => { + const wrapper = await loadWrapper(invocationData); + expect(wrapper.find("[data-description='workflow invocation graph']").exists()).toBeTruthy(); + }); + + it("displays an alert for an unowned workflow", async () => { + const wrapper = await loadWrapper({ ...invocationData, workflow_id: "unowned-workflow" }); + expect(wrapper.find("[data-description='workflow invocation graph']").exists()).toBeFalsy(); + const alert = wrapper.find(selectors.bAlertStub); + expect(alert.text()).toContain(alertMessages.unOwned); + }); + + it("displays an alert for a nonexistant workflow", async () => { + const wrapper = await loadWrapper({ ...invocationData, workflow_id: "nonexistant-workflow" }); + expect(wrapper.find("[data-description='workflow invocation graph']").exists()).toBeFalsy(); + const alert = wrapper.find(selectors.bAlertStub); + expect(alert.text()).toContain(alertMessages.nonExistent); + }); +}); diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue index ae69835b6479..bf04dd5ce80c 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue @@ -27,7 +27,7 @@ function getUrl(path: string): string { } interface Props { - invocation?: WorkflowInvocationElementView; + invocation: WorkflowInvocationElementView; invocationAndJobTerminal: boolean; invocationSchedulingTerminal: boolean; isFullPage?: boolean; @@ -40,9 +40,9 @@ const props = defineProps(); const generatePdfTooltip = "Generate PDF report for this workflow invocation"; -const { workflow } = useWorkflowInstance(props.invocation?.workflow_id ?? ""); +const { workflow, loading, error } = useWorkflowInstance(props.invocation.workflow_id); -const invocationId = computed(() => props.invocation?.id); +const invocationId = computed(() => props.invocation.id); const indexStr = computed(() => { if (props.index == undefined) { @@ -53,7 +53,7 @@ const indexStr = computed(() => { }); const invocationState = computed(() => { - return props.invocation?.state || "new"; + return props.invocation.state || "new"; }); const invocationStateSuccess = computed(() => { @@ -73,17 +73,14 @@ const disabledReportTooltip = computed(() => { }); const stepCount = computed(() => { - return props.invocation?.steps.length || 0; + return props.invocation.steps.length || 0; }); type StepStateType = { [state: string]: number }; const stepStates = computed(() => { const stepStates: StepStateType = {}; - if (!props.invocation) { - return {}; - } - const steps: InvocationStep[] = props.invocation?.steps || []; + const steps: InvocationStep[] = props.invocation.steps || []; for (const step of steps) { if (!step) { continue; @@ -108,8 +105,10 @@ const invocationPdfLink = computed(() => { } }); -const hasMessages = computed(() => { - return props.invocation?.messages.length ? true : false; +const uniqueMessages = computed(() => { + const messages = props.invocation.messages || []; + const uniqueMessagesSet = new Set(messages.map((message) => JSON.stringify(message))); + return Array.from(uniqueMessagesSet).map((message) => JSON.parse(message)) as typeof messages; }); const stepStatesStr = computed(() => { @@ -155,6 +154,12 @@ function onCancel() {