diff --git a/package-lock.json b/package-lock.json index 38ed8820300cd8a55be6eb5599ca2aabebadad01..4a8df54652d28d83f9db907513b3e329ce6214ce 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 1460ccd60a37d0ea736c567205c59be9805f573c..9480d9a3f0588cdfd80db0867fe8ffc9214fd4e7 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 359fecb2daa41b1f0944b576b527f521dd287805..5a48cd877e574e7149407d4eb83051985c4bee4f 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 7024e78b25fc6d1c60c77ff425523d2a21fca9e4..8237a72ed8cd830c4dfca825a3cc69eb7ed07564 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/object-storage/modals/CreateBucketModal.vue b/src/components/object-storage/modals/CreateBucketModal.vue index 83b0f554de64265e8785fbb4b9eb09a7fe7dde6c..69d625863ec8dc4fc61d7bca107bd85c08930dd4 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 00a4663b4832a538dfb97adeb2e61f77c711fd8d..f84ed693432b0c2eaa33f587970937ad0fcafe21 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/ParameterSchemaDescriptionComponent.vue b/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..43ce261108a16742579d4ca04515b9f378952f39 --- /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/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..b80fd1eb59bcedf639a898f81065ad497ae78be6 --- /dev/null +++ b/src/components/parameter-schema/ParameterSchemaFormComponent.vue @@ -0,0 +1,317 @@ +<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 { 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"; + +// 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) => { + updateSchema(newValue); + } +); + +// Functions +// ============================================================================= +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars */ +function updateSchema(schema: Record<string, any>) { + validateSchema = schemaCompiler.compile(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 */ + +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); + + 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" + id="launchWorkflowForm" + ref="launchForm" + :class="{ 'was-validated': formState.validated }" + > + <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=" + (newValue) => (formState.formInput[groupName] = newValue) + " + v-if="formState.formInput[groupName]" + :parameter-group-name="groupName" + :parameter-group="group" + /> + </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-flex pt-2"> + <button + type="submit" + form="launchWorkflowForm" + @click.prevent="startWorkflow" + 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" + :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 0000000000000000000000000000000000000000..0c2371d0f042e31b87d79e416b5c94bad70fad70 --- /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 0000000000000000000000000000000000000000..f8cc45c630c199a6ac292be346af74cfc8c4bdb9 --- /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/components/parameter-schema/form-mode/ParameterBooleanInput.vue b/src/components/parameter-schema/form-mode/ParameterBooleanInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..2ee50311314e471790e694dd2574ce0b895de99e --- /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 0000000000000000000000000000000000000000..a2e20d7c16c872493b4b16e126bacd2889afab3c --- /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 0000000000000000000000000000000000000000..c68eb662a236b5fbaf1583530267492f189cb7ed --- /dev/null +++ b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue @@ -0,0 +1,185 @@ +<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 formInput = computed(() => props.modelValue); +const emit = defineEmits<{ + ( + e: "update:modelValue", + value: Record<string, number | string | boolean | undefined> + ): 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( + formInput, + (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="parameterRequired(parameterGroup, parameterName)" + :model-value="formInput[parameterName]" + @update:model-value=" + (newValue) => (formInput[parameterName] = newValue) + " + /> + <parameter-boolean-input + v-else-if="parameter['type'] === 'boolean'" + :parameter-name="parameterName" + :parameter="parameter" + :help-id="parameterName + '-help'" + :model-value="formInput[parameterName]" + @update:model-value=" + (newValue) => (formInput[parameterName] = newValue) + " + /> + <template v-else-if="parameter['type'] === 'string'"> + <parameter-enum-input + v-if="parameter['enum']" + :parameter-name="parameterName" + :parameter="parameter" + :model-value="formInput[parameterName]" + :required="parameterRequired(parameterGroup, parameterName)" + @update:model-value=" + (newValue) => (formInput[parameterName] = newValue) + " + /> + <parameter-string-input + v-else + :parameter-name="parameterName" + :parameter="parameter" + :model-value="formInput[parameterName]" + :required="parameterRequired(parameterGroup, parameterName)" + @update:model-value=" + (newValue) => (formInput[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']" + /> + <p v-if="parameter['pattern']"> + Pattern: <code>{{ parameter["pattern"] }}</code> + </p> + </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 0000000000000000000000000000000000000000..513b783282e6b08ce8e78b4dd4360ddd4fe963ce --- /dev/null +++ b/src/components/parameter-schema/form-mode/ParameterNumberInput.vue @@ -0,0 +1,50 @@ +<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']" + step="0.01" + :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 0000000000000000000000000000000000000000..b6be2cb88238b1e10ead54d2a32c37cd99ac0505 --- /dev/null +++ b/src/components/parameter-schema/form-mode/ParameterStringInput.vue @@ -0,0 +1,189 @@ +<script setup lang="ts"> +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: { + 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 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<{ + (e: "update:modelValue", value: string | undefined): void; +}>(); + +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() { + 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" + :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 1c2ac3a8cce1d74ad09c8ec68a25f5bf698d5e92..ffddccfe35a024c27b092626aa737f0f36a763a8 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/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 9ffb18d2f806e318300a3410824feb094ac3026a..c450386da0df32cdc98e00c1c072ae6fcac951b6 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 f8fd224a7a1d91f5aa3b06ae980460f9124acd1c..9a5b54fc2778d4531c8f134cfc42095633b0e40d 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 >