Unverified Commit 3cb00da0 authored by Ahmed Awan's avatar Ahmed Awan Committed by mvdbeek
Browse files

add `useWorkflowRunGraph` composable for workflow run graph

parent ddea44e2
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -271,7 +271,7 @@ async function onExecute() {

        <WorkflowAnnotation :workflow-id="model.runData.workflow_id" :history-id="model.historyId" show-details />

        <div class="overflow-auto">
        <div class="overflow-auto h-100">
            <div class="d-flex h-100">
                <div
                    :class="showGraph ? 'w-50 flex-grow-1' : 'w-100'"
@@ -292,7 +292,7 @@ async function onExecute() {
                    <WorkflowRunGraph
                        v-if="isConfigLoaded"
                        :workflow-id="model.workflowId"
                        :step-validation="stepValidation"
                        :step-validation="stepValidation || undefined"
                        :stored-id="model.runData.workflow_id"
                        :version="model.runData.version"
                        :inputs="formData"
+17 −152
Original line number Diff line number Diff line
<script setup lang="ts">
import { faCheckCircle, faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { BAlert, BCard } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { ref, set, watch } from "vue";
import { ref, toRef } from "vue";

import { getWorkflowFull } from "@/components/Workflow/workflows.services";
import { getHeaderClass } from "@/composables/useInvocationGraph";
import { type DataInput, useWorkflowRunGraph } from "@/composables/useWorkflowRunGraph";
import { useDatatypesMapperStore } from "@/stores/datatypesMapperStore";
import { useWorkflowStateStore } from "@/stores/workflowEditorStateStore";
import { errorMessageAsString } from "@/utils/simple-error";

import { isWorkflowInput } from "../constants";
import { fromSimple } from "../Editor/modules/model";

import Heading from "@/components/Common/Heading.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
import WorkflowGraph from "@/components/Workflow/Editor/WorkflowGraph.vue";

const STEP_DESCRIPTIONS = {
    TextToolParameter: "Provide text input",
    IntegerToolParameter: "Provide an integer",
    FloatToolParameter: "Provide a float",
    ColorToolParameter: "Provide a color",
    DirectoryUriToolParameter: "Provide a directory",
    DataToolParameter: "Provide a dataset",
    DataCollectionToolParameter: "Provide a collection",
    SelectToolParameter: "Select an option",
    BooleanToolParameter: "",
};

interface BaseDataToolParameterInput {
    batch: boolean;
    product: boolean;
    values: { id: string; src: "dce" | "hda" | "hdca" | "ldda"; map_over_type: string }[];
}
interface DataToolParameterInput extends BaseDataToolParameterInput {}
interface DataCollectionToolParameterInput extends BaseDataToolParameterInput {}
type DataInput = DataToolParameterInput | DataCollectionToolParameterInput | boolean | string | null;

interface Props {
    workflowId: string;
    storedId: string;
    version: number;
    inputs: Record<string, DataInput | null>;
    stepValidation: any; // TODO: type as [string, string] | null;
    stepValidation?: [string, string];
    formInputs: any[];
}

const props = defineProps<Props>();

const hasLoadedGraph = ref(false);
const errorMessage = ref<string | null>(null);
const loadedWorkflow = ref<any | null>(null);

const { activeNodeId } = storeToRefs(useWorkflowStateStore(props.workflowId));

const datatypesMapperStore = useDatatypesMapperStore();
const { datatypesMapper, loading: datatypesMapperLoading } = storeToRefs(datatypesMapperStore);

watch(
    () => props.workflowId,
    async () => {
        try {
            loadedWorkflow.value = await getWorkflowFull(props.workflowId, props.version);

            syncStepsWithInputVals();
const { steps, loading, loadWorkflowRunGraph } = useWorkflowRunGraph(
    props.workflowId,
    props.version,
    toRef(props, "inputs"),
    toRef(props, "formInputs"),
    toRef(props, "stepValidation")
);

            await fromSimple(props.workflowId, loadedWorkflow.value);
try {
    loadWorkflowRunGraph();
} catch (error) {
    errorMessage.value = errorMessageAsString(error);
        } finally {
            hasLoadedGraph.value = true;
        }
    },
    { immediate: true }
);

/** Sync the workflow graph steps with the current input values */
function syncStepsWithInputVals() {
    if (!loadedWorkflow.value?.steps) {
        return;
    }
    for (const s of Object.values(loadedWorkflow.value?.steps)) {
        const step = s as any;
        if (isWorkflowInput(step.type)) {
            let dataInput = props.inputs[step.id.toString()];
            const formInput = props.formInputs.find((input) => parseInt(input.name) === step.id);
            const optional = formInput?.optional as boolean;
            const modelClass = formInput?.model_class as keyof typeof STEP_DESCRIPTIONS;

            if (modelClass === "BooleanToolParameter") {
                setStepDescription(step, dataInput as boolean, true);
            } else if (modelClass === "DataToolParameter" || modelClass === "DataCollectionToolParameter") {
                dataInput = dataInput as DataToolParameterInput | DataCollectionToolParameterInput;
                const inputVals = dataInput?.values;
                const options = formInput?.options;

                if (inputVals?.length === 1 && inputVals[0]) {
                    const { id, src } = inputVals[0];
                    const item = options[src].find((option: any) => option.id === id);
                    setStepDescription(step, `${item.hid}: <b>${item.name}</b>`, true);
                } else if (inputVals?.length) {
                    setStepDescription(step, `${inputVals.length} inputs provided`, true);
                } else {
                    setStepDescription(step, STEP_DESCRIPTIONS[modelClass], false, optional);
                }
            } else if (Object.keys(STEP_DESCRIPTIONS).includes(modelClass)) {
                if (!dataInput || dataInput.toString().trim() === "") {
                    setStepDescription(step, STEP_DESCRIPTIONS[modelClass], false, optional);
                } else {
                    let text: string;
                    switch (modelClass) {
                        case "ColorToolParameter":
                            text = dataInput as string;
                            break;
                        case "DirectoryUriToolParameter":
                            text = `Directory: <b>${dataInput}</b>`;
                            break;
                        default:
                            text = `<b>${dataInput}</b>`;
                    }
                    setStepDescription(step, text, true);
                }
            } else {
                set(step, "nodeText", "This is an input");
}
        }
    }
}

/** Annotate the step for the workflow graph with the current input value or prompt
 * @param step The step to annotate
 * @param text The text to display
 * @param populated Whether the input is populated, undefined for optional inputs
 * @param optional Whether the input is optional
 */
function setStepDescription(step: any, text: string | boolean, populated: boolean, optional?: boolean) {
    // color variant for `paused` state works best for unpopulated inputs,
    // "" for optional inputs and `ok` for populated inputs
    const headerClass = optional ? "" : populated ? "ok" : "paused";
    const headerIcon = populated ? faCheckCircle : faExclamationCircle;

    text = typeof text === "boolean" ? text : !optional ? text : `${text} (optional)`;

    set(step, "nodeText", text);
    set(step, "headerClass", getHeaderClass(headerClass));
    set(step, "headerIcon", headerIcon);
}

function setStepDescriptionForValidation(): boolean {
    if (props.stepValidation && props.stepValidation.length == 2) {
        const [stepId, message] = props.stepValidation;
        const step = loadedWorkflow.value.steps[stepId];
        if (step) {
            const text = message.length < 20 ? message : "Fix error(s) for this step";
            setStepDescription(step, text, false);
            return true;
        }
    }
    return false;
}

watch(
    () => props.inputs,
    () => {
        syncStepsWithInputVals();
    }
);

watch(
    () => props.stepValidation,
    () => {
        if (!loadedWorkflow.value?.steps) {
            return;
        }
        setStepDescriptionForValidation();
    },
    { immediate: true }
);
</script>

<template>
    <BAlert v-if="errorMessage" variant="danger" show>
        {{ errorMessage }}
    </BAlert>
    <BAlert v-else-if="datatypesMapperLoading || !loadedWorkflow" variant="info" show>
    <BAlert v-else-if="datatypesMapperLoading || loading" variant="info" show>
        <LoadingSpan message="Loading workflow" />
    </BAlert>
    <div v-else-if="datatypesMapper && hasLoadedGraph" class="d-flex flex-column">
    <div v-else-if="datatypesMapper && !loading" class="d-flex flex-column">
        <Heading h2 separator bold size="sm"> Graph </Heading>
        <BCard class="workflow-preview mx-1 flex-grow-1">
            <WorkflowGraph
                v-if="loadedWorkflow.steps"
                :steps="loadedWorkflow.steps"
                v-if="steps"
                :steps="steps"
                :datatypes-mapper="datatypesMapper"
                :scroll-to-id="activeNodeId"
                populated-inputs
+188 −0
Original line number Diff line number Diff line
import { faCheckCircle, faExclamationCircle, type IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { computed, type Ref, ref, set } from "vue";

import { isWorkflowInput } from "@/components/Workflow/constants";
import { fromSimple } from "@/components/Workflow/Editor/modules/model";
import { getWorkflowFull } from "@/components/Workflow/workflows.services";
import { type Step } from "@/stores/workflowStepStore";
import { rethrowSimple } from "@/utils/simple-error";

import { getHeaderClass } from "./useInvocationGraph";

const STEP_DESCRIPTIONS = {
    TextToolParameter: "Provide text input",
    IntegerToolParameter: "Provide an integer",
    FloatToolParameter: "Provide a float",
    ColorToolParameter: "Provide a color",
    DirectoryUriToolParameter: "Provide a directory",
    DataToolParameter: "Provide a dataset",
    DataCollectionToolParameter: "Provide a collection",
    SelectToolParameter: "Select an option",
    BooleanToolParameter: "",
};

interface BaseDataToolParameterInput {
    batch: boolean;
    product: boolean;
    values: { id: string; src: "dce" | "hda" | "hdca" | "ldda"; map_over_type: string }[];
}
interface DataToolParameterInput extends BaseDataToolParameterInput {}
interface DataCollectionToolParameterInput extends BaseDataToolParameterInput {}
export type DataInput = DataToolParameterInput | DataCollectionToolParameterInput | boolean | string | null;

interface WorkflowRunStep extends Step {
    headerClass?: Record<string, boolean>;
    headerIcon?: IconDefinition;
    nodeText?: string | boolean;
}

/** Composable that creates a readonly workflow run graph and loads it onto a workflow editor canvas for display.
 * This graph updates as the user changes the inputs of the workflow.
 * @param workflowId - The id of the workflow
 * @param workflowVersion - The version of the workflow
 * @param inputs - The current inputs of the workflow
 * @param formInputs - The form inputs of the workflow
 * @param stepValidation - The current error if any at the stepId: [stepId, message]
 */
export function useWorkflowRunGraph(
    workflowId: string | undefined,
    workflowVersion: number | undefined,
    inputs: Ref<Record<string, DataInput | null>>,
    formInputs: Ref<any[]>,
    stepValidation: Ref<[string, string] | undefined>
) {
    /** The workflow that is to be run */
    const loadedWorkflow = ref<any>(null);

    const loading = ref(true);

    async function loadWorkflowRunGraph() {
        loading.value = true;

        try {
            if (!workflowId) {
                throw new Error("Workflow Id is not defined");
            }
            if (workflowVersion === undefined) {
                throw new Error("Workflow Version is not defined");
            }

            // initialize the original full workflow ref
            if (!loadedWorkflow.value) {
                loadedWorkflow.value = await getWorkflowFull(workflowId, workflowVersion);
            }

            await fromSimple(workflowId, loadedWorkflow.value);
        } catch (e) {
            rethrowSimple(e);
        } finally {
            loading.value = false;
        }
    }

    const workflowSteps = computed<Record<string, Step>>(() => loadedWorkflow.value?.steps);

    const steps = computed<{ [index: string]: WorkflowRunStep }>(() => {
        if (!workflowSteps.value || !formInputs.value || !inputs.value) {
            return {};
        }

        return Object.keys(workflowSteps.value).reduce((acc: { [index: string]: WorkflowRunStep }, k: string) => {
            const step = workflowSteps.value[k];
            const key = parseInt(k);
            if (step) {
                if (isWorkflowInput(step.type) && !checkAndSetStepDescriptionForValidation(step)) {
                    let dataInput = inputs.value[step.id.toString()];
                    const formInput = formInputs.value.find((input) => parseInt(input.name) === step.id);
                    const optional = formInput?.optional as boolean;
                    const modelClass = formInput?.model_class as keyof typeof STEP_DESCRIPTIONS;

                    if (modelClass === "BooleanToolParameter") {
                        setStepDescription(step, dataInput as boolean, true);
                    } else if (modelClass === "DataToolParameter" || modelClass === "DataCollectionToolParameter") {
                        dataInput = dataInput as DataToolParameterInput | DataCollectionToolParameterInput;
                        const inputVals = dataInput?.values;
                        const options = formInput?.options;

                        if (inputVals?.length === 1 && inputVals[0]) {
                            const { id, src } = inputVals[0];
                            const item = options[src].find((option: any) => option.id === id);

                            if (item && item.hid && item.name) {
                                setStepDescription(step, `${item.hid}: <b>${item.name}</b>`, true);
                            }
                        } else if (inputVals?.length) {
                            setStepDescription(step, `${inputVals.length} inputs provided`, true);
                        } else {
                            setStepDescription(step, STEP_DESCRIPTIONS[modelClass], false, optional);
                        }
                    } else if (Object.keys(STEP_DESCRIPTIONS).includes(modelClass)) {
                        if (!dataInput || dataInput.toString().trim() === "") {
                            setStepDescription(step, STEP_DESCRIPTIONS[modelClass], false, optional);
                        } else {
                            let text: string;
                            switch (modelClass) {
                                case "ColorToolParameter":
                                    text = dataInput as string;
                                    break;
                                case "DirectoryUriToolParameter":
                                    text = `Directory: <b>${dataInput}</b>`;
                                    break;
                                default:
                                    text = `<b>${dataInput}</b>`;
                            }
                            setStepDescription(step, text, true);
                        }
                    } else {
                        set(step, "nodeText", "This is an input");
                    }
                }

                acc[key] = step;
            }
            return acc;
        }, {});
    });

    /** Annotate the step for the workflow graph with the current input value or prompt
     * @param step The step to annotate
     * @param text The text to display
     * @param populated Whether the input is populated, undefined for optional inputs
     * @param optional Whether the input is optional
     */
    function setStepDescription(step: Step, text: string | boolean, populated: boolean, optional?: boolean) {
        // color variant for `paused` state works best for unpopulated inputs,
        // "" for optional inputs and `ok` for populated inputs
        const headerClass = optional ? "" : populated ? "ok" : "paused";
        const headerIcon = populated ? faCheckCircle : faExclamationCircle;

        text = typeof text === "boolean" ? text : !optional ? text : `${text} (optional)`;

        set(step, "nodeText", text);
        set(step, "headerClass", getHeaderClass(headerClass));
        set(step, "headerIcon", headerIcon);
    }

    function checkAndSetStepDescriptionForValidation(step: Step): boolean {
        if (stepValidation.value && stepValidation.value.length == 2) {
            const [stepId, message] = stepValidation.value;

            if (stepId === step.id.toString()) {
                const text = message.length < 20 ? message : "Fix error(s) for this step";
                setStepDescription(step, text, false);
                return true;
            }
        }
        return false;
    }

    return {
        /** The steps of the workflow run graph */
        steps,
        /** Fetches the original workflow structure (once) and syncs the step
         * descriptions given the current user inputs.
         */
        loadWorkflowRunGraph,
        loading,
    };
}