<script setup lang="ts"> import { computed, onMounted, reactive, ref, watch } from "vue"; import { Modal, Toast, Collapse, Tooltip } from "bootstrap"; import type { WorkflowIn, WorkflowOut, WorkflowModeOut } from "@/client"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import { ApiError } from "@/client"; import { GitRepository, requiredRepositoryFiles, determineGitIcon, } from "@/utils/GitRepository"; import { valid } from "semver"; import WorkflowModeTransitionGroup from "@/components/transitions/WorkflowModeTransitionGroup.vue"; import { useWorkflowStore } from "@/stores/workflows"; import BootstrapToast from "@/components/BootstrapToast.vue"; import dayjs from "dayjs"; const workflowRepository = useWorkflowStore(); // Emitted Events // ============================================================================= const emit = defineEmits<{ (e: "workflow-created", workflow: WorkflowOut): void; }>(); // Props // ============================================================================= const props = defineProps<{ modalId: string; }>(); // Bootstrap Elements // ============================================================================= 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 // ============================================================================= const workflowCreateForm = ref<HTMLFormElement | undefined>(undefined); const workflowVersionElement = ref<HTMLInputElement | undefined>(undefined); const workflowGitCommitHashElement = ref<HTMLInputElement | undefined>( undefined, ); const workflowNameElement = ref<HTMLInputElement | undefined>(undefined); const workflowRepositoryElement = ref<HTMLInputElement | undefined>(undefined); // Constants // ============================================================================= const randomIDSuffix = Math.random().toString(16).substring(2, 8); // Reactive State // ============================================================================= const workflow = reactive<WorkflowIn>({ name: "", short_description: "", repository_url: "", git_commit_hash: "", initial_version: undefined, token: undefined, modes: [], }); const formState = reactive<{ loading: boolean; checkRepoLoading: boolean; validated: boolean; allowUpload: boolean; missingFiles: string[]; unsupportedRepository: boolean; ratelimit_reset: number; }>({ validated: false, allowUpload: false, loading: false, checkRepoLoading: false, missingFiles: [], unsupportedRepository: false, ratelimit_reset: 0, }); const repositoryCredentials = reactive<{ token: string; privateRepo: boolean; }>({ token: "", 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>(() => determineGitIcon(workflow.repository_url), ); watch( () => repositoryCredentials.privateRepo, (show) => { if (show) { privateRepositoryCollapse?.show(); } else { privateRepositoryCollapse?.hide(); tokenHelpCollapse?.hide(); } }, ); watch( () => workflowModes.hasModes, (show) => { if (show) { workflowModesCollapse?.show(); } else { workflowModesCollapse?.hide(); } }, ); // Functions // ============================================================================= function modalClosed() { formState.validated = false; formState.allowUpload = false; formState.missingFiles = []; formState.unsupportedRepository = false; workflowGitCommitHashElement.value?.setCustomValidity(""); workflowRepositoryElement.value?.setCustomValidity(""); workflowNameElement.value?.setCustomValidity(""); tokenHelpCollapse?.hide(); } /** * Create a workflow in the backend. */ function createWorkflow() { formState.validated = true; workflow.name = workflow.name.trim(); workflow.short_description = workflow.short_description.trim(); workflow.initial_version = workflow.initial_version?.trim(); if (workflowCreateForm.value?.checkValidity() && formState.allowUpload) { formState.loading = true; workflowNameElement.value?.setCustomValidity(""); workflowGitCommitHashElement.value?.setCustomValidity(""); if ( repositoryCredentials.privateRepo && repositoryCredentials.token.length > 0 ) { 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, }; }); } workflowRepository .createWorkflow(workflow) .then((w) => { emit("workflow-created", w); successToast?.show(); createWorkflowModal?.hide(); resetForm(); }) .catch((error: ApiError) => { const errorText = error.body["detail"]; if (errorText.startsWith("Workflow with name")) { workflowNameElement.value?.setCustomValidity("Name is already taken"); } else if (errorText.startsWith("Workflow with git_commit_hash")) { workflowGitCommitHashElement.value?.setCustomValidity( "Git commit is already used by a workflow", ); } }) .finally(() => { formState.loading = false; }); } } /** * Reset the form to an empty state. */ function resetForm() { modalClosed(); workflow.name = ""; workflow.short_description = ""; workflow.repository_url = ""; 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(); } /** * Check the workflow repository for the necessary files. */ function checkRepository() { formState.validated = true; workflowRepositoryElement.value?.setCustomValidity(""); workflowGitCommitHashElement.value?.setCustomValidity(""); // remove trailing slash (/) workflow.repository_url = workflow.repository_url .trim() .replace(/(^\/+|\/+$)/g, ""); if (workflowCreateForm.value?.checkValidity() && !formState.allowUpload) { formState.unsupportedRepository = false; formState.missingFiles = []; try { const repo = GitRepository.buildRepository( workflow.repository_url, workflow.git_commit_hash, repositoryCredentials.privateRepo ? repositoryCredentials.token : undefined, ); const requiredFiles = requiredRepositoryFiles( workflowModes.hasModes ? workflowModes.modes : [], ); repo .checkFilesExist(requiredFiles, true) .then(() => { formState.allowUpload = true; }) .catch((e: Error) => { try { const headers = JSON.parse(e.message); formState.ratelimit_reset = parseInt(headers["x-ratelimit-reset"]); } catch { formState.missingFiles = e.message.split(","); workflowGitCommitHashElement.value?.setCustomValidity( "Files are missing in the repository", ); } }); } catch (e) { formState.unsupportedRepository = true; workflowRepositoryElement.value?.setCustomValidity( "Repository is not supported", ); } } } /** * Check if the version is a valid semantic version */ function checkVersionValidity() { if (valid(workflow.initial_version) == null) { workflowVersionElement.value?.setCustomValidity( "Please use semantic versioning", ); } else { workflowVersionElement.value?.setCustomValidity(""); } } 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(() => { createWorkflowModal = new Modal("#" + props.modalId); successToast = new Toast("#successToast-" + randomIDSuffix); privateRepositoryCollapse = new Collapse("#privateRepositoryCollapse", { toggle: false, }); 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); }); </script> <template> <bootstrap-toast :toast-id="'successToast-' + randomIDSuffix"> Successfully created Workflow </bootstrap-toast> <bootstrap-modal :modalId="modalId" :static-backdrop="true" modal-label="Create Workflow Modal" v-on="{ 'hidden.bs.modal': modalClosed }" size-modifier-modal="lg" > <template #header> Create new Workflow</template> <template #body> <form id="workflowCreateForm" :class="{ 'was-validated': formState.validated }" ref="workflowCreateForm" > <div class="mb-3"> <label for="workflowNameInput" class="form-label" >Workflow Name</label > <input type="text" class="form-control" id="workflowNameInput" placeholder="Short descriptive name" required ref="workflowNameElement" minlength="3" maxlength="64" v-model="workflow.name" /> </div> <div class="mb-3"> <label for="workflowDescriptionInput" class="form-label"> Short Description {{ workflow.short_description.length }} / 64 </label> <div class="input-group"> <textarea class="form-control" id="workflowDescriptionInput" required rows="3" minlength="64" maxlength="256" v-model="workflow.short_description" placeholder="Describe the purpose of the workflow in a few words." ></textarea> <div class="invalid-feedback"> Description needs to be at least 64 characters long. </div> </div> </div> <div class="mb-3"> <label for="createWorkflowRepositoryInput" class="form-label" >Git Repository URL</label > <div class="input-group"> <div class="input-group-text"> <font-awesome-icon :icon="gitIcon" /> </div> <input type="url" class="form-control" id="createWorkflowRepositoryInput" placeholder="https://..." required ref="workflowRepositoryElement" v-model="workflow.repository_url" @change="formState.allowUpload = false" aria-describedby="gitRepoProviderHelp" /> <div class="input-group-text hover-info" :id="'tooltip-url-' + randomIDSuffix" data-bs-toggle="tooltip" data-bs-title="The URL of the git repository containing the workflow" > <font-awesome-icon icon="fa-solid fa-circle-question" /> </div> </div> <div id="gitRepoProviderHelp" class="form-text"> We support GitHub and GitLab Repositories </div> <div class="text-danger"> <div v-if="formState.unsupportedRepository"> Repository is not supported </div> </div> </div> <div class="row mb-3"> <div class="col-8"> <label for="workflowGitCommitInput" class="form-label" >Git Commit Hash</label > <div class="input-group"> <div class="input-group-text"> <font-awesome-icon icon="fa-solid fa-code-commit" /> </div> <input type="text" class="form-control text-lowercase" id="workflowGitCommitInput" placeholder="ba8bcd9..." required ref="workflowGitCommitHashElement" maxlength="40" minlength="40" pattern="^[0-9a-f]+$" v-model="workflow.git_commit_hash" @change="formState.allowUpload = false" /> <div class="input-group-text hover-info" :id="'tooltip-commit-' + randomIDSuffix" data-bs-toggle="tooltip" data-bs-title="Hash of the Git commit used for the initial version" > <font-awesome-icon icon="fa-solid fa-circle-question" /> </div> </div> <div v-if="formState.ratelimit_reset > 0" class="text-danger"> Can't check GitHub repository because the default <a href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users" target="_blank" >rate-limit</a > for your IP address was exhausted <br /> Rate-limit resets {{ dayjs.unix(formState.ratelimit_reset).fromNow() }} </div> <div v-else-if="formState.missingFiles.length > 0" class="text-danger" > The following files are missing in the repository <ul> <li v-for="file in formState.missingFiles" :key="file"> {{ file }} </li> </ul> </div> </div> <div class="col-4"> <label for="createWorkflowVersionInput" class="form-label" >Initial Version</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="createWorkflowVersionInput" placeholder="v1.0.0" maxlength="10" ref="workflowVersionElement" @change="checkVersionValidity" v-model="workflow.initial_version" /> <div class="input-group-text hover-info" :id="'tooltip-version-' + randomIDSuffix" data-bs-toggle="tooltip" data-bs-title="Should follow semantic versioning" > <font-awesome-icon icon="fa-solid fa-circle-question" /> </div> </div> </div> </div> <div class="mb-3"> <div class="form-check fs-5"> <input class="form-check-input" type="checkbox" v-model="repositoryCredentials.privateRepo" id="privateRepositoryCheckbox" @change="formState.allowUpload = false" aria-controls="#privateRepositoryCollapse" /> <label class="form-check-label" for="privateRepositoryCheckbox"> Enable private Git Repository </label> </div> <div class="collapse" id="privateRepositoryCollapse"> <label for="createRepositoryTokenInput" class="form-label" >Token</label > <div class="input-group"> <div class="input-group-text"> <font-awesome-icon icon="fa-solid fa-key" /> </div> <input type="password" class="form-control" id="createRepositoryTokenInput" v-model="repositoryCredentials.token" @change="formState.allowUpload = false" :required="repositoryCredentials.privateRepo" aria-controls="#tokenHelpCollapse" /> <div class="input-group-text cursor-pointer hover-info" @click="tokenHelpCollapse?.toggle()" > <font-awesome-icon icon="fa-solid fa-circle-question" /> </div> </div> <div class="collapse" id="tokenHelpCollapse"> <div class="card card-body mt-3"> <h5>GitHub</h5> <p> For private GitHub repositories, CloWM needs a Personal Access Token (classic) with the scope <code>repo</code>.<br /> Read this <a target="_blank" href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic" >Tutorial</a > on how to create such a token. </p> <h5>GitLab</h5> <p> For private GitLab repositories, CloWM needs a Project Access Token with the <code>read_api</code> scope and at least <code>Reporter</code> role.<br /> Read this <a target="_blank" href="https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html#create-a-project-access-token" >Tutorial</a > on how to create such a token. </p> <p> Select a distant expiration date for both providers to ensure that there won't be any problems in the short future. </p> </div> </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> <button type="button" class="btn btn-info me-auto" @click="checkRepository" :disabled="formState.allowUpload" > <span v-if="formState.checkRepoLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true" ></span> Check Repository </button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> Close </button> <button type="submit" form="workflowCreateForm" class="btn btn-primary" :disabled="formState.loading || !formState.allowUpload" @click.prevent="createWorkflow" > <span v-if="formState.loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true" ></span> Save </button> </template> </bootstrap-modal> </template> <style scoped></style>