Skip to content

Commit 45ad065

Browse files
authored
Merge pull request galaxyproject#20073 from ahmedhamidawan/fix_outputs_tab_not_appearing_invocation
Always render Outputs tab in invocation view
2 parents 0fa402c + 05a2761 commit 45ad065

6 files changed

Lines changed: 328 additions & 24 deletions

File tree

client/src/components/Workflow/test/json/invocation.json

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
"action": null,
2020
"order_index": 0,
2121
"workflow_step_label": "input dataset",
22-
"workflow_step_uuid": "161efd0b-9cdb-42eb-bc9a-fc529b1bb638"
22+
"jobs": [],
23+
"workflow_step_uuid": "161efd0b-9cdb-42eb-bc9a-fc529b1bb638",
24+
"outputs": {},
25+
"output_collections": {}
2326
}
2427
],
2528
"inputs": {
@@ -30,9 +33,27 @@
3033
"workflow_step_id": "84e9a019416adb3d"
3134
}
3235
},
33-
"input_step_parameters": {},
34-
"outputs": {},
35-
"output_collections": {},
36+
"input_step_parameters": {
37+
"Workflow Input Parameter": {
38+
"parameter_value": true,
39+
"label": "Workflow Input Parameter",
40+
"workflow_step_id": "22bdbe9d6e0775fe"
41+
}
42+
},
43+
"outputs": {
44+
"Workflow Output Dataset": {
45+
"id": "4f352876a933b584",
46+
"workflow_step_id": "05f39d2a289deb5a",
47+
"src": "hda"
48+
}
49+
},
50+
"output_collections": {
51+
"Workflow Output Collection": {
52+
"id": "ecbc86ac41da8f7b",
53+
"workflow_step_id": "c486c1d29a0743d3",
54+
"src": "hdca"
55+
}
56+
},
3657
"output_values": {},
3758
"messages": []
3859
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { createTestingPinia } from "@pinia/testing";
2+
import { mount, type Wrapper } from "@vue/test-utils";
3+
import flushPromises from "flush-promises";
4+
5+
import { HttpResponse, useServerMock } from "@/api/client/__mocks__";
6+
import type { WorkflowInvocationElementView } from "@/api/invocations";
7+
8+
import invocationData from "../Workflow/test/json/invocation.json";
9+
10+
import WorkflowInvocationInputOutputTabs from "./WorkflowInvocationInputOutputTabs.vue";
11+
12+
const { server, http } = useServerMock();
13+
14+
const selectors = {
15+
parametersTable: "[data-description='input parameters table']",
16+
terminalInvocationOutput: "[data-description='terminal invocation output']",
17+
terminalInvocationOutputItem: "[data-description='terminal invocation output item']",
18+
nonTerminalInvocationOutput: "[data-description='non-terminal invocation output']",
19+
nonTerminalInvocationOutputLoading: "[data-description='non-terminal invocation output loading']",
20+
};
21+
22+
// Mock the workflow store to return a workflow for `getStoredWorkflowByInstanceId`
23+
jest.mock("@/stores/workflowStore", () => {
24+
const originalModule = jest.requireActual("@/stores/workflowStore");
25+
return {
26+
...originalModule,
27+
useWorkflowStore: () => ({
28+
...originalModule.useWorkflowStore(),
29+
getStoredWorkflowByInstanceId: jest.fn().mockImplementation(() => {
30+
return {
31+
id: "workflow-id",
32+
name: "Test Workflow",
33+
version: 0,
34+
};
35+
}),
36+
getFullWorkflowCached: jest.fn().mockImplementation(() => {
37+
/** The actual outputs of the workflow invocation */
38+
const testDatasetOutputLabels = Object.keys(invocationData.outputs);
39+
const testCollectionOutputsLabels = Object.keys(invocationData.output_collections);
40+
41+
return {
42+
id: "workflow-id",
43+
name: "Test Workflow",
44+
version: 0,
45+
steps: {
46+
"0": {
47+
workflow_outputs: testDatasetOutputLabels.map((label) => ({
48+
output_name: `output`,
49+
label,
50+
uuid: `uuid`,
51+
})),
52+
},
53+
"1": {
54+
workflow_outputs: testCollectionOutputsLabels.map((label) => ({
55+
output_name: `output`,
56+
label,
57+
uuid: `uuid`,
58+
})),
59+
},
60+
},
61+
};
62+
}),
63+
}),
64+
};
65+
});
66+
67+
/** Mount the WorkflowInvocationInputOutputTabs component with the given invocation
68+
* @param invocation The invocation data to be used
69+
* @param terminal Whether the invocation is terminal
70+
* @returns The mounted wrapper
71+
*/
72+
async function mountWorkflowInvocationInputOutputTabs(invocation: WorkflowInvocationElementView, terminal = true) {
73+
server.use(
74+
http.get("/api/datasets/{dataset_id}", ({ response, params }) => {
75+
// We need to use untyped here because this endpoint is not
76+
// described in the OpenAPI spec due to its complexity for now.
77+
return response.untyped(
78+
HttpResponse.json({
79+
id: params.dataset_id,
80+
})
81+
);
82+
}),
83+
http.get("/api/dataset_collections/{hdca_id}", ({ response, params }) => {
84+
// We need to use untyped here because this endpoint is not
85+
// described in the OpenAPI spec due to its complexity for now.
86+
return response.untyped(
87+
HttpResponse.json({
88+
id: params.hdca_id,
89+
})
90+
);
91+
})
92+
);
93+
94+
const wrapper = mount(WorkflowInvocationInputOutputTabs as object, {
95+
propsData: {
96+
invocation,
97+
terminal,
98+
},
99+
stubs: {
100+
ContentItem: true,
101+
ParameterStep: true,
102+
},
103+
pinia: createTestingPinia(),
104+
});
105+
await flushPromises();
106+
return wrapper;
107+
}
108+
109+
describe("WorkflowInvocationInputOutputTabs", () => {
110+
it("shows invocation inputs", async () => {
111+
const wrapper = await mountWorkflowInvocationInputOutputTabs(invocationData as WorkflowInvocationElementView);
112+
113+
/** The actual parameters are in the input_step_parameters field of the invocation data */
114+
const testParameters = Object.values(invocationData.input_step_parameters);
115+
116+
// Test that the parameters table is displayed
117+
const parametersTable = wrapper.find(selectors.parametersTable);
118+
expect(parametersTable.exists()).toBe(true);
119+
120+
// Test that the parameters table has the correct number of rows
121+
const tableParamValues = parametersTable.findAll("tbody tr");
122+
expect(tableParamValues.length).toEqual(testParameters.length);
123+
124+
// Test that the parameters are displayed correctly
125+
for (let i = 0; i < testParameters.length; i++) {
126+
const testParameter = testParameters[i];
127+
const tableRow = tableParamValues.at(i);
128+
expect(tableRow.find("td").text()).toEqual(testParameter?.label);
129+
expect(tableRow.findAll("td").at(1).text()).toEqual(testParameter?.parameter_value.toString());
130+
}
131+
132+
/** The actual inputs of the workflow invocation */
133+
const testInputs = Object.values(invocationData.inputs);
134+
135+
// Test that the inputs are displayed
136+
for (let i = 0; i < testInputs.length; i++) {
137+
const testInput = testInputs[i];
138+
expect(wrapper.find(`[data-label='${testInput?.label}']`).exists()).toBe(true);
139+
}
140+
});
141+
142+
it("shows invocation outputs when invocation is terminal", async () => {
143+
const wrapper = await mountWorkflowInvocationInputOutputTabs(invocationData as WorkflowInvocationElementView);
144+
145+
testOutputsDisplayed(wrapper);
146+
});
147+
148+
it("shows workflow output labels when invocation is not terminal", async () => {
149+
const nonTerminalInvocation = {
150+
...invocationData,
151+
outputs: {},
152+
output_collections: {},
153+
} as WorkflowInvocationElementView;
154+
const wrapper = await mountWorkflowInvocationInputOutputTabs(nonTerminalInvocation, false);
155+
156+
testOutputsDisplayed(wrapper, false);
157+
});
158+
159+
function testOutputsDisplayed(wrapper: Wrapper<Vue>, terminal = true) {
160+
/** The actual outputs of the workflow invocation */
161+
const testDatasetOutputLabels = Object.keys(invocationData.outputs);
162+
const testCollectionOutputsLabels = Object.keys(invocationData.output_collections);
163+
const expectedLabels = [...testDatasetOutputLabels, ...testCollectionOutputsLabels];
164+
165+
// Test that the invocation outputs are displayed
166+
const invocationOutputs = wrapper.findAll(
167+
terminal ? selectors.terminalInvocationOutput : selectors.nonTerminalInvocationOutput
168+
);
169+
expect(invocationOutputs.length).toEqual(expectedLabels.length);
170+
171+
// Test that the output labels are shown
172+
for (let i = 0; i < invocationOutputs.length; i++) {
173+
const testOutput = invocationOutputs.at(i);
174+
const testLabel = expectedLabels[i];
175+
expect(testOutput.text()).toContain(testLabel);
176+
expect(testOutput.find(selectors.terminalInvocationOutputItem).exists()).toBe(terminal);
177+
expect(testOutput.find(selectors.nonTerminalInvocationOutputLoading).exists()).toBe(!terminal);
178+
}
179+
}
180+
});

