diff --git a/src/components/parameter-schema/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue index e7e5232af42607426b973700113e0c5ba6eb6b77..b80fd1eb59bcedf639a898f81065ad497ae78be6 100644 --- a/src/components/parameter-schema/ParameterSchemaFormComponent.vue +++ b/src/components/parameter-schema/ParameterSchemaFormComponent.vue @@ -2,38 +2,95 @@ import { computed, ref, reactive, watch, onMounted } from "vue"; import ParameterGroupForm from "@/components/parameter-schema/form-mode/ParameterGroupForm.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import { WorkflowExecutionService } from "@/client/workflow"; +import type { ApiError } from "@/client/workflow"; import Ajv from "ajv"; import type { ValidateFunction } from "ajv"; +import ParameterStringInput from "@/components/parameter-schema/form-mode/ParameterStringInput.vue"; +import { Toast } from "bootstrap"; -const launchForm = ref<HTMLFormElement | null>(null); -const schemaCompiler = new Ajv({ - strict: false, -}); - -let validateSchema: ValidateFunction; - +// Props +// ============================================================================= const props = defineProps({ schema: { type: Object, required: true, }, + workflowVersionId: { + type: String, + required: true, + }, }); +// Bootstrap Elements +// ============================================================================= +let errorToast: 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<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any formInput: Record<string, any>; validated: boolean; + pipelineNotes: string; + report_bucket?: string; + loading: boolean; + errorType?: string; }>({ formInput: {}, validated: false, + pipelineNotes: "", + report_bucket: undefined, + loading: false, + errorType: undefined, }); +// Computed Properties +// ============================================================================= +const parameterGroups = computed<Record<string, never>>( + () => props.schema["definitions"] +); + +// Create a list with the names of all parameter groups +const navParameterGroups = computed<ParameterGroup[]>(() => + Object.keys(parameterGroups.value) + .map((group) => { + return { + group: group, + title: parameterGroups.value[group]["title"], + icon: parameterGroups.value[group]["fa_icon"], + }; + }) + .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 + ) +); + +// Watchers +// ============================================================================= watch( () => props.schema, (newValue) => { @@ -41,6 +98,8 @@ watch( } ); +// 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(props.schema); @@ -61,33 +120,6 @@ function updateSchema(schema: Record<string, any>) { } /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars */ -onMounted(() => { - updateSchema(props.schema); -}); - -const parameterGroups = computed<Record<string, never>>( - () => props.schema["definitions"] -); - -const navParameterGroups = computed<ParameterGroup[]>(() => - Object.keys(parameterGroups.value) - .map((group) => { - return { - group: group, - title: parameterGroups.value[group]["title"], - icon: parameterGroups.value[group]["fa_icon"], - }; - }) - .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 - ) -); - function startWorkflow() { formState.validated = true; if (launchForm.value?.checkValidity()) { @@ -96,15 +128,70 @@ function startWorkflow() { }); const schemaValid = validateSchema(realInput); - console.log(realInput); - if (!schemaValid) console.log(validateSchema.errors); - } else { - console.log("invalid"); + if (!schemaValid) { + console.error(validateSchema.errors); + errorToast?.show(); + } else { + formState.errorType = undefined; + formState.loading = true; + WorkflowExecutionService.workflowExecutionStartWorkflow({ + workflow_version_id: props.workflowVersionId, + parameters: realInput, + notes: formState.pipelineNotes, + report_output_bucket: formState.report_bucket, + }) + .then(() => { + console.log("Started Workflow"); + }) + .catch((err: ApiError) => { + console.error(err); + if (err.body["detail"].includes("workflow execution limit")) { + formState.errorType = "limit"; + } + errorToast?.show(); + }) + .finally(() => { + formState.loading = false; + }); + } } } + +// Lifecycle Events +// ============================================================================= +onMounted(() => { + updateSchema(props.schema); + errorToast = new Toast("#workflowExecutionErrorToast"); +}); </script> <template> + <div class="toast-container position-fixed top-toast end-0 p-3"> + <div + role="alert" + aria-live="assertive" + aria-atomic="true" + class="toast text-bg-danger align-items-center border-0" + data-bs-autohide="true" + id="workflowExecutionErrorToast" + > + <div class="d-flex p-2"> + <div v-if="formState.errorType === 'limit'" class="toast-body"> + You have too many active workflow executions to start a new one + </div> + <div v-else> + There was an error with starting the workflow execution. Look in the + console for more information + </div> + <button + type="button" + class="btn-close btn-close-white m-auto" + data-bs-dismiss="toast" + aria-label="Close" + ></button> + </div> + </div> + </div> <div class="row mb-5 align-items-start"> <form class="col-9" @@ -112,7 +199,56 @@ function startWorkflow() { ref="launchForm" :class="{ 'was-validated': formState.validated }" > - <div v-for="(group, groupName) in parameterGroups" :key="groupName"> + <div class="card bg-dark mb-3"> + <h2 class="card-header" id="pipelineGeneralOptions"> + <font-awesome-icon icon="fa-solid fa-gear" class="me-2" /> + Pipeline Options + </h2> + <div class="card-body"> + <h5 class="card-title"> + General Options about the pipeline execution + </h5> + <div class="input-group"> + <span class="input-group-text" id="pipelineNotes"> + <font-awesome-icon + class="me-2 text-dark" + icon="fa-solid fa-sticky-note" + /> + <code>--notes</code> + </span> + <textarea + class="form-control" + rows="2" + v-model="formState.pipelineNotes" + /> + </div> + <label class="mb-3" + >Personal notes about the pipeline execution</label + > + <div class="input-group"> + <span class="input-group-text" id="pipelineNotes"> + <font-awesome-icon + class="me-2 text-dark" + icon="fa-solid fa-sticky-note" + /> + <code>--report_output_bucket</code> + </span> + <parameter-string-input + parameter-name="report_output_bucket" + v-model="formState.report_bucket" + :parameter="{ + format: 'directory-path', + type: 'string', + }" + /> + </div> + <label class="mb-3"> + Directory in bucket where to save the Nextflow report about the + pipeline execution + </label> + </div> + </div> + <template v-for="(group, groupName) in parameterGroups" :key="groupName"> <parameter-group-form :modelValue="formState.formInput[groupName]" @update:model-value=" @@ -122,25 +258,42 @@ function startWorkflow() { :parameter-group-name="groupName" :parameter-group="group" /> - </div> + </template> </form> <div class="col-3 sticky-top bg-dark rounded-1 px-0" style="top: 70px !important; max-height: calc(100vh - 150px)" > - <div class="d-grid gap-2 col-6"> + <div class="d-flex pt-2"> <button type="submit" form="launchWorkflowForm" @click.prevent="startWorkflow" - class="btn btn-success" + class="btn btn-success w-50 mx-2" + :disabled="formState.loading" > + <font-awesome-icon icon="fa-solid fa-rocket" class="me-2" /> Launch </button> + <router-link + role="button" + class="btn btn-success w-50 mx-2" + target="_blank" + :to="{ name: 'buckets' }" + > + <font-awesome-icon icon="fa-solid fa-upload" class="me-2" /> + Upload files + </router-link> </div> <nav class="h-100"> <nav class="nav"> <ul class="ps-0"> + <li class="nav-link"> + <a href="#pipelineGeneralOptions" + ><font-awesome-icon icon="fa-solid fa-gear" class="me-2" /> + General Pipeline Options + </a> + </li> <li class="nav-link" v-for="group in navParameterGroups" diff --git a/src/components/parameter-schema/form-mode/ParameterGroupForm.vue b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue index 3043606ade85c4c241b45ad151de2e0545f668b8..c68eb662a236b5fbaf1583530267492f189cb7ed 100644 --- a/src/components/parameter-schema/form-mode/ParameterGroupForm.vue +++ b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue @@ -37,7 +37,7 @@ const parameters = computed<Record<string, never>>( () => props.parameterGroup["properties"] ); -const modelStuff = computed(() => props.modelValue); +const formInput = computed(() => props.modelValue); const emit = defineEmits<{ ( e: "update:modelValue", @@ -45,8 +45,21 @@ const emit = defineEmits<{ ): void; }>(); +function parameterRequired( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameterGroup: Record<string, any>, + parameterName: string +): boolean { + return ( + parameterGroup["required"]?.includes(parameterName) || // parameter is required + parameterGroup["dependentRequired"]?.[parameterName] // parameter is required when another parameter is set + ?.map((param: string) => formInput.value[param]) + ?.reduce((acc: boolean, val: string) => acc || val, false) + ); +} + watch( - modelStuff, + formInput, (newVal) => { //console.log("Group", props.parameterGroupName, newVal); emit("update:modelValue", newVal); @@ -87,10 +100,10 @@ watch( :parameter-name="parameterName" :parameter="parameter" :help-id="parameterName + '-help'" - :required="parameterGroup['required']?.includes(parameterName)" - :model-value="modelStuff[parameterName]" + :required="parameterRequired(parameterGroup, parameterName)" + :model-value="formInput[parameterName]" @update:model-value=" - (newValue) => (modelStuff[parameterName] = newValue) + (newValue) => (formInput[parameterName] = newValue) " /> <parameter-boolean-input @@ -98,9 +111,9 @@ watch( :parameter-name="parameterName" :parameter="parameter" :help-id="parameterName + '-help'" - :model-value="modelStuff[parameterName]" + :model-value="formInput[parameterName]" @update:model-value=" - (newValue) => (modelStuff[parameterName] = newValue) + (newValue) => (formInput[parameterName] = newValue) " /> <template v-else-if="parameter['type'] === 'string'"> @@ -108,20 +121,20 @@ watch( v-if="parameter['enum']" :parameter-name="parameterName" :parameter="parameter" - :model-value="modelStuff[parameterName]" - :required="parameterGroup['required']?.includes(parameterName)" + :model-value="formInput[parameterName]" + :required="parameterRequired(parameterGroup, parameterName)" @update:model-value=" - (newValue) => (modelStuff[parameterName] = newValue) + (newValue) => (formInput[parameterName] = newValue) " /> <parameter-string-input v-else :parameter-name="parameterName" :parameter="parameter" - :model-value="modelStuff[parameterName]" - :required="parameterGroup['required']?.includes(parameterName)" + :model-value="formInput[parameterName]" + :required="parameterRequired(parameterGroup, parameterName)" @update:model-value=" - (newValue) => (modelStuff[parameterName] = newValue) + (newValue) => (formInput[parameterName] = newValue) " /> </template> @@ -151,6 +164,9 @@ watch( class="helpTextCode" :markdown="parameter['help_text']" /> + <p v-if="parameter['pattern']"> + Pattern: <code>{{ parameter["pattern"] }}</code> + </p> </div> </template> </template> diff --git a/src/components/parameter-schema/form-mode/ParameterNumberInput.vue b/src/components/parameter-schema/form-mode/ParameterNumberInput.vue index 345deb5b75aa39a21f64cf4c931d08c0be6c481b..513b783282e6b08ce8e78b4dd4360ddd4fe963ce 100644 --- a/src/components/parameter-schema/form-mode/ParameterNumberInput.vue +++ b/src/components/parameter-schema/form-mode/ParameterNumberInput.vue @@ -39,6 +39,7 @@ function updateValue() { ref="numberInput" :max="props.parameter['maximum']" :min="props.parameter['minimum']" + step="0.01" :value="props.modelValue" :required="props.required" :aria-describedby="props.helpId" diff --git a/src/components/parameter-schema/form-mode/ParameterStringInput.vue b/src/components/parameter-schema/form-mode/ParameterStringInput.vue index d247cbc5b9da04dc1ca068cfd9509b5f2e63833c..b6be2cb88238b1e10ead54d2a32c37cd99ac0505 100644 --- a/src/components/parameter-schema/form-mode/ParameterStringInput.vue +++ b/src/components/parameter-schema/form-mode/ParameterStringInput.vue @@ -1,5 +1,9 @@ <script setup lang="ts"> -import { computed, watch, ref } from "vue"; +import { computed, watch, ref, onMounted, reactive } from "vue"; +import { useBucketStore } from "@/stores/buckets"; +import { ObjectService } from "@/client/s3proxy"; + +const bucketRepository = useBucketStore(); const props = defineProps({ parameter: { @@ -22,14 +26,41 @@ const props = defineProps({ }, }); +const randomIDSuffix = Math.random().toString(16).substr(2, 8); + const defaultValue = computed<string>(() => props.parameter["default"]); +const s3Path = reactive<{ + bucket: string | undefined; + key: string | undefined; +}>({ + bucket: undefined, + key: undefined, +}); + +const keysInBucket = ref<string[]>([]); + watch(defaultValue, (newVal, oldVal) => { if (newVal != oldVal && newVal != undefined) { emit("update:modelValue", newVal); } }); +watch(s3Path, () => { + if (format.value) { + updateValue(); + } +}); + +watch( + () => s3Path.bucket, + (newVal, oldVal) => { + if (newVal !== oldVal) { + updateKeysInBucket(newVal); + } + } +); + const pattern = computed<string>(() => props.parameter["pattern"]); const emit = defineEmits<{ @@ -38,16 +69,112 @@ const emit = defineEmits<{ const stringInput = ref<HTMLInputElement | undefined>(undefined); +const format = computed<string | undefined>(() => props.parameter["format"]); + +const filesInBucket = computed<string[]>(() => + keysInBucket.value.filter( + (obj) => !obj.endsWith("/") && !obj.endsWith(".s3keep") + ) +); + +const foldersInBucket = computed<string[]>(() => + keysInBucket.value + .map((obj) => { + const parts = obj.split("/"); + return parts + .slice(0, parts.length - 1) + .map((part, index) => + parts.slice(0, index + 1).reduce((acc, val) => `${acc}/${val}`) + ); + }) + .flat() + .filter((val, index, array) => array.indexOf(val) === index) +); + +const filesAndFoldersInBucket = computed<string[]>(() => + filesInBucket.value.concat(foldersInBucket.value) +); + +const keyDataList = computed<string[]>(() => { + switch (format.value) { + case "file-path": + return filesInBucket.value; + case "directory-path": + return foldersInBucket.value; + case "path": + return filesAndFoldersInBucket.value; + default: + return []; + } +}); + function updateValue() { - emit( - "update:modelValue", - stringInput.value?.value ? stringInput.value?.value : undefined - ); + if (format.value) { + emit( + "update:modelValue", + !s3Path.bucket && s3Path.key + ? undefined + : `s3://${s3Path.bucket}/${s3Path.key}` + ); + } else { + emit( + "update:modelValue", + stringInput.value?.value ? stringInput.value?.value : undefined + ); + } +} + +const helpTextPresent = computed<boolean>(() => props.parameter["help_text"]); + +function updateKeysInBucket(bucketName?: string) { + if (bucketName != null) { + ObjectService.objectGetBucketObjects(bucketName).then((objs) => { + keysInBucket.value = objs.map((obj) => obj.key); + }); + } else { + keysInBucket.value = []; + } } + +onMounted(() => { + bucketRepository.fetchBuckets(); + if (format.value) { + s3Path.key = defaultValue.value; + } +}); </script> <template> + <template v-if="format"> + <select + class="form-select" + :required="props.required" + v-model="s3Path.bucket" + > + <option selected disabled value="">Please select a bucket</option> + <option + v-for="bucket in bucketRepository.ownBucketsAndFullPermissions" + :key="bucket" + :value="bucket" + > + {{ bucket }} + </option> + </select> + <input + class="form-control" + :class="{ 'rounded-end': !helpTextPresent }" + :list="'datalistOptions2' + randomIDSuffix" + placeholder="Type to search in bucket..." + :required="props.required && format === 'file-path'" + v-model="s3Path.key" + :pattern="pattern" + /> + <datalist :id="'datalistOptions2' + randomIDSuffix"> + <option v-for="obj in keyDataList" :value="obj" :key="obj" /> + </datalist> + </template> <input + v-else ref="stringInput" class="form-control" type="text" diff --git a/src/stores/buckets.ts b/src/stores/buckets.ts index e3ab60b32801feb038b904a8293c0ec9cf6502e9..c95040f8eeee280d35e822af6ed3fdb2f55cb4af 100644 --- a/src/stores/buckets.ts +++ b/src/stores/buckets.ts @@ -3,6 +3,7 @@ import { BucketPermissionService, BucketService, Constraint, + Permission, } from "@/client/s3proxy"; import type { BucketOut, @@ -10,6 +11,7 @@ import type { BucketPermissionOut, } from "@/client/s3proxy"; import { useAuthStore } from "@/stores/auth"; +import type { CancelablePromise } from "@/client/auth"; export const useBucketStore = defineStore({ id: "buckets", @@ -17,11 +19,27 @@ export const useBucketStore = defineStore({ ({ buckets: [], ownPermissions: {}, + _lastFetchBucketPromise: undefined, + _bla: 0, } as { buckets: BucketOut[]; ownPermissions: Record<string, BucketPermissionOut>; + _lastFetchBucketPromise?: CancelablePromise<never>; + _bla: number; }), getters: { + ownBucketsAndFullPermissions(): string[] { + const names = this.buckets + .map((bucket) => bucket.name) + .concat( + Object.values(this.ownPermissions) + .filter((perm) => perm.permission === Permission.READWRITE) + .map((perm) => perm.bucket_name) + ); + names.sort(); + return names; + }, + permissionFeatureAllowed(): (bucketName: string) => boolean { return (bucketName) => { // If a permission for the bucket exist, then false @@ -131,15 +149,27 @@ export const useBucketStore = defineStore({ onRejected: ((reason: any) => void) | null | undefined = null, onFinally: (() => void) | null | undefined = null ) { - const authStore = useAuthStore(); - BucketService.bucketListBuckets(authStore.currentUID) - .then((buckets) => { - this.buckets = buckets; - onFulfilled?.(buckets); - }) - .catch(onRejected) - .finally(onFinally); - this._fetchOwnPermissions(); + /* If the time between two calls to this function is less than 5 seconds, + * then no API call will be made and the last one reused + */ + const currentTime = new Date().getTime(); + if (currentTime - this._bla > 5000) { + this._bla = currentTime; + const authStore = useAuthStore(); + BucketService.bucketListBuckets(authStore.currentUID) + .then((buckets) => { + this.buckets = buckets; + onFulfilled?.(buckets); + }) + .catch(onRejected) + .finally(onFinally); + this._fetchOwnPermissions(); + } else { + this._lastFetchBucketPromise + ?.then(onFulfilled) + .catch(onRejected) + .finally(onFinally); + } }, fetchBucket( bucketName: string, diff --git a/src/views/workflows/WorkflowVersionView.vue b/src/views/workflows/WorkflowVersionView.vue index a769f3f992f153274d5ad979fd11f13331c7eb17..c450386da0df32cdc98e00c1c072ae6fcac951b6 100644 --- a/src/views/workflows/WorkflowVersionView.vue +++ b/src/views/workflows/WorkflowVersionView.vue @@ -4,7 +4,7 @@ import { WorkflowVersionService } from "@/client/workflow"; import type { WorkflowVersionFull } from "@/client/workflow"; import axios from "axios"; import MarkdownRenderer from "@/components/MarkdownRenderer.vue"; -import ParameterSchemaFormComponent from "@/components/parameter-schema/ParameterSchemaFormComponent.vue"; +import ParameterSchemaDescriptionComponent from "@/components/parameter-schema/ParameterSchemaDescriptionComponent.vue"; const props = defineProps<{ versionId: string; @@ -132,7 +132,7 @@ onMounted(() => { <p v-if="props.activeTab === 'description'"> <markdown-renderer :markdown="versionState.descriptionMarkdown" /> </p> - <parameter-schema-form-component + <parameter-schema-description-component v-else-if="props.activeTab === 'parameters'" :schema="versionState.parameterSchema" />