From 463356b964e744c356094b9e49c2111612a19fe1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Fri, 17 Mar 2023 17:29:13 +0100
Subject: [PATCH] Add parser to render basic HTML form for parameter schema

#38
---
 .../modals/CreateBucketModal.vue              |   8 +-
 .../object-storage/modals/PermissionModal.vue |   6 +-
 .../ParameterSchemaFormComponent.vue          | 164 +++++++++++++++++
 .../form-mode/ParameterBooleanInput.vue       |  73 ++++++++
 .../form-mode/ParameterEnumInput.vue          |  59 ++++++
 .../form-mode/ParameterGroupForm.vue          | 169 ++++++++++++++++++
 .../form-mode/ParameterNumberInput.vue        |  49 +++++
 .../form-mode/ParameterStringInput.vue        |  62 +++++++
 src/components/workflows/WorkflowCard.vue     |   1 -
 src/views/workflows/WorkflowVersionView.vue   |   4 +-
 10 files changed, 581 insertions(+), 14 deletions(-)
 create mode 100644 src/components/parameter-schema/ParameterSchemaFormComponent.vue
 create mode 100644 src/components/parameter-schema/form-mode/ParameterBooleanInput.vue
 create mode 100644 src/components/parameter-schema/form-mode/ParameterEnumInput.vue
 create mode 100644 src/components/parameter-schema/form-mode/ParameterGroupForm.vue
 create mode 100644 src/components/parameter-schema/form-mode/ParameterNumberInput.vue
 create mode 100644 src/components/parameter-schema/form-mode/ParameterStringInput.vue

diff --git a/src/components/object-storage/modals/CreateBucketModal.vue b/src/components/object-storage/modals/CreateBucketModal.vue
index 83b0f55..69d6258 100644
--- a/src/components/object-storage/modals/CreateBucketModal.vue
+++ b/src/components/object-storage/modals/CreateBucketModal.vue
@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { BucketIn } from "@/client/s3proxy";
-import { reactive, onMounted, computed, ref } from "vue";
+import { reactive, onMounted, ref } from "vue";
 import BootstrapModal from "@/components/modals/BootstrapModal.vue";
 import { useRouter } from "vue-router";
 import { Modal } from "bootstrap";
@@ -30,17 +30,13 @@ onMounted(() => {
   createBucketModal = new Modal("#" + props.modalID);
 });
 
