Skip to content

Commit

Permalink
Merge pull request #18615 from ahmedhamidawan/invocation_view_improve…
Browse files Browse the repository at this point in the history
…ments

Workflow Invocation view improvements
  • Loading branch information
mvdbeek authored Nov 12, 2024
2 parents e34f6ad + 0c50efc commit 723004e
Show file tree
Hide file tree
Showing 40 changed files with 1,070 additions and 1,017 deletions.
3 changes: 3 additions & 0 deletions client/src/api/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ 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"];
export type JobMetric = components["schemas"]["JobMetric"];
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, watch } 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

0 comments on commit 723004e

Please sign in to comment.