Skip to content
Snippets Groups Projects
Verified Commit 2e823fca authored by Daniel Göbel's avatar Daniel Göbel
Browse files

Differentiate between simple string input and file/directory input

#38
parent 463356b9
No related branches found
No related tags found
1 merge request!40Resolve "Implement json schema parser"
......@@ -2,38 +2,95 @@
import { computed, ref, reactive, watch, onMounted } from "vue";
import ParameterGroupForm from "@/components/parameter-schema/form-mode/ParameterGroupForm.vue";
import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
import { WorkflowExecutionService } from "@/client/workflow";
import type { ApiError } from "@/client/workflow";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
import ParameterStringInput from "@/components/parameter-schema/form-mode/ParameterStringInput.vue";
import { Toast } from "bootstrap";
const launchForm = ref<HTMLFormElement | null>(null);
const schemaCompiler = new Ajv({
strict: false,
});
let validateSchema: ValidateFunction;
// Props
// =============================================================================
const props = defineProps({
schema: {
type: Object,
required: true,
},
workflowVersionId: {
type: String,
required: true,
},
});
// Bootstrap Elements
// =============================================================================
let errorToast: Toast | null = null;
// Types
// =============================================================================
type ParameterGroup = {
group: string;
title: string;
icon?: string;
};
// JSON Schema package
// =============================================================================
const schemaCompiler = new Ajv({
strict: false,
});
let validateSchema: ValidateFunction;
// Reactive State
// =============================================================================
const launchForm = ref<HTMLFormElement | null>(null);
const formState = reactive<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formInput: Record<string, any>;
validated: boolean;
pipelineNotes: string;
report_bucket?: string;
loading: boolean;
errorType?: string;
}>({
formInput: {},
validated: false,
pipelineNotes: "",
report_bucket: undefined,
loading: false,
errorType: undefined,
});
// Computed Properties
// =============================================================================
const parameterGroups = computed<Record<string, never>>(
() => props.schema["definitions"]
);
// Create a list with the names of all parameter groups
const navParameterGroups = computed<ParameterGroup[]>(() =>
Object.keys(parameterGroups.value)
.map((group) => {
return {
group: group,
title: parameterGroups.value[group]["title"],
icon: parameterGroups.value[group]["fa_icon"],
};
})
.filter(
// filter all groups that have only hidden parameters
(group) =>
Object.keys(parameterGroups.value[group.group]["properties"]).filter(
(key) =>
!parameterGroups.value[group.group]["properties"][key]["hidden"]
).length > 0
)
);
// Watchers
// =============================================================================
watch(
() => props.schema,
(newValue) => {
......@@ -41,6 +98,8 @@ watch(
}
);
// Functions
// =============================================================================
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars */
function updateSchema(schema: Record<string, any>) {
validateSchema = schemaCompiler.compile(props.schema);
......@@ -61,33 +120,6 @@ function updateSchema(schema: Record<string, any>) {
}
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars */
onMounted(() => {
updateSchema(props.schema);
});
const parameterGroups = computed<Record<string, never>>(
() => props.schema["definitions"]
);
const navParameterGroups = computed<ParameterGroup[]>(() =>
Object.keys(parameterGroups.value)
.map((group) => {
return {
group: group,
title: parameterGroups.value[group]["title"],
icon: parameterGroups.value[group]["fa_icon"],
};
})
.filter(
// filter all groups that have only hidden parameters
(group) =>
Object.keys(parameterGroups.value[group.group]["properties"]).filter(
(key) =>
!parameterGroups.value[group.group]["properties"][key]["hidden"]
).length > 0
)
);
function startWorkflow() {
formState.validated = true;
if (launchForm.value?.checkValidity()) {
......@@ -96,15 +128,70 @@ function startWorkflow() {
});
const schemaValid = validateSchema(realInput);
console.log(realInput);
if (!schemaValid) console.log(validateSchema.errors);
} else {
console.log("invalid");
if (!schemaValid) {
console.error(validateSchema.errors);
errorToast?.show();
} else {
formState.errorType = undefined;
formState.loading = true;
WorkflowExecutionService.workflowExecutionStartWorkflow({
workflow_version_id: props.workflowVersionId,
parameters: realInput,
notes: formState.pipelineNotes,
report_output_bucket: formState.report_bucket,
})
.then(() => {
console.log("Started Workflow");
})
.catch((err: ApiError) => {
console.error(err);
if (err.body["detail"].includes("workflow execution limit")) {
formState.errorType = "limit";
}
errorToast?.show();
})
.finally(() => {
formState.loading = false;
});
}
}
}
// Lifecycle Events
// =============================================================================
onMounted(() => {
updateSchema(props.schema);
errorToast = new Toast("#workflowExecutionErrorToast");
});
</script>
<template>
<div class="toast-container position-fixed top-toast end-0 p-3">
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
class="toast text-bg-danger align-items-center border-0"
data-bs-autohide="true"
id="workflowExecutionErrorToast"
>
<div class="d-flex p-2">
<div v-if="formState.errorType === 'limit'" class="toast-body">
You have too many active workflow executions to start a new one
</div>
<div v-else>
There was an error with starting the workflow execution. Look in the
console for more information
</div>
<button
type="button"
class="btn-close btn-close-white m-auto"
data-bs-dismiss="toast"
aria-label="Close"
></button>
</div>
</div>
</div>
<div class="row mb-5 align-items-start">
<form
class="col-9"
......@@ -112,7 +199,56 @@ function startWorkflow() {
ref="launchForm"
:class="{ 'was-validated': formState.validated }"
>
<div v-for="(group, groupName) in parameterGroups" :key="groupName">
<div class="card bg-dark mb-3">
<h2 class="card-header" id="pipelineGeneralOptions">
<font-awesome-icon icon="fa-solid fa-gear" class="me-2" />
Pipeline Options
</h2>
<div class="card-body">
<h5 class="card-title">
General Options about the pipeline execution
</h5>
<div class="input-group">
<span class="input-group-text" id="pipelineNotes">
<font-awesome-icon
class="me-2 text-dark"
icon="fa-solid fa-sticky-note"
/>
<code>--notes</code>
</span>
<textarea
class="form-control"
rows="2"
v-model="formState.pipelineNotes"
/>
</div>
<label class="mb-3"
>Personal notes about the pipeline execution</label
>
<div class="input-group">
<span class="input-group-text" id="pipelineNotes">
<font-awesome-icon
class="me-2 text-dark"
icon="fa-solid fa-sticky-note"
/>
<code>--report_output_bucket</code>
</span>
<parameter-string-input
parameter-name="report_output_bucket"
v-model="formState.report_bucket"
:parameter="{
format: 'directory-path',
type: 'string',
}"
/>
</div>
<label class="mb-3">
Directory in bucket where to save the Nextflow report about the
pipeline execution
</label>
</div>
</div>
<template v-for="(group, groupName) in parameterGroups" :key="groupName">
<parameter-group-form
:modelValue="formState.formInput[groupName]"
@update:model-value="
......@@ -122,25 +258,42 @@ function startWorkflow() {
:parameter-group-name="groupName"
:parameter-group="group"
/>
</div>
</template>
</form>
<div
class="col-3 sticky-top bg-dark rounded-1 px-0"
style="top: 70px !important; max-height: calc(100vh - 150px)"
>
<div class="d-grid gap-2 col-6">
<div class="d-flex pt-2">
<button
type="submit"
form="launchWorkflowForm"
@click.prevent="startWorkflow"
class="btn btn-success"
class="btn btn-success w-50 mx-2"
:disabled="formState.loading"
>
<font-awesome-icon icon="fa-solid fa-rocket" class="me-2" />
Launch
</button>
<router-link
role="button"
class="btn btn-success w-50 mx-2"
target="_blank"
:to="{ name: 'buckets' }"
>
<font-awesome-icon icon="fa-solid fa-upload" class="me-2" />
Upload files
</router-link>
</div>
<nav class="h-100">
<nav class="nav">
<ul class="ps-0">
<li class="nav-link">
<a href="#pipelineGeneralOptions"
><font-awesome-icon icon="fa-solid fa-gear" class="me-2" />
General Pipeline Options
</a>
</li>
<li
class="nav-link"
v-for="group in navParameterGroups"
......
......@@ -37,7 +37,7 @@ const parameters = computed<Record<string, never>>(
() => props.parameterGroup["properties"]
);
const modelStuff = computed(() => props.modelValue);
const formInput = computed(() => props.modelValue);
const emit = defineEmits<{
(
e: "update:modelValue",
......@@ -45,8 +45,21 @@ const emit = defineEmits<{
): void;
}>();
function parameterRequired(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameterGroup: Record<string, any>,
parameterName: string
): boolean {
return (
parameterGroup["required"]?.includes(parameterName) || // parameter is required
parameterGroup["dependentRequired"]?.[parameterName] // parameter is required when another parameter is set
?.map((param: string) => formInput.value[param])
?.reduce((acc: boolean, val: string) => acc || val, false)
);
}
watch(
modelStuff,
formInput,
(newVal) => {
//console.log("Group", props.parameterGroupName, newVal);
emit("update:modelValue", newVal);
......@@ -87,10 +100,10 @@ watch(
:parameter-name="parameterName"
:parameter="parameter"
:help-id="parameterName + '-help'"
:required="parameterGroup['required']?.includes(parameterName)"
:model-value="modelStuff[parameterName]"
:required="parameterRequired(parameterGroup, parameterName)"
:model-value="formInput[parameterName]"
@update:model-value="
(newValue) => (modelStuff[parameterName] = newValue)
(newValue) => (formInput[parameterName] = newValue)
"
/>
<parameter-boolean-input
......@@ -98,9 +111,9 @@ watch(
:parameter-name="parameterName"
:parameter="parameter"
:help-id="parameterName + '-help'"
:model-value="modelStuff[parameterName]"
:model-value="formInput[parameterName]"
@update:model-value="
(newValue) => (modelStuff[parameterName] = newValue)
(newValue) => (formInput[parameterName] = newValue)
"
/>
<template v-else-if="parameter['type'] === 'string'">
......@@ -108,20 +121,20 @@ watch(
v-if="parameter['enum']"
:parameter-name="parameterName"
:parameter="parameter"
:model-value="modelStuff[parameterName]"
:required="parameterGroup['required']?.includes(parameterName)"
:model-value="formInput[parameterName]"
:required="parameterRequired(parameterGroup, parameterName)"
@update:model-value="
(newValue) => (modelStuff[parameterName] = newValue)
(newValue) => (formInput[parameterName] = newValue)
"
/>
<parameter-string-input
v-else
:parameter-name="parameterName"
:parameter="parameter"
:model-value="modelStuff[parameterName]"
:required="parameterGroup['required']?.includes(parameterName)"
:model-value="formInput[parameterName]"
:required="parameterRequired(parameterGroup, parameterName)"
@update:model-value="
(newValue) => (modelStuff[parameterName] = newValue)
(newValue) => (formInput[parameterName] = newValue)
"
/>
</template>
......@@ -151,6 +164,9 @@ watch(
class="helpTextCode"
:markdown="parameter['help_text']"
/>
<p v-if="parameter['pattern']">
Pattern: <code>{{ parameter["pattern"] }}</code>
</p>
</div>
</template>
</template>
......
......@@ -39,6 +39,7 @@ function updateValue() {
ref="numberInput"
:max="props.parameter['maximum']"
:min="props.parameter['minimum']"
step="0.01"
:value="props.modelValue"
:required="props.required"
:aria-describedby="props.helpId"
......
<script setup lang="ts">
import { computed, watch, ref } from "vue";
import { computed, watch, ref, onMounted, reactive } from "vue";
import { useBucketStore } from "@/stores/buckets";
import { ObjectService } from "@/client/s3proxy";
const bucketRepository = useBucketStore();
const props = defineProps({
parameter: {
......@@ -22,14 +26,41 @@ const props = defineProps({
},
});
const randomIDSuffix = Math.random().toString(16).substr(2, 8);
const defaultValue = computed<string>(() => props.parameter["default"]);
const s3Path = reactive<{
bucket: string | undefined;
key: string | undefined;
}>({
bucket: undefined,
key: undefined,
});
const keysInBucket = ref<string[]>([]);
watch(defaultValue, (newVal, oldVal) => {
if (newVal != oldVal && newVal != undefined) {
emit("update:modelValue", newVal);
}
});
watch(s3Path, () => {
if (format.value) {
updateValue();
}
});
watch(
() => s3Path.bucket,
(newVal, oldVal) => {
if (newVal !== oldVal) {
updateKeysInBucket(newVal);
}
}
);
const pattern = computed<string>(() => props.parameter["pattern"]);
const emit = defineEmits<{
......@@ -38,16 +69,112 @@ const emit = defineEmits<{
const stringInput = ref<HTMLInputElement | undefined>(undefined);
const format = computed<string | undefined>(() => props.parameter["format"]);
const filesInBucket = computed<string[]>(() =>
keysInBucket.value.filter(
(obj) => !obj.endsWith("/") && !obj.endsWith(".s3keep")
)
);
const foldersInBucket = computed<string[]>(() =>
keysInBucket.value
.map((obj) => {
const parts = obj.split("/");
return parts
.slice(0, parts.length - 1)
.map((part, index) =>
parts.slice(0, index + 1).reduce((acc, val) => `${acc}/${val}`)
);
})
.flat()
.filter((val, index, array) => array.indexOf(val) === index)
);
const filesAndFoldersInBucket = computed<string[]>(() =>
filesInBucket.value.concat(foldersInBucket.value)
);
const keyDataList = computed<string[]>(() => {
switch (format.value) {
case "file-path":
return filesInBucket.value;
case "directory-path":
return foldersInBucket.value;
case "path":
return filesAndFoldersInBucket.value;
default:
return [];
}
});
function updateValue() {
emit(
"update:modelValue",
stringInput.value?.value ? stringInput.value?.value : undefined
);
if (format.value) {
emit(
"update:modelValue",
!s3Path.bucket && s3Path.key
? undefined
: `s3://${s3Path.bucket}/${s3Path.key}`
);
} else {
emit(
"update:modelValue",
stringInput.value?.value ? stringInput.value?.value : undefined
);
}
}
const helpTextPresent = computed<boolean>(() => props.parameter["help_text"]);
function updateKeysInBucket(bucketName?: string) {
if (bucketName != null) {
ObjectService.objectGetBucketObjects(bucketName).then((objs) => {
keysInBucket.value = objs.map((obj) => obj.key);
});
} else {
keysInBucket.value = [];
}
}
onMounted(() => {
bucketRepository.fetchBuckets();
if (format.value) {
s3Path.key = defaultValue.value;
}
});
</script>
<template>
<template v-if="format">
<select
class="form-select"
:required="props.required"
v-model="s3Path.bucket"
>
<option selected disabled value="">Please select a bucket</option>
<option
v-for="bucket in bucketRepository.ownBucketsAndFullPermissions"
:key="bucket"
:value="bucket"
>
{{ bucket }}
</option>
</select>
<input
class="form-control"
:class="{ 'rounded-end': !helpTextPresent }"
:list="'datalistOptions2' + randomIDSuffix"
placeholder="Type to search in bucket..."
:required="props.required && format === 'file-path'"
v-model="s3Path.key"
:pattern="pattern"
/>
<datalist :id="'datalistOptions2' + randomIDSuffix">
<option v-for="obj in keyDataList" :value="obj" :key="obj" />
</datalist>
</template>
<input
v-else
ref="stringInput"
class="form-control"
type="text"
......
......@@ -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,
......
......@@ -4,7 +4,7 @@ import { WorkflowVersionService } from "@/client/workflow";
import type { WorkflowVersionFull } from "@/client/workflow";
import axios from "axios";
import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
import ParameterSchemaFormComponent from "@/components/parameter-schema/ParameterSchemaFormComponent.vue";
import ParameterSchemaDescriptionComponent from "@/components/parameter-schema/ParameterSchemaDescriptionComponent.vue";
const props = defineProps<{
versionId: string;
......@@ -132,7 +132,7 @@ onMounted(() => {
<p v-if="props.activeTab === 'description'">
<markdown-renderer :markdown="versionState.descriptionMarkdown" />
</p>
<parameter-schema-form-component
<parameter-schema-description-component
v-else-if="props.activeTab === 'parameters'"
:schema="versionState.parameterSchema"
/>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment