From 5e86d9778ee91792b8f6890d55c7042888b7becb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Wed, 15 Mar 2023 20:16:19 +0100
Subject: [PATCH] Add renderer to show help page for parameter schema

#38
---
 package-lock.json                             | 144 ++++++++++++++----
 package.json                                  |   1 +
 src/App.vue                                   |   2 +-
 src/assets/main.css                           |  10 ++
 .../ParameterSchemaDescriptionComponent.vue   |  80 ++++++++++
 .../description-mode/ParameterDescription.vue | 136 +++++++++++++++++
 .../ParameterGroupDescription.vue             |  58 +++++++
 src/views/workflows/WorkflowVersionView.vue   |  10 +-
 src/views/workflows/WorkflowView.vue          |   1 +
 9 files changed, 408 insertions(+), 34 deletions(-)
 create mode 100644 src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue
 create mode 100644 src/components/parameter-schema/description-mode/ParameterDescription.vue
 create mode 100644 src/components/parameter-schema/description-mode/ParameterGroupDescription.vue

diff --git a/package-lock.json b/package-lock.json
index 38ed882..4a8df54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
         "@aws-sdk/s3-request-presigner": "^3.290.0",
         "@fortawesome/fontawesome-free": "^6.3.0",
         "@popperjs/core": "^2.11.6",
+        "ajv": "^8.12.0",
         "bootstrap": "^5.2.3",
         "dayjs": "^1.11.7",
         "dompurify": "^3.0.1",
@@ -2237,6 +2238,28 @@
         "url": "https://opencollective.com/eslint"
       }
     },
+    "node_modules/@eslint/eslintrc/node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
     "node_modules/@fortawesome/fontawesome-free": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.3.0.tgz",
@@ -2837,14 +2860,13 @@
       }
     },
     "node_modules/ajv": {
-      "version": "6.12.6",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
-      "dev": true,
+      "version": "8.12.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
+      "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
       "dependencies": {
         "fast-deep-equal": "^3.1.1",
-        "fast-json-stable-stringify": "^2.0.0",
-        "json-schema-traverse": "^0.4.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
         "uri-js": "^4.2.2"
       },
       "funding": {
@@ -3901,6 +3923,22 @@
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
+    "node_modules/eslint/node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
     "node_modules/eslint/node_modules/eslint-scope": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
@@ -3923,6 +3961,12 @@
         "node": ">=4.0"
       }
     },
+    "node_modules/eslint/node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
     "node_modules/espree": {
       "version": "9.4.0",
       "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.0.tgz",
@@ -4016,8 +4060,7 @@
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "node_modules/fast-diff": {
       "version": "1.2.0",
@@ -4914,10 +4957,9 @@
       }
     },
     "node_modules/json-schema-traverse": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
-      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-      "dev": true
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
     },
     "node_modules/json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
@@ -5672,7 +5714,6 @@
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
       "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
-      "dev": true,
       "engines": {
         "node": ">=6"
       }
@@ -5777,6 +5818,14 @@
         "url": "https://github.com/sponsors/mysticatea"
       }
     },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/resolve": {
       "version": "1.22.1",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -6329,7 +6378,6 @@
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
       "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
-      "dev": true,
       "dependencies": {
         "punycode": "^2.1.0"
       }
@@ -8596,6 +8644,26 @@
         "js-yaml": "^4.1.0",
         "minimatch": "^3.1.2",
         "strip-json-comments": "^3.1.1"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "6.12.6",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+          "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "fast-json-stable-stringify": "^2.0.0",
+            "json-schema-traverse": "^0.4.1",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "0.4.1",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+          "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+          "dev": true
+        }
       }
     },
     "@fortawesome/fontawesome-free": {
@@ -9040,14 +9108,13 @@
       "requires": {}
     },
     "ajv": {
-      "version": "6.12.6",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
-      "dev": true,
+      "version": "8.12.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
+      "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
       "requires": {
         "fast-deep-equal": "^3.1.1",
-        "fast-json-stable-stringify": "^2.0.0",
-        "json-schema-traverse": "^0.4.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
         "uri-js": "^4.2.2"
       }
     },
