<script setup lang="ts"> import { computed, ref, reactive, watch, onMounted, type PropType } from "vue"; import ParameterGroupForm from "@/components/parameter-schema/form-mode/ParameterGroupForm.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import Ajv from "ajv"; import type { ValidateFunction } from "ajv"; import ParameterStringInput from "@/components/parameter-schema/form-mode/ParameterStringInput.vue"; import { Toast, Tooltip } from "bootstrap"; import { useBucketStore } from "@/stores/buckets"; import { useS3KeyStore } from "@/stores/s3keys"; import BootstrapToast from "@/components/BootstrapToast.vue"; import { useResourceStore } from "@/stores/resources"; import { useRoute, useRouter } from "vue-router"; import type { ClowmInfo } from "@/types/ClowmInfo"; import UploadParameterFileModal from "@/components/parameter-schema/UploadParameterFileModal.vue"; import type { TemporaryParams, WorkflowMetaParameters, WorkflowParameters, } from "@/types/WorkflowParameters"; import { useWorkflowExecutionStore } from "@/stores/workflowExecutions"; const bucketRepository = useBucketStore(); const resourceRepository = useResourceStore(); const keyRepository = useS3KeyStore(); const executionRepository = useWorkflowExecutionStore(); const router = useRouter(); const route = useRoute(); // Props // ============================================================================= const props = defineProps({ schema: { type: Object, }, clowmInfo: { type: Object as PropType<ClowmInfo>, required: false, }, loading: { type: Boolean, }, allowNotes: { type: Boolean, }, viewMode: { type: String, default: "simple", validator(value: string) { return ["simple", "advanced", "expert"].includes(value); }, }, }); const emit = defineEmits<{ ( e: "start-workflow", parameters: WorkflowParameters, metaParameters: WorkflowMetaParameters, ): void; }>(); // Bootstrap Elements // ============================================================================= let errorToast: Toast | null = null; let parameterLoadToast: Toast | null = null; // Types // ============================================================================= type ParameterGroup = { group: string; title: string; icon?: string; }; // JSON Schema package // ============================================================================= const schemaCompiler = new Ajv({ strict: false, }); let validateSchema: ValidateFunction; // Reactive State // ============================================================================= const launchForm = ref<HTMLFormElement | null>(null); const formState = reactive<{ formInput: WorkflowParameters; validated: boolean; metaParameters: WorkflowMetaParameters; errorType?: string; }>({ formInput: {}, validated: false, metaParameters: { logs_s3_path: undefined, debug_s3_path: undefined, provenance_s3_path: undefined, notes: undefined, }, errorType: undefined, }); // Computed Properties // ============================================================================= const parameterGroups = computed<Record<string, never>>(() => { if (Object.keys(props.schema?.["properties"] ?? {}).length > 0) { return { ...props.schema?.["definitions"], ungrouped_parameters: { title: "Ungrouped Parameters", properties: props.schema?.["properties"], type: "object", }, }; } return props.schema?.["definitions"]; }); // Create a list with the names of all parameter groups const navParameterGroups = computed<ParameterGroup[]>(() => { let groups = Object.keys(parameterGroups.value).map((group) => { return { group: group, title: parameterGroups.value[group]["title"], icon: parameterGroups.value[group]["fa_icon"], }; }); if (!showHidden.value) { groups = groups.filter( // filter all groups that have only hidden parameters (group) => Object.keys(parameterGroups.value[group.group]["properties"]).filter( (key) => !parameterGroups.value[group.group]["properties"][key]["hidden"], ).length > 0, ); } if (!showOptional.value) { groups = groups.filter( // filter all groups that have no required parameter (group) => ( (parameterGroups.value[group.group]["required"] as Array<string>) ?? [] ).length > 0, ); } return groups; }); const showHidden = computed<boolean>(() => props.viewMode === "expert"); const showOptional = computed<boolean>(() => props.viewMode !== "simple"); // Watchers // ============================================================================= watch( () => props.schema, (newValue) => { if (newValue) { updateSchema(newValue); } }, ); // Functions // ============================================================================= /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars */ function updateSchema(schema: Record<string, any>) { validateSchema = schemaCompiler.compile(schema); const groupedParameters = Object.keys(parameterGroups.value).map( (groupName) => Object.fromEntries( Object.entries(parameterGroups.value[groupName]["properties"]).map( ([parameterName, parameter]) => [ parameterName, // @ts-ignore parameter["default"], ], ), ), ); formState.formInput = groupedParameters.reduce((acc, val) => { return { ...acc, ...val }; }); loadParameters(executionRepository.popTemporaryParameters()); } function startWorkflow() { errorToast?.hide(); formState.validated = true; formState.errorType = undefined; // delete parameters that are strings and have a length of 0 for (const paramName of Object.keys(formState.formInput)) { const param = formState.formInput[paramName]; if (typeof param === "string" && param?.trim().length === 0) { delete formState.formInput[paramName]; } } if (launchForm.value?.checkValidity()) { const schemaValid = validateSchema(formState.formInput); if (!schemaValid) { console.error(validateSchema.errors); errorToast?.show(); } else { emit("start-workflow", formState.formInput, formState.metaParameters); } } else { formState.errorType = "form"; errorToast?.show(); } } function loadParameters(tempParams?: TemporaryParams) { if (tempParams) { for (const param in tempParams.params) { if (param in formState.formInput) { formState.formInput[param] = tempParams.params[param]; } } formState.metaParameters = tempParams.metaParams; if (Object.keys(tempParams?.params ?? {}).length > 0) { parameterLoadToast?.show(); } } } function scroll(selectedAnchor: string) { document.querySelector(selectedAnchor)?.scrollIntoView({ behavior: "smooth", }); } // Lifecycle Events // ============================================================================= onMounted(() => { if (props.schema) updateSchema(props.schema); if (props.clowmInfo?.exampleParameters) Tooltip.getOrCreateInstance("#exampleDataButton"); bucketRepository.fetchBuckets(); bucketRepository.fetchOwnPermissions(); keyRepository.fetchS3Keys(); resourceRepository.fetchPublicResources(); errorToast = new Toast("#workflowExecutionErrorToast"); parameterLoadToast = new Toast("#workflowExecutionParameterLoadToast"); }); </script> <template> <bootstrap-toast toast-id="workflowExecutionParameterLoadToast" color-class="success" > Successfully loaded parameters </bootstrap-toast> <bootstrap-toast toast-id="workflowExecutionErrorToast" color-class="danger"> <template #default>Error starting workflow</template> <template #body> <template v-if="formState.errorType === 'form'"> Some inputs are not valid. </template> <template v-else> There was an error with starting the workflow execution. Look in the console for more information. </template> </template> </bootstrap-toast> <upload-parameter-file-modal modal-id="parameterUploadModal" @parameters-uploaded=" (params: WorkflowParameters) => loadParameters({ params: params, metaParams: {}, }) " /> <div class="row align-items-start"> <form v-if="props.schema" class="col-9" id="launchWorkflowForm" ref="launchForm" :class="{ 'was-validated': formState.validated }" @submit.prevent="startWorkflow" novalidate > <template v-for="(group, groupName) in parameterGroups" :key="groupName"> <parameter-group-form v-model="formState.formInput" v-if="formState.formInput" :parameter-group-name="groupName" :parameter-group="group" :showHidden="showHidden" :show-optional="showOptional" :resource-parameters="props.clowmInfo?.resourceParameters" /> </template> <div class="card mb-3"> <h3 class="card-header" id="pipelineGeneralOptions"> <font-awesome-icon icon="fa-solid fa-gear" class="me-2" /> Pipeline Options </h3> <div class="card-body"> <h5 class="card-title"> General Options about the pipeline execution </h5> <div v-if="props.allowNotes" :hidden="!showOptional"> <code class="bg-secondary-subtle p-2 rounded-top border border-secondary" >--notes</code > <div class="input-group"> <span class="input-group-text border border-secondary" id="pipelineNotes" > <font-awesome-icon icon="fa-solid fa-sticky-note" /> </span> <textarea class="form-control border border-secondary" rows="2" v-model="formState.metaParameters.notes" /> </div> <label class="mb-3" for="pipelineNotes" >Personal notes about the pipeline execution</label > </div> <div> <code class="bg-secondary-subtle p-2 rounded-top border border-secondary" >--logs_s3_path</code > <div class="input-group"> <span class="input-group-text border border-secondary"> <font-awesome-icon icon="fa-solid fa-folder" /> </span> <parameter-string-input parameter-name="logs_s3_path" v-model="formState.metaParameters.logs_s3_path" :parameter="{ format: 'directory-path', type: 'string', }" remove-advanced /> </div> <label class="mb-3" for="logsS3Path"> Directory in bucket where to save Nextflow log and reports </label> </div> <div> <code class="bg-secondary-subtle p-2 rounded-top border border-secondary" >--provenance_s3_path</code > <div class="input-group"> <span class="input-group-text border border-secondary"> <font-awesome-icon icon="fa-solid fa-folder" /> </span> <parameter-string-input parameter-name="provenance_s3_path" v-model="formState.metaParameters.provenance_s3_path" :parameter="{ format: 'directory-path', type: 'string', }" remove-advanced /> </div> <label class="mb-3" for="provenanceS3Path"> Directory in bucket where to save provenance information about the workflow execution </label> </div> <div :hidden="!showHidden"> <code class="bg-secondary-subtle p-2 rounded-top border border-secondary" >--debug_s3_path</code > <div class="input-group"> <span class="input-group-text border border-secondary"> <font-awesome-icon icon="fa-solid fa-folder" /> </span> <parameter-string-input parameter-name="debug_s3_path" v-model="formState.metaParameters.debug_s3_path" :parameter="{ format: 'directory-path', type: 'string', }" remove-advanced /> </div> <label class="mb-3" for="debugS3Path"> Directory in bucket where to save debug information about the workflow execution </label> </div> </div> </div> </form> <!-- Loading card --> <div v-else class="col-9"> <div class="card mb-3"> <h2 class="card-header placeholder-glow"> <span class="placeholder col-6"></span> </h2> <div class="card-body"> <h5 class="card-title placeholder-glow"> <span class="placeholder col-5"> </span> </h5> <template v-for="n in 4" :key="n"> <div class="placeholder-glow fs-5"> <span class="placeholder w-100"> </span> </div> <div class="mb-3 placeholder-glow"> <span class="placeholder col-3"> </span> </div> </template> </div> </div> </div> <div class="col-3 sticky-top border shadow-sm rounded-1 px-0" style="top: 70px !important; max-height: calc(100vh - 150px)" > <h5 class="mx-3 mt-2">Parameter View</h5> <div class="mx-2"> <div class="btn-group my-1 w-100" role="group" aria-label="Basic radio toggle button group" > <input type="radio" class="btn-check" name="view-mode" id="view-mode-simple" autocomplete="off" :checked="props.viewMode === 'simple'" @click=" router.replace({ query: { ...route.query, viewMode: 'simple' }, hash: route.hash, }) " /> <label class="btn btn-outline-primary" for="view-mode-simple" >Simple</label > <input type="radio" class="btn-check" name="view-mode" id="view-mode-advanced" autocomplete="off" :checked="props.viewMode === 'advanced'" @click=" router.replace({ query: { ...route.query, viewMode: 'advanced' }, hash: route.hash, }) " /> <label class="btn btn-outline-primary" for="view-mode-advanced" >Advanced</label > <input type="radio" class="btn-check" name="view-mode" id="view-mode-expert" autocomplete="off" :checked="props.viewMode === 'expert'" @click=" router.replace({ query: { ...route.query, viewMode: 'expert' }, hash: route.hash, }) " /> <label class="btn btn-outline-primary" for="view-mode-expert" >Expert</label > </div> </div> <nav class="h-100"> <nav v-if="props.schema" class="nav"> <ul class="ps-0"> <li class="nav-link" v-for="group in navParameterGroups" :key="group.group" > <router-link :to="{ hash: '#' + group.group, query: route.query }" replace @click="scroll('#' + group.group)" > <font-awesome-icon :icon="group.icon" v-if="group.icon" class="me-2" /> {{ group.title }} </router-link> </li> <li class="nav-link"> <router-link :to="{ hash: '#pipelineGeneralOptions', query: route.query }" replace @click="scroll('#pipelineGeneralOptions')" > <font-awesome-icon icon="fa-solid fa-gear" class="me-2" /> General Pipeline Options </router-link> </li> </ul> </nav> <!-- Loading nav links --> <div v-else class="placeholder-glow ps-3 pt-3"> <span v-for="n in 5" :key="n" class="placeholder col-8 mt-2 mb-3" ></span> </div> </nav> <div class="d-grid gap-2 mb-2 px-2"> <button type="button" class="btn btn-primary" v-if="props.clowmInfo?.exampleParameters" data-bs-toggle="tooltip" id="exampleDataButton" data-bs-title="Load example parameters/data for this workflow" @click=" loadParameters({ params: props.clowmInfo?.exampleParameters, metaParams: {}, }) " > Try it out </button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#parameterUploadModal" > <font-awesome-icon icon="fa-solid fa-upload" class="me-2" /> Upload Parameters </button> <button type="submit" form="launchWorkflowForm" class="btn btn-success btn-lg" :disabled="props.loading || !props.schema" > <font-awesome-icon icon="fa-solid fa-play" class="me-2" /> Launch </button> </div> </div> </div> </template> <style> .was-validated *:invalid { border-color: var(--bs-form-invalid-border-color) !important; background: var(--bs-danger-bg-subtle) !important; } </style>