diff --git a/src/client/workflow/models/WorkflowIn.ts b/src/client/workflow/models/WorkflowIn.ts index 544b0e3d50e28167d433cf1dda6a1bfaef7d54f6..9367556e9ffa26da9b64044efbd577d24dc145f7 100644 --- a/src/client/workflow/models/WorkflowIn.ts +++ b/src/client/workflow/models/WorkflowIn.ts @@ -33,6 +33,6 @@ export type WorkflowIn = { /** * List of modes with alternative entrypoint the new workflow has */ - modes?: (Array<WorkflowModeIn> | null); + modes?: Array<WorkflowModeIn>; }; diff --git a/src/client/workflow/models/WorkflowUpdate.ts b/src/client/workflow/models/WorkflowUpdate.ts index 97c2577769795b6fc537326f61b7772fc98ab668..6aa6f1874344aff94967d05ddd3958cd86726d71 100644 --- a/src/client/workflow/models/WorkflowUpdate.ts +++ b/src/client/workflow/models/WorkflowUpdate.ts @@ -17,10 +17,10 @@ export type WorkflowUpdate = { /** * Add modes to the new workflow version */ - append_modes?: (Array<WorkflowModeIn> | null); + append_modes?: Array<WorkflowModeIn>; /** * Delete modes for the new workflow version. */ - delete_modes?: (Array<string> | null); + delete_modes?: Array<string>; }; diff --git a/src/client/workflow/models/WorkflowVersion.ts b/src/client/workflow/models/WorkflowVersion.ts index 563cdc6e4db57e903736c8896b28ca734511f12d..a4248ce4db3ba9fdb32e70dfc800e6b71a7f42ca 100644 --- a/src/client/workflow/models/WorkflowVersion.ts +++ b/src/client/workflow/models/WorkflowVersion.ts @@ -33,6 +33,6 @@ export type WorkflowVersion = { /** * Optional modes his workflow version has */ - modes: (Array<string> | null); + modes: Array<string>; }; diff --git a/src/components/NavbarTop.vue b/src/components/NavbarTop.vue index 2c08939a0e2769614f48e6c99a829fa8469d8094..1116c0dbc77dcfb0dd6ed6ee8f3b2761a31dfdbb 100644 --- a/src/components/NavbarTop.vue +++ b/src/components/NavbarTop.vue @@ -9,6 +9,7 @@ import { OpenAPI as S3ProxyOpenAPI } from "@/client/s3proxy"; import { OpenAPI as AuthOpenAPI } from "@/client/auth"; import { OpenAPI as WorkflowOpenAPI } from "@/client/workflow"; import CopyToClipboardIcon from "@/components/CopyToClipboardIcon.vue"; +import dayjs from "dayjs"; const store = useAuthStore(); const { cookies } = useCookies(); @@ -200,6 +201,7 @@ watch( static-backdrop modal-i-d="advancedUsageModal" modal-label="Advanced Usage Modal" + v-if="store.authenticated" > <template v-slot:header> <h3>Advanced Usage</h3> @@ -243,12 +245,17 @@ watch( </tbody> </table> <div class="mt-4"> - <label for="clowm-jwt" class="form-label">JWT for Services</label> + <label for="clowm-jwt" class="form-label" + >JWT for Services (expires at + {{ + dayjs.unix(store.decodedToken!.exp).format("DD.MM.YYYY HH:mm") + }})</label + > <div class="input-group"> <input type="text" readonly - class="form-control" + class="form-control text-truncate" id="clowm-jwt" :value="store.token" aria-describedby="clowm-jwt-copy" diff --git a/src/components/object-storage/modals/CreateBucketModal.vue b/src/components/object-storage/modals/CreateBucketModal.vue index 94fe7288e46131437e33d981575aa6696f423f6e..8922b13083d06c926298a6def9b78eb64a2caf9c 100644 --- a/src/components/object-storage/modals/CreateBucketModal.vue +++ b/src/components/object-storage/modals/CreateBucketModal.vue @@ -97,7 +97,7 @@ function modalClosed() { required minlength="3" maxlength="63" - pattern="(?!(^((2(5[0-5]|[0-4]\d)|[01]?\d{1,2})\.){3}(2(5[0-5]|[0-4]\d)|[01]?\d{1,2})$))^[a-z\d][a-z\d.-]{1,61}[a-z\d]$" + pattern="(?!(^((2(5[0-5]|[0-4]\d)|[01]?\d{1,2})\.){3}(2(5[0-5]|[0-4]\d)|[01]?\d{1,2})$))^[a-z\d][a-z\d.\-]{1,61}[a-z\d]$" v-model="bucket.name" /> <div class="invalid-feedback"> diff --git a/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue b/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue index df8d127eafb60b82640405358fbe5a08ffd2429b..79063a1285586e9679ba55d776e8a4510be72423 100644 --- a/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue +++ b/src/components/parameter-schema/ParameterSchemaDescriptionComponent.vue @@ -17,22 +17,13 @@ type ParameterGroup = { }; 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, - ), + Object.keys(parameterGroups.value).map((group) => { + return { + group: group, + title: parameterGroups.value[group]["title"], + icon: parameterGroups.value[group]["fa_icon"], + }; + }), ); const parameterGroups = computed<Record<string, never>>( diff --git a/src/components/parameter-schema/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue index d59dd130a49818b67eef8848ae94754fa04ac6cf..4dd82b93caac988eaa925c201c61d335546fad13 100644 --- a/src/components/parameter-schema/ParameterSchemaFormComponent.vue +++ b/src/components/parameter-schema/ParameterSchemaFormComponent.vue @@ -78,22 +78,13 @@ const parameterGroups = computed<Record<string, never>>( // 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, - ), + Object.keys(parameterGroups.value).map((group) => { + return { + group: group, + title: parameterGroups.value[group]["title"], + icon: parameterGroups.value[group]["fa_icon"], + }; + }), ); // Watchers @@ -115,14 +106,13 @@ function updateSchema(schema: Record<string, any>) { 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]) => [ + Object.entries(schema["definitions"][groupName]["properties"]).map( + ([parameterName, parameter]) => [ parameterName, // @ts-ignore parameter["default"], - ]), + ], + ), ), ]); formState.formInput = Object.fromEntries(b); diff --git a/src/components/parameter-schema/description-mode/ParameterDescription.vue b/src/components/parameter-schema/description-mode/ParameterDescription.vue index cd7a63ef1c7ac3b2acfb8f780220663e6c3ebc00..bace4110ccb3454541deb450e061f2161c353ebc 100644 --- a/src/components/parameter-schema/description-mode/ParameterDescription.vue +++ b/src/components/parameter-schema/description-mode/ParameterDescription.vue @@ -32,7 +32,7 @@ const defaultValue = computed<string | undefined>( const enumValues = computed<string[] | undefined>( () => props.parameter["enum"]?.map((val: string) => val.toString()), ); -const hidden = computed<boolean>(() => props.parameter["hidden"] ?? false); +// const hidden = computed<boolean>(() => props.parameter["hidden"] ?? false); const parameterPattern = computed<string | undefined>( () => props.parameter["pattern"], ); @@ -48,7 +48,6 @@ const showRightColum = computed<boolean>( <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" /> diff --git a/src/components/parameter-schema/description-mode/ParameterGroupDescription.vue b/src/components/parameter-schema/description-mode/ParameterGroupDescription.vue index 93b8b9416f0f5379d7a69808a71d98c4129cdf4b..6ced29b2ce7be8ed1ab88d209acc12eca02c9e92 100644 --- a/src/components/parameter-schema/description-mode/ParameterGroupDescription.vue +++ b/src/components/parameter-schema/description-mode/ParameterGroupDescription.vue @@ -20,19 +20,22 @@ const props = defineProps({ 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="mb-5"> <div class="row"> <h2 :id="props.parameterGroupName"> <font-awesome-icon :icon="icon" class="me-3" v-if="icon" />{{ title }} diff --git a/src/components/parameter-schema/form-mode/ParameterGroupForm.vue b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue index f84a78da9d0b2b993fa901f47ac6a6eede04327f..4dae916a8f6d301e1b52adcafd4d31f558c99c36 100644 --- a/src/components/parameter-schema/form-mode/ParameterGroupForm.vue +++ b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue @@ -71,7 +71,7 @@ watch( </script> <template> - <div class="card mb-3" v-if="!groupHidden"> + <div class="card mb-3" :hidden="groupHidden"> <h2 class="card-header" :id="props.parameterGroupName"> <font-awesome-icon :icon="icon" class="me-2" v-if="icon" /> {{ title }} @@ -82,7 +82,7 @@ watch( v-for="(parameter, parameterName) in parameters" :key="parameterName" > - <template v-if="!parameter['hidden']"> + <div :hidden="parameter['hidden']"> <div class="input-group"> <span class="input-group-text" :id="parameterName + '-help'"> <font-awesome-icon @@ -170,7 +170,7 @@ watch( </p> </div> </div> - </template> + </div> </template> </div> </div> diff --git a/src/components/transitions/WorkflowModeTransitionGroup.vue b/src/components/transitions/WorkflowModeTransitionGroup.vue new file mode 100644 index 0000000000000000000000000000000000000000..13d5944c31065e17b099cae95787750e4d284ccb --- /dev/null +++ b/src/components/transitions/WorkflowModeTransitionGroup.vue @@ -0,0 +1,31 @@ +<script setup lang="ts"></script> + +<template> + <transition-group name="modelist" tag="div"> + <slot></slot> + </transition-group> +</template> + +<style> +.modelist-move, /* apply transition to moving elements */ +.modelist-enter-active, +.modelist-leave-active { + transition: all 0.5s ease; +} + +.modelist-enter-from { + opacity: 0; + transform: translateX(50%); +} + +.modelist-leave-to { + opacity: 0; + transform: translateX(-50%); +} + +/* ensure leaving items are taken out of layout flow so that moving + animations can be calculated correctly. */ +.modelist-leave-active { + position: absolute; +} +</style> diff --git a/src/components/workflows/WorkflowCard.vue b/src/components/workflows/WorkflowCard.vue index 61f2beb88232deac2326c7a36efedb60c38f3074..50c8ddc00ff1848343aaed427b788496ee3dcabe 100644 --- a/src/components/workflows/WorkflowCard.vue +++ b/src/components/workflows/WorkflowCard.vue @@ -42,6 +42,9 @@ onMounted(() => { workflowId: workflow.workflow_id, versionId: latestVersion?.git_commit_hash, }, + query: { + workflowModeId: latestVersion?.modes?.[0] ?? undefined, + }, }" >{{ props.workflow.name }} </router-link> @@ -77,6 +80,9 @@ onMounted(() => { workflowId: workflow.workflow_id, versionId: latestVersion?.git_commit_hash, }, + query: { + workflowModeId: latestVersion?.modes?.[0] ?? undefined, + }, }" class="btn btn-outline-success" role="button" diff --git a/src/components/workflows/WorkflowWithVersionsCard.vue b/src/components/workflows/WorkflowWithVersionsCard.vue index 0e72ccb8b29d9f807d5b2fc914dc1ca03d005460..d43ce26bb4024457da84b853064cbf880ee29317 100644 --- a/src/components/workflows/WorkflowWithVersionsCard.vue +++ b/src/components/workflows/WorkflowWithVersionsCard.vue @@ -119,6 +119,15 @@ onMounted(() => { </div> <div v-else> <table class="table table-sm table-hover"> + <thead> + <tr> + <th scope="col">Version</th> + <th scope="col">Status</th> + <th scope="col">Updated at</th> + <th scope="col" class="text-align-center">Icon</th> + <th scope="col">Link</th> + </tr> + </thead> <tbody> <tr v-for="version in sortedVersions(props.workflow.versions)" @@ -141,7 +150,7 @@ onMounted(() => { <td> {{ dayjs.unix(version.created_at).format("D MMMM YYYY") }} </td> - <td class="w-fit"> + <td class="text-align-center"> <img v-if="version.icon_url != null" :src="version.icon_url" @@ -169,6 +178,10 @@ onMounted(() => { workflowId: props.workflow.workflow_id, versionId: version.git_commit_hash, }, + query: { + workflowModeId: version.modes?.[0] ?? undefined, + developerView: 'true', + }, }" >View </router-link> @@ -207,4 +220,8 @@ td > img { .add-icon-hover:hover { color: var(--bs-success) !important; } + +.text-align-center { + text-align: center; +} </style> diff --git a/src/components/workflows/modals/ArbitraryWorkflowModal.vue b/src/components/workflows/modals/ArbitraryWorkflowModal.vue index 9abbaf2783fa0d6868361493de778f1114a70f86..5d85f01f1f61b54bfbbe113863e0a5c015ed9fdc 100644 --- a/src/components/workflows/modals/ArbitraryWorkflowModal.vue +++ b/src/components/workflows/modals/ArbitraryWorkflowModal.vue @@ -9,6 +9,8 @@ import { determineGitIcon, } from "@/utils/GitRepository"; import { Collapse, Modal } from "bootstrap"; +import { useArbitraryWorkflowStore } from "@/stores/devWorkflow"; +import type { WorkflowModeOut } from "@/client/workflow"; const props = defineProps<{ modalID: string; @@ -17,9 +19,11 @@ const props = defineProps<{ let createWorkflowModal: Modal | null = null; let privateRepositoryCollapse: Collapse | null = null; let tokenHelpCollapse: Collapse | null = null; +let workflowModeCollapse: Collapse | null = null; const arbitraryWorkflowForm = ref<HTMLFormElement | undefined>(undefined); const workflowRepositoryElement = ref<HTMLInputElement | undefined>(undefined); const router = useRouter(); +const workflowStore = useArbitraryWorkflowStore(); const workflow = reactive<{ repository_url: string; @@ -37,6 +41,19 @@ const repositoryCredentials = reactive<{ privateRepo: false, }); +const workflowMode = reactive<{ + mode: WorkflowModeOut; + modeEnabled: boolean; +}>({ + mode: { + entrypoint: "", + schema_path: "", + name: "", + mode_id: crypto.randomUUID(), + }, + modeEnabled: false, +}); + const formState = reactive<{ loading: boolean; checkRepoLoading: boolean; @@ -65,6 +82,17 @@ watch( }, ); +watch( + () => workflowMode.modeEnabled, + (show) => { + if (show) { + workflowModeCollapse?.show(); + } else { + workflowModeCollapse?.hide(); + } + }, +); + function modalClosed() { formState.validated = false; tokenHelpCollapse?.hide(); @@ -72,15 +100,20 @@ function modalClosed() { function viewWorkflow() { createWorkflowModal?.hide(); + const wid = workflowStore.setWorkflow({ + ...workflow, + name: "", + short_description: "", + modes: workflowMode.modeEnabled ? [workflowMode.mode] : [], + token: + repositoryCredentials.token.length > 0 + ? repositoryCredentials.token + : undefined, + }); router.push({ name: "arbitrary-workflow", query: { - repository: encodeURI(workflow.repository_url), - commit_hash: workflow.git_commit_hash, - token: - repositoryCredentials.token.length > 0 - ? encodeURIComponent(repositoryCredentials.token) - : undefined, + wid: wid, }, }); } @@ -100,7 +133,12 @@ function checkRepository() { : undefined, ); repo - .checkFilesExist(requiredRepositoryFiles, true) + .checkFilesExist( + requiredRepositoryFiles( + workflowMode.modeEnabled ? [workflowMode.mode] : [], + ), + true, + ) .then(() => { formState.allowUpload = true; }) @@ -133,6 +171,9 @@ onMounted(() => { privateRepositoryCollapse = new Collapse("#privateRepositoryCollapse", { toggle: false, }); + workflowModeCollapse = new Collapse("#workflowModeCollapse", { + toggle: false, + }); tokenHelpCollapse = new Collapse("#tokenHelpCollapse", { toggle: false, }); @@ -224,7 +265,7 @@ onMounted(() => { aria-controls="#privateRepositoryCollapse" /> <label class="form-check-label" for="privateRepositoryCheckbox"> - Private Git Repository + Enable Private Git Repository </label> </div> <div class="collapse" id="privateRepositoryCollapse"> @@ -280,6 +321,64 @@ onMounted(() => { </div> </div> </div> + <div class="mb-3"> + <div class="form-check fs-5"> + <input + class="form-check-input" + type="checkbox" + v-model="workflowMode.modeEnabled" + id="workflowModeCheckbox" + @change="formState.allowUpload = false" + aria-controls="#workflowModeCollapse" + /> + <label class="form-check-label" for="workflowModeCheckbox"> + Enable Workflow Mode + </label> + </div> + <div class="collapse" id="workflowModeCollapse"> + <div class="row"> + <div class="col-6 mb-2"> + <label for="modeEntryInput-" class="form-label" + >Entrypoint</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-tag" /> + </div> + <input + type="text" + class="form-control" + id="modeEntryInput" + maxlength="128" + v-model="workflowMode.mode.entrypoint" + :required="workflowMode.modeEnabled" + @change="formState.allowUpload = false" + /> + </div> + </div> + <div class="col-6"> + <label for="modeSchemaInput-" class="form-label" + >Schema File</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-file-code" /> + </div> + <input + type="text" + class="form-control" + id="modeSchemaInput-" + maxlength="128" + pattern=".*\.json$" + v-model="workflowMode.mode.schema_path" + :required="workflowMode.modeEnabled" + @change="formState.allowUpload = false" + /> + </div> + </div> + </div> + </div> + </div> </form> </template> <template v-slot:footer> diff --git a/src/components/workflows/modals/CreateWorkflowModal.vue b/src/components/workflows/modals/CreateWorkflowModal.vue index 3971a16223b875c5fb295d56bcaf601f7b02c631..308b83a47ee870e7dc85581127fbfe6f0cbd5561 100644 --- a/src/components/workflows/modals/CreateWorkflowModal.vue +++ b/src/components/workflows/modals/CreateWorkflowModal.vue @@ -1,7 +1,11 @@ <script setup lang="ts"> import { computed, onMounted, reactive, ref, watch } from "vue"; import { Modal, Toast, Collapse, Tooltip } from "bootstrap"; -import type { WorkflowIn, WorkflowOut } from "@/client/workflow"; +import type { + WorkflowIn, + WorkflowOut, + WorkflowModeOut, +} from "@/client/workflow"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import { ApiError, WorkflowService } from "@/client/workflow"; @@ -11,6 +15,7 @@ import { determineGitIcon, } from "@/utils/GitRepository"; import { valid } from "semver"; +import WorkflowModeTransitionGroup from "@/components/transitions/WorkflowModeTransitionGroup.vue"; // Emitted Events // ============================================================================= @@ -30,6 +35,7 @@ let createWorkflowModal: Modal | null = null; let successToast: Toast | null = null; let privateRepositoryCollapse: Collapse | null = null; let tokenHelpCollapse: Collapse | null = null; +let workflowModesCollapse: Collapse | null = null; // HTML Form Elements // ============================================================================= @@ -81,6 +87,21 @@ const repositoryCredentials = reactive<{ privateRepo: false, }); +const workflowModes = reactive<{ + hasModes: boolean; + modes: WorkflowModeOut[]; +}>({ + hasModes: false, + modes: [ + { + mode_id: crypto.randomUUID(), + name: "", + schema_path: "", + entrypoint: "", + }, + ], +}); + // Computed Properties // ============================================================================= const gitIcon = computed<string>(() => @@ -99,6 +120,17 @@ watch( }, ); +watch( + () => workflowModes.hasModes, + (show) => { + if (show) { + workflowModesCollapse?.show(); + } else { + workflowModesCollapse?.hide(); + } + }, +); + // Functions // ============================================================================= function modalClosed() { @@ -130,6 +162,15 @@ function createWorkflow() { ) { workflow.token = repositoryCredentials.token; } + if (workflowModes.hasModes) { + workflow.modes = workflowModes.modes.map((mode) => { + return { + name: mode.name, + schema_path: mode.schema_path, + entrypoint: mode.entrypoint, + }; + }); + } WorkflowService.workflowCreateWorkflow(workflow) .then((w) => { emit("workflow-created", w); @@ -164,18 +205,21 @@ function resetForm() { workflow.git_commit_hash = ""; workflow.initial_version = undefined; workflow.token = undefined; + workflow.modes = []; + workflowModes.modes = [ + { + mode_id: crypto.randomUUID(), + name: "", + schema_path: "", + entrypoint: "", + }, + ]; + workflowModes.hasModes = false; repositoryCredentials.privateRepo = false; repositoryCredentials.token = ""; privateRepositoryCollapse?.hide(); } -/** - * Watcher function for the file upload in the form. - */ -//function iconChanged() { -// workflow.icon = workflowIconInput.value?.files?.[0].slice(); -//} - /** * Check the workflow repository for the necessary files. */ @@ -194,8 +238,11 @@ function checkRepository() { ? repositoryCredentials.token : undefined, ); + const requiredFiles = requiredRepositoryFiles( + workflowModes.hasModes ? workflowModes.modes : [], + ); repo - .checkFilesExist(requiredRepositoryFiles, true) + .checkFilesExist(requiredFiles, true) .then(() => { formState.allowUpload = true; }) @@ -227,6 +274,27 @@ function checkVersionValidity() { } } +function addMode() { + if (workflowModes.modes.length < 11) { + workflowModes.modes.push({ + mode_id: crypto.randomUUID(), + name: "", + schema_path: "", + entrypoint: "", + }); + } +} + +function removeMode(index: number) { + if ( + workflowModes.modes.length > 1 && + index > -1 && + index < (workflowModes.modes.length ?? 0) + ) { + workflowModes.modes.splice(index, 1); + } +} + // Lifecycle Events // ============================================================================= onMounted(() => { @@ -238,6 +306,9 @@ onMounted(() => { tokenHelpCollapse = new Collapse("#tokenHelpCollapse", { toggle: false, }); + workflowModesCollapse = new Collapse("#workflowModesCollapse", { + toggle: false, + }); new Tooltip("#tooltip-version-" + randomIDSuffix); new Tooltip("#tooltip-commit-" + randomIDSuffix); new Tooltip("#tooltip-url-" + randomIDSuffix); @@ -430,7 +501,7 @@ onMounted(() => { aria-controls="#privateRepositoryCollapse" /> <label class="form-check-label" for="privateRepositoryCheckbox"> - Private Git Repository + Enable private Git Repository </label> </div> <div class="collapse" id="privateRepositoryCollapse"> @@ -490,6 +561,101 @@ onMounted(() => { </div> </div> </div> + <div class="mb-3"> + <div class="form-check fs-5"> + <input + class="form-check-input" + type="checkbox" + v-model="workflowModes.hasModes" + id="workflowModesCheckbox" + @change="formState.allowUpload = false" + aria-controls="#workflowModesCollapse" + /> + <label class="form-check-label" for="workflowModesCheckbox"> + Enable Workflow Modes + </label> + <button + v-if="workflowModes.hasModes" + class="btn btn-primary float-end" + @click="addMode" + :disabled="workflow.modes!.length >= 10" + > + Add Mode + </button> + </div> + </div> + <div class="collapse" id="workflowModesCollapse"> + <WorkflowModeTransitionGroup> + <div + v-for="(mode, index) in workflowModes.modes" + :key="mode.mode_id" + class="row mb-3" + > + <h6> + <font-awesome-icon + icon="fa-solid fa-minus" + class="text-danger me-1 fs-6 cursor-pointer" + @click="removeMode(index)" + /> + Mode {{ index + 1 }} + </h6> + <div class="col-6"> + <label :for="'modeNameInput-' + index" class="form-label" + >Name</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-tag" /> + </div> + <input + type="text" + class="form-control" + :id="'modeNameInput-' + index" + maxlength="128" + v-model="mode.name" + :required="workflowModes.hasModes" + /> + </div> + </div> + <div class="col-6 mb-2"> + <label :for="'modeEntryInput-' + index" class="form-label" + >Entrypoint</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-turn-down" /> + </div> + <input + type="text" + class="form-control" + :id="'modeEntryInput-' + index" + maxlength="128" + v-model="mode.entrypoint" + :required="workflowModes.hasModes" + /> + </div> + </div> + <label :for="'modeSchemaInput-' + index" class="form-label" + >Schema File</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-file-code" /> + </div> + <input + type="text" + class="form-control" + :id="'modeSchemaInput-' + index" + maxlength="128" + pattern=".*\.json$" + v-model="mode.schema_path" + @change="formState.allowUpload = false" + :required="workflowModes.hasModes" + /> + </div> + </div> + </WorkflowModeTransitionGroup> + </div> </form> </template> <template v-slot:footer> diff --git a/src/components/workflows/modals/UpdateWorkflowModal.vue b/src/components/workflows/modals/UpdateWorkflowModal.vue index 4ce43dc0026daf6c66241584f25372d8601187d1..14cea75f652be8cdf1181f35d6b938ad60bce32a 100644 --- a/src/components/workflows/modals/UpdateWorkflowModal.vue +++ b/src/components/workflows/modals/UpdateWorkflowModal.vue @@ -1,15 +1,21 @@ <script setup lang="ts"> import { computed, onMounted, reactive, ref, watch } from "vue"; -import { Modal, Toast } from "bootstrap"; +import { Collapse, Modal, Toast } from "bootstrap"; import type { WorkflowUpdate, WorkflowOut, + WorkflowModeIn, + WorkflowModeOut, WorkflowVersion, ApiError, } from "@/client/workflow"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; -import { WorkflowService, WorkflowCredentialsService } from "@/client/workflow"; +import { + WorkflowService, + WorkflowCredentialsService, + WorkflowModeService, +} from "@/client/workflow"; import { GitRepository, requiredRepositoryFiles, @@ -17,11 +23,13 @@ import { } from "@/utils/GitRepository"; import { valid, lte, inc } from "semver"; import { latestVersion as calculateLatestVersion } from "@/utils/Workflow"; +import WorkflowModeTransitionGroup from "@/components/transitions/WorkflowModeTransitionGroup.vue"; // Bootstrap Elements // ============================================================================= let updateWorkflowModal: Modal | null = null; let successToast: Toast | null = null; +let workflowModesCollapse: Collapse | null = null; // Form Elements // ============================================================================= @@ -53,6 +61,14 @@ const workflowUpdate = reactive<WorkflowUpdate>({ append_modes: [], }); +const workflowModes = reactive<{ + deleteModes: string[]; + addModes: WorkflowModeOut[]; +}>({ + deleteModes: [], + addModes: [], +}); + const formState = reactive<{ validated: boolean; missingFiles: string[]; @@ -61,6 +77,8 @@ const formState = reactive<{ allowUpload: boolean; loadCredentials: boolean; workflowToken?: string; + modeMapping: Record<string, WorkflowModeOut>; + modesEnabled: boolean; }>({ loading: false, checkRepoLoading: false, @@ -69,12 +87,16 @@ const formState = reactive<{ missingFiles: [], loadCredentials: false, workflowToken: undefined, + modeMapping: {}, + modesEnabled: false, }); watch( () => props.workflow, () => { resetForm(); + updateModeMapping(); + formState.modesEnabled = latestVersion.value.modes.length > 0; if (props.workflow.private) { formState.loadCredentials = true; WorkflowCredentialsService.workflowCredentialsGetWorkflowCredentials( @@ -95,6 +117,23 @@ watch( }, ); +watch( + () => formState.modesEnabled, + (show) => { + if (show) { + workflowModesCollapse?.show(); + if ( + latestVersion.value.modes.length + workflowModes.addModes.length === + 0 + ) { + addMode(); + } + } else { + workflowModesCollapse?.hide(); + } + }, +); + // Computed Properties // ============================================================================= const latestVersion = computed<WorkflowVersion>(() => { @@ -148,8 +187,16 @@ function checkRepository() { workflowUpdate.git_commit_hash, formState.workflowToken, ); + const oldModes = latestVersion.value.modes + .filter((mode_id) => !workflowModes.deleteModes.includes(mode_id)) // filter all old modes that should be deleted + .map((mode_id) => formState.modeMapping[mode_id]) // map mode id to mode object + .filter((mode) => mode != undefined); // filter all mode objects that are undefined + const newModes = formState.modesEnabled ? workflowModes.addModes : []; repo - .checkFilesExist(requiredRepositoryFiles, true) + .checkFilesExist( + requiredRepositoryFiles([...oldModes, ...newModes]), + true, + ) .then(() => { formState.allowUpload = true; }) @@ -168,6 +215,10 @@ function resetForm() { workflowIconElement.value!.src = latestVersion.value.icon_url ?? ""; workflowUpdate.version = ""; workflowUpdate.git_commit_hash = ""; + workflowUpdate.delete_modes = []; + workflowUpdate.append_modes = []; + workflowModes.addModes = []; + workflowModes.deleteModes = []; if (workflowIconInputElement.value != undefined) { workflowIconInputElement.value.value = ""; } @@ -179,6 +230,16 @@ function updateWorkflow() { if (workflowUpdateForm.value?.checkValidity() && formState.allowUpload) { formState.loading = true; workflowGitCommitHashElement.value?.setCustomValidity(""); + if (formState.modesEnabled) { + workflowUpdate.append_modes = workflowModes.addModes.map((mode) => { + return { + schema_path: mode.schema_path, + entrypoint: mode.entrypoint, + name: mode.name, + } as WorkflowModeIn; + }); + workflowUpdate.delete_modes = workflowModes.deleteModes; + } WorkflowService.workflowUpdateWorkflow( props.workflow.workflow_id, workflowUpdate, @@ -196,6 +257,8 @@ function updateWorkflow() { "Git commit is already used by a workflow", ); } + workflowUpdate.delete_modes = []; + workflowUpdate.append_modes = []; }) .finally(() => { formState.loading = false; @@ -203,11 +266,67 @@ function updateWorkflow() { } } +function updateModeMapping() { + const modeIds = latestVersion.value.modes.filter( + (modeId) => !formState.modeMapping[modeId], + ); + Promise.all( + modeIds.map((modeId) => + WorkflowModeService.workflowModeGetWorkflowMode(modeId), + ), + ).then((modes) => { + for (const mode of modes) { + formState.modeMapping[mode.mode_id] = mode; + } + }); +} + +function addMode() { + workflowModes.addModes.push({ + mode_id: crypto.randomUUID(), + name: "", + schema_path: "", + entrypoint: "", + }); + formState.allowUpload = false; +} + +function addOldMode(mode_id: string) { + if ( + latestVersion.value.modes.length + + workflowModes.addModes.length - + workflowModes.deleteModes.length < + 10 + ) { + workflowModes.deleteModes = workflowModes.deleteModes.filter( + (mode) => mode != mode_id, + ); + formState.allowUpload = false; + } +} + +function removeNewMode(mode_id: string) { + if (latestVersion.value.modes.length + workflowModes.addModes.length > 1) { + workflowModes.addModes = workflowModes.addModes.filter( + (mode) => mode.mode_id != mode_id, + ); + formState.allowUpload = false; + } +} + +function removeOldMode(mode_id: string) { + workflowModes.deleteModes.push(mode_id); + formState.allowUpload = false; +} + // Lifecycle Events // ============================================================================= onMounted(() => { updateWorkflowModal = new Modal("#" + props.modalID); successToast = new Toast("#successToast-" + randomIDSuffix); + workflowModesCollapse = new Collapse("#updateWorkflowModesCollapse", { + toggle: false, + }); }); </script> @@ -269,7 +388,7 @@ onMounted(() => { :hidden="!showIcon" /> </div> - <div class="row"> + <div class="row mb-3"> <div class="col-8"> <label for="workflowGitCommitInput" class="form-label" >Git Commit Hash</label @@ -324,6 +443,216 @@ onMounted(() => { </div> </div> </div> + <div class="mb-3"> + <div class="form-check fs-5"> + <input + class="form-check-input" + type="checkbox" + v-model="formState.modesEnabled" + id="updateWorkflowModesCheckbox" + @change="formState.allowUpload = false" + aria-controls="#updateWorkflowModesCollapse" + :disabled="latestVersion.modes.length > 0" + /> + <label class="form-check-label" for="updateWorkflowModesCheckbox"> + Enable Workflow Modes + </label> + <button + v-if="formState.modesEnabled" + class="btn btn-primary float-end" + @click="addMode" + :disabled=" + latestVersion.modes.length + + workflowModes.addModes.length - + workflowModes.deleteModes.length >= + 10 + " + > + Add Mode + </button> + </div> + </div> + <div class="collapse" id="updateWorkflowModesCollapse"> + <WorkflowModeTransitionGroup> + <div + v-for="(mode_id, index) in latestVersion.modes" + :key="mode_id" + class="row mb-3" + > + <template v-if="formState.modeMapping[mode_id]"> + <h6> + <font-awesome-icon + v-if="workflowModes.deleteModes.includes(mode_id)" + icon="fa-solid fa-plus" + class="text-success me-1 fs-6 cursor-pointer" + @click="addOldMode(mode_id)" + /> + <font-awesome-icon + v-else + icon="fa-solid fa-minus" + class="text-danger me-1 fs-6 cursor-pointer" + @click="removeOldMode(mode_id)" + /> + <span + :class="{ + 'opacity-75': workflowModes.deleteModes.includes(mode_id), + }" + >Previous Mode {{ index + 1 }}</span + > + </h6> + <div class="col-6"> + <label + :for="'modeNameInput-' + mode_id" + class="form-label" + :class="{ + 'opacity-75': workflowModes.deleteModes.includes(mode_id), + }" + >Name</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-tag" /> + </div> + <input + type="text" + class="form-control" + :id="'modeNameInput-' + mode_id" + maxlength="128" + readonly + :disabled="workflowModes.deleteModes.includes(mode_id)" + v-model="formState.modeMapping[mode_id].name" + :required=" + formState.modesEnabled && + !workflowModes.deleteModes.includes(mode_id) + " + /> + </div> + </div> + <div class="col-6 mb-2"> + <label + :for="'modeEntryInput-' + mode_id" + class="form-label" + :class="{ + 'opacity-75': workflowModes.deleteModes.includes(mode_id), + }" + >Entrypoint</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-turn-down" /> + </div> + <input + type="text" + class="form-control" + :id="'modeEntryInput-' + mode_id" + readonly + :disabled="workflowModes.deleteModes.includes(mode_id)" + v-model="formState.modeMapping[mode_id].entrypoint" + :required=" + formState.modesEnabled && + !workflowModes.deleteModes.includes(mode_id) + " + /> + </div> + </div> + <label + :for="'modeSchemaInput-' + index" + class="form-label" + :class="{ + 'opacity-75': workflowModes.deleteModes.includes(mode_id), + }" + >Schema File</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-file-code" /> + </div> + <input + type="text" + class="form-control" + :id="'modeSchemaInput-' + mode_id" + readonly + :disabled="workflowModes.deleteModes.includes(mode_id)" + v-model="formState.modeMapping[mode_id].schema_path" + :required=" + formState.modesEnabled && + !workflowModes.deleteModes.includes(mode_id) + " + /> + </div> + </template> + </div> + + <div + v-for="(mode, index) in workflowModes.addModes" + :key="mode.mode_id" + class="row mb-3" + > + <h6> + <font-awesome-icon + icon="fa-solid fa-minus" + class="text-danger me-1 fs-6 cursor-pointer" + @click="removeNewMode(mode.mode_id)" + /> + New Mode {{ latestVersion.modes.length + index + 1 }} + </h6> + <div class="col-6"> + <label :for="'modeNameInput-' + index" class="form-label" + >Name</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-tag" /> + </div> + <input + type="text" + class="form-control" + :id="'modeNameInput-' + index" + maxlength="128" + v-model="mode.name" + :required="formState.modesEnabled" + /> + </div> + </div> + <div class="col-6 mb-2"> + <label :for="'modeEntryInput-' + index" class="form-label" + >Entrypoint</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-turn-down" /> + </div> + <input + type="text" + class="form-control" + :id="'modeEntryInput-' + index" + maxlength="128" + v-model="mode.entrypoint" + :required="formState.modesEnabled" + /> + </div> + </div> + <label :for="'modeSchemaInput-' + index" class="form-label" + >Schema File</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-file-code" /> + </div> + <input + type="text" + class="form-control" + :id="'modeSchemaInput-' + index" + maxlength="128" + pattern=".*\.json$" + v-model="mode.schema_path" + @change="formState.allowUpload = false" + :required="formState.modesEnabled" + /> + </div> + </div> + </WorkflowModeTransitionGroup> + </div> </form> </template> <template v-slot:footer> diff --git a/src/router/index.ts b/src/router/index.ts index cd6d25646eb4b2ff556af43ba1b667106b4e973a..817d3f059e3cd40841a2cf475af6601bb32ac7d3 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -58,16 +58,19 @@ const router = createRouter({ import("../views/workflows/ArbitraryWorkflowView.vue"), meta: { requiresDeveloperRole: true }, props: (route) => ({ - repository: route.query.repository, - commit_hash: route.query.commit_hash, - token: route.query.token, + wid: route.query.wid, }), }, { path: "workflows/:workflowId", name: "workflow", component: () => import("../views/workflows/WorkflowView.vue"), - props: true, + props: (route) => ({ + versionId: route.params.versionId ?? undefined, + workflowId: route.params.workflowId, + workflowModeId: route.query.workflowModeId ?? undefined, + developerView: route.query.developerView == "true" ?? false, + }), children: [ { path: "version/:versionId", @@ -78,6 +81,7 @@ const router = createRouter({ versionId: route.params.versionId, workflowId: route.params.workflowId, activeTab: route.query.tab ?? "description", + workflowModeId: route.query.workflowModeId ?? undefined, }), }, { @@ -85,7 +89,11 @@ const router = createRouter({ name: "workflow-start", component: () => import("../views/workflows/StartWorkflowView.vue"), - props: true, + props: (route) => ({ + versionId: route.params.versionId, + workflowId: route.params.workflowId, + workflowModeId: route.query.workflowModeId ?? undefined, + }), }, ], }, diff --git a/src/stores/devWorkflow.ts b/src/stores/devWorkflow.ts new file mode 100644 index 0000000000000000000000000000000000000000..38d625c301186e460250f23e5fd08b4a0deb12fb --- /dev/null +++ b/src/stores/devWorkflow.ts @@ -0,0 +1,18 @@ +import { defineStore } from "pinia"; +import type { WorkflowIn } from "@/client/workflow"; + +export const useArbitraryWorkflowStore = defineStore({ + id: "arbitraryWorkflows", + state: () => { + return { + arbitraryWorkflows: {} as Record<string, WorkflowIn>, + } as { arbitraryWorkflows: Record<string, WorkflowIn> }; + }, + actions: { + setWorkflow(workflow: WorkflowIn): string { + const wid = crypto.randomUUID(); + this.arbitraryWorkflows[wid] = workflow; + return wid; + }, + }, +}); diff --git a/src/utils/GitRepository.ts b/src/utils/GitRepository.ts index 68004e6c06e19000f3710ff2db33f8430c200301..648010ce8ffda08bc69539d393a7520cd303a67a 100644 --- a/src/utils/GitRepository.ts +++ b/src/utils/GitRepository.ts @@ -1,14 +1,24 @@ import axios from "axios"; import type { AxiosInstance, AxiosBasicCredentials } from "axios"; - -export const requiredRepositoryFiles = [ - "main.nf", - "CHANGELOG.md", - "README.md", - "nextflow_schema.json", - "docs/usage.md", - "docs/output.md", -]; +import type { WorkflowModeOut, WorkflowModeIn } from "@/client/workflow"; + +export function requiredRepositoryFiles( + modes?: WorkflowModeIn[] | WorkflowModeOut[], +): string[] { + const list = [ + "main.nf", + "CHANGELOG.md", + "README.md", + "docs/usage.md", + "docs/output.md", + ]; + if (modes && modes.length > 0) { + list.push(...modes.map((mode) => mode.schema_path)); + } else { + list.push("nextflow_schema.json"); + } + return list; +} export function determineGitIcon(repo_url?: string): string { let gitProvider = "git-alt"; @@ -109,16 +119,20 @@ class GithubRepository extends GitRepository { this.repoName = pathParts[1]; if (token) { this.httpClient.interceptors.request.use((req) => { - req.auth = { - password: this.token, - username: this.account, - } as AxiosBasicCredentials; + if (!req.url?.includes("raw")) { + req.auth = { + password: this.token, + username: this.account, + } as AxiosBasicCredentials; + } return req; }); } this.httpClient.interceptors.request.use((req) => { - req.headers.setAccept("application/vnd.github.object+json"); - req.headers["X-GitHub-Api-Version"] = "2022-11-28"; + if (!req.url?.includes("raw")) { + req.headers["X-GitHub-Api-Version"] = "2022-11-28"; + req.headers.setAccept("application/vnd.github.object+json"); + } return req; }); } @@ -132,7 +146,7 @@ class GithubRepository extends GitRepository { } protected async downloadFileUrl(filepath: string): Promise<string> { - if (this.token != undefined) { + if (this.token == undefined) { return Promise.resolve( `https://raw.githubusercontent.com/${this.account}/${this.repoName}/${this.gitCommitHash}/${filepath}`, ); diff --git a/src/utils/Workflow.ts b/src/utils/Workflow.ts index 9257886a74986eebd6e7bd1da9ed3a3e17bc4fd2..e01237b68f4f3c5baaec652c5c10c5c3a36f6e2f 100644 --- a/src/utils/Workflow.ts +++ b/src/utils/Workflow.ts @@ -1,7 +1,5 @@ import type { WorkflowVersion } from "@/client/workflow"; -export function sortedVersions(versions: WorkflowVersion[]): WorkflowVersion[]; - export function sortedVersions( versions: WorkflowVersion[], desc = true, @@ -15,10 +13,6 @@ export function sortedVersions( return vs; } -export function latestVersion( - versions: WorkflowVersion[], -): WorkflowVersion | undefined; - export function latestVersion( versions: WorkflowVersion[], ): WorkflowVersion | undefined { diff --git a/src/views/workflows/ArbitraryWorkflowView.vue b/src/views/workflows/ArbitraryWorkflowView.vue index cc1c87b0c28732dd69e14989523e1f7c01230f64..ee5f05c9328b76a811aa141e7ba8ea7143c90125 100644 --- a/src/views/workflows/ArbitraryWorkflowView.vue +++ b/src/views/workflows/ArbitraryWorkflowView.vue @@ -1,30 +1,38 @@ <script setup lang="ts"> import WorkflowDocumentationTabs from "@/components/workflows/WorkflowDocumentationTabs.vue"; -import { onMounted, reactive, ref } from "vue"; +import { onMounted, reactive, ref, watch } from "vue"; import { GitRepository, requiredRepositoryFiles } from "@/utils/GitRepository"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import ParameterSchemaFormComponent from "@/components/parameter-schema/ParameterSchemaFormComponent.vue"; import { WorkflowExecutionService } from "@/client/workflow"; import { useRouter } from "vue-router"; import { Toast } from "bootstrap"; +import { useArbitraryWorkflowStore } from "@/stores/devWorkflow"; +import type { WorkflowIn } from "@/client/workflow"; const props = defineProps<{ - repository?: string; - commit_hash?: string; - token?: string; + wid: string; }>(); const router = useRouter(); +const workflowStore = useArbitraryWorkflowStore(); const workflowState = reactive<{ + workflow?: WorkflowIn; loading: boolean; changelogMarkdown?: string; descriptionMarkdown?: string; usageMarkdown?: string; outputMarkdown?: string; parameterSchema?: Record<string, never>; + repo: GitRepository; }>({ loading: true, + workflow: undefined, + repo: GitRepository.buildRepository( + "https://github.de/eample/example", + "0123456789abcdef", + ), }); const workflowExecutionState = reactive<{ @@ -38,49 +46,63 @@ const workflowExecutionState = reactive<{ const showDocumentation = ref<boolean>(true); let errorToast: Toast | null = null; -function downloadVersionFiles( - repository: string, - commit_hash: string, - token?: string, -) { - workflowState.loading = true; - const repo = GitRepository.buildRepository(repository, commit_hash, token); - Promise.all( - requiredRepositoryFiles.map((file) => - repo.downloadFile(file).then((response) => { - if (file.includes("README")) { - workflowState.descriptionMarkdown = response.data; - } else if (file.includes("CHANGELOG")) { - workflowState.changelogMarkdown = response.data; - } else if (file.includes("schema")) { - workflowState.parameterSchema = response.data; - } else if (file.includes("usage")) { - workflowState.usageMarkdown = response.data; - } else if (file.includes("output")) { - workflowState.outputMarkdown = response.data; - } - }), - ), - ).finally(() => { - workflowState.loading = false; - }); +function downloadVersionFiles() { + if (workflowState.workflow) { + workflowState.loading = true; + Promise.all( + requiredRepositoryFiles(workflowState.workflow.modes).map((file) => + workflowState.repo.downloadFile(file).then((response) => { + if (file.includes("README")) { + workflowState.descriptionMarkdown = response.data; + } else if (file.includes("CHANGELOG")) { + workflowState.changelogMarkdown = response.data; + } else if (file.includes("usage")) { + workflowState.usageMarkdown = response.data; + } else if (file.includes("output")) { + workflowState.outputMarkdown = response.data; + } else { + workflowState.parameterSchema = response.data; + } + }), + ), + ).finally(() => { + workflowState.loading = false; + }); + } } +watch( + () => workflowState.workflow, + (newWorkflow, oldWorkflow) => { + if ( + newWorkflow && + newWorkflow?.git_commit_hash !== oldWorkflow?.git_commit_hash + ) { + workflowState.repo = GitRepository.buildRepository( + newWorkflow.repository_url, + newWorkflow.git_commit_hash, + newWorkflow.token ?? undefined, + ); + downloadVersionFiles(); + } + }, +); + function startWorkflow( // eslint-disable-next-line @typescript-eslint/no-explicit-any parameters: Record<string, any>, notes?: string, report_output_bucket?: string, ) { - if (props.repository && props.commit_hash) { + if (workflowState.workflow) { errorToast?.hide(); workflowExecutionState.loading = true; WorkflowExecutionService.workflowExecutionStartArbitraryWorkflow({ - git_commit_hash: props.commit_hash, + git_commit_hash: workflowState.workflow.git_commit_hash, parameters: parameters, report_output_bucket: report_output_bucket, - repository_url: props.repository, - token: props.token, + repository_url: workflowState.workflow.repository_url, + token: workflowState.workflow.token ?? undefined, }) .then(() => { router.push({ @@ -102,9 +124,7 @@ function startWorkflow( onMounted(() => { errorToast = new Toast("#arbitraryWorkflowExecutionViewErrorToast"); - if (props.commit_hash && props.repository) { - downloadVersionFiles(props.repository, props.commit_hash, props.token); - } + workflowState.workflow = workflowStore.arbitraryWorkflows[props.wid]; }); </script> @@ -137,26 +157,38 @@ onMounted(() => { </div> </div> </div> - <div class="row m-1 border-bottom mb-4"> - <h1 class="mb-2">Arbitrary Workflow</h1> - </div> - <h4> - Git Repository: <a target="_blank" :href="repository">{{ repository }}</a> - </h4> - <h4 class="mb-5">Git Commit Hash: {{ commit_hash }}</h4> - <div class="d-flex justify-content-center mb-5" v-if="showDocumentation"> - <a - role="button" - href="#" - class="btn btn-success btn-lg mx-auto fs-4" - :class="{ disabled: !workflowState.parameterSchema }" - @click="showDocumentation = false" - > - <font-awesome-icon icon="fa-solid fa-rocket" class="me-2" /> - <span class="align-middle">Launch</span> - </a> - </div> - <template v-if="repository && commit_hash"> + <template v-if="workflowState.workflow"> + <div class="row m-1 border-bottom mb-4"> + <h1 class="mb-2">Arbitrary Workflow</h1> + </div> + <h4> + Git Repository: + <a target="_blank" :href="workflowState.workflow?.repository_url">{{ + workflowState.workflow?.repository_url + }}</a> + </h4> + <h4 :class="{ 'mb-5': workflowState.workflow.modes!.length < 1 }"> + Git Commit Hash: {{ workflowState.workflow?.git_commit_hash }} + </h4> + <template v-if="workflowState.workflow.modes!.length > 0"> + <h5>Entrypoint: {{ workflowState.workflow?.modes?.[0].entrypoint }}</h5> + <h5 class="mb-5"> + Schema File: + {{ workflowState.workflow?.modes?.[0].schema_path }} + </h5> + </template> + <div class="d-flex justify-content-center mb-5" v-if="showDocumentation"> + <a + role="button" + href="#" + class="btn btn-success btn-lg mx-auto fs-4" + :class="{ disabled: !workflowState.parameterSchema }" + @click="showDocumentation = false" + > + <font-awesome-icon icon="fa-solid fa-rocket" class="me-2" /> + <span class="align-middle">Launch</span> + </a> + </div> <workflow-documentation-tabs v-if="showDocumentation" :loading="workflowState.loading" @@ -173,7 +205,17 @@ onMounted(() => { @start-workflow="startWorkflow" /> </template> - <p v-else>Nope</p> + <template v-else> + <div class="text-center fs-1 mt-5"> + <font-awesome-icon + icon="fa-solid fa-magnifying-glass" + class="my-5 fs-0" + style="color: var(--bs-secondary)" + /> + <p class="my-5">Could not find your workflow.<br />Please re-enter it.</p> + <router-link :to="{ name: 'workflows' }" class="mt-5">Back</router-link> + </div> + </template> </template> <style scoped></style> diff --git a/src/views/workflows/MyWorkflowsView.vue b/src/views/workflows/MyWorkflowsView.vue index 882df7869b1feb2b2c6338b46e4f8a0604d3a6de..de39691f3a6ccf2e2520040edef187b9107691bd 100644 --- a/src/views/workflows/MyWorkflowsView.vue +++ b/src/views/workflows/MyWorkflowsView.vue @@ -45,7 +45,7 @@ const workflowsState = reactive<{ version: "", workflow_id: "", git_commit_hash: "", - modes: null, + modes: [], icon_url: null, created_at: 0, status: Status.CREATED, @@ -64,6 +64,25 @@ function workflowUpdated(version: WorkflowVersion) { workflowsState.workflows .find((w) => w.workflow_id == version.workflow_id) ?.versions.push(version); + workflowsState.updateWorkflow = { + short_description: "", + name: "", + versions: [ + { + version: "1.0.0", + created_at: 0, + git_commit_hash: "", + status: Status.CREATED, + workflow_id: "", + icon_url: "", + modes: [], + }, + ], + repository_url: "", + workflow_id: "", + developer_id: "", + private: false, + }; } function workflowDeleteClicked(workflow: WorkflowOut) { diff --git a/src/views/workflows/ReviewWorkflowsView.vue b/src/views/workflows/ReviewWorkflowsView.vue index fd121ea16ac775d9c0c96742781fe7cac805bf46..3e34f2098262994918ba1f16ea90d9257d9c1461 100644 --- a/src/views/workflows/ReviewWorkflowsView.vue +++ b/src/views/workflows/ReviewWorkflowsView.vue @@ -165,15 +165,19 @@ onMounted(() => { {{ version.git_commit_hash }} </a> </td> + <td>#Modes: {{ version.modes.length }}</td> <td> <router-link - target="_blank" :to="{ name: 'workflow-version', params: { workflowId: workflow.workflow_id, versionId: version.git_commit_hash, }, + query: { + developerView: 'true', + workflowModeId: version.modes[0], + }, }" > View diff --git a/src/views/workflows/StartWorkflowView.vue b/src/views/workflows/StartWorkflowView.vue index 6a6dba2545d4b2bbb27f325e2c5d124932f2ec46..608163089902ec91729ba4ed91f2f13486dd3dcf 100644 --- a/src/views/workflows/StartWorkflowView.vue +++ b/src/views/workflows/StartWorkflowView.vue @@ -7,13 +7,14 @@ import { WorkflowExecutionService, WorkflowVersionService, } from "@/client/workflow"; -import { onMounted, ref, reactive } from "vue"; +import { onMounted, ref, reactive, watch } from "vue"; import { useRouter } from "vue-router"; import { Toast } from "bootstrap"; const props = defineProps<{ versionId: string; workflowId: string; + workflowModeId?: string; }>(); const parameterSchema = ref(undefined); @@ -33,26 +34,29 @@ const versionState = reactive<{ workflowExecutionError: undefined, }); -function downloadVersion() { - WorkflowVersionService.workflowVersionGetWorkflowVersion( - props.versionId, - props.workflowId, - ) - .then((version) => { - versionState.workflowVersion = version; - return version; - }) - .then(downloadVersionFiles); -} +watch( + () => props.workflowModeId, + (newModeId, oldModeId) => { + if (newModeId !== oldModeId) { + downloadVersionFiles(); + } + }, +); -function downloadVersionFiles(version: WorkflowVersion) { +function downloadVersionFiles() { + versionState.loading = true; WorkflowVersionService.workflowVersionDownloadWorkflowDocumentation( - version.workflow_id, - version.git_commit_hash, + props.workflowId, + props.versionId, DocumentationEnum.PARAMETER_SCHEMA, - ).then((markdown) => { - parameterSchema.value = markdown; - }); + props.workflowModeId, + ) + .then((markdown) => { + parameterSchema.value = markdown; + }) + .finally(() => { + versionState.loading = false; + }); } function startWorkflow( @@ -69,6 +73,7 @@ function startWorkflow( parameters: parameters, notes: notes, report_output_bucket: report_output_bucket, + mode: props.workflowModeId, }) .then(() => { router.push({ @@ -90,7 +95,7 @@ function startWorkflow( onMounted(() => { errorToast = new Toast("#workflowExecutionViewErrorToast"); - downloadVersion(); + downloadVersionFiles(); }); </script> diff --git a/src/views/workflows/WorkflowVersionView.vue b/src/views/workflows/WorkflowVersionView.vue index 42de6e1fddad6a8e27da3cfbb69136abbc90b946..e815705d6df515463f850a4a7066a44f94978b6e 100644 --- a/src/views/workflows/WorkflowVersionView.vue +++ b/src/views/workflows/WorkflowVersionView.vue @@ -7,6 +7,7 @@ import WorkflowDocumentationTabs from "@/components/workflows/WorkflowDocumentat const props = defineProps<{ versionId: string; workflowId: string; + workflowModeId?: string; }>(); const versionState = reactive<{ @@ -41,6 +42,22 @@ watch( }, ); +watch( + () => props.workflowModeId, + (newModeId, oldModeId) => { + if (newModeId !== oldModeId) { + WorkflowVersionService.workflowVersionDownloadWorkflowDocumentation( + props.workflowId, + props.versionId, + DocumentationEnum.PARAMETER_SCHEMA, + newModeId, + ).then((schema) => { + versionState.parameterSchema = schema; + }); + } + }, +); + function updateVersion(versionId: string, workflowId: string) { versionState.loading = true; versionState.fileLoading = true; @@ -84,8 +101,9 @@ function downloadVersionFiles(version: WorkflowVersion) { version.workflow_id, version.git_commit_hash, DocumentationEnum.PARAMETER_SCHEMA, - ).then((markdown) => { - versionState.parameterSchema = markdown; + props.workflowModeId, + ).then((schema) => { + versionState.parameterSchema = schema; }); const usagePromise = WorkflowVersionService.workflowVersionDownloadWorkflowDocumentation( diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue index 05e3ee2cfe6507f6fc40e63604ab74e8071ba375..32e1dc3b5d252431a8b55e2b469d10f739573e67 100644 --- a/src/views/workflows/WorkflowView.vue +++ b/src/views/workflows/WorkflowView.vue @@ -1,16 +1,18 @@ <script setup lang="ts"> import { computed, onMounted, reactive, watch } from "vue"; import type { + WorkflowModeOut, WorkflowOut, WorkflowStatistic, WorkflowVersion, } from "@/client/workflow"; -import WorkflowStatisticsChart from "@/components/workflows/WorkflowStatisticsChart.vue"; import { Status, + WorkflowModeService, WorkflowService, WorkflowVersionService, } from "@/client/workflow"; +import WorkflowStatisticsChart from "@/components/workflows/WorkflowStatisticsChart.vue"; import { useRoute, useRouter } from "vue-router"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import { @@ -27,6 +29,8 @@ const userRepository = useAuthStore(); const props = defineProps<{ workflowId: string; versionId?: string; + workflowModeId?: string; + developerView: boolean; }>(); // Constants @@ -42,12 +46,16 @@ const workflowState = reactive<{ activeVersionId: string; initialOpen: boolean; stats: WorkflowStatistic[]; + modeMapping: Record<string, WorkflowModeOut>; + activeModeId?: string; }>({ loading: true, workflow: undefined, - activeVersionId: "", + activeVersionId: props.versionId ?? "", initialOpen: true, stats: [], + modeMapping: {}, + activeModeId: props.workflowModeId, }); // Watchers @@ -68,6 +76,15 @@ watch( }, ); +watch( + () => props.workflowModeId, + (newModeId) => { + if (newModeId) { + workflowState.activeModeId = newModeId; + } + }, +); + watch( () => workflowState.activeVersionId, (newVersionId, oldVersionId) => { @@ -76,10 +93,38 @@ watch( newVersionId !== oldVersionId && route.name !== "workflow-start" ) { + // If mode does not exist in other version, select another mode + if ( + activeVersionModeIds.value.length > 0 && + activeVersionModeIds.value.findIndex( + (modeId) => modeId === workflowState.activeModeId, + ) == -1 + ) { + workflowState.activeModeId = activeVersionModeIds.value[0]; + } else if (activeVersionModeIds.value.length == 0) { + // If new version does not has any modes, then set mode id to none + workflowState.activeModeId = undefined; + } router.push({ name: "workflow-version", params: { versionId: newVersionId }, - query: { tab: route.query.tab }, + query: { + tab: route.query.tab, + workflowModeId: workflowState.activeModeId, + }, + }); + } + }, +); + +watch( + () => workflowState.activeModeId, + (newModeId, oldModeId) => { + if (newModeId != oldModeId) { + router.push({ + name: route.name ?? undefined, + params: { versionId: workflowState.activeVersionId }, + query: { tab: route.query.tab, workflowModeId: newModeId }, }); } }, @@ -101,6 +146,10 @@ const activeVersion = computed<WorkflowVersion | undefined>( ), ); +const activeVersionModeIds = computed<string[]>( + () => activeVersion.value?.modes ?? [], +); + const activeVersionString = computed<string>( () => activeVersion.value?.version ?? "", ); @@ -135,7 +184,10 @@ const allowVersionDeprecation = computed<boolean>(() => { // ============================================================================= function updateWorkflow(workflowId: string) { workflowState.loading = true; - WorkflowService.workflowGetWorkflow(workflowId) + WorkflowService.workflowGetWorkflow( + workflowId, + props.developerView ? Object.values(Status) : undefined, + ) .then((workflow) => { workflowState.workflow = workflow; if (!workflowState.initialOpen || !route.params.versionId) { @@ -144,6 +196,43 @@ function updateWorkflow(workflowId: string) { } else { workflowState.activeVersionId = route.params.versionId as string; } + workflowState.activeModeId = activeVersionModeIds.value[0] ?? undefined; + return workflow; + }) + .then( + ( + workflow, // map to mode ids + ) => workflow.versions.map((version) => version.modes).flat(), + ) + .then( + ( + modeIds, // filter mode ids + ) => + modeIds + .filter((modeId) => modeId != null) // filter null modes + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .filter((modeId) => !workflowState.modeMapping[modeId!]) + .filter( + // filter unique workflow versions + (modeId, index, array) => + array.findIndex((val) => val === modeId) === index, + ), + ) + .then( + ( + modeIds, // download workflow modes + ) => + Promise.all( + modeIds.map((modeId) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + WorkflowModeService.workflowModeGetWorkflowMode(modeId!), + ), + ), + ) + .then((modes) => { + for (const mode of modes) { + workflowState.modeMapping[mode.mode_id] = mode; + } }) .catch(() => { workflowState.workflow = undefined; @@ -214,7 +303,24 @@ onMounted(() => { alt="Workflow icon" /> </div> - <p class="fs-4 mb-5 mt-3">{{ workflowState.workflow.short_description }}</p> + <p class="fs-4 mt-3">{{ workflowState.workflow.short_description }}</p> + <div + v-if="activeVersionModeIds.length > 0" + class="row align-items-center mb-3 fs-5" + > + <label class="col-sm-1 col-form-label">Mode:</label> + <div class="col-sm-11"> + <select class="form-select w-fit" v-model="workflowState.activeModeId"> + <option + v-for="modeId of activeVersionModeIds" + :key="modeId" + :value="modeId" + > + {{ workflowState.modeMapping[modeId].name }} + </option> + </select> + </div> + </div> <template v-if="route.name !== 'workflow-start'"> <div v-if="!versionLaunchable" @@ -256,6 +362,9 @@ onMounted(() => { versionId: props.versionId, workflowId: props.workflowId, }, + query: { + workflowModeId: workflowState.activeModeId, + }, }" > <font-awesome-icon icon="fa-solid fa-rocket" class="me-2" />