-
Daniel Göbel authored
#129
Daniel Göbel authored#129
CreateWorkflowModal.vue 22.98 KiB
<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>