@@ -9656,6 +9723,18 @@
         "text-table": "^0.2.0"
       },
       "dependencies": {
+        "ajv": {
+          "version": "6.12.6",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+          "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "fast-json-stable-stringify": "^2.0.0",
+            "json-schema-traverse": "^0.4.1",
+            "uri-js": "^4.2.2"
+          }
+        },
         "eslint-scope": {
           "version": "7.1.1",
           "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
@@ -9671,6 +9750,12 @@
           "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
           "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
           "dev": true
+        },
+        "json-schema-traverse": {
+          "version": "0.4.1",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+          "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+          "dev": true
         }
       }
     },
@@ -9808,8 +9893,7 @@
     "fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "fast-diff": {
       "version": "1.2.0",
@@ -10438,10 +10522,9 @@
       }
     },
     "json-schema-traverse": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
-      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-      "dev": true
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
     },
     "json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
@@ -10981,8 +11064,7 @@
     "punycode": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
-      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
-      "dev": true
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
     },
     "queue-microtask": {
       "version": "1.2.3",
@@ -11048,6 +11130,11 @@
       "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
       "dev": true
     },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
+    },
     "resolve": {
       "version": "1.22.1",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -11443,7 +11530,6 @@
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
       "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
-      "dev": true,
       "requires": {
         "punycode": "^2.1.0"
       }
diff --git a/package.json b/package.json
index 1460ccd..9480d9a 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
     "@aws-sdk/s3-request-presigner": "^3.290.0",
     "@fortawesome/fontawesome-free": "^6.3.0",
     "@popperjs/core": "^2.11.6",
+    "ajv": "^8.12.0",
     "bootstrap": "^5.2.3",
     "dayjs": "^1.11.7",
     "dompurify": "^3.0.1",
diff --git a/src/App.vue b/src/App.vue
index 359fecb..5a48cd8 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -44,7 +44,7 @@ onBeforeMount(() => {
 
 <template>
   <NavbarTop />
-  <div class="container mt-4">
+  <div class="container-xxl mt-4">
     <router-view></router-view>
   </div>
 </template>
diff --git a/src/assets/main.css b/src/assets/main.css
index 7024e78..8237a72 100644
--- a/src/assets/main.css
+++ b/src/assets/main.css
@@ -14,3 +14,13 @@ body {
 .cursor-pointer {
     cursor: pointer;
 }
+
+.helpTextCode > pre {
+    border: thin solid var(--bs-secondary);
+    border-radius: var(--bs-border-radius);
+    background: var(--bs-dark);
+    filter: brightness(0.9);
+    padding: 1rem;
+    margin: 1em 2em;
+    color: var(--bs-code-color);
+}
diff --git a/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue b/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue
new file mode 100644
index 0000000..43ce261
--- /dev/null
+++ b/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue
@@ -0,0 +1,80 @@
+<script setup lang="ts">
+import { computed } from "vue";
+import ParameterGroupDescription from "@/components/parameter-schema/description-mode/ParameterGroupDescription.vue";
+import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
+
+const props = defineProps({
+  schema: {
+    type: Object,
+    required: true,
+  },
+});
+
+type ParameterGroup = {
+  group: string;
+  title: string;
+  icon?: string;
+};
+
+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
+    )
+);
+
+const parameterGroups = computed<Record<string, never>>(
+  () => props.schema["definitions"]
+);
+</script>
+
+<template>
+  <div class="row mb-5 align-items-start">
+    <div class="col-9">
+      <div v-for="(group, groupName) in parameterGroups" :key="groupName">
+        <parameter-group-description
+          :parameter-group="group"
+          :parameter-group-name="groupName"
+        />
+      </div>
+    </div>
+    <div
+      class="col-3 sticky-top"
+      style="top: 70px !important; max-height: calc(100vh - 150px)"
+    >
+      <nav class="h-100 bg-dark rounded-1">
+        <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/description-mode/ParameterDescription.vue b/src/components/parameter-schema/description-mode/ParameterDescription.vue
new file mode 100644
index 0000000..0c2371d
--- /dev/null
+++ b/src/components/parameter-schema/description-mode/ParameterDescription.vue
@@ -0,0 +1,136 @@
+<script setup lang="ts">
+import { computed } from "vue";
+import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
+import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
+
+const props = defineProps({
+  parameter: {
+    type: Object,
+    required: true,
+    validator(value: Record<string, never>) {
+      return ["boolean", "array", "number", "string"].includes(value["type"]);
+    },
+  },
+  required: Boolean,
+  parameterName: {
+    type: String,
+    required: true,
+  },
+});
+
+const randomIDSuffix = Math.random().toString(16).substr(2, 8);
+
+const helpText = computed<string | undefined>(
+  () => props.parameter["help_text"]
+);
+const parameterType = computed<string>(() => props.parameter["type"]);
+const icon = computed<string | undefined>(() => props.parameter["fa_icon"]);
+const description = computed<string>(() => props.parameter["description"]);
+const defaultValue = computed<string | undefined>(() =>
+  props.parameter["default"]?.toString()
+);
+const enumValues = computed<string[] | undefined>(() =>
+  props.parameter["enum"]?.map((val: string) => val.toString())
+);
+const hidden = computed<boolean>(() => props.parameter["hidden"] ?? false);
+const parameterPattern = computed<string | undefined>(
+  () => props.parameter["pattern"]
+);
+
+const showRightColum = computed<boolean>(
+  () =>
+    helpText.value != undefined ||
+    props.required ||
+    defaultValue.value != undefined
+);
+</script>
+
+<template>
+  <div
+    class="row border-top border-bottom border-secondary align-items-start py-2"
+    v-if="!hidden"
+  >
+    <div class="fs-6 col-3">
+      <font-awesome-icon :icon="icon" v-if="icon" class="me-2" />
+      <code class="bg-dark p-1" :id="props.parameterName"
+        >--{{ props.parameterName }}</code
+      >
+      <br />
+      <span>type: '{{ parameterType }}'</span>
+    </div>
+    <div
+      :class="{ 'col-7': showRightColum, 'col-9': !showRightColum }"
+      class="flex-fill"
+    >
+      <markdown-renderer :markdown="description" />
+    </div>
+    <div
+      class="col-auto d-flex flex-column align-items-end flex-fill"
+      v-if="showRightColum"
+    >
+      <button
+        class="btn btn-outline-info btn-sm my-1"
+        type="button"
+        data-bs-toggle="collapse"
+        :data-bs-target="'#helpCollapse' + randomIDSuffix"
+        aria-expanded="false"
+        aria-controls="collapseExample"
+        v-if="helpText"
+      >
+        <font-awesome-icon icon="fa-solid fa-circle-info" />
+        Help
+      </button>
+      <div v-if="enumValues" class="dropdown w-fit my-1">
+        <a
+          class="bg-dark rounded-1 p-1 dropdown-toggle text-reset text-decoration-none"
+          href="#"
+          role="button"
+          data-bs-toggle="dropdown"
+          aria-expanded="false"
+        >
+          Options:
+          <span v-if="defaultValue"
+            ><code>{{ defaultValue }}</code> (default)</span
+          >
+        </a>
+        <ul class="dropdown-menu dropdown-menu-dark" v-if="enumValues">
+          <li v-for="val in enumValues" :key="val" class="px-2 w-100">
+            {{ val }} <span v-if="val === defaultValue">(default)</span>
+          </li>
+        </ul>
+      </div>
+      <span v-else-if="defaultValue" class="bg-dark rounded-1 py-0 px-1 my-1"
+        >default: <code>{{ defaultValue }}</code></span
+      >
+
+      <span
+        v-if="props.required"
+        class="bg-warning rounded-1 px-1 py-0 text-white"
+        >required</span
+      >
+    </div>
+    <div
+      class="collapse p-2 pb-0 bg-dark m-2 flex-shrink-1"
+      :id="'helpCollapse' + randomIDSuffix"
+      v-if="helpText"
+    >
+      <markdown-renderer class="helpTextCode" :markdown="helpText" />
+      <span v-if="parameterPattern" class="mb-2"
+        >Pattern: <code>{{ parameterPattern }}</code></span
+      >
+    </div>
+  </div>
+</template>
+
+<style scoped>
+code {
+  color: var(--bs-code-color) !important;
+}
+
+li:hover {
+  background: var(--bs-secondary);
+}
+a:hover {
+  filter: brightness(0.8);
+}
+</style>
diff --git a/src/components/parameter-schema/description-mode/ParameterGroupDescription.vue b/src/components/parameter-schema/description-mode/ParameterGroupDescription.vue
new file mode 100644
index 0000000..f8cc45c
--- /dev/null
+++ b/src/components/parameter-schema/description-mode/ParameterGroupDescription.vue
@@ -0,0 +1,58 @@
+<script setup lang="ts">
+import { computed } from "vue";
+import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
+import ParameterDescription from "@/components/parameter-schema/description-mode/ParameterDescription.vue";
+
+const props = defineProps({
+  parameterGroup: {
+    type: Object,
+    required: true,
+    validator(value: Record<string, never>) {
+      return "object" === value["type"];
+    },
+  },
+  parameterGroupName: {
+    type: String,
+    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"]
+);
+</script>
+
+<template>
+  <div class="mb-5" v-if="!groupHidden">
+    <div class="row">
+      <h2 :id="props.parameterGroupName">
+        <font-awesome-icon :icon="icon" class="me-3" v-if="icon" />{{ title }}
+      </h2>
+      <h4>{{ description }}</h4>
+    </div>
+    <template
+      v-for="(parameter, parameterName) in parameters"
+      :key="parameterName"
+    >
+      <parameter-description
+        v-if="parameter['type'] !== 'object'"
+        :parameter-name="parameterName"
+        :parameter="parameter"
+        :required="
+          props.parameterGroup['required']?.includes(parameterName) ?? false
+        "
+      />
+    </template>
+  </div>
+</template>
+
+<style scoped></style>
diff --git a/src/views/workflows/WorkflowVersionView.vue b/src/views/workflows/WorkflowVersionView.vue
index 9ffb18d..c450386 100644
--- a/src/views/workflows/WorkflowVersionView.vue
+++ b/src/views/workflows/WorkflowVersionView.vue
@@ -4,6 +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";
 
 const props = defineProps<{
   versionId: string;
@@ -127,13 +128,14 @@ onMounted(() => {
       ></span>
     </p>
   </div>
-  <div v-else>
+  <div v-else class="px-2">
     <p v-if="props.activeTab === 'description'">
       <markdown-renderer :markdown="versionState.descriptionMarkdown" />
     </p>
-    <pre v-else-if="props.activeTab === 'parameters'"
-      >{{ JSON.stringify(versionState.parameterSchema, null, 2) }}
-    </pre>
+    <parameter-schema-description-component
+      v-else-if="props.activeTab === 'parameters'"
+      :schema="versionState.parameterSchema"
+    />
     <p v-else-if="props.activeTab === 'changes'">
       <markdown-renderer :markdown="versionState.changelogMarkdown" />
     </p>
diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue
index f8fd224..9a5b54f 100644
--- a/src/views/workflows/WorkflowView.vue
+++ b/src/views/workflows/WorkflowView.vue
@@ -169,6 +169,7 @@ onMounted(() => {
           params: {
             versionId: latestVersion.git_commit_hash,
           },
+          query: { tab: route.query.tab },
         }"
         >Try the latest version {{ latestVersion?.version }}.</router-link
       >
-- 
GitLab