From 2e823fca81cb18a2470a197619e53de72ab64eb2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Mon, 20 Mar 2023 15:35:21 +0100
Subject: [PATCH] Differentiate between simple string input and file/directory
 input

#38
---
 .../ParameterSchemaFormComponent.vue          | 237 ++++++++++++++----
 .../form-mode/ParameterGroupForm.vue          |  42 +++-
 .../form-mode/ParameterNumberInput.vue        |   1 +
 .../form-mode/ParameterStringInput.vue        | 137 +++++++++-
 src/stores/buckets.ts                         |  48 +++-
 src/views/workflows/WorkflowVersionView.vue   |   4 +-
 6 files changed, 398 insertions(+), 71 deletions(-)

diff --git a/src/components/parameter-schema/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue
index e7e5232..b80fd1e 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 3043606..c68eb66 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 345deb5..513b783 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 d247cbc..b6be2cb 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 e3ab60b..c95040f 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 a769f3f..c450386 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"
     />
-- 
GitLab