-const formValid = computed<boolean>(
-  () => bucketCreateForm.value?.checkValidity() ?? false
-);
-
 function createBucket() {
   formState.validated = true;
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   formState.bucketNameTaken = false;
   bucket.description = bucket.description.trim();
   bucket.name = bucket.name.trim();
-  if (formValid.value) {
+  if (bucketCreateForm.value?.checkValidity()) {
     formState.loading = true;
     bucketRepository.createBucket(
       bucket,
diff --git a/src/components/object-storage/modals/PermissionModal.vue b/src/components/object-storage/modals/PermissionModal.vue
index 00a4663..f84ed69 100644
--- a/src/components/object-storage/modals/PermissionModal.vue
+++ b/src/components/object-storage/modals/PermissionModal.vue
@@ -75,10 +75,6 @@ const permissionUserReadonly = computed<boolean>(() => {
   return formState.readonly || editPermission.value;
 });
 
-const formValid = computed<boolean>(
-  () => permissionForm.value?.checkValidity() ?? false
-);
-
 // Watchers
 // -----------------------------------------------------------------------------
 watch(
@@ -189,7 +185,7 @@ function findSubFolders(
  */
 function formSubmit() {
   formState.error = false;
-  if (formValid.value) {
+  if (permissionForm.value?.checkValidity()) {
     const tempPermission: BucketPermissionIn = permission;
     if (permission.from_timestamp != null) {
       tempPermission.from_timestamp =
diff --git a/src/components/parameter-schema/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue
new file mode 100644
index 0000000..e7e5232
--- /dev/null
+++ b/src/components/parameter-schema/ParameterSchemaFormComponent.vue
@@ -0,0 +1,164 @@
+<script setup lang="ts">
+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 Ajv from "ajv";
+import type { ValidateFunction } from "ajv";
+
+const launchForm = ref<HTMLFormElement | null>(null);
+const schemaCompiler = new Ajv({
+  strict: false,
+});
+
+let validateSchema: ValidateFunction;
+
+const props = defineProps({
+  schema: {
+    type: Object,
+    required: true,
+  },
+});
+
+type ParameterGroup = {
+  group: string;
+  title: string;
+  icon?: string;
+};
+
+const formState = reactive<{
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  formInput: Record<string, any>;
+  validated: boolean;
+}>({
+  formInput: {},
+  validated: false,
+});
+
+watch(
+  () => props.schema,
+  (newValue) => {
+    updateSchema(newValue);
+  }
+);
+
+/* 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);
+  const b = Object.keys(schema["definitions"]).map((groupName) => [
+    groupName,
+    Object.fromEntries(
+      Object.entries(schema["definitions"][groupName]["properties"])
+        // @ts-ignore
+        .filter(([_, parameter]) => !parameter["hidden"])
+        .map(([parameterName, parameter]) => [
+          parameterName,
+          // @ts-ignore
+          parameter["default"],
+        ])
+    ),
+  ]);
+  formState.formInput = Object.fromEntries(b);
+}
+/* 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()) {
+    const realInput = Object.values(formState.formInput).reduce((acc, val) => {
+      return { ...acc, ...val };
+    });
+    const schemaValid = validateSchema(realInput);
+
+    console.log(realInput);
+    if (!schemaValid) console.log(validateSchema.errors);
+  } else {
+    console.log("invalid");
+  }
+}
+</script>
+
+<template>
+  <div class="row mb-5 align-items-start">
+    <form
+      class="col-9"
+      id="launchWorkflowForm"
+      ref="launchForm"
+      :class="{ 'was-validated': formState.validated }"
+    >
+      <div v-for="(group, groupName) in parameterGroups" :key="groupName">
+        <parameter-group-form
+          :modelValue="formState.formInput[groupName]"
+          @update:model-value="
+            (newValue) => (formState.formInput[groupName] = newValue)
+          "
+          v-if="formState.formInput[groupName]"
+          :parameter-group-name="groupName"
+          :parameter-group="group"
+        />
+      </div>
+    </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">
+        <button
+          type="submit"
+          form="launchWorkflowForm"
+          @click.prevent="startWorkflow"
+          class="btn btn-success"
+        >
+          Launch
+        </button>
+      </div>
+      <nav class="h-100">
+        <nav class="nav">
+          <ul class="ps-0">
+            <li
+              class="nav-link"
+              v-for="group in navParameterGroups"
+              :key="group.group"
+            >
+              <a :href="'#' + group.group"
+                ><font-awesome-icon
+                  :icon="group.icon"
+                  v-if="group.icon"
+                  class="me-2"
+                />{{ group.title }}</a
+              >
+            </li>
+          </ul>
+        </nav>
+      </nav>
+    </div>
+  </div>
+</template>
+
+<style scoped></style>
diff --git a/src/components/parameter-schema/form-mode/ParameterBooleanInput.vue b/src/components/parameter-schema/form-mode/ParameterBooleanInput.vue
new file mode 100644
index 0000000..2ee5031
--- /dev/null
+++ b/src/components/parameter-schema/form-mode/ParameterBooleanInput.vue
@@ -0,0 +1,73 @@
+<script setup lang="ts">
+import { computed } from "vue";
+
+const props = defineProps({
+  parameter: {
+    type: Object,
+    required: true,
+    validator(value: Record<string, never>) {
+      return "number" === value["type"];
+    },
+  },
+  required: Boolean,
+  parameterName: {
+    type: String,
+    required: true,
+  },
+  modelValue: {
+    type: Boolean,
+  },
+  helpId: {
+    type: String,
+  },
+});
+
+const randomIDSuffix = Math.random().toString(16).substr(2, 8);
+
+const helpTextPresent = computed<boolean>(() => props.parameter["help_text"]);
+const defaultValue = computed<boolean>(
+  () => props.parameter["default"] ?? false
+);
+
+const emit = defineEmits<{
+  (e: "update:modelValue", value: boolean): void;
+}>();
+</script>
+
+<template>
+  <div
+    class="flex-fill mb-0 text-bg-light fs-6 ps-4 d-flex align-items-center justify-content-start"
+    :class="{ 'rounded-end': !helpTextPresent }"
+  >
+    <div class="form-check form-check-inline">
+      <label class="form-check-label" :for="'trueOption' + randomIDSuffix"
+        >True</label
+      >
+      <input
+        class="form-check-input"
+        type="radio"
+        :name="'inlineRadioOptions' + randomIDSuffix"
+        :id="'trueOption' + randomIDSuffix"
+        :value="true"
+        :checked="defaultValue"
+        @input="emit('update:modelValue', true)"
+      />
+    </div>
+    <div class="form-check form-check-inline">
+      <input
+        class="form-check-input"
+        type="radio"
+        :name="'inlineRadioOptions' + randomIDSuffix"
+        :id="'falseOption' + randomIDSuffix"
+        :value="false"
+        @input="emit('update:modelValue', false)"
+        :checked="!defaultValue"
+      />
+      <label class="form-check-label" :for="'falseOption' + randomIDSuffix"
+        >False</label
+      >
+    </div>
+  </div>
+</template>
+
+<style scoped></style>
diff --git a/src/components/parameter-schema/form-mode/ParameterEnumInput.vue b/src/components/parameter-schema/form-mode/ParameterEnumInput.vue
new file mode 100644
index 0000000..a2e20d7
--- /dev/null
+++ b/src/components/parameter-schema/form-mode/ParameterEnumInput.vue
@@ -0,0 +1,59 @@
+<script setup lang="ts">
+import { computed, ref } from "vue";
+
+const props = defineProps({
+  parameter: {
+    type: Object,
+    required: true,
+    validator(value: Record<string, never>) {
+      return "string" === value["type"] && value["enum"];
+    },
+  },
+  required: Boolean,
+  parameterName: {
+    type: String,
+    required: true,
+  },
+  modelValue: {
+    type: String,
+  },
+  helpId: {
+    type: String,
+  },
+});
+
+const defaultValue = computed<string>(() => props.parameter["default"]);
+
+const possibleValues = computed<string[]>(() => props.parameter["enum"]);
+
+const enumSelection = ref<HTMLSelectElement | undefined>(undefined);
+
+const emit = defineEmits<{
+  (e: "update:modelValue", value: string | undefined): void;
+}>();
+
+function updateValue() {
+  emit("update:modelValue", enumSelection.value?.value);
+}
+</script>
+
+<template>
+  <select
+    ref="enumSelection"
+    :value="props.modelValue"
+    @input="updateValue"
+    class="form-select"
+    :required="required"
+    :aria-describedby="props.helpId"
+  >
+    <option
+      v-for="val in possibleValues"
+      :key="val"
+      :selected="defaultValue === val"
+    >
+      {{ val }}
+    </option>
+  </select>
+</template>
+
+<style scoped></style>
diff --git a/src/components/parameter-schema/form-mode/ParameterGroupForm.vue b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue
new file mode 100644
index 0000000..3043606
--- /dev/null
+++ b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue
@@ -0,0 +1,169 @@
+<script setup lang="ts">
+import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
+import { computed, watch } from "vue";
+import ParameterNumberInput from "@/components/parameter-schema/form-mode/ParameterNumberInput.vue";
+import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
+import ParameterBooleanInput from "@/components/parameter-schema/form-mode/ParameterBooleanInput.vue";
+import ParameterEnumInput from "@/components/parameter-schema/form-mode/ParameterEnumInput.vue";
+import ParameterStringInput from "@/components/parameter-schema/form-mode/ParameterStringInput.vue";
+
+const props = defineProps({
+  parameterGroup: {
+    type: Object,
+    required: true,
+    validator(value: Record<string, never>) {
+      return "object" === value["type"];
+    },
+  },
+  parameterGroupName: {
+    type: String,
+    required: true,
+  },
+  modelValue: {
+    type: Object,
+    required: true,
+  },
+});
+const title = computed<string>(() => props.parameterGroup["title"]);
+const icon = computed<string>(() => props.parameterGroup["fa_icon"]);
+const description = computed<string>(() => props.parameterGroup["description"]);
+const groupHidden = computed<boolean>(() =>
+  Object.keys(parameters.value).reduce(
+    (acc: boolean, val: string) => acc && parameters.value[val]["hidden"],
+    true
+  )
+);
+const parameters = computed<Record<string, never>>(
+  () => props.parameterGroup["properties"]
+);
+
+const modelStuff = computed(() => props.modelValue);
+const emit = defineEmits<{
+  (
+    e: "update:modelValue",
+    value: Record<string, number | string | boolean | undefined>
+  ): void;
+}>();
+
+watch(
+  modelStuff,
+  (newVal) => {
+    //console.log("Group", props.parameterGroupName, newVal);
+    emit("update:modelValue", newVal);
+  },
+  {
+    deep: true,
+  }
+);
+</script>
+
+<template>
+  <div class="card bg-dark mb-3" v-if="!groupHidden">
+    <h2 class="card-header" :id="props.parameterGroupName">
+      <font-awesome-icon :icon="icon" class="me-2" v-if="icon" />
+      {{ title }}
+    </h2>
+    <div class="card-body">
+      <h5 class="card-title" v-if="description">{{ description }}</h5>
+      <template
+        v-for="(parameter, parameterName) in parameters"
+        :key="parameterName"
+      >
+        <template v-if="!parameter['hidden']">
+          <div class="input-group">
+            <span class="input-group-text" :id="parameterName + '-help'">
+              <font-awesome-icon
+                class="me-2 text-dark"
+                :icon="parameter['fa_icon']"
+                v-if="parameter['fa_icon']"
+              />
+              <code>--{{ parameterName }}</code>
+            </span>
+            <parameter-number-input
+              v-if="
+                parameter['type'] === 'number' ||
+                parameter['type'] === 'integer'
+              "
+              :parameter-name="parameterName"
+              :parameter="parameter"
+              :help-id="parameterName + '-help'"
+              :required="parameterGroup['required']?.includes(parameterName)"
+              :model-value="modelStuff[parameterName]"
+              @update:model-value="
+                (newValue) => (modelStuff[parameterName] = newValue)
+              "
+            />
+            <parameter-boolean-input
+              v-else-if="parameter['type'] === 'boolean'"
+              :parameter-name="parameterName"
+              :parameter="parameter"
+              :help-id="parameterName + '-help'"
+              :model-value="modelStuff[parameterName]"
+              @update:model-value="
+                (newValue) => (modelStuff[parameterName] = newValue)
+              "
+            />
+            <template v-else-if="parameter['type'] === 'string'">
+              <parameter-enum-input
+                v-if="parameter['enum']"
+                :parameter-name="parameterName"
+                :parameter="parameter"
+                :model-value="modelStuff[parameterName]"
+                :required="parameterGroup['required']?.includes(parameterName)"
+                @update:model-value="
+                  (newValue) => (modelStuff[parameterName] = newValue)
+                "
+              />
+              <parameter-string-input
+                v-else
+                :parameter-name="parameterName"
+                :parameter="parameter"
+                :model-value="modelStuff[parameterName]"
+                :required="parameterGroup['required']?.includes(parameterName)"
+                @update:model-value="
+                  (newValue) => (modelStuff[parameterName] = newValue)
+                "
+              />
+            </template>
+            <span
+              class="input-group-text cursor-pointer px-2"
+              v-if="parameter['help_text']"
+              data-bs-toggle="collapse"
+              :data-bs-target="'#helpCollapse' + parameterName"
+              aria-expanded="false"
+              aria-controls="collapseExample"
+            >
+              <font-awesome-icon
+                class="cursor-pointer"
+                icon="fa-solid fa-circle-question"
+              />
+            </span>
+          </div>
+          <label v-if="parameter['description']"
+            ><markdown-renderer :markdown="parameter['description']"
+          /></label>
+          <div
+            class="collapse p-2 pb-0 bg-dark mx-2 mt-1 flex-shrink-1"
+            :id="'helpCollapse' + parameterName"
+            v-if="parameter['help_text']"
+          >
+            <markdown-renderer
+              class="helpTextCode"
+              :markdown="parameter['help_text']"
+            />
+          </div>
+        </template>
+      </template>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+div.card-body {
+  filter: brightness(0.9);
+}
+span.cursor-pointer:hover {
+  color: var(--bs-info);
+  background: var(--bs-light);
+}
+</style>
diff --git a/src/components/parameter-schema/form-mode/ParameterNumberInput.vue b/src/components/parameter-schema/form-mode/ParameterNumberInput.vue
new file mode 100644
index 0000000..345deb5
--- /dev/null
+++ b/src/components/parameter-schema/form-mode/ParameterNumberInput.vue
@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import { ref } from "vue";
+const props = defineProps({
+  parameter: {
+    type: Object,
+    required: true,
+    validator(value: Record<string, never>) {
+      return "number" === value["type"] || "integer" === value["type"];
+    },
+  },
+  required: Boolean,
+  parameterName: {
+    type: String,
+    required: true,
+  },
+  modelValue: {
+    type: Number,
+  },
+  helpId: {
+    type: String,
+  },
+});
+
+const emit = defineEmits<{
+  (e: "update:modelValue", value: number | undefined): void;
+}>();
+
+const numberInput = ref<HTMLInputElement | undefined>(undefined);
+
+function updateValue() {
+  emit("update:modelValue", Number(numberInput.value?.value));
+}
+</script>
+
+<template>
+  <input
+    class="form-control"
+    type="number"
+    ref="numberInput"
+    :max="props.parameter['maximum']"
+    :min="props.parameter['minimum']"
+    :value="props.modelValue"
+    :required="props.required"
+    :aria-describedby="props.helpId"
+    @input="updateValue"
+  />
+</template>
+
+<style scoped></style>
diff --git a/src/components/parameter-schema/form-mode/ParameterStringInput.vue b/src/components/parameter-schema/form-mode/ParameterStringInput.vue
new file mode 100644
index 0000000..d247cbc
--- /dev/null
+++ b/src/components/parameter-schema/form-mode/ParameterStringInput.vue
@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import { computed, watch, ref } from "vue";
+
+const props = defineProps({
+  parameter: {
+    type: Object,
+    required: true,
+    validator(value: Record<string, never>) {
+      return "string" === value["type"] && value["enum"];
+    },
+  },
+  required: Boolean,
+  parameterName: {
+    type: String,
+    required: true,
+  },
+  modelValue: {
+    type: String,
+  },
+  helpId: {
+    type: String,
+  },
+});
+
+const defaultValue = computed<string>(() => props.parameter["default"]);
+
+watch(defaultValue, (newVal, oldVal) => {
+  if (newVal != oldVal && newVal != undefined) {
+    emit("update:modelValue", newVal);
+  }
+});
+
+const pattern = computed<string>(() => props.parameter["pattern"]);
+
+const emit = defineEmits<{
+  (e: "update:modelValue", value: string | undefined): void;
+}>();
+
+const stringInput = ref<HTMLInputElement | undefined>(undefined);
+
+function updateValue() {
+  emit(
+    "update:modelValue",
+    stringInput.value?.value ? stringInput.value?.value : undefined
+  );
+}
+</script>
+
+<template>
+  <input
+    ref="stringInput"
+    class="form-control"
+    type="text"
+    :value="props.modelValue"
+    :required="props.required"
+    :aria-describedby="props.helpId"
+    :pattern="pattern"
+    @input="updateValue"
+  />
+</template>
+
+<style scoped></style>
diff --git a/src/components/workflows/WorkflowCard.vue b/src/components/workflows/WorkflowCard.vue
index 1c2ac3a..ffddccf 100644
--- a/src/components/workflows/WorkflowCard.vue
+++ b/src/components/workflows/WorkflowCard.vue
@@ -50,7 +50,6 @@ onMounted(() => {
           v-if="latestVersion?.icon_url != null"
           :src="latestVersion.icon_url"
           class="img-fluid float-end icon"
-          alt="Workflow icon"
         />
       </div>
       <p class="card-text" :class="{ 'text-truncate': truncateDescription }">
diff --git a/src/views/workflows/WorkflowVersionView.vue b/src/views/workflows/WorkflowVersionView.vue
index c450386..a769f3f 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 ParameterSchemaDescriptionComponent from "@/components/parameter-schema/ParameterSchemaDescriptionComponent.vue";
+import ParameterSchemaFormComponent from "@/components/parameter-schema/ParameterSchemaFormComponent.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-description-component
+    <parameter-schema-form-component
       v-else-if="props.activeTab === 'parameters'"
       :schema="versionState.parameterSchema"
     />
-- 
GitLab