Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Workflow Invocation view improvements #18615

Open
wants to merge 20 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6bf32b5
Workflow Invocation view improvements
ahmedhamidawan Jul 29, 2024
b1ddf4f
move invocation steps to a separate tab, show whole step below
ahmedhamidawan Jul 31, 2024
28affd4
add workflow input indicator to `WorkflowInvocationStepHeader`
ahmedhamidawan Jul 31, 2024
fbd6b17
decrease step header size in `InvocationGraph` to "sm"
ahmedhamidawan Jul 31, 2024
c151eeb
add a tab view for jobs under a step in invocation view
ahmedhamidawan Aug 1, 2024
6142a59
use `openapi-fetch` for fetching job
ahmedhamidawan Aug 15, 2024
158d1cb
remove redundant props/emits for invocation steps
ahmedhamidawan Aug 15, 2024
d204ca9
convert `ProgressBar.vue` to composition API + ts
ahmedhamidawan Aug 16, 2024
17ea7ea
do not include `Steps` tab for subworkflows
ahmedhamidawan Aug 25, 2024
9ad4196
add `getContentItemState` function
ahmedhamidawan Sep 3, 2024
643d70e
add job duration, `WorkflowInvocationJob` component
ahmedhamidawan Sep 3, 2024
bd74d94
check `activeNodeId` for `null` (it can be 0 as well)
ahmedhamidawan Sep 3, 2024
9a6b190
show workflow input value on graph
ahmedhamidawan Sep 3, 2024
c87cd3a
move progress bars to header, shorten `WorkflowRunSuccess`
ahmedhamidawan Sep 20, 2024
ddb1503
do not auto scroll to the clicked step
ahmedhamidawan Sep 20, 2024
ec972bc
add `force` prop to `WorkflowRunButton` for routing
ahmedhamidawan Sep 20, 2024
ef06b4c
remove jest since buttons have moved to parent
ahmedhamidawan Sep 21, 2024
7b5616a
add a jest for `JobStep`
ahmedhamidawan Oct 1, 2024
4c4720b
adjust seleniums for changes in `JobStep` and invocations in general
ahmedhamidawan Oct 2, 2024
312669c
add a jest for `SwitchToHistoryLink` that tests current history case
ahmedhamidawan Oct 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/api/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ import { type components } from "@/api/schema";

export type JobDestinationParams = components["schemas"]["JobDestinationParams"];
export type ShowFullJobResponse = components["schemas"]["ShowFullJobResponse"];
export type JobBaseModel = components["schemas"]["JobBaseModel"];
export type JobDetails = components["schemas"]["ShowFullJobResponse"] | components["schemas"]["EncodedJobDetails"];
export type JobInputSummary = components["schemas"]["JobInputSummary"];
export type JobDisplayParametersSummary = components["schemas"]["JobDisplayParametersSummary"];
16 changes: 13 additions & 3 deletions client/src/components/Grid/GridInvocation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import { storeToRefs } from "pinia";
import { computed } from "vue";

import type { WorkflowInvocation } from "@/api/invocations";
import invocationsGridConfig from "@/components/Grid/configs/invocations";
import invocationsBatchConfig from "@/components/Grid/configs/invocationsBatch";
import invocationsHistoryGridConfig from "@/components/Grid/configs/invocationsHistory";
import invocationsWorkflowGridConfig from "@/components/Grid/configs/invocationsWorkflow";
import { useUserStore } from "@/stores/userStore";
Expand All @@ -19,19 +21,22 @@ interface Props {
headerMessage?: string;
ownerGrid?: boolean;
filteredFor?: { type: "History" | "StoredWorkflow"; id: string; name: string };
invocationsList?: WorkflowInvocation[];
}

const props = withDefaults(defineProps<Props>(), {
noInvocationsMessage: "No Workflow Invocations to display",
headerMessage: "",
ownerGrid: true,
filteredFor: undefined,
invocationsList: undefined,
});

const { currentUser } = storeToRefs(useUserStore());

const forStoredWorkflow = computed(() => props.filteredFor?.type === "StoredWorkflow");
const forHistory = computed(() => props.filteredFor?.type === "History");
const forBatch = computed(() => !!props.invocationsList?.length);

