diff --git a/src/components/FontAwesomeIcon.vue b/src/components/FontAwesomeIcon.vue index e0a4e3d16d54a67456f8e7d3064b6e9fe912799a..6c0400dbe5accfc0550be51c54e8c110596252ba 100644 --- a/src/components/FontAwesomeIcon.vue +++ b/src/components/FontAwesomeIcon.vue @@ -3,8 +3,6 @@ class="align-middle" :class="icon" :style="{ - width: props.width, - height: props.height, color: props.fill, }" ></div> @@ -13,8 +11,6 @@ <script setup lang="ts"> const props = defineProps({ icon: { type: String, required: true }, - width: { type: String, default: "1em", required: false }, - height: { type: String, default: "1em", required: false }, fill: { type: String, default: "currentColor", required: false }, }); </script> diff --git a/src/components/NavbarTop.vue b/src/components/NavbarTop.vue index b91aaf0818812c05f5e362bd1bd289642068624f..e878966ba393096c4aec9ef1a8270f6ff56a56f7 100644 --- a/src/components/NavbarTop.vue +++ b/src/components/NavbarTop.vue @@ -4,7 +4,6 @@ import { useAuthStore } from "@/stores/auth"; import { useRoute, useRouter } from "vue-router"; import { useCookies } from "vue3-cookies"; import { watch, ref, computed } from "vue"; -import type { ComputedRef } from "vue"; const router = useRouter(); const store = useAuthStore(); @@ -18,10 +17,10 @@ function logout() { } const activeRoute = ref(""); -const objectStorageActive: ComputedRef<boolean> = computed( +const objectStorageActive = computed<boolean>( () => activeRoute.value == "buckets" || activeRoute.value == "s3_keys" ); -const workflowActive: ComputedRef<boolean> = computed( +const workflowActive = computed<boolean>( () => activeRoute.value == "workflows" ); @@ -63,7 +62,7 @@ watch( <router-link class="navbar-brand ms-3" to="/"> <img src="/src/assets/images/denbi.svg" - alt="" + alt="Denbi Icon" width="24" height="24" class="d-inline-block align-text-top me-2" @@ -137,6 +136,9 @@ watch( >My Workflows</router-link > </li> + <li v-if="store.workflowReviewer || store.admin"> + <a class="dropdown-item" href="#">Reviews</a> + </li> </ul> </li> </ul> diff --git a/src/components/modals/DeleteModal.vue b/src/components/modals/DeleteModal.vue index 644cebf12fa0594e7792f83bec2da51f25b24a5a..439d0c3e1700839a5707199f5afc17aceb71a33b 100644 --- a/src/components/modals/DeleteModal.vue +++ b/src/components/modals/DeleteModal.vue @@ -1,16 +1,15 @@ <script setup lang="ts"> import { onMounted, ref } from "vue"; -import type { Ref } from "vue"; import { Modal } from "bootstrap"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; const props = defineProps<{ modalID: string; objectNameDelete: string; - backModalId: string | undefined; + backModalId?: string; }>(); -const confirmDelete: Ref<boolean> = ref(false); +const confirmDelete = ref<boolean>(false); const emit = defineEmits<{ (e: "confirm-delete"): void; }>(); diff --git a/src/components/modals/SearchUserModal.vue b/src/components/modals/SearchUserModal.vue index d5b5fcd691b82c255413f8b0272c92ee0d572758..44c2c8a948ca83b11cd02819e8273a309758dd36 100644 --- a/src/components/modals/SearchUserModal.vue +++ b/src/components/modals/SearchUserModal.vue @@ -8,24 +8,24 @@ import { useAuthStore } from "@/stores/auth"; const props = defineProps<{ modalID: string; - backModalId: string | undefined; + backModalId?: string; }>(); const randomIDSuffix = Math.random().toString(16).substr(2, 8); const store = useAuthStore(); -const formState = reactive({ - searchString: "", - potentialUsers: [], - lastSearchTimerId: null, - error: false, - loading: false, -} as { +const formState = reactive<{ searchString: string; potentialUsers: User[]; lastSearchTimerId: ReturnType<typeof setTimeout> | null; error: boolean; loading: boolean; +}>({ + searchString: "", + potentialUsers: [], + lastSearchTimerId: null, + error: false, + loading: false, }); watch( @@ -100,9 +100,7 @@ function searchUser(name: string) { <font-awesome-icon icon="fa-solid fa-x" class="mb-2" - width="56" - height="56" - style="color: var(--bs-danger)" + style="color: var(--bs-danger); font-size: 4em" /><br /> <span class="text-danger" >There seems to be an error<br />Try again later</span @@ -125,9 +123,7 @@ function searchUser(name: string) { <font-awesome-icon icon="fa-solid fa-magnifying-glass" class="mb-2" - width="56" - height="56" - style="color: var(--bs-secondary)" + style="color: var(--bs-secondary); font-size: 4em" /><br /> <span v-if="formState.searchString.length > 2" >Could not find any Users</span diff --git a/src/components/object-storage/BucketListItem.vue b/src/components/object-storage/BucketListItem.vue index 611015d8334f219a0d8427d2fa74ef3f062dd88a..00c40364c0d400a1e9dab89fbe61ea8aeb3ef751 100644 --- a/src/components/object-storage/BucketListItem.vue +++ b/src/components/object-storage/BucketListItem.vue @@ -10,7 +10,6 @@ import BucketDetailModal from "@/components/object-storage/modals/BucketDetailMo import dayjs from "dayjs"; import { filesize } from "filesize"; import { computed, onMounted } from "vue"; -import type { ComputedRef } from "vue"; import { Tooltip } from "bootstrap"; import { useBucketStore } from "@/stores/buckets"; import { useRouter } from "vue-router"; @@ -26,7 +25,7 @@ const randomIDSuffix = Math.random().toString(16).substr(2, 8); const permissionRepository = useBucketStore(); const router = useRouter(); -const permission: ComputedRef<BucketPermissionOut | undefined> = computed(() => +const permission = computed<BucketPermissionOut | undefined>(() => permissionRepository.getBucketPermission(props.bucket.name) ); diff --git a/src/components/object-storage/modals/CopyObjectModal.vue b/src/components/object-storage/modals/CopyObjectModal.vue index 09fb71ecb3a27d1db8979495e1452005099c7532..b7221031f678f5dcbebdd215a4c4341900a6dcdc 100644 --- a/src/components/object-storage/modals/CopyObjectModal.vue +++ b/src/components/object-storage/modals/CopyObjectModal.vue @@ -14,14 +14,14 @@ const props = defineProps<{ s3Client: S3Client; }>(); -const formState = reactive({ - destKey: "", - destBucket: "", - uploading: false, -} as { +const formState = reactive<{ destKey: string; destBucket: string; uploading: boolean; +}>({ + destKey: "", + destBucket: "", + uploading: false, }); const bucketRepository = useBucketStore(); diff --git a/src/components/object-storage/modals/CreateBucketModal.vue b/src/components/object-storage/modals/CreateBucketModal.vue index b1dd71a7fe8f5f5b46b57531079c1d25e83c7347..83b0f554de64265e8785fbb4b9eb09a7fe7dde6c 100644 --- a/src/components/object-storage/modals/CreateBucketModal.vue +++ b/src/components/object-storage/modals/CreateBucketModal.vue @@ -1,6 +1,6 @@ <script setup lang="ts"> import type { BucketIn } from "@/client/s3proxy"; -import { reactive, onMounted } from "vue"; +import { reactive, onMounted, computed, ref } from "vue"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import { useRouter } from "vue-router"; import { Modal } from "bootstrap"; @@ -8,15 +8,16 @@ import { useBucketStore } from "@/stores/buckets"; const router = useRouter(); const bucketRepository = useBucketStore(); -const bucket = reactive({ name: "", description: "" } as BucketIn); -const formState = reactive({ - validated: false, - bucketNameTaken: false, - loading: false, -} as { +const bucket = reactive<BucketIn>({ name: "", description: "" }); +const bucketCreateForm = ref<HTMLFormElement | undefined>(undefined); +const formState = reactive<{ validated: boolean; bucketNameTaken: boolean; loading: boolean; +}>({ + validated: false, + bucketNameTaken: false, + loading: false, }); const props = defineProps<{ @@ -29,14 +30,17 @@ onMounted(() => { createBucketModal = new Modal("#" + props.modalID); }); +const formValid = computed<boolean>( + () => bucketCreateForm.value?.checkValidity() ?? false +); + function createBucket() { formState.validated = true; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const form = document.getElementById("bucketCreateForm")! as HTMLFormElement; formState.bucketNameTaken = false; bucket.description = bucket.description.trim(); bucket.name = bucket.name.trim(); - if (form.checkValidity()) { + if (formValid.value) { formState.loading = true; bucketRepository.createBucket( bucket, @@ -85,6 +89,7 @@ function modalClosed() { id="bucketCreateForm" :class="{ 'was-validated': formState.validated }" novalidate + ref="bucketCreateForm" > <div class="mb-3"> <label for="bucketNameInput" class="form-label">Bucket Name</label> diff --git a/src/components/object-storage/modals/CreateFolderModal.vue b/src/components/object-storage/modals/CreateFolderModal.vue index ef0cf39a93a721e96c555b354387e03e27a8b917..433053548a88834652e4aae2aeb515b13d05865a 100644 --- a/src/components/object-storage/modals/CreateFolderModal.vue +++ b/src/components/object-storage/modals/CreateFolderModal.vue @@ -3,7 +3,6 @@ import type { S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand } from "@aws-sdk/client-s3"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import { computed, onMounted, reactive } from "vue"; -import type { ComputedRef } from "vue"; import type { S3ObjectMetaInformation } from "@/client/s3proxy"; import dayjs from "dayjs"; import { Modal, Toast } from "bootstrap"; @@ -20,20 +19,18 @@ let uploadModal: Modal | null = null; let successToast: Toast | null = null; let errorToast: Toast | null = null; -const currentFolders: ComputedRef<string[]> = computed(() => - props.keyPrefix.split("/") -); +const currentFolders = computed<string[]>(() => props.keyPrefix.split("/")); const emit = defineEmits<{ (e: "folder-created", object: S3ObjectMetaInformation): void; }>(); -const formState = reactive({ - folderName: "", - uploading: false, -} as { +const formState = reactive<{ folderName: string; uploading: boolean; +}>({ + folderName: "", + uploading: false, }); function uploadFolder() { diff --git a/src/components/object-storage/modals/PermissionListModal.vue b/src/components/object-storage/modals/PermissionListModal.vue index 37b2125381ff18dbe0f4e6bd70e7a2811b1a5e9d..ef67554bd6f4758550666f67eee240db566237de 100644 --- a/src/components/object-storage/modals/PermissionListModal.vue +++ b/src/components/object-storage/modals/PermissionListModal.vue @@ -18,7 +18,11 @@ const props = defineProps<{ // Reactive State // ----------------------------------------------------------------------------- -const state = reactive({ +const state = reactive<{ + permissions: BucketPermissionOut[]; + loading: boolean; + currentPermission: BucketPermissionOut; +}>({ permissions: [], loading: true, currentPermission: { @@ -27,10 +31,6 @@ const state = reactive({ permission: "READ", grantee_display_name: "display_name", }, -} as { - permissions: BucketPermissionOut[]; - loading: boolean; - currentPermission: BucketPermissionOut; }); const randomIDSuffix = Math.random().toString(16).substr(2, 8); diff --git a/src/components/object-storage/modals/PermissionModal.vue b/src/components/object-storage/modals/PermissionModal.vue index 82bfd4a9f3d8ef8eb4ad52ad1d383fed2e9e50cf..00a4663b4832a538dfb97adeb2e61f77c711fd8d 100644 --- a/src/components/object-storage/modals/PermissionModal.vue +++ b/src/components/object-storage/modals/PermissionModal.vue @@ -12,7 +12,6 @@ import type { } from "@/client/s3proxy"; import type { User } from "@/client/auth"; import type { FolderTree } from "@/types/PseudoFolder"; -import type { ComputedRef, Ref } from "vue"; import { Permission, BucketPermissionService } from "@/client/s3proxy"; import { Toast } from "bootstrap"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; @@ -23,11 +22,11 @@ const props = defineProps<{ modalID: string; bucketName: string; subFolders: FolderTree; - editUserPermission: BucketPermissionOut | undefined; + editUserPermission?: BucketPermissionOut; readonly: boolean; editable: boolean; deletable: boolean; - backModalId: string | undefined; + backModalId?: string; }>(); // Variables @@ -38,43 +37,48 @@ let successToast: Toast | null = null; // Reactive State // ----------------------------------------------------------------------------- -const formState = reactive({ - loading: false, - grantee_name: "", - error: false, - readonly: props.readonly, -} as { +const formState = reactive<{ loading: boolean; grantee_name: string; error: boolean; readonly: boolean; +}>({ + loading: false, + grantee_name: "", + error: false, + readonly: props.readonly, }); -const permission = reactive({ +const permission = reactive<BucketPermissionIn>({ from_timestamp: undefined, to_timestamp: undefined, file_prefix: undefined, permission: undefined, uid: "", bucket_name: props.bucketName, -} as BucketPermissionIn); +}); -const permissionDeleted: Ref<boolean> = ref(false); +const permissionDeleted = ref<boolean>(false); +const permissionForm = ref<HTMLFormElement | undefined>(undefined); // Computes Properties // ----------------------------------------------------------------------------- -const editPermission: ComputedRef<boolean> = computed( +const editPermission = computed<boolean>( () => props.editUserPermission != undefined ); -const possibleSubFolders: ComputedRef<string[]> = computed(() => +const possibleSubFolders = computed<string[]>(() => findSubFolders(props.subFolders, []) ); -const permissionUserReadonly: ComputedRef<boolean> = computed(() => { +const permissionUserReadonly = computed<boolean>(() => { return formState.readonly || editPermission.value; }); +const formValid = computed<boolean>( + () => permissionForm.value?.checkValidity() ?? false +); + // Watchers // ----------------------------------------------------------------------------- watch( @@ -122,7 +126,7 @@ function toastHidden() { * Check if an input should be visible based on its state * @param input Input which visibility should be determined. */ -function inputVisible(input: string | undefined): boolean { +function inputVisible(input?: string): boolean { return !formState.readonly || input != undefined; } @@ -185,11 +189,7 @@ function findSubFolders( */ function formSubmit() { formState.error = false; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const form = document.getElementById( - "permissionCreateEditForm" + randomIDSuffix - )! as HTMLFormElement; - if (form.checkValidity()) { + if (formValid.value) { const tempPermission: BucketPermissionIn = permission; if (permission.from_timestamp != null) { tempPermission.from_timestamp = @@ -352,6 +352,7 @@ onMounted(() => { <form @submit.prevent="formSubmit" :id="'permissionCreateEditForm' + randomIDSuffix" + ref="permissionForm" > <div class="mb-3 row"> <label for="bucketNameInput" class="col-2 col-form-label" diff --git a/src/components/object-storage/modals/UploadObjectModal.vue b/src/components/object-storage/modals/UploadObjectModal.vue index 5a4e8c4971321b49f74b4136eeb592ff4f630a38..59b00a5e6e2428678a3c4d04c3b61b29f1db4ccc 100644 --- a/src/components/object-storage/modals/UploadObjectModal.vue +++ b/src/components/object-storage/modals/UploadObjectModal.vue @@ -2,8 +2,7 @@ import type { S3Client } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; -import { computed, onMounted, reactive, watch } from "vue"; -import type { ComputedRef } from "vue"; +import { computed, onMounted, reactive, ref, watch } from "vue"; import type { S3ObjectMetaInformation } from "@/client/s3proxy"; import dayjs from "dayjs"; import { filesize } from "filesize"; @@ -14,17 +13,16 @@ const props = defineProps<{ bucketName: string; keyPrefix: string; s3Client: S3Client; - editObjectFileName: string | undefined; + editObjectFileName?: string; }>(); const randomIDSuffix = Math.random().toString(16).substr(2, 8); +const objectFileInput = ref<HTMLInputElement | undefined>(undefined); let uploadModal: Modal | null = null; let successToast: Toast | null = null; let errorToast: Toast | null = null; -const currentFolders: ComputedRef<string[]> = computed(() => - props.keyPrefix.split("/") -); +const currentFolders = computed<string[]>(() => props.keyPrefix.split("/")); const emit = defineEmits<{ (e: "object-created", object: S3ObjectMetaInformation): void; @@ -51,11 +49,11 @@ const formState = reactive({ uploadTotal: number; }); -const uploadProgress: ComputedRef<number> = computed(() => +const uploadProgress = computed<number>(() => Math.round((100 * formState.uploadDone) / formState.uploadTotal) ); -const editObject: ComputedRef<boolean> = computed( +const editObject = computed<boolean>( () => props.editObjectFileName !== undefined ); @@ -97,9 +95,9 @@ async function uploadObject() { content_type: formState.file?.type ?? "binary/octet-stream", }); formState.key = ""; - ( - document.getElementById("objectFile" + randomIDSuffix) as HTMLInputElement - ).value = ""; + if (objectFileInput.value != undefined) { + objectFileInput.value.value = ""; + } } catch (e) { console.error(e); errorToast?.show(); @@ -108,11 +106,13 @@ async function uploadObject() { } } -// eslint-disable-next-line -function fileChange(event: any) { - formState.file = event.target.files[0]; - if (!editObject.value) { - formState.key = formState.file.name; +function fileChange() { + if (objectFileInput.value != undefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + formState.file = objectFileInput.value.files![0]; + if (!editObject.value) { + formState.key = formState.file.name; + } } } @@ -212,6 +212,7 @@ onMounted(() => { class="form-control" type="file" :id="'objectFile' + randomIDSuffix" + ref="objectFileInput" required @change="fileChange" /> diff --git a/src/components/workflows/WorkflowCard.vue b/src/components/workflows/WorkflowCard.vue index 030081c85a42872ea497f0acf3f219b00e4642a1..1c2ac3a8cce1d74ad09c8ec68a25f5bf698d5e92 100644 --- a/src/components/workflows/WorkflowCard.vue +++ b/src/components/workflows/WorkflowCard.vue @@ -3,23 +3,18 @@ import type { WorkflowOut, WorkflowVersionReduced } from "@/client/workflow"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import dayjs from "dayjs"; import { onMounted, ref, computed } from "vue"; -import type { Ref, ComputedRef } from "vue"; import { Tooltip } from "bootstrap"; -import { useWorkflowStore } from "@/stores/workflows"; +import { latestVersion as calculateLatestVersion } from "@/utils/Workflow"; const props = defineProps<{ workflow: WorkflowOut; loading: boolean; }>(); -const workflowRepository = useWorkflowStore(); const randomIDSuffix: string = Math.random().toString(16).substr(2, 8); -const truncateDescription: Ref<boolean> = ref(true); -const latestVersion: ComputedRef<WorkflowVersionReduced | undefined> = computed( - () => - props.loading - ? undefined - : workflowRepository.latestVersion(props.workflow.workflow_id) +const truncateDescription = ref<boolean>(true); +const latestVersion = computed<WorkflowVersionReduced | undefined>(() => + calculateLatestVersion(props.workflow.versions) ); onMounted(() => { diff --git a/src/components/workflows/WorkflowWithVersionsCard.vue b/src/components/workflows/WorkflowWithVersionsCard.vue index cd399336c2bbb72ee164b2de9135a51bf3a3b90e..9ea8df454075e436ae091b7e1514b2e19c7a8ff7 100644 --- a/src/components/workflows/WorkflowWithVersionsCard.vue +++ b/src/components/workflows/WorkflowWithVersionsCard.vue @@ -1,16 +1,20 @@ <script setup lang="ts"> import type { WorkflowOut } from "@/client/workflow"; import { ref } from "vue"; -import type { Ref } from "vue"; import { Status } from "@/client/workflow"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import dayjs from "dayjs"; +import { sortedVersions } from "@/utils/Workflow"; const props = defineProps<{ workflow: WorkflowOut; loading: boolean; }>(); -const truncateDescription: Ref<boolean> = ref(true); +const truncateDescription = ref<boolean>(true); + +const emit = defineEmits<{ + (e: "workflow-update-click", workflow: WorkflowOut): void; +}>(); const statusToIconMapping: Record<string, string> = { PUBLISHED: "fa-solid fa-circle-check", @@ -34,6 +38,9 @@ const statusToIconMapping: Record<string, string> = { type="button" class="btn btn-success" :class="{ disabled: props.loading }" + @click="emit('workflow-update-click', props.workflow)" + data-bs-toggle="modal" + data-bs-target="#updateWorkflowModal" > Update </button> @@ -62,9 +69,7 @@ const statusToIconMapping: Record<string, string> = { <table class="table table-dark table-sm table-hover"> <tbody> <tr - v-for="version in [...props.workflow.versions].sort((a, b) => - dayjs(a.created_at).isBefore(b.created_at) ? 1 : -1 - )" + v-for="version in sortedVersions(props.workflow.versions)" :key="version.git_commit_hash" > <td class="w-fit"> diff --git a/src/components/workflows/modals/CreateWorkflowModal.vue b/src/components/workflows/modals/CreateWorkflowModal.vue index 9a4a96c54a423a86e7487b7353a08ba3e55f5245..f1a60f51908f14e339360e5d146ca36f47d01c6a 100644 --- a/src/components/workflows/modals/CreateWorkflowModal.vue +++ b/src/components/workflows/modals/CreateWorkflowModal.vue @@ -1,6 +1,5 @@ <script setup lang="ts"> import { computed, onMounted, reactive, ref } from "vue"; -import type { ComputedRef } from "vue"; import { Modal, Toast } from "bootstrap"; import type { Body_Workflow_create_workflow, @@ -9,11 +8,32 @@ import type { import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import { ApiError, WorkflowService } from "@/client/workflow"; -import { GitRepository } from "@/utils/GitRepository"; -import valid from "semver/functions/valid"; +import { + GitRepository, + requiredRepositoryFiles, + determineGitIcon, +} from "@/utils/GitRepository"; +import { valid } from "semver"; +// 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; + +// HTML Form Elements +// ============================================================================= const workflowCreateForm = ref<HTMLFormElement | undefined>(undefined); const workflowIconInput = ref<HTMLInputElement | undefined>(undefined); const workflowVersionElement = ref<HTMLInputElement | undefined>(undefined); @@ -22,8 +42,13 @@ const workflowGitCommitHashElement = ref<HTMLInputElement | undefined>( ); const workflowNameElement = ref<HTMLInputElement | undefined>(undefined); const workflowRepositoryElement = ref<HTMLInputElement | undefined>(undefined); + +// Constants +// ============================================================================= const randomIDSuffix = Math.random().toString(16).substr(2, 8); +// Reactive State +// ============================================================================= const workflow = reactive<Body_Workflow_create_workflow>({ icon: undefined, name: "", @@ -33,22 +58,6 @@ const workflow = reactive<Body_Workflow_create_workflow>({ initial_version: undefined, }); -const gitIcon: ComputedRef<string> = computed(() => { - let gitProvider = "git-alt"; - if (workflow.repository_url.includes("github")) { - gitProvider = "github"; - } else if (workflow.repository_url.includes("gitlab")) { - gitProvider = "gitlab"; - } else if (workflow.repository_url.includes("bitbucket")) { - gitProvider = "bitbucket"; - } - return "fa-brands fa-".concat(gitProvider); -}); - -const emit = defineEmits<{ - (e: "workflow-created", workflow: WorkflowOut): void; -}>(); - const formState = reactive<{ loading: boolean; checkRepoLoading: boolean; @@ -65,24 +74,33 @@ const formState = reactive<{ unsupportedRepository: false, }); -const props = defineProps<{ - modalID: string; -}>(); - -const formValid = computed<boolean>( - () => workflowCreateForm.value?.checkValidity() ?? false +// Computed Properties +// ============================================================================= +const gitIcon = computed<string>(() => + determineGitIcon(workflow.repository_url) ); +// Functions +// ============================================================================= function modalClosed() { formState.validated = false; + formState.allowUpload = false; + formState.missingFiles = []; + formState.unsupportedRepository = false; + workflowGitCommitHashElement.value?.setCustomValidity(""); + workflowRepositoryElement.value?.setCustomValidity(""); + workflowNameElement.value?.setCustomValidity(""); } +/** + * 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 (formValid.value && formState.allowUpload) { + if (workflowCreateForm.value?.checkValidity() && formState.allowUpload) { formState.loading = true; workflowNameElement.value?.setCustomValidity(""); workflowGitCommitHashElement.value?.setCustomValidity(""); @@ -109,8 +127,11 @@ function createWorkflow() { } } +/** + * Reset the form to an empty state. + */ function resetForm() { - formState.validated = false; + modalClosed(); workflow.icon = undefined; workflow.name = ""; workflow.short_description = ""; @@ -122,13 +143,19 @@ function resetForm() { } } +/** + * 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. + */ function checkRepository() { formState.validated = true; - if (formValid.value && !formState.allowUpload) { + if (workflowCreateForm.value?.checkValidity() && !formState.allowUpload) { formState.unsupportedRepository = false; formState.missingFiles = []; workflowRepositoryElement.value?.setCustomValidity(""); @@ -139,10 +166,7 @@ function checkRepository() { workflow.git_commit_hash ); repo - .checkFilesExist( - ["main.nf", "CHANGELOG.md", "README.md", "nextflow_schema.json"], - true - ) + .checkFilesExist(requiredRepositoryFiles, true) .then(() => { formState.allowUpload = true; }) @@ -161,6 +185,9 @@ function checkRepository() { } } +/** + * Check if the version is a valid semantic version + */ function checkVersionValidity() { if (valid(workflow.initial_version) == null) { workflowVersionElement.value?.setCustomValidity( @@ -171,6 +198,8 @@ function checkVersionValidity() { } } +// Lifecycle Events +// ============================================================================= onMounted(() => { createWorkflowModal = new Modal("#" + props.modalID); successToast = new Toast("#successToast-" + randomIDSuffix); diff --git a/src/components/workflows/modals/UpdateWorkflowModal.vue b/src/components/workflows/modals/UpdateWorkflowModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..897a48c87909c3dc8f2ae70617549db37ebf070c --- /dev/null +++ b/src/components/workflows/modals/UpdateWorkflowModal.vue @@ -0,0 +1,376 @@ +<script setup lang="ts"> +import { computed, onMounted, reactive, ref, watch } from "vue"; +import { Modal, Toast } from "bootstrap"; +import type { + Body_Workflow_update_workflow, + WorkflowOut, + WorkflowVersionFull, +} from "@/client/workflow"; +import BootstrapModal from "@/components/modals/BootstrapModal.vue"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import type { WorkflowVersionReduced, ApiError } from "@/client/workflow"; +import { WorkflowService } from "@/client/workflow"; +import { + GitRepository, + requiredRepositoryFiles, + determineGitIcon, +} from "@/utils/GitRepository"; +import { valid, lte, inc } from "semver"; +import { latestVersion as calculateLatestVersion } from "@/utils/Workflow"; + +// Bootstrap Elements +// ============================================================================= +let updateWorkflowModal: Modal | null = null; +let successToast: Toast | null = null; + +// Form Elements +// ============================================================================= +const workflowUpdateForm = ref<HTMLFormElement | undefined>(undefined); +const workflowIconInputElement = ref<HTMLInputElement | undefined>(undefined); +const workflowVersionElement = ref<HTMLInputElement | undefined>(undefined); +const workflowGitCommitHashElement = ref<HTMLInputElement | undefined>( + undefined +); +const workflowIconElement = ref<HTMLImageElement | undefined>(undefined); + +// Constants +// ============================================================================= +const randomIDSuffix = Math.random().toString(16).substr(2, 8); + +// Props +// ============================================================================= +const props = defineProps<{ + modalID: string; + workflow: WorkflowOut; +}>(); + +// Reactive State +// ============================================================================= +const workflowUpdate = reactive<Body_Workflow_update_workflow>({ + icon: undefined, + version: "", + git_commit_hash: "", +}); + +const formState = reactive<{ + validated: boolean; + missingFiles: string[]; + loading: boolean; + checkRepoLoading: boolean; + allowUpload: boolean; +}>({ + loading: false, + checkRepoLoading: false, + allowUpload: false, + validated: false, + missingFiles: [], +}); + +watch( + () => props.workflow, + () => { + resetForm(); + } +); + +// Computed Properties +// ============================================================================= +const latestVersion = computed<WorkflowVersionReduced>(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return calculateLatestVersion(props.workflow.versions)!; +}); +const gitIcon = computed<string>(() => + determineGitIcon(props.workflow.repository_url) +); + +const showIcon = computed<boolean>( + () => + latestVersion.value.icon_url != undefined || + workflowUpdate.icon != undefined +); + +// Emitted Events +// ============================================================================= +const emit = defineEmits<{ + (e: "workflow-updated", workflow: WorkflowVersionFull): void; +}>(); + +// Functions +// ============================================================================= +function iconChanged() { + workflowUpdate.icon = workflowIconInputElement.value?.files?.[0].slice(); + if (workflowUpdate.icon != undefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + workflowIconElement.value!.src = URL.createObjectURL( + workflowUpdate.icon.slice() + ); + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + workflowIconElement.value!.src = latestVersion.value.icon_url ?? ""; + } +} + +function modalClosed() { + formState.validated = false; + formState.missingFiles = []; + formState.allowUpload = false; + workflowGitCommitHashElement.value?.setCustomValidity(""); +} + +function checkVersionValidity() { + if (valid(workflowUpdate.version) == null) { + workflowVersionElement.value?.setCustomValidity( + "Please use semantic versioning" + ); + } else if (lte(workflowUpdate.version, latestVersion.value.version)) { + workflowVersionElement.value?.setCustomValidity( + "The new version must be greater than previous version" + ); + } else { + workflowVersionElement.value?.setCustomValidity(""); + } +} + +function checkRepository() { + formState.validated = true; + if (workflowUpdateForm.value?.checkValidity() && !formState.allowUpload) { + formState.missingFiles = []; + workflowGitCommitHashElement.value?.setCustomValidity(""); + const repo = GitRepository.buildRepository( + props.workflow.repository_url, + workflowUpdate.git_commit_hash + ); + repo + .checkFilesExist(requiredRepositoryFiles, true) + .then(() => { + formState.allowUpload = true; + }) + .catch((e: Error) => { + formState.missingFiles = e.message.split(","); + workflowGitCommitHashElement.value?.setCustomValidity( + "Files are missing in the repository" + ); + }); + } +} + +function resetForm() { + modalClosed(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + workflowIconElement.value!.src = latestVersion.value.icon_url ?? ""; + workflowUpdate.version = ""; + workflowUpdate.icon = undefined; + workflowUpdate.git_commit_hash = ""; + if (workflowIconInputElement.value != undefined) { + workflowIconInputElement.value.value = ""; + } +} + +function updateWorkflow() { + formState.validated = true; + workflowUpdate.version = workflowUpdate.version.trim(); + if (workflowUpdateForm.value?.checkValidity() && formState.allowUpload) { + formState.loading = true; + workflowGitCommitHashElement.value?.setCustomValidity(""); + WorkflowService.workflowUpdateWorkflow( + props.workflow.workflow_id, + workflowUpdate + ) + .then((version) => { + emit("workflow-updated", version); + successToast?.show(); + updateWorkflowModal?.hide(); + resetForm(); + }) + .catch((error: ApiError) => { + const errorText = error.body["detail"]; + if (errorText.startsWith("Workflow with git_commit_hash")) { + workflowGitCommitHashElement.value?.setCustomValidity( + "Git commit is already used by a workflow" + ); + } + }) + .finally(() => { + formState.loading = false; + }); + } +} + +// Lifecycle Events +// ============================================================================= +onMounted(() => { + updateWorkflowModal = new Modal("#" + props.modalID); + successToast = new Toast("#successToast-" + randomIDSuffix); +}); +</script> + +<template> + <div class="toast-container position-fixed top-toast end-0 p-3"> + <div + role="alert" + aria-live="assertive" + aria-atomic="true" + class="toast text-bg-success align-items-center border-0" + data-bs-autohide="true" + :id="'successToast-' + randomIDSuffix" + > + <div class="d-flex"> + <div class="toast-body">Successfully updated Workflow</div> + <button + type="button" + class="btn-close btn-close-white me-2 m-auto" + data-bs-dismiss="toast" + aria-label="Close" + ></button> + </div> + </div> + </div> + <bootstrap-modal + :modalID="modalID" + :static-backdrop="true" + modal-label="Update Workflow Modal" + v-on="{ 'hidden.bs.modal': modalClosed }" + > + <template v-slot:header> + Update Workflow + <span class="fw-bold">{{ props.workflow.name }}</span> + </template> + <template v-slot:body> + <form + id="workflowUpdateForm" + :class="{ 'was-validated': formState.validated }" + ref="workflowUpdateForm" + > + <div class="mb-3"> + <span class="me-3">Git Repository URL:</span> + <font-awesome-icon :icon="gitIcon" /> + <a + class="ms-2" + :href="props.workflow.repository_url" + target="_blank" + >{{ props.workflow.repository_url }}</a + > + <img + :src="latestVersion.icon_url" + ref="workflowIconElement" + class="float-end" + :hidden="!showIcon" + /> + </div> + <div class="mb-3"> + <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" + pattern="[0-9a-f]{40}" + v-model="workflowUpdate.git_commit_hash" + @change="formState.allowUpload = false" + /> + </div> + </div> + <div v-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 class="row mb-3"> + <div class="col-4"> + <label for="workflowVersionInput" class="form-label">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="workflowRepositoryInput" + :placeholder="inc(latestVersion.version, 'patch') ?? undefined" + maxlength="10" + required + ref="workflowVersionElement" + @change="checkVersionValidity" + v-model="workflowUpdate.version" + aria-describedby="versionHelp" + /> + </div> + <div id="versionHelp" class="form-text"> + Previous Version: {{ latestVersion.version }} + </div> + </div> + <div class="col-8"> + <label for="workflowIconInput" class="form-label" + >Optional Icon</label + > + <input + type="file" + ref="workflowIconInputElement" + accept="image/*" + class="form-control" + id="workflowIconInput" + @change="iconChanged" + aria-describedby="iconHelp" + /> + <div id="iconHelp" class="form-text"> + If not set, the previous icon will be used + </div> + </div> + </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="workflowUpdateForm" + class="btn btn-primary" + :disabled="formState.loading || !formState.allowUpload" + @click.prevent="updateWorkflow" + > + <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> +img { + max-height: 32px; + max-width: 32px; +} +</style> diff --git a/src/stores/workflows.ts b/src/stores/workflows.ts deleted file mode 100644 index 9a2df34df16bf4a2e52c73df7386642a977d5253..0000000000000000000000000000000000000000 --- a/src/stores/workflows.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { defineStore } from "pinia"; -import { WorkflowService } from "@/client/workflow"; -import type { WorkflowVersionReduced } from "@/client/workflow"; -import type { WorkflowOut } from "@/client/workflow"; -import dayjs from "dayjs"; - -export const useWorkflowStore = defineStore({ - id: "workflows", - state: () => - ({ - workflows: [], - } as { - workflows: WorkflowOut[]; - }), - getters: { - latestVersion(): ( - workflowId: string - ) => WorkflowVersionReduced | undefined { - return (workflowId) => { - const workflow = this.workflows.find( - (w) => workflowId == w.workflow_id - ); - if (workflow == null || workflow.versions.length == 0) { - return undefined; - } - const vs = [...workflow.versions]; - vs.sort((a, b) => - dayjs(a.created_at).isBefore(b.created_at) ? -1 : 1 - ); - return vs[vs.length - 1]; - }; - }, - }, - actions: { - fetchWorkflows( - onFulfilled: - | ((workflows: WorkflowOut[]) => void) - | null - | undefined = null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onRejected: ((reason: any) => void) | null | undefined = null, - onFinally: (() => void) | null | undefined = null - ) { - WorkflowService.workflowListWorkflows() - .then((workflows) => { - this.workflows = workflows; - onFulfilled?.(workflows); - }) - .catch(onRejected) - .finally(onFinally); - }, - }, -}); diff --git a/src/utils/GitRepository.ts b/src/utils/GitRepository.ts index 04e183394da94378cb1266ff70723436eb549b8b..74d25e0de8d840eec565d5dc25830449c71e7095 100644 --- a/src/utils/GitRepository.ts +++ b/src/utils/GitRepository.ts @@ -1,5 +1,26 @@ import axios from "axios"; +export const requiredRepositoryFiles = [ + "main.nf", + "CHANGELOG.md", + "README.md", + "nextflow_schema.json", +]; + +export function determineGitIcon(repo_url?: string): string { + let gitProvider = "git-alt"; + if (repo_url != null) { + if (repo_url.includes("github")) { + gitProvider = "github"; + } else if (repo_url.includes("gitlab")) { + gitProvider = "gitlab"; + } else if (repo_url.includes("bitbucket")) { + gitProvider = "bitbucket"; + } + } + return "fa-brands fa-".concat(gitProvider); +} + export abstract class GitRepository { protected repo: URL; protected gitCommitHash: string; diff --git a/src/utils/Workflow.ts b/src/utils/Workflow.ts new file mode 100644 index 0000000000000000000000000000000000000000..c37cab3b551cd8cf4b95597d3304910b83587b4c --- /dev/null +++ b/src/utils/Workflow.ts @@ -0,0 +1,40 @@ +import type { + WorkflowVersionReduced, + WorkflowVersionFull, +} from "@/client/workflow"; +import dayjs from "dayjs"; + +export function sortedVersions( + versions: WorkflowVersionFull[] +): WorkflowVersionFull[]; +export function sortedVersions( + versions: WorkflowVersionReduced[] +): WorkflowVersionReduced[]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function sortedVersions(versions: any[], desc = true): any[] { + const vs = [...versions]; + if (desc) { + vs.sort((a, b) => (dayjs(a.created_at).isBefore(b.created_at) ? 1 : -1)); + } else { + vs.sort((a, b) => (dayjs(a.created_at).isBefore(b.created_at) ? -1 : 1)); + } + return vs; +} + +export function latestVersion( + versions: WorkflowVersionFull[] +): WorkflowVersionFull | undefined; + +export function latestVersion( + versions: WorkflowVersionReduced[] +): WorkflowVersionReduced | undefined; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function latestVersion(versions: any[]): any | undefined { + if (versions == undefined || versions.length == 0) { + return undefined; + } + const vs = sortedVersions(versions); + return vs[0]; +} diff --git a/src/views/object-storage/BucketView.vue b/src/views/object-storage/BucketView.vue index fdb9a366d05a74a29050a73d409b7609961b2481..4ebcf3dd1150a5919b04f26b3b610d1a9e9c25af 100644 --- a/src/views/object-storage/BucketView.vue +++ b/src/views/object-storage/BucketView.vue @@ -1,6 +1,5 @@ <script setup lang="ts"> import { onMounted, reactive, watch, computed } from "vue"; -import type { ComputedRef } from "vue"; import type { S3ObjectMetaInformation, BucketPermissionOut, @@ -91,7 +90,7 @@ authStore.$onAction(({ name, args }) => { const props = defineProps<{ bucketName: string; subFolders: string[] | string; - permission: BucketPermissionOut | undefined; + permission?: BucketPermissionOut; }>(); const randomIDSuffix = Math.random().toString(16).substr(2, 8); let successToast: Toast | null = null; @@ -99,17 +98,27 @@ let successToast: Toast | null = null; // Reactive State // ----------------------------------------------------------------------------- -const deleteObjectsState = reactive({ - deletedItem: "", - potentialObjectToDelete: "", - deleteFolder: true, -} as { +const deleteObjectsState = reactive<{ deletedItem: string; potentialObjectToDelete: string; deleteFolder: boolean; +}>({ + deletedItem: "", + potentialObjectToDelete: "", + deleteFolder: true, }); -const objectState = reactive({ +const objectState = reactive<{ + objects: S3ObjectMetaInformation[]; + loading: boolean; + filterString: string; + bucketNotFoundError: boolean; + bucketPermissionError: boolean; + createdPermission: undefined | BucketPermissionOut; + editObjectKey: string; + copyObject: S3ObjectMetaInformation; + viewDetailObject: S3ObjectMetaInformation; +}>({ objects: [], loading: true, filterString: "", @@ -131,16 +140,6 @@ const objectState = reactive({ last_modified: "2022-01-01", content_type: "text/plain", }, -} as { - objects: S3ObjectMetaInformation[]; - loading: boolean; - filterString: string; - bucketNotFoundError: boolean; - bucketPermissionError: boolean; - createdPermission: undefined | BucketPermissionOut; - editObjectKey: string; - copyObject: S3ObjectMetaInformation; - viewDetailObject: S3ObjectMetaInformation; }); // Watcher @@ -158,16 +157,17 @@ watch( // Computed Properties // ----------------------------------------------------------------------------- -const filteredObjects: ComputedRef<(S3ObjectWithFolder | S3PseudoFolder)[]> = - computed(() => { +const filteredObjects = computed<(S3ObjectWithFolder | S3PseudoFolder)[]>( + () => { return objectState.filterString.length > 0 ? visibleObjects.value.filter((obj) => obj.key.includes(objectState.filterString) ) : visibleObjects.value; - }); + } +); -const folderStructure: ComputedRef<FolderTree> = computed(() => { +const folderStructure = computed<FolderTree>(() => { /** * Store the entire folder structure in a bucket in a tree-like data structure */ @@ -209,7 +209,7 @@ const folderStructure: ComputedRef<FolderTree> = computed(() => { ); }); -const objectsWithFolders: ComputedRef<S3ObjectWithFolder[]> = computed(() => { +const objectsWithFolders = computed<S3ObjectWithFolder[]>(() => { /** * Add to the meta information from objects the pseudo filename and their pseudo folder * This can be inferred from the key of the object where the '/' character is the delimiter, e.g. @@ -227,7 +227,7 @@ const objectsWithFolders: ComputedRef<S3ObjectWithFolder[]> = computed(() => { }); }); -const currentSubFolders: ComputedRef<string[]> = computed(() => { +const currentSubFolders = computed<string[]>(() => { /** * Transform a single sub folder from a string to an array containing the string and * replace an empty string with an empty list @@ -239,54 +239,53 @@ const currentSubFolders: ComputedRef<string[]> = computed(() => { : []; }); -const visibleObjects: ComputedRef<(S3ObjectWithFolder | S3PseudoFolder)[]> = - computed(() => { - /** - * Compute the visible objects based on the current sub folder - */ - let currentFolder = folderStructure.value; - // Navigate into right sub folder - for (const subFolder of currentSubFolders.value) { - if (currentFolder.subFolders[subFolder] == null) { - // If sub folder doesn't exist, no object is visible - return []; - } else { - currentFolder = currentFolder.subFolders[subFolder]; - } +const visibleObjects = computed<(S3ObjectWithFolder | S3PseudoFolder)[]>(() => { + /** + * Compute the visible objects based on the current sub folder + */ + let currentFolder = folderStructure.value; + // Navigate into right sub folder + for (const subFolder of currentSubFolders.value) { + if (currentFolder.subFolders[subFolder] == null) { + // If sub folder doesn't exist, no object is visible + return []; + } else { + currentFolder = currentFolder.subFolders[subFolder]; } - // Add all objects and sub folders from the current sub folder as visible object - const arr = []; - arr.push(...currentFolder.files); - arr.push( - ...Object.keys(currentFolder.subFolders).map((subFolderName) => { - const folderSize = calculateFolderSize( - currentFolder.subFolders[subFolderName] - ); - const folderLastModified = dayjs( - calculateFolderLastModified(currentFolder.subFolders[subFolderName]) - ).toISOString(); - return { - name: subFolderName, - size: folderSize, - key: subFolderName, - parentFolder: currentSubFolders.value, - last_modified: folderLastModified, - } as S3PseudoFolder; - }) - ); - return arr.filter((obj) => !obj.key.endsWith(".s3keep")); - }); + } + // Add all objects and sub folders from the current sub folder as visible object + const arr = []; + arr.push(...currentFolder.files); + arr.push( + ...Object.keys(currentFolder.subFolders).map((subFolderName) => { + const folderSize = calculateFolderSize( + currentFolder.subFolders[subFolderName] + ); + const folderLastModified = dayjs( + calculateFolderLastModified(currentFolder.subFolders[subFolderName]) + ).toISOString(); + return { + name: subFolderName, + size: folderSize, + key: subFolderName, + parentFolder: currentSubFolders.value, + last_modified: folderLastModified, + } as S3PseudoFolder; + }) + ); + return arr.filter((obj) => !obj.key.endsWith(".s3keep")); +}); -const subFolderInUrl: ComputedRef<boolean> = computed( +const subFolderInUrl = computed<boolean>( () => currentSubFolders.value.length > 0 ); -const errorLoadingObjects: ComputedRef<boolean> = computed( +const errorLoadingObjects = computed<boolean>( () => objectState.bucketPermissionError || objectState.bucketNotFoundError ); -const writableBucket: ComputedRef<boolean> = computed(() => +const writableBucket = computed<boolean>(() => bucketRepository.writableBucket(props.bucketName) ); -const readableBucket: ComputedRef<boolean> = computed(() => +const readableBucket = computed<boolean>(() => bucketRepository.readableBucket(props.bucketName) ); @@ -693,9 +692,7 @@ watch( <div v-if="objectState.bucketNotFoundError" class="text-center fs-2 mt-5"> <font-awesome-icon icon="fa-solid fa-magnifying-glass" - class="mb-3" - width="64" - height="64" + class="mb-3 fs-0" style="color: var(--bs-secondary)" /> <p> @@ -709,9 +706,7 @@ watch( > <font-awesome-icon icon="fa-solid fa-folder-xmark" - class="mb-3" - width="64" - height="64" + class="mb-3 fs-0" style="color: var(--bs-secondary)" /> <p>You don't have permission for this bucket</p> diff --git a/src/views/object-storage/BucketsView.vue b/src/views/object-storage/BucketsView.vue index 0db316eefa73bfd42697942865532ce0404b853f..f62c71472a525e3444ed4a7161fe9c483928f568 100644 --- a/src/views/object-storage/BucketsView.vue +++ b/src/views/object-storage/BucketsView.vue @@ -1,5 +1,4 @@ <script setup lang="ts"> -import type { ComputedRef } from "vue"; import { computed, onMounted, reactive } from "vue"; import type { BucketOut } from "@/client/s3proxy"; import { useRoute, useRouter } from "vue-router"; @@ -16,14 +15,14 @@ const router = useRouter(); const bucketRepository = useBucketStore(); const authStore = useAuthStore(); -const bucketsState = reactive({ - filterString: "", - potentialDeleteBucketName: "", - loading: true, -} as { +const bucketsState = reactive<{ loading: boolean; filterString: string; potentialDeleteBucketName: string; +}>({ + filterString: "", + potentialDeleteBucketName: "", + loading: true, }); let deleteModal: Modal | null = null; @@ -33,7 +32,7 @@ function fetchBuckets() { }); } -const filteredBuckets: ComputedRef<BucketOut[]> = computed(() => { +const filteredBuckets = computed<BucketOut[]>(() => { return bucketsState.filterString.length > 0 ? bucketRepository.buckets.filter((bucket) => bucket.name.includes(bucketsState.filterString) @@ -133,9 +132,7 @@ onMounted(() => { <div v-else class="text-center fs-2 mt-5"> <font-awesome-icon icon="fa-solid fa-magnifying-glass" - class="mb-2" - width="56" - height="56" + class="mb-2 fs-0" style="color: var(--bs-secondary)" /> <br /> @@ -165,16 +162,14 @@ onMounted(() => { <router-view></router-view> <div v-if="router.currentRoute.value.name === 'buckets'" - class="text-center fs-2 mt-5" + class="text-center mt-5" > <font-awesome-icon - icon="fa-solid fa-hand-back-point-up" + icon="fa-solid fa-hand-pointer" class="mb-5" - width="64" - height="64" - style="color: var(--bs-secondary)" + style="color: var(--bs-secondary); font-size: 5em" /> - <p>Click on a bucket to browse its content</p> + <p class="fs-2">Click on a bucket to browse its content</p> </div> </div> </div> diff --git a/src/views/object-storage/S3KeyView.vue b/src/views/object-storage/S3KeyView.vue index 7757fb5952eba5de62b67e32778c1b81c6542dcc..ed567a80fa4c3d177a9a3ef1d2729b9635938b01 100644 --- a/src/views/object-storage/S3KeyView.vue +++ b/src/views/object-storage/S3KeyView.vue @@ -1,6 +1,5 @@ <script setup lang="ts"> import type { S3Key } from "@/client/s3proxy"; -import type { Ref } from "vue"; import { ref, watch } from "vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import DeleteModal from "@/components/modals/DeleteModal.vue"; @@ -22,7 +21,7 @@ watch( } ); -const visibleSecret: Ref<boolean> = ref(false); +const visibleSecret = ref<boolean>(false); function deleteKeyTrigger() { if (props.deletable) { diff --git a/src/views/object-storage/S3KeysView.vue b/src/views/object-storage/S3KeysView.vue index 0ccd0f1b21a1b3f979fe4753ed58eafa4093f87e..f7b997e110a351f6520c36b2bf638749f0e11833 100644 --- a/src/views/object-storage/S3KeysView.vue +++ b/src/views/object-storage/S3KeysView.vue @@ -2,7 +2,6 @@ import S3KeyView from "@/views/object-storage/S3KeyView.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import { reactive, onMounted, computed } from "vue"; -import type { ComputedRef } from "vue"; import type { S3Key } from "@/client/s3proxy"; import { S3KeyService } from "@/client/s3proxy"; import { useAuthStore } from "@/stores/auth"; @@ -18,21 +17,19 @@ authStore.$onAction(({ name, args }) => { let successToast: Toast | null = null; -const keyState = reactive({ - keys: [], - activeKey: 0, - initialLoading: true, - deletedKey: "", -} as { +const keyState = reactive<{ keys: S3Key[]; activeKey: number; initialLoading: boolean; deletedKey: string; +}>({ + keys: [], + activeKey: 0, + initialLoading: true, + deletedKey: "", }); -const allowKeyDeletion: ComputedRef<boolean> = computed( - () => keyState.keys.length > 1 -); +const allowKeyDeletion = computed<boolean>(() => keyState.keys.length > 1); function refreshKeys(uid: string) { S3KeyService.s3KeyGetUserKeys(uid) diff --git a/src/views/workflows/ListWorkflowsView.vue b/src/views/workflows/ListWorkflowsView.vue index f1e0d6ef15a4bd20e26e51234024c058461b07e4..464e75d2f711f4e20cc14d373d9fda2dc6c81f0f 100644 --- a/src/views/workflows/ListWorkflowsView.vue +++ b/src/views/workflows/ListWorkflowsView.vue @@ -1,25 +1,24 @@ <script setup lang="ts"> import { computed, onMounted, reactive } from "vue"; -import type { ComputedRef } from "vue"; -import { useWorkflowStore } from "@/stores/workflows"; import type { WorkflowOut } from "@/client/workflow"; import WorkflowCard from "@/components/workflows/WorkflowCard.vue"; import CardTransitionGroup from "@/components/transitions/CardTransitionGroup.vue"; import dayjs from "dayjs"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import { WorkflowService } from "@/client/workflow"; -const workflowRepository = useWorkflowStore(); - -const workflowsState = reactive({ - loading: true, - filterString: "", - sortByAttribute: "name", - sortDesc: true, -} as { +const workflowsState = reactive<{ loading: boolean; filterString: string; sortByAttribute: string; sortDesc: boolean; + workflows: WorkflowOut[]; +}>({ + loading: true, + filterString: "", + sortByAttribute: "name", + sortDesc: true, + workflows: [], }); const filterFunctionMapping: Record< @@ -47,9 +46,9 @@ function filterWorkflowWithoutVersion(workflow: WorkflowOut): boolean { return workflow.versions.length > 0; } -const processedWorkflows: ComputedRef<WorkflowOut[]> = computed(() => { +const processedWorkflows = computed<WorkflowOut[]>(() => { return [ - ...workflowRepository.workflows.filter( + ...workflowsState.workflows.filter( (workflow) => filterWorkflowByString(workflow) && filterWorkflowWithoutVersion(workflow) @@ -59,12 +58,18 @@ const processedWorkflows: ComputedRef<WorkflowOut[]> = computed(() => { ); }); +function fetchWorkflows() { + WorkflowService.workflowListWorkflows() + .then((workflows) => { + workflowsState.workflows = workflows; + }) + .finally(() => { + workflowsState.loading = false; + }); +} + onMounted(() => { - workflowRepository.fetchWorkflows( - null, - null, - () => (workflowsState.loading = false) - ); + fetchWorkflows(); }); </script> @@ -131,19 +136,17 @@ onMounted(() => { : 'fa-solid fa-arrow-up-wide-short' " @click="workflowsState.sortDesc = !workflowsState.sortDesc" - class="fs-4 ms-3 cursor-pointer" + class="fs-5 ms-3 cursor-pointer" /> </div> <div v-if="!workflowsState.loading"> <div - v-if="workflowRepository.workflows.length === 0" + v-if="workflowsState.workflows.length === 0" class="text-center fs-2 mt-5" > <font-awesome-icon icon="fa-solid fa-x" - class="my-5" - width="75" - height="75" + class="my-5 fs-0" style="color: var(--bs-secondary)" /> <p>There are no workflows in the system. Please come again later.</p> @@ -154,9 +157,7 @@ onMounted(() => { > <font-awesome-icon icon="fa-solid fa-magnifying-glass" - class="my-5" - width="75" - height="75" + class="my-5 fs-0" style="color: var(--bs-secondary)" /> <p> diff --git a/src/views/workflows/MyWorkflowsView.vue b/src/views/workflows/MyWorkflowsView.vue index 079aa54799a1d01281e291bb8beb28f50ea99d03..94b8b0b15aa33cf2a4790a342b14d344a1535a16 100644 --- a/src/views/workflows/MyWorkflowsView.vue +++ b/src/views/workflows/MyWorkflowsView.vue @@ -1,21 +1,53 @@ <script setup lang="ts"> import { onMounted, reactive } from "vue"; +import type { WorkflowOut, WorkflowVersionFull } from "@/client/workflow"; import { Status, WorkflowService } from "@/client/workflow"; -import type { WorkflowOut } from "@/client/workflow"; import { useAuthStore } from "@/stores/auth"; import WorkflowWithVersionsCard from "@/components/workflows/WorkflowWithVersionsCard.vue"; import CreateWorkflowModal from "@/components/workflows/modals/CreateWorkflowModal.vue"; import CardTransitionGroup from "@/components/transitions/CardTransitionGroup.vue"; +import UpdateWorkflowModal from "@/components/workflows/modals/UpdateWorkflowModal.vue"; const userRepository = useAuthStore(); const workflowsState = reactive<{ workflows: WorkflowOut[]; loading: boolean; + updateWorkflow: WorkflowOut; }>({ workflows: [], loading: true, + updateWorkflow: { + short_description: "", + name: "", + versions: [ + { + version: "1.0.0", + created_at: "01.01.2023", + git_commit_hash: "", + status: Status.CREATED, + }, + ], + repository_url: "", + workflow_id: "", + }, }); +function workflowUpdateClick(workflow: WorkflowOut) { + workflowsState.updateWorkflow = workflow; +} + +function workflowUpdated(version: WorkflowVersionFull) { + workflowsState.workflows + .find((w) => w.workflow_id == version.workflow_id) + ?.versions.push({ + status: version.status, + git_commit_hash: version.git_commit_hash, + icon_url: version.icon_url, + created_at: version.created_at, + version: version.version, + }); +} + onMounted(() => { WorkflowService.workflowListWorkflows( undefined, @@ -36,6 +68,11 @@ onMounted(() => { modal-i-d="createWorkflowModal" @workflow-created="(w) => workflowsState.workflows.push(w)" /> + <update-workflow-modal + :workflow="workflowsState.updateWorkflow" + modal-i-d="updateWorkflowModal" + @workflow-updated="workflowUpdated" + /> <div class="d-flex justify-content-between align-items-center mt-5"> <div class="fs-1 w-fit">My Workflows</div> <button @@ -46,17 +83,26 @@ onMounted(() => { Create </button> </div> - <card-transition-group - v-if="!workflowsState.loading" - class="d-flex flex-wrap align-items-center justify-content-between mt-5" - > - <workflow-with-versions-card - v-for="workflow in workflowsState.workflows" - :key="workflow.workflow_id" - :workflow="workflow" - :loading="false" - /> - </card-transition-group> + <div v-if="!workflowsState.loading"> + <card-transition-group + v-if="workflowsState.workflows.length > 0" + class="d-flex flex-wrap align-items-center justify-content-between mt-5" + > + <workflow-with-versions-card + v-for="workflow in workflowsState.workflows" + :key="workflow.workflow_id" + :workflow="workflow" + :loading="false" + @workflow-update-click="workflowUpdateClick" + /> + </card-transition-group> + <div v-else class="text-center mt-5 fs-2"> + There are currently no workflows that you created.<br /> + <a href="#" data-bs-toggle="modal" data-bs-target="#createWorkflowModal" + >Create a new one</a + > + </div> + </div> <div v-else class="d-flex flex-wrap align-items-center justify-content-between mt-5" diff --git a/src/views/workflows/WorkflowVersionView.vue b/src/views/workflows/WorkflowVersionView.vue index 63e70256d39e7e6277389851ad95e11de252c5d7..9ffb18d2f806e318300a3410824feb094ac3026a 100644 --- a/src/views/workflows/WorkflowVersionView.vue +++ b/src/views/workflows/WorkflowVersionView.vue @@ -11,7 +11,15 @@ const props = defineProps<{ activeTab: string; }>(); -const versionState = reactive({ +const versionState = reactive<{ + loading: boolean; + fileLoading: boolean; + version: undefined | WorkflowVersionFull; + descriptionMarkdown: string; + changelogMarkdown: string; + errorLoading: boolean; + parameterSchema: Record<string, never>; +}>({ loading: true, fileLoading: true, version: undefined, @@ -19,13 +27,6 @@ const versionState = reactive({ descriptionMarkdown: "", changelogMarkdown: "", parameterSchema: {}, -} as { - loading: boolean; - fileLoading: boolean; - version: undefined | WorkflowVersionFull; - descriptionMarkdown: string; - changelogMarkdown: string; - parameterSchema: Record<string, never>; }); watch( @@ -57,10 +58,6 @@ function updateVersion(versionId: string, workflowId: string) { }); } -onMounted(() => { - updateVersion(props.versionId, props.workflowId); -}); - function downloadVersionFiles(version: WorkflowVersionFull) { versionState.fileLoading = true; const descriptionPromise = axios.get(version.readme_url).then((response) => { @@ -80,6 +77,10 @@ function downloadVersionFiles(version: WorkflowVersionFull) { } ); } + +onMounted(() => { + updateVersion(props.versionId, props.workflowId); +}); </script> <template> diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue index a89cc5d7b76560e73aa521a9b6d5d7e781ebc427..f8fd224a7a1d91f5aa3b06ae980460f9124acd1c 100644 --- a/src/views/workflows/WorkflowView.vue +++ b/src/views/workflows/WorkflowView.vue @@ -1,31 +1,43 @@ <script setup lang="ts"> -import type { ComputedRef } from "vue"; import { computed, onMounted, reactive, watch } from "vue"; import type { WorkflowOut, WorkflowVersionReduced } from "@/client/workflow"; import { Status, WorkflowService } from "@/client/workflow"; import { useRoute, useRouter } from "vue-router"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; -import dayjs from "dayjs"; - +import { + latestVersion as calculateLatestVersion, + sortedVersions, +} from "@/utils/Workflow"; +import { determineGitIcon } from "@/utils/GitRepository"; + +// Props +// ============================================================================= const props = defineProps<{ workflowId: string; - versionId: string | undefined; + versionId?: string; }>(); + +// Constants +// ============================================================================= const router = useRouter(); const route = useRoute(); -const workflowState = reactive({ - loading: true, - workflow: undefined, - activeVersionId: "", - initialOpen: true, -} as { +// Reactive State +// ============================================================================= +const workflowState = reactive<{ loading: boolean; workflow?: WorkflowOut; activeVersionId: string; initialOpen: boolean; +}>({ + loading: true, + workflow: undefined, + activeVersionId: "", + initialOpen: true, }); +// Watchers +// ============================================================================= watch( () => props.workflowId, (newWorkflowId, oldWorkflowId) => { @@ -55,21 +67,35 @@ watch( } ); -function calculateLatestVersion( - versions: WorkflowVersionReduced[] | undefined -): WorkflowVersionReduced | undefined { - if (versions == undefined || versions.length == 0) { - return undefined; - } - const vs = [...versions]; - vs.sort((a, b) => (dayjs(a.created_at).isBefore(b.created_at) ? -1 : 1)); - return vs[versions.length - 1]; -} +// Computed Properties +// ============================================================================= +const latestVersion = computed<WorkflowVersionReduced | undefined>(() => + calculateLatestVersion(workflowState.workflow?.versions || []) +); +const activeVersion = computed<WorkflowVersionReduced | undefined>(() => + workflowState.workflow?.versions.find( + (w) => w.git_commit_hash === workflowState.activeVersionId + ) +); + +const activeVersionString = computed<string>( + () => activeVersion.value?.version ?? "" +); + +const activeVersionIcon = computed<string | undefined>( + () => activeVersion.value?.icon_url +); -const latestVersion: ComputedRef<WorkflowVersionReduced | undefined> = computed( - () => calculateLatestVersion(workflowState.workflow?.versions) +const versionLaunchable = computed<boolean>( + () => activeVersion.value?.status == Status.PUBLISHED ?? false ); +const gitIcon = computed<string>(() => + determineGitIcon(workflowState.workflow?.repository_url) +); + +// Functions +// ============================================================================= function updateWorkflow(workflowId: string) { workflowState.loading = true; WorkflowService.workflowGetWorkflow(workflowId) @@ -91,39 +117,8 @@ function updateWorkflow(workflowId: string) { }); } -const activeVersion: ComputedRef<WorkflowVersionReduced | undefined> = computed( - () => - workflowState.workflow?.versions.find( - (w) => w.git_commit_hash === workflowState.activeVersionId - ) -); - -const activeVersionString: ComputedRef<string> = computed( - () => activeVersion.value?.version ?? "" -); - -const activeVersionIcon: ComputedRef<string | undefined> = computed( - () => activeVersion.value?.icon_url -); - -const versionLaunchable: ComputedRef<boolean> = computed( - () => activeVersion.value?.status == Status.PUBLISHED ?? false -); - -const gitIcon: ComputedRef<string> = computed(() => { - let gitProvider = "git-alt"; - if (workflowState.workflow !== undefined) { - if (workflowState.workflow.repository_url.includes("github")) { - gitProvider = "github"; - } else if (workflowState.workflow.repository_url.includes("gitlab")) { - gitProvider = "gitlab"; - } else if (workflowState.workflow.repository_url.includes("bitbucket")) { - gitProvider = "bitbucket"; - } - } - return "fa-brands fa-".concat(gitProvider); -}); - +// Lifecycle Events +// ============================================================================= onMounted(() => { updateWorkflow(props.workflowId); }); @@ -202,7 +197,7 @@ onMounted(() => { v-model="workflowState.activeVersionId" > <option - v-for="version in [...workflowState.workflow.versions].reverse()" + v-for="version in sortedVersions(workflowState.workflow?.versions)" :key="version.git_commit_hash" :value="version.git_commit_hash" > @@ -228,9 +223,7 @@ onMounted(() => { <div v-else class="text-center fs-1 mt-5"> <font-awesome-icon icon="fa-solid fa-magnifying-glass" - class="my-5" - width="85" - height="85" + class="my-5 fs-0" style="color: var(--bs-secondary)" /> <p class="my-5">