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