client/src/components/WorkflowInvocationState/WorkflowInvocationInputOutputTabs.vue

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,57 @@
11
<script setup lang="ts">
2-
import { BTab } from "bootstrap-vue";
3-
import { computed } from "vue";
2+
import { BAlert, BTab } from "bootstrap-vue";
3+
import { computed, ref, watch } from "vue";
44
55
import type { InvocationInput, WorkflowInvocationElementView } from "@/api/invocations";
6+
import { useWorkflowInstance } from "@/composables/useWorkflowInstance";
7+
import type { Step } from "@/stores/workflowStepStore";
8+
import { useWorkflowStore } from "@/stores/workflowStore";
69
710
import Heading from "../Common/Heading.vue";
11+
import LoadingSpan from "../LoadingSpan.vue";
812
import ParameterStep from "./ParameterStep.vue";
913
import GenericHistoryItem from "components/History/Content/GenericItem.vue";
1014
15+
const OUTPUTS_NOT_AVAILABLE_YET_MSG =
16+
"Either no outputs have been produced yet, or no steps were checked to " +
17+
"mark their outputs as primary workflow outputs.";
18+
1119
const props = defineProps<{
1220
invocation: WorkflowInvocationElementView;
13-
notLazy?: boolean;
21+
terminal?: boolean;
1422
}>();
1523
24+
// Fetching full workflow to get the workflow output labels (for when invocation is not terminal)
25+
const workflowOutputLabels = ref<string[]>([]);
26+
const workflow = computed(() => {
27+
if (!props.terminal) {
28+
const { workflow } = useWorkflowInstance(props.invocation.workflow_id);
29+
return workflow.value;
30+
}
31+
return undefined;
32+
});
33+
const workflowStore = useWorkflowStore();
34+
watch(
35+
workflow,
36+
async (newWorkflow) => {
37+
if (newWorkflow) {
38+
try {
39+
const wf = await workflowStore.getFullWorkflowCached(newWorkflow.id, newWorkflow.version);
40+
if (wf) {
41+
const fullWorkflowSteps = (wf.steps ? Object.values(wf.steps) : []) as Step[];
42+
workflowOutputLabels.value = fullWorkflowSteps
43+
.flatMap((step) => step.workflow_outputs || [])
44+
.map((output) => output.label)
45+
.filter((label) => label !== null && label !== undefined);
46+
}
47+
} catch (error) {
48+
console.error("Error fetching full workflow:", error);
49+
}
50+
}
51+
},
52+
{ immediate: true }
53+
);
54+
1655
const inputData = computed(() => Object.entries(props.invocation.inputs));
1756
1857
const outputs = computed(() => {
@@ -38,26 +77,74 @@ function dataInputStepLabel(key: string, input: InvocationInput) {
3877
}
3978
</script>
4079
<template>
41-
<span v-if="invocation">
42-
<BTab v-if="inputData.length || parameters.length" title="Inputs">
80+
<span>
81+
<BTab title="Inputs">
4382
<div v-if="parameters.length">
4483
<Heading size="text" bold separator>Parameter Values</Heading>
4584
<div class="mx-1">
46-
<ParameterStep :parameters="parameters" styled-table />
85+
<ParameterStep data-description="input parameters table" :parameters="parameters" styled-table />
4786
</div>
4887
</div>
49-
<div v-for="([key, input], index) in inputData" :key="index" :data-label="dataInputStepLabel(key, input)">
50-
<Heading size="text" bold separator>
51-
{{ dataInputStepLabel(key, input) }}
52-
</Heading>
53-
<GenericHistoryItem :item-id="input.id" :item-src="input.src" />
88+
<div v-if="inputData.length">
89+
<div
90+
v-for="([key, input], index) in inputData"
91+
:key="index"
92+
:data-label="dataInputStepLabel(key, input)">
93+
<Heading size="text" bold separator>
94+
{{ dataInputStepLabel(key, input) }}
95+
</Heading>
96+
<GenericHistoryItem :item-id="input.id" :item-src="input.src" />
97+
</div>
5498
</div>
99+
<BAlert v-else show variant="info"> No input data was provided for this workflow invocation. </BAlert>
55100
</BTab>
56-
<BTab v-if="outputs.length" title="Outputs" :lazy="!props.notLazy">
57-
<div v-for="([key, output], index) in outputs" :key="index">
58-
<Heading size="text" bold separator>{{ key }}</Heading>
59-
<GenericHistoryItem :item-id="output.id" :item-src="output.src" />
101+
<BTab title="Outputs">
102+
<div v-if="outputs.length">
103+
<div
104+
v-for="([key, output], index) in outputs"
105+
:key="index"
106+
data-description="terminal invocation output">
107+
<Heading size="text" bold separator>{{ key }}</Heading>
108+
<GenericHistoryItem
109+
:item-id="output.id"
110+
:item-src="output.src"
111+
data-description="terminal invocation output item" />
112+
</div>
113+
</div>
114+
<div v-else-if="workflowOutputLabels.length">
115+
<div
116+
v-for="(label, index) in workflowOutputLabels"
117+
:key="index"
118+
data-description="non-terminal invocation output">
119+
<Heading size="text" bold separator>{{ label }}</Heading>
120+
<BAlert
121+
v-if="!props.terminal"
122+
class="m-1 py-2"
123+
show
124+
variant="info"
125+
data-description="non-terminal invocation output loading">
126+
<LoadingSpan message="Output not created yet" />
127+
</BAlert>
128+
<BAlert v-else class="m-1 py-2" show variant="danger">
129+
<LoadingSpan message="Output not available" />
130+
</BAlert>
131+
</div>
60132
</div>
133+
<BAlert v-else show variant="info">
134+
<p>
135+
<LoadingSpan v-if="!props.terminal" :message="OUTPUTS_NOT_AVAILABLE_YET_MSG" />
136+
<span v-else v-localize>
137+
No steps were checked to mark their outputs as primary workflow outputs.
138+
</span>
139+
</p>
140+
<p>
141+
To get outputs from a workflow in this tab, you need to check the
142+
<i>
143+
"Checked outputs will become primary workflow outputs and are available as subworkflow outputs."
144+
</i>
145+
option on individual outputs on individual steps in the workflow.
146+
</p>
147+
</BAlert>
61148
</BTab>
62149
</span>
63150
</template>

client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -339,10 +339,7 @@ async function onCancel() {
339339
:store-id="storeId"
340340
:is-full-page="props.isFullPage" />
341341
</BTab>
342-
<WorkflowInvocationInputOutputTabs :invocation="invocation" :not-lazy="invocationAndJobTerminal" />
343-
<!-- <BTab title="Workflow Overview">
344-
<p>TODO: Insert readonly version of workflow editor here</p>
345-
</BTab> -->
342+
<WorkflowInvocationInputOutputTabs :invocation="invocation" :terminal="invocationAndJobTerminal" />
346343
<BTab
347344
v-if="!props.isSubworkflow"
348345
title="Report"

client/src/entry/analysis/router.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ export function getRouter(Galaxy) {
663663
props: (route) => ({
664664
invocationId: route.params.invocationId,
665665
isFullPage: true,
666-
success: route.query.success,
666+
success: Boolean(route.query.success),
667667
}),
668668
},
669669
{

0 commit comments

Comments
 (0)