From 3ed5326ea18fd97ac2420a43f3393cba3c126a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Tue, 7 Mar 2023 17:02:27 +0100 Subject: [PATCH] Create page to list all workflows of a developer #43 --- src/App.vue | 5 + .../workflow/services/WorkflowService.ts | 5 +- src/components/NavbarTop.vue | 14 ++ src/components/modals/SearchUserModal.vue | 2 +- .../workflows/WorkflowWithVersionsCard.vue | 130 ++++++++++++++++++ src/router/index.ts | 6 + src/stores/auth.ts | 53 ++++++- src/stores/buckets.ts | 6 +- src/stores/workflows.ts | 12 +- src/views/object-storage/S3KeysView.vue | 14 +- src/views/workflows/MyWorkflowsView.vue | 63 +++++++++ src/views/workflows/WorkflowView.vue | 91 +++++++++--- 12 files changed, 364 insertions(+), 37 deletions(-) create mode 100644 src/components/workflows/WorkflowWithVersionsCard.vue create mode 100644 src/views/workflows/MyWorkflowsView.vue diff --git a/src/App.vue b/src/App.vue index 405ef89..bd04244 100644 --- a/src/App.vue +++ b/src/App.vue @@ -27,6 +27,11 @@ onBeforeMount(() => { ) { // redirect the user to the login page and preserve query params for login error message return { name: "login", query: to.query }; + } else if ( + to.meta.requiresDeveloperRole && + !(store.workflowDev || store.admin) + ) { + return { name: "dashboard" }; } }); }); diff --git a/src/client/workflow/services/WorkflowService.ts b/src/client/workflow/services/WorkflowService.ts index ba3fe73..38cff14 100644 --- a/src/client/workflow/services/WorkflowService.ts +++ b/src/client/workflow/services/WorkflowService.ts @@ -20,13 +20,15 @@ export class WorkflowService { * * Permission "workflow:list" required. * @param nameSubstring Filter workflows by a substring in their name. - * @param versionStatus Which versions of the workflow to include in the response. Permission 'workflow:list_filter required'. Default PUBLISHED and DEPRECATED. + * @param versionStatus Which versions of the workflow to include in the response. Permission 'workflow:list_filter required', unless 'developer_id' is provided and current user is developer, then only permission 'workflow:list' required. Default PUBLISHED and DEPRECATED. + * @param developerId Filter for workflow by developer. If current user is the same as developer ID, permission 'workflow:list' required, otherwise 'workflow:list_filter'. * @returns WorkflowOut Successful Response * @throws ApiError */ public static workflowListWorkflows( nameSubstring?: string, versionStatus?: Array<Status>, + developerId?: string, ): CancelablePromise<Array<WorkflowOut>> { return __request(OpenAPI, { method: 'GET', @@ -34,6 +36,7 @@ export class WorkflowService { query: { 'name_substring': nameSubstring, 'version_status': versionStatus, + 'developer_id': developerId, }, errors: { 400: `Error decoding JWT Token`, diff --git a/src/components/NavbarTop.vue b/src/components/NavbarTop.vue index 94a91a0..2c68c63 100644 --- a/src/components/NavbarTop.vue +++ b/src/components/NavbarTop.vue @@ -123,6 +123,20 @@ watch( <li> <a class="dropdown-item" href="#">Executions</a> </li> + <li + v-if=" + store.workflowDev || store.workflowReviewer || store.admin + " + > + <hr class="dropdown-divider" /> + </li> + <li v-if="store.workflowDev || store.admin"> + <router-link + class="dropdown-item" + :to="{ name: 'workflows-developer' }" + >My Workflows</router-link + > + </li> </ul> </li> </ul> diff --git a/src/components/modals/SearchUserModal.vue b/src/components/modals/SearchUserModal.vue index 658f095..9fb35d4 100644 --- a/src/components/modals/SearchUserModal.vue +++ b/src/components/modals/SearchUserModal.vue @@ -58,7 +58,7 @@ function searchUser(name: string) { UserService.userListUsers(name) .then((userSuggestions) => { formState.potentialUsers = userSuggestions.filter( - (user) => store.user?.uid != user.uid + (user) => store.currentUID != user.uid ); }) .catch((err) => { diff --git a/src/components/workflows/WorkflowWithVersionsCard.vue b/src/components/workflows/WorkflowWithVersionsCard.vue new file mode 100644 index 0000000..bd423d0 --- /dev/null +++ b/src/components/workflows/WorkflowWithVersionsCard.vue @@ -0,0 +1,130 @@ +<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 BootstrapIcon from "@/components/BootstrapIcon.vue"; +import dayjs from "dayjs"; + +const props = defineProps<{ + workflow: WorkflowOut; + loading: boolean; +}>(); +const truncateDescription: Ref<boolean> = ref(true); + +const statusToIconMapping: Record<string, string> = { + PUBLISHED: "check-circle-fill", + DENIED: "x-lg", + CREATED: "check-circle", + DEPRECATED: "archive-fill", +}; +</script> + +<template> + <div class="card-hover border border-secondary card text-bg-dark m-2"> + <div class="card-body"> + <div + class="card-title fs-3 d-flex justify-content-between align-items-center" + > + <div v-if="props.loading" class="placeholder-glow w-100"> + <span class="placeholder col-6"></span> + </div> + <span v-else class="text-truncate">{{ props.workflow.name }}</span> + <button + type="button" + class="btn btn-success" + :class="{ disabled: props.loading, placeholder: props.loading }" + > + Update + </button> + </div> + <p class="card-text" :class="{ 'text-truncate': truncateDescription }"> + <span v-if="props.loading" class="placeholder-glow" + ><span class="placeholder col-12"></span + ></span> + <span + v-else + @click="truncateDescription = false" + :class="{ + 'cursor-pointer': truncateDescription, + }" + >{{ props.workflow.short_description }}</span + > + </p> + <div> + <div v-if="props.loading" class="row placeholder-glow p-3"> + <span class="placeholder col-md-1"></span> + <span class="placeholder col-md-2 offset-md-1 bg-success"></span> + <span class="placeholder col-md-2 offset-md-3"></span> + <span class="placeholder col-md-1 offset-md-1 bg-primary"></span> + </div> + <div v-else> + <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 + )" + :key="version.git_commit_hash" + > + <td class="w-fit"> + <img + v-if="version.icon_url != null" + :src="version.icon_url" + /> + </td> + <th scope="row" class="fw-bold">{{ version.version }}</th> + <td + :class="{ + 'text-success': version.status === Status.PUBLISHED, + 'text-danger': version.status === Status.DENIED, + 'text-secondary': version.status === Status.CREATED, + 'text-warning': version.status === Status.DEPRECATED, + }" + > + <bootstrap-icon :icon="statusToIconMapping[version.status]" /> + {{ version.status }} + </td> + <td>{{ dayjs(version.created_at).format("DD.MM.YYYY") }}</td> + <td> + <router-link + class="w-fit mx-0" + :to="{ + name: 'workflow-version', + params: { + workflowId: props.workflow.workflow_id, + versionId: version.git_commit_hash, + }, + }" + >View</router-link + > + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.card-hover { + transition: transform 0.3s ease-out; +} + +@media (min-width: 992px) { + .card-hover { + transition: transform 0.3s ease-out; + width: 48%; + } +} + +.card-hover:hover { + transform: translate(0, -5px); +} +td > img { + max-width: 1em; + max-height: 1em; +} +</style> diff --git a/src/router/index.ts b/src/router/index.ts index f98facb..28865df 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -33,6 +33,12 @@ const router = createRouter({ name: "workflows", component: () => import("../views/workflows/ListWorkflowsView.vue"), }, + { + path: "developer/workflows", + name: "workflows-developer", + component: () => import("../views/workflows/MyWorkflowsView.vue"), + meta: { requiresDeveloperRole: true }, + }, { path: "workflows/:workflowId/", name: "workflow", diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 3926c95..6c52e59 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -7,39 +7,79 @@ import { OpenAPI as S3ProxyOpenAPI } from "@/client/s3proxy"; import { OpenAPI as AuthOpenAPI } from "@/client/auth"; import { OpenAPI as WorkflowOpenAPI } from "@/client/workflow"; +type DecodedToken = { + exp: number; + iss: string; + roles: RoleEnum[]; + sub: string; +}; + export type RootState = { token: string | null; + decodedToken: DecodedToken | null; user: User | null; s3key: S3Key | null; }; +function parseJwt(token: string): DecodedToken { + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + window + .atob(base64) + .split("") + .map(function (c) { + return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join("") + ); + + return JSON.parse(jsonPayload) as DecodedToken; +} + export const useAuthStore = defineStore({ id: "auth", state: () => ({ token: null, + decodedToken: null, user: null, s3key: null, } as RootState), getters: { authenticated: (state) => state.token != null, + currentUID(): string { + return this.decodedToken?.["sub"] ?? ""; + }, foreignUser: (state) => - state.user?.roles?.includes(RoleEnum.FOREIGN_USER) ?? true, - normalUser: (state) => state.user?.roles?.includes(RoleEnum.USER) ?? false, + state.user?.roles?.includes(RoleEnum.FOREIGN_USER) ?? + state.decodedToken?.roles.includes(RoleEnum.FOREIGN_USER) ?? + true, + normalUser: (state) => + state.user?.roles?.includes(RoleEnum.USER) ?? + state.decodedToken?.roles.includes(RoleEnum.USER) ?? + false, workflowReviewer: (state) => - state.user?.roles?.includes(RoleEnum.REVIEWER) ?? false, + state.user?.roles?.includes(RoleEnum.REVIEWER) ?? + state.decodedToken?.roles.includes(RoleEnum.REVIEWER) ?? + false, workflowDev: (state) => - state.user?.roles?.includes(RoleEnum.DEVELOPER) ?? false, + state.user?.roles?.includes(RoleEnum.DEVELOPER) ?? + state.decodedToken?.roles.includes(RoleEnum.DEVELOPER) ?? + false, admin: (state) => - state.user?.roles?.includes(RoleEnum.ADMINISTRATOR) ?? false, + state.user?.roles?.includes(RoleEnum.ADMINISTRATOR) ?? + state.decodedToken?.roles.includes(RoleEnum.ADMINISTRATOR) ?? + false, }, actions: { setToken(token: string | null) { if (token != null) { + this.token = token; + this.decodedToken = parseJwt(token); S3ProxyOpenAPI.TOKEN = token; AuthOpenAPI.TOKEN = token; WorkflowOpenAPI.TOKEN = token; - this.token = token; UserService.userGetLoggedInUser() .then((user) => { this.updateUser(user); @@ -49,6 +89,7 @@ export const useAuthStore = defineStore({ }); } else { this.token = null; + this.decodedToken = null; this.user = null; } }, diff --git a/src/stores/buckets.ts b/src/stores/buckets.ts index b12c2be..e3ab60b 100644 --- a/src/stores/buckets.ts +++ b/src/stores/buckets.ts @@ -103,9 +103,9 @@ export const useBucketStore = defineStore({ onFinally: (() => void) | null | undefined = null ) { const authStore = useAuthStore(); - if (authStore.user != null && !authStore.foreignUser) { + if (authStore.authenticated && !authStore.foreignUser) { BucketPermissionService.bucketPermissionListPermissionsPerUser( - authStore.user.uid + authStore.currentUID ) .then((permissions) => { const new_permissions: Record<string, BucketPermissionOut> = {}; @@ -132,7 +132,7 @@ export const useBucketStore = defineStore({ onFinally: (() => void) | null | undefined = null ) { const authStore = useAuthStore(); - BucketService.bucketListBuckets(authStore.user?.uid) + BucketService.bucketListBuckets(authStore.currentUID) .then((buckets) => { this.buckets = buckets; onFulfilled?.(buckets); diff --git a/src/stores/workflows.ts b/src/stores/workflows.ts index a15e6c1..9a2df34 100644 --- a/src/stores/workflows.ts +++ b/src/stores/workflows.ts @@ -2,6 +2,7 @@ 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", @@ -19,9 +20,14 @@ export const useWorkflowStore = defineStore({ const workflow = this.workflows.find( (w) => workflowId == w.workflow_id ); - return workflow?.versions[ - Math.max(workflow?.versions?.length - 1 ?? 0, 0) - ]; + 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]; }; }, }, diff --git a/src/views/object-storage/S3KeysView.vue b/src/views/object-storage/S3KeysView.vue index 4c09afb..3d0e964 100644 --- a/src/views/object-storage/S3KeysView.vue +++ b/src/views/object-storage/S3KeysView.vue @@ -47,8 +47,8 @@ function refreshKeys(uid: string) { } function deleteKey(accessKey: string) { - if (allowKeyDeletion.value && authStore.user != null) { - S3KeyService.s3KeyDeleteUserKey(accessKey, authStore.user.uid) + if (allowKeyDeletion.value && authStore.authenticated) { + S3KeyService.s3KeyDeleteUserKey(accessKey, authStore.currentUID) .then(() => { keyState.deletedKey = accessKey; keyState.activeKey = 0; @@ -63,8 +63,8 @@ function deleteKey(accessKey: string) { } function createKey() { - if (authStore.user != null) { - S3KeyService.s3KeyCreateUserKey(authStore.user.uid) + if (authStore.authenticated) { + S3KeyService.s3KeyCreateUserKey(authStore.currentUID) .then((s3key) => { keyState.keys.push(s3key); keyState.keys = [...keyState.keys].sort((keyA, keyB) => @@ -77,8 +77,8 @@ function createKey() { onMounted(() => { successToast = new Toast("#successKeyToast"); - if (authStore.user != null) { - refreshKeys(authStore.user.uid); + if (authStore.authenticated) { + refreshKeys(authStore.currentUID); } }); </script> @@ -116,7 +116,7 @@ onMounted(() => { <button type="button" class="btn btn-light" - @click="refreshKeys(authStore.user?.uid ?? 'impossible')" + @click="refreshKeys(authStore.currentUID)" > <bootstrap-icon icon="arrow-clockwise" class="fs-5" /> <span class="visually-hidden">Refresh S3 Keys</span> diff --git a/src/views/workflows/MyWorkflowsView.vue b/src/views/workflows/MyWorkflowsView.vue new file mode 100644 index 0000000..7f091d3 --- /dev/null +++ b/src/views/workflows/MyWorkflowsView.vue @@ -0,0 +1,63 @@ +<script setup lang="ts"> +import { onMounted, reactive } from "vue"; +import { Status, WorkflowService } from "@/client/workflow"; +import type { WorkflowOut } from "@/client/workflow"; +import { useAuthStore } from "@/stores/auth"; +import WorkflowWithVersionsCard from "@/components/workflows/WorkflowWithVersionsCard.vue"; + +const userRepository = useAuthStore(); +const workflowsState = reactive<{ + workflows: WorkflowOut[]; + loading: boolean; +}>({ + workflows: [], + loading: true, +}); + +onMounted(() => { + WorkflowService.workflowListWorkflows( + undefined, + Object.values(Status), + userRepository.currentUID + ) + .then((workflows) => { + workflowsState.workflows = workflows; + }) + .finally(() => { + workflowsState.loading = false; + }); +}); +</script> + +<template> + <h1 class="mt-5">My Workflows</h1> + <div + 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" + /> + </div> + <div + v-else + class="d-flex flex-wrap align-items-center justify-content-between mt-5" + > + <workflow-with-versions-card + v-for="n in 4" + :key="n" + :workflow="{ + workflow_id: '', + versions: [], + name: '', + repository_url: '', + short_description: '', + }" + loading + /> + </div> +</template> +<style scoped></style> diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue index a13beb3..bf1eaeb 100644 --- a/src/views/workflows/WorkflowView.vue +++ b/src/views/workflows/WorkflowView.vue @@ -1,13 +1,15 @@ <script setup lang="ts"> -import { computed, onMounted, reactive, watch } from "vue"; import type { ComputedRef } from "vue"; -import type { WorkflowOut } from "@/client/workflow"; -import { WorkflowService } from "@/client/workflow"; +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 BootstrapIcon from "@/components/BootstrapIcon.vue"; +import dayjs from "dayjs"; const props = defineProps<{ workflowId: string; + versionId: string | undefined; }>(); const router = useRouter(); const route = useRoute(); @@ -16,10 +18,12 @@ const workflowState = reactive({ loading: true, workflow: undefined, activeVersionId: "", + initialOpen: true, } as { loading: boolean; workflow?: WorkflowOut; activeVersionId: string; + initialOpen: boolean; }); watch( @@ -31,6 +35,13 @@ watch( } ); +watch( + () => props.versionId, + (newWorkflowId) => { + workflowState.activeVersionId = newWorkflowId ?? ""; + } +); + watch( () => workflowState.activeVersionId, (newVersionId, oldVersionId) => { @@ -44,35 +55,60 @@ 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]; +} + +const latestVersion: ComputedRef<WorkflowVersionReduced | undefined> = computed( + () => calculateLatestVersion(workflowState.workflow?.versions) +); + function updateWorkflow(workflowId: string) { workflowState.loading = true; WorkflowService.workflowGetWorkflow(workflowId) .then((workflow) => { workflowState.workflow = workflow; - workflowState.activeVersionId = - workflow.versions[workflow.versions.length - 1].git_commit_hash; + if (!workflowState.initialOpen) { + workflowState.activeVersionId = + workflow.versions[workflow.versions.length - 1].git_commit_hash; + } else { + workflowState.activeVersionId = route.params.versionId as string; + } }) .catch(() => { workflowState.workflow = undefined; }) .finally(() => { workflowState.loading = false; + workflowState.initialOpen = false; }); } -const activeVersionString: ComputedRef<string> = computed(() => { - return ( +const activeVersion: ComputedRef<WorkflowVersionReduced | undefined> = computed( + () => workflowState.workflow?.versions.find( (w) => w.git_commit_hash === workflowState.activeVersionId - )?.version ?? "" - ); -}); + ) +); -const activeVersionIcon: ComputedRef<string | undefined> = computed(() => { - return workflowState.workflow?.versions.find( - (w) => w.git_commit_hash === workflowState.activeVersionId - )?.icon_url; -}); +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 +); onMounted(() => { updateWorkflow(props.workflowId); @@ -110,8 +146,31 @@ onMounted(() => { /></a> </div> <p class="fs-4 mb-5 mt-3">{{ workflowState.workflow.short_description }}</p> + <div + v-if="!versionLaunchable" + class="alert alert-warning w-fit mx-auto" + role="alert" + > + This version can not be used. + <router-link + v-if="latestVersion != null" + class="alert-link" + :to="{ + name: 'workflow-version', + params: { + versionId: latestVersion.git_commit_hash, + }, + }" + >Try the latest version {{ latestVersion?.version }}.</router-link + > + </div> <div class="row align-items-center"> - <a role="button" class="btn btn-success btn-lg w-fit mx-auto" href="#"> + <a + role="button" + class="btn btn-success btn-lg w-fit mx-auto" + :class="{ disabled: !versionLaunchable }" + href="#" + > <bootstrap-icon icon="rocket-takeoff-fill" class="me-2" /> <span class="align-middle">Launch {{ activeVersionString }}</span> </a> -- GitLab