From bc3355d745a6acbbf4e43fc99a0a495008093dd3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Tue, 27 Aug 2024 13:57:56 +0000
Subject: [PATCH] Add UI to manage OTRS for workflows

#145
---
 src/client/services/WorkflowService.ts        |   2 +-
 src/components/modals/ShowOtrModal.vue        |   8 +-
 .../workflows/WorkflowWithVersionsCard.vue    |  47 ++++----
 .../workflows/modals/CreateWorkflowModal.vue  |  19 ++--
 src/stores/otrs.ts                            |  42 ++++---
 src/stores/workflows.ts                       |  18 +--
 src/views/object-storage/BucketsView.vue      |   6 +-
 src/views/workflows/MyWorkflowsView.vue       | 103 ++++++++++++++++--
 8 files changed, 180 insertions(+), 65 deletions(-)

diff --git a/src/client/services/WorkflowService.ts b/src/client/services/WorkflowService.ts
index 05e48cb..f1cc9bf 100644
--- a/src/client/services/WorkflowService.ts
+++ b/src/client/services/WorkflowService.ts
@@ -245,7 +245,7 @@ export class WorkflowService {
      * @returns OwnershipTransferRequestOut Successful Response
      * @throws ApiError
      */
-    public static workflowCreateBucketOtr(
+    public static workflowCreateWorkflowOtr(
         wid: string,
         requestBody: OwnershipTransferRequestIn,
     ): CancelablePromise<OwnershipTransferRequestOut> {
diff --git a/src/components/modals/ShowOtrModal.vue b/src/components/modals/ShowOtrModal.vue
index ec34501..478a51d 100644
--- a/src/components/modals/ShowOtrModal.vue
+++ b/src/components/modals/ShowOtrModal.vue
@@ -65,13 +65,9 @@ onMounted(() => {
     color-class="success"
   >
     <template #default v-if="accepted">
-      Successfully accepted request for {{ otr?.target_type }}
-      {{ otr?.target_name }}
-    </template>
-    <template #default v-else>
-      Successfully deleted request for {{ otr?.target_type }}
-      {{ otr?.target_name }}
+      Successfully accepted request
     </template>
+    <template #default v-else> Successfully deleted request </template>
   </bootstrap-toast>
   <delete-modal
     :modal-id="`delete-otr-modal-${randomIDSuffix}`"
diff --git a/src/components/workflows/WorkflowWithVersionsCard.vue b/src/components/workflows/WorkflowWithVersionsCard.vue
index 0e76c9d..4c5366c 100644
--- a/src/components/workflows/WorkflowWithVersionsCard.vue
+++ b/src/components/workflows/WorkflowWithVersionsCard.vue
@@ -5,14 +5,17 @@ import type {
   WorkflowVersion,
 } from "@/client";
 import { WorkflowVersionStatus, WorkflowExecutionStatus } from "@/client";
-import { computed, onMounted, ref, watch } from "vue";
+import { computed, onMounted, ref } from "vue";
 import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
 import dayjs from "dayjs";
 import { sortedVersions } from "@/utils/Workflow";
 import { Tooltip } from "bootstrap";
 import { useWorkflowExecutionStore } from "@/stores/workflowExecutions";
+import { useOTRStore } from "@/stores/otrs";
 
 const workflowExecutionRepository = useWorkflowExecutionStore();
+const otrRepository = useOTRStore();
+
 const props = defineProps<{
   workflow: WorkflowOut;
   loading: boolean;
@@ -25,17 +28,10 @@ const emit = defineEmits<{
   (e: "workflow-delete-click", workflow: WorkflowOut): void;
   (e: "workflow-update-credentials-click", workflow: WorkflowOut): void;
   (e: "workflow-update-icon-click", version: WorkflowVersion): void;
+  (e: "show-otr-click", workflowId: string): void;
+  (e: "create-otr-click", workflow: WorkflowOut): void;
 }>();
 
-watch(
-  () => props.loading,
-  (loading) => {
-    if (!loading && props.workflow.private) {
-      new Tooltip(`#tooltip-${randomIDSuffix}`);
-    }
-  },
-);
-
 const workflowExecutions = computed<AnonymizedWorkflowExecution[]>(() =>
   workflowExecutionRepository.anonymizedExecutions.filter(
     (execution) => execution.workflow_id == props.workflow.workflow_id,
@@ -89,9 +85,6 @@ function uniqueUsers(executions: AnonymizedWorkflowExecution[]): number {
 
 onMounted(() => {
   if (!props.loading) {
-    if (props.workflow.private) {
-      new Tooltip(`#tooltip-${randomIDSuffix}`);
-    }
     document
       .querySelector("#workflow-card-" + randomIDSuffix)
       ?.querySelectorAll('[data-bs-toggle="tooltip"]')
@@ -120,9 +113,14 @@ onMounted(() => {
             v-if="props.workflow.private"
             icon="fa-solid fa-lock"
             class="fs-5 ms-2 tooltip-private-repository"
-            :id="'tooltip-' + randomIDSuffix"
-            data-bs-toggle="tooltip"
-            data-bs-title="Private Git repository"
+            tooltip="Private Git repository"
+          />
+          <font-awesome-icon
+            v-if="otrRepository.otrMapping[workflow.workflow_id]"
+            icon="fa-solid fa-people-arrows"
+            class="fs-5 ms-2 cursor-pointer hover-info"
+            tooltip="Ownership transfer requested"
+            @click="emit('show-otr-click', workflow.workflow_id)"
           />
         </div>
         <div class="btn-group">
@@ -130,7 +128,7 @@ onMounted(() => {
             type="button"
             class="btn btn-outline-success"
             :class="{ disabled: props.loading }"
-            @click="emit('workflow-update-click', props.workflow)"
+            @click="emit('workflow-update-click', workflow)"
             data-bs-toggle="modal"
             data-bs-target="#updateWorkflowModal"
           >
@@ -145,6 +143,17 @@ onMounted(() => {
             <span class="visually-hidden">Toggle Dropdown</span>
           </button>
           <ul class="dropdown-menu z-3">
+            <li
+              v-if="otrRepository.otrMapping[workflow.workflow_id] == undefined"
+            >
+              <button
+                type="button"
+                class="dropdown-item"
+                @click="emit('create-otr-click', workflow)"
+              >
+                Transfer ownership
+              </button>
+            </li>
             <li>
               <a
                 class="dropdown-item"
@@ -344,8 +353,8 @@ onMounted(() => {
                         }"
                       >
                         <template v-if="version.parameter_extension"
-                          >Update</template
-                        >
+                          >Update
+                        </template>
                         <template v-else>Add</template>
                         Parameter Translation
                       </router-link>
diff --git a/src/components/workflows/modals/CreateWorkflowModal.vue b/src/components/workflows/modals/CreateWorkflowModal.vue
index a31a402..d06cf4f 100644
--- a/src/components/workflows/modals/CreateWorkflowModal.vue
+++ b/src/components/workflows/modals/CreateWorkflowModal.vue
@@ -335,9 +335,12 @@ function removeMode(index: number) {
 onMounted(() => {
   createWorkflowModal = new Modal("#" + props.modalId);
   successToast = new Toast("#successToast-" + randomIDSuffix);
-  privateRepositoryCollapse = new Collapse("#privateRepositoryCollapse", {
-    toggle: false,
-  });
+  privateRepositoryCollapse = new Collapse(
+    "#createWorkflowPrivateRepositoryCheckbox",
+    {
+      toggle: false,
+    },
+  );
   tokenHelpCollapse = new Collapse("#tokenHelpCollapse", {
     toggle: false,
   });
@@ -443,7 +446,7 @@ onMounted(() => {
         </div>
         <div class="row mb-3">
           <div class="col-8">
-            <label for="workflowGitCommitInput" class="form-label"
+            <label for="createWorkflowGitCommitInput" class="form-label"
               >Git Commit Hash</label
             >
             <div class="input-group">
@@ -453,7 +456,7 @@ onMounted(() => {
               <input
                 type="text"
                 class="form-control text-lowercase"
-                id="workflowGitCommitInput"
+                id="createWorkflowGitCommitInput"
                 placeholder="ba8bcd9..."
                 required
                 ref="workflowGitCommitHashElement"
@@ -549,15 +552,15 @@ onMounted(() => {
               class="form-check-input"
               type="checkbox"
               v-model="repositoryCredentials.privateRepo"
-              id="privateRepositoryCheckbox"
+              id="createWorkflowPrivateRepositoryCheckbox"
               @change="formState.allowUpload = false"
-              aria-controls="#privateRepositoryCollapse"
+              aria-controls="#createWorkflowPrivateRepositoryCheckbox"
             />
             <label class="form-check-label" for="privateRepositoryCheckbox">
               Enable private Git Repository
             </label>
           </div>
-          <div class="collapse" id="privateRepositoryCollapse">
+          <div class="collapse" id="createWorkflowPrivateRepositoryCheckbox">
             <label for="createRepositoryTokenInput" class="form-label"
               >Token</label
             >
diff --git a/src/stores/otrs.ts b/src/stores/otrs.ts
index c7b1586..0b24961 100644
--- a/src/stores/otrs.ts
+++ b/src/stores/otrs.ts
@@ -9,6 +9,7 @@ import {
 } from "@/client";
 import { useUserStore } from "@/stores/users";
 import { useBucketStore } from "@/stores/buckets";
+import { useWorkflowStore } from "@/stores/workflows";
 
 export const useOTRStore = defineStore({
   id: "otrs",
@@ -49,26 +50,41 @@ export const useOTRStore = defineStore({
         }
       }
     },
-    fetchBucketOtrs(
+    fetchOtrs(
+      targetType: OwnershipTypeEnum,
       currentOwnerId?: string,
       newOwnerId?: string,
     ): Promise<OwnershipTransferRequestOut[]> {
-      return BucketService.bucketListBucketOtrs(currentOwnerId, newOwnerId);
+      switch (targetType) {
+        case OwnershipTypeEnum.BUCKET:
+          return BucketService.bucketListBucketOtrs(currentOwnerId, newOwnerId);
+        case OwnershipTypeEnum.WORKFLOW:
+          return WorkflowService.workflowListWorkflowOtrs(
+            currentOwnerId,
+            newOwnerId,
+          );
+        case OwnershipTypeEnum.RESOURCE:
+          return ResourceService.resourceListResourceOtrs(
+            currentOwnerId,
+            newOwnerId,
+          );
+      }
     },
-    fetchOwnBucketOtrs(
+    fetchOwnOtrs(
+      targetType: OwnershipTypeEnum,
       onFinally?: () => void,
     ): Promise<OwnershipTransferRequestOut[]> {
-      if (this.__otrLoaded[OwnershipTypeEnum.BUCKET]) {
+      if (this.__otrLoaded[targetType]) {
         onFinally?.();
       }
       const userRepo = useUserStore();
       return Promise.all([
-        this.fetchBucketOtrs(userRepo.currentUID),
-        this.fetchBucketOtrs(undefined, userRepo.currentUID),
+        this.fetchOtrs(targetType, userRepo.currentUID),
+        this.fetchOtrs(targetType, undefined, userRepo.currentUID),
       ])
         .then((otrs) => otrs.flat())
         .then((otrs) => {
-          this.__filterOTRMapping(OwnershipTypeEnum.BUCKET);
+          this.__filterOTRMapping(targetType);
           for (const otr of otrs) {
             this.otrMapping[otr.target_id] = otr;
           }
@@ -81,7 +97,7 @@ export const useOTRStore = defineStore({
           return otrs;
         })
         .finally(() => {
-          this.__otrLoaded[OwnershipTypeEnum.BUCKET] = true;
+          this.__otrLoaded[targetType] = true;
           onFinally?.();
         });
     },
@@ -95,10 +111,10 @@ export const useOTRStore = defineStore({
                 return bucket.name;
               });
           case OwnershipTypeEnum.WORKFLOW:
-            return useBucketStore()
-              .acceptBucketOtr(otr)
-              .then((bucket) => {
-                return bucket.name;
+            return useWorkflowStore()
+              .acceptWorkflowOtr(otr)
+              .then((workflow) => {
+                return workflow.workflow_id;
               });
           case OwnershipTypeEnum.RESOURCE:
             return useBucketStore()
@@ -139,7 +155,7 @@ export const useOTRStore = defineStore({
           case OwnershipTypeEnum.BUCKET:
             return BucketService.bucketCreateBucketOtr(targetId, otr);
           case OwnershipTypeEnum.WORKFLOW:
-            return WorkflowService.workflowCreateBucketOtr(targetId, otr);
+            return WorkflowService.workflowCreateWorkflowOtr(targetId, otr);
           case OwnershipTypeEnum.RESOURCE:
             return ResourceService.resourceCreateResourceOtr(targetId, otr);
         }
diff --git a/src/stores/workflows.ts b/src/stores/workflows.ts
index 9b71a1d..aee87d8 100644
--- a/src/stores/workflows.ts
+++ b/src/stores/workflows.ts
@@ -2,6 +2,7 @@ import { defineStore } from "pinia";
 import type {
   Body_Workflow_Version_upload_workflow_version_icon,
   IconUpdateOut,
+  OwnershipTransferRequestOut,
   ParameterExtension,
   WorkflowCredentialsIn,
   WorkflowIn,
@@ -90,15 +91,6 @@ export const useWorkflowStore = defineStore({
     __addNameToMapping(key: string, value: string) {
       useNameStore().addNameToMapping(key, value);
     },
-    fetchWorkflowVersion(wid: string, vid: string): Promise<WorkflowVersion> {
-      return WorkflowVersionService.workflowVersionGetWorkflowVersion(
-        vid,
-        wid,
-      ).then((version) => {
-        this.__addNameToMapping(version.workflow_version_id, version.version);
-        return version;
-      });
-    },
     fetchWorkflows(onFinally?: () => void): Promise<WorkflowOut[]> {
       if (Object.keys(this.workflowMapping).length > 0) {
         onFinally?.();
@@ -637,5 +629,13 @@ export const useWorkflowStore = defineStore({
         }
       });
     },
+    acceptWorkflowOtr(otr: OwnershipTransferRequestOut): Promise<WorkflowOut> {
+      return WorkflowService.workflowAcceptWorkflowOtr(otr.target_id).then(
+        (workflow) => {
+          this.fetchWorkflow(workflow.workflow_id, true);
+          return workflow;
+        },
+      );
+    },
   },
 });
diff --git a/src/views/object-storage/BucketsView.vue b/src/views/object-storage/BucketsView.vue
index af5200f..3ddb701 100644
--- a/src/views/object-storage/BucketsView.vue
+++ b/src/views/object-storage/BucketsView.vue
@@ -54,7 +54,7 @@ let detailModal: Modal | null = null;
 let refreshTimeout: NodeJS.Timeout | undefined = undefined;
 
 function fetchBuckets() {
-  otrRepository.fetchOwnBucketOtrs();
+  otrRepository.fetchOwnOtrs(OwnershipTypeEnum.BUCKET);
   bucketRepository.fetchOwnPermissions();
   bucketRepository
     .fetchOwnBuckets(() => {
@@ -190,7 +190,9 @@ onMounted(() => {
             style="font-size: 0.8em"
           >
             {{ otrForNewBuckets.length }}
-            <span class="visually-hidden">unread messages</span>
+            <span class="visually-hidden"
+              >open bucket ownership transfer requests</span
+            >
           </span>
         </button>
         <button
diff --git a/src/views/workflows/MyWorkflowsView.vue b/src/views/workflows/MyWorkflowsView.vue
index 7ccfeb4..09ddb60 100644
--- a/src/views/workflows/MyWorkflowsView.vue
+++ b/src/views/workflows/MyWorkflowsView.vue
@@ -2,6 +2,8 @@
 import { computed, onMounted, reactive } from "vue";
 import {
   NextflowVersion,
+  type OwnershipTransferRequestOut,
+  OwnershipTypeEnum,
   type WorkflowOut,
   type WorkflowVersion,
   WorkflowVersionStatus,
@@ -17,18 +19,43 @@ import { useWorkflowStore } from "@/stores/workflows";
 import { useWorkflowExecutionStore } from "@/stores/workflowExecutions";
 import { environment } from "@/environment";
 import ArbitraryWorkflowModal from "@/components/workflows/modals/ArbitraryWorkflowModal.vue";
+import OtrModal from "@/components/modals/ShowOtrModal.vue";
+import ListOtrsModal from "@/components/modals/ListOtrsModal.vue";
+import { useOTRStore } from "@/stores/otrs";
+import { useUserStore } from "@/stores/users";
+import { Modal, Tooltip } from "bootstrap";
+import { useSettingsStore } from "@/stores/settings";
+import CreateOtrModal from "@/components/modals/CreateOtrModal.vue";
 
 const workflowRepository = useWorkflowStore();
 const workflowExecutionRepository = useWorkflowExecutionStore();
+const otrRepository = useOTRStore();
+const userRepository = useUserStore();
+const settingsStore = useSettingsStore();
+
+let showOtrModalInstance: Modal | null = null;
+let createOtrModalInstance: Modal | null = null;
 
 const workflowsState = reactive<{
   loading: boolean;
   updateWorkflow: WorkflowOut;
   potentialWorkflowDelete?: WorkflowOut;
   updateIconVersion: WorkflowVersion;
+  showOtrTarget: string;
+  createOtrTarget: WorkflowOut;
 }>({
   loading: true,
   potentialWorkflowDelete: undefined,
+  showOtrTarget: "",
+  createOtrTarget: {
+    short_description: "",
+    name: "",
+    versions: [],
+    repository_url: "",
+    workflow_id: "",
+    developer_id: "",
+    private: false,
+  },
   updateWorkflow: {
     short_description: "",
     name: "",
@@ -67,6 +94,12 @@ const sortedWorkflows = computed<WorkflowOut[]>(() => {
   return temp;
 });
 
+const otrForNewWorkflows = computed<OwnershipTransferRequestOut[]>(() =>
+  otrRepository.workflowOtrs.filter(
+    (otr) => otr.new_owner_uid === userRepository.currentUID,
+  ),
+);
+
 function workflowUpdateClicked(workflow: WorkflowOut) {
   workflowsState.updateWorkflow = workflow;
 }
@@ -91,15 +124,44 @@ function updateIconClicked(version: WorkflowVersion) {
   workflowsState.updateIconVersion = version;
 }
 
+function showOtrModal(workflowId: string) {
+  workflowsState.showOtrTarget = workflowId;
+  showOtrModalInstance?.show();
+}
+
+function showCreateOtrModal(workflow: WorkflowOut) {
+  workflowsState.createOtrTarget = workflow;
+  createOtrModalInstance?.show();
+}
+
 onMounted(() => {
+  showOtrModalInstance = Modal.getOrCreateInstance("#view-workflow-otr-modal");
+  createOtrModalInstance = Modal.getOrCreateInstance(
+    "#create-workflow-otr-modal",
+  );
   workflowRepository.fetchOwnWorkflows(() => {
     workflowsState.loading = false;
   });
+  otrRepository.fetchOwnOtrs(OwnershipTypeEnum.WORKFLOW);
   workflowExecutionRepository.fetchExecutionsForDevStatistics();
+  new Tooltip("#showWorkflowOtrsButtons");
 });
 </script>
 
 <template>
+  <otr-modal
+    :otr-target-id="workflowsState.showOtrTarget"
+    modalId="view-workflow-otr-modal"
+  />
+  <list-otrs-modal
+    :otrs="otrForNewWorkflows"
+    :otr-type="OwnershipTypeEnum.WORKFLOW"
+    modal-id="list-workflow-otrs-modal"
+  />
+  <create-otr-modal
+    modal-id="create-workflow-otr-modal"
+    :target="workflowsState.createOtrTarget"
+  />
   <create-workflow-modal modal-id="createWorkflowModal" />
   <update-workflow-modal
     :workflow="workflowsState.updateWorkflow"
@@ -131,13 +193,38 @@ onMounted(() => {
     class="row border-bottom mb-4 justify-content-between align-items-center pb-2 pe-2"
   >
     <h2 class="w-fit">My Workflows</h2>
-    <button
-      class="btn btn-lg btn-primary w-fit"
-      data-bs-toggle="modal"
-      data-bs-target="#createWorkflowModal"
-    >
-      Create
-    </button>
+    <div class="w-fit">
+      <button
+        id="showWorkflowOtrsButtons"
+        :hidden="otrForNewWorkflows.length === 0"
+        class="btn border btn-lg shadow-sm position-relative me-3"
+        :class="{
+          'btn-light': settingsStore.lightThemeActive,
+          'btn-secondary': settingsStore.darkThemeActive,
+        }"
+        data-bs-title="Ownership transfer requests"
+        data-bs-target="#list-workflow-otrs-modal"
+        data-bs-toggle="modal"
+      >
+        Requests
+        <span
+          class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
+          style="font-size: 0.8em"
+        >
+          {{ otrForNewWorkflows.length }}
+          <span class="visually-hidden"
+            >open workflow ownership transfer requests</span
+          >
+        </span>
+      </button>
+      <button
+        class="btn btn-lg btn-primary w-fit"
+        data-bs-toggle="modal"
+        data-bs-target="#createWorkflowModal"
+      >
+        Create
+      </button>
+    </div>
   </div>
   <div v-if="environment.DEV_SYSTEM" class="d-grid gap-2 col-4 mx-auto">
     <button
@@ -164,6 +251,8 @@ onMounted(() => {
         @workflow-update-credentials-click="workflowUpdateClicked"
         @workflow-delete-click="workflowDeleteClicked"
         @workflow-update-icon-click="iconUpdateClicked"
+        @create-otr-click="showCreateOtrModal"
+        @show-otr-click="showOtrModal"
       />
     </card-transition-group>
     <div v-else class="text-center mt-5 fs-2">
-- 
GitLab