const effectiveNoInvocationsMessage = computed(() => {
let message = props.noInvocationsMessage;
Expand All @@ -51,6 +56,9 @@ const effectiveTitle = computed(() => {
});

const extraProps = computed(() => {
if (forBatch.value) {
return Object.fromEntries(props.invocationsList.map((invocation) => [invocation.id, invocation]));
}
const params: {
workflow_id?: string;
history_id?: string;
Expand All @@ -72,7 +80,9 @@ const extraProps = computed(() => {
});

let gridConfig: GridConfig;
if (forStoredWorkflow.value) {
if (forBatch.value) {
gridConfig = invocationsBatchConfig;
} else if (forStoredWorkflow.value) {
gridConfig = invocationsWorkflowGridConfig;
} else if (forHistory.value) {
gridConfig = invocationsHistoryGridConfig;
Expand All @@ -97,9 +107,9 @@ function refreshTable() {
:grid-message="props.headerMessage"
:no-data-message="effectiveNoInvocationsMessage"
:extra-props="extraProps"
:embedded="forStoredWorkflow || forHistory">
:embedded="forStoredWorkflow || forHistory || forBatch">
<template v-slot:expanded="{ rowData }">
<span class="float-right position-absolute mr-4" style="right: 0" :data-invocation-id="rowData.id">
<span class="position-absolute ml-4" :data-invocation-id="rowData.id">
<small>
<b>Last updated: <UtcDate :date="rowData.update_time" mode="elapsed" />; Invocation ID:</b>
</small>
Expand Down
80 changes: 80 additions & 0 deletions client/src/components/Grid/configs/invocationsBatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { faEye } from "@fortawesome/free-solid-svg-icons";

import { type WorkflowInvocation } from "@/api/invocations";
import { getAppRoot } from "@/onload";

import { type FieldArray, type GridConfig } from "./types";

/**
* Request and return invocations for the given workflow (and current user) from server
*/
async function getData(
offset: number,
limit: number,
search: string,
sort_by: string,
sort_desc: boolean,
extraProps?: Record<string, unknown>
) {
// extra props will be Record<string, Invocation>; get array of invocations
const data = Object.values(extraProps ?? {}) as WorkflowInvocation[];
const totalMatches = data.length;
return [data, totalMatches];
}

/**
* Declare columns to be displayed
*/
const fields: FieldArray = [
{
key: "expand",
title: null,
type: "expand",
},
{
key: "view",
title: "View",
type: "button",
icon: faEye,
handler: (data) => {
const url = `${getAppRoot()}workflows/invocations/${(data as WorkflowInvocation).id}`;
window.open(url, "_blank");
},
converter: () => "",
},
{
key: "history_id",
title: "History",
type: "history",
},
{
key: "create_time",
title: "Invoked",
type: "date",
},
{
key: "state",
title: "State",
type: "helptext",
converter: (data) => {
const invocation = data as WorkflowInvocation;
return `galaxy.invocations.states.${invocation.state}`;
},
},
];

/**
* Grid configuration
*/
const gridConfig: GridConfig = {
id: "invocations-batch-grid",
fields: fields,
getData: getData,
plural: "Workflow Invocations",
sortBy: "create_time",
sortDesc: true,
sortKeys: [],
title: "Workflow Invocations in Batch",
};

export default gridConfig;
22 changes: 2 additions & 20 deletions client/src/components/History/Content/ContentItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { useEventStore } from "@/stores/eventStore";
import { clearDrag } from "@/utils/setDrag";

import { JobStateSummary } from "./Collection/JobStateSummary";
import { HIERARCHICAL_COLLECTION_JOB_STATES, type StateMap, STATES } from "./model/states";
import { getContentItemState, type StateMap, STATES } from "./model/states";

import CollectionDescription from "./Collection/CollectionDescription.vue";
import ContentOptions from "./ContentOptions.vue";
Expand Down Expand Up @@ -135,25 +135,7 @@ const state = computed<keyof StateMap>(() => {
if (props.isPlaceholder) {
return "placeholder";
}
if (props.item.accessible === false) {
return "inaccessible";
}
if (props.item.populated_state === "failed") {
return "failed_populated_state";
}
if (props.item.populated_state === "new") {
return "new_populated_state";
}
if (props.item.job_state_summary) {
for (const jobState of HIERARCHICAL_COLLECTION_JOB_STATES) {
if (props.item.job_state_summary[jobState] > 0) {
return jobState;
}
}
} else if (props.item.state) {
return props.item.state;
}
return "ok";
return getContentItemState(props.item);
});

const dataState = computed(() => {
Expand Down
24 changes: 24 additions & 0 deletions client/src/components/History/Content/model/states.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isHDCA } from "@/api";
import { type components } from "@/api/schema";

type DatasetState = components["schemas"]["DatasetState"];
Expand Down Expand Up @@ -146,3 +147,26 @@ export const HIERARCHICAL_COLLECTION_JOB_STATES = [
"queued",
"new",
] as const;

export function getContentItemState(item: any) {
if (isHDCA(item)) {
if (item.populated_state === "failed") {
return "failed_populated_state";
}
if (item.populated_state === "new") {
return "new_populated_state";
}
if (item.job_state_summary) {
for (const jobState of HIERARCHICAL_COLLECTION_JOB_STATES) {
if (item.job_state_summary[jobState] > 0) {
return jobState;
}
}
}
} else if (item.accessible === false) {
return "inaccessible";
} else if (item.state) {
return item.state;
}
return "ok";
}
24 changes: 24 additions & 0 deletions client/src/components/History/SwitchToHistoryLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ const selectors = {
historyLink: ".history-link",
} as const;

// Mock the history store to always return the same current history id
jest.mock("@/stores/historyStore", () => {
const originalModule = jest.requireActual("@/stores/historyStore");
return {
...originalModule,
useHistoryStore: () => ({
...originalModule.useHistoryStore(),
currentHistoryId: "current-history-id",
}),
};
});

function mountSwitchToHistoryLinkForHistory(history: HistorySummaryExtended) {
const pinia = createTestingPinia();

Expand Down Expand Up @@ -98,6 +110,18 @@ describe("SwitchToHistoryLink", () => {
await expectOptionForHistory("Switch", history);
});

it("should display the appropriate text when the history is the Current history", async () => {
const history = {
id: "current-history-id",
name: "History Current",
deleted: false,
purged: false,
archived: false,
user_id: "user_id",
} as HistorySummaryExtended;
await expectOptionForHistory("This is your current history", history);
});

it("should display the View option when the history is purged", async () => {
const history = {
id: "purged-history-id",
Expand Down
14 changes: 13 additions & 1 deletion client/src/components/History/SwitchToHistoryLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,20 @@ const actionText = computed(() => {
return "View in new tab";
});

const linkTitle = computed(() => {
if (historyStore.currentHistoryId === props.historyId) {
return "This is your current history";
} else {
return `<b>${actionText.value}</b><br>${history.value?.name}`;
}
});

async function onClick(event: MouseEvent, history: HistorySummary) {
const eventStore = useEventStore();
const ctrlKey = eventStore.isMac ? event.metaKey : event.ctrlKey;
if (!ctrlKey && historyStore.currentHistoryId === history.id) {
return;
}
if (!ctrlKey && canSwitch.value) {
if (props.filters) {
historyStore.applyFilters(history.id, props.filters);
Expand Down Expand Up @@ -78,9 +89,10 @@ function viewHistoryInNewTab(history: HistorySummary) {
<div v-else class="history-link">
<BLink
v-b-tooltip.hover.top.noninteractive.html
data-description="switch to history link"
class="truncate"
href="#"
:title="`<b>${actionText}</b><br>${history.name}`"
:title="linkTitle"
@click.stop="onClick($event, history)">
{{ history.name }}
</BLink>
Expand Down
7 changes: 3 additions & 4 deletions client/src/components/JobInformation/JobInformation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import HelpText from "components/Help/HelpText";
import { JobDetailsProvider } from "components/providers/JobProvider";
import UtcDate from "components/UtcDate";
import { NON_TERMINAL_STATES } from "components/WorkflowInvocationState/util";
import { formatDuration, intervalToDuration } from "date-fns";
import { computed, ref } from "vue";

import { GalaxyApi } from "@/api";
import { rethrowSimple } from "@/utils/simple-error";

import { getJobDuration } from "./utilities";

import DecodedId from "../DecodedId.vue";
import CodeRow from "./CodeRow.vue";

Expand All @@ -27,9 +28,7 @@ const props = defineProps({
},
});

const runTime = computed(() =>
formatDuration(intervalToDuration({ start: new Date(job.value.create_time), end: new Date(job.value.update_time) }))
);
const runTime = computed(() => getJobDuration(job.value));

const jobIsTerminal = computed(() => job.value && !NON_TERMINAL_STATES.includes(job.value.state));

Expand Down
7 changes: 7 additions & 0 deletions client/src/components/JobInformation/utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { formatDuration, intervalToDuration } from "date-fns";

import type { JobBaseModel } from "@/api/jobs";

export function getJobDuration(job: JobBaseModel): string {
return formatDuration(intervalToDuration({ start: new Date(job.create_time), end: new Date(job.update_time) }));
}
17 changes: 15 additions & 2 deletions client/src/components/Panels/InvocationsPanel.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
<script setup lang="ts">
import { BAlert } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { ref } from "vue";

import { useUserStore } from "@/stores/userStore";

import InvocationScrollList from "../Workflow/Invocation/InvocationScrollList.vue";
import ActivityPanel from "./ActivityPanel.vue";

const { currentUser, toggledSideBar } = storeToRefs(useUserStore());

const shouldCollapse = ref(false);
function collapseOnLeave() {
if (shouldCollapse.value) {
toggledSideBar.value = "";
}
}
</script>

<template>
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
<ActivityPanel
title="Workflow Invocations"
go-to-all-title="Open Invocations List"
href="/workflows/invocations"
@goToAll="toggledSideBar = ''">
<InvocationScrollList v-if="currentUser && !currentUser?.isAnonymous" in-panel />
@goToAll="shouldCollapse = true"
@mouseleave.native="collapseOnLeave">
<InvocationScrollList
v-if="currentUser && !currentUser?.isAnonymous"
in-panel
@invocation-clicked="shouldCollapse = true" />
<BAlert v-else variant="info" class="mt-3" show>Please log in to view your Workflow Invocations.</BAlert>
</ActivityPanel>
</template>
Loading
Loading