Skip to content
Snippets Groups Projects
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>