From 81aa8cd66e134b4286db0b05dded7a30b37548e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Tue, 5 Mar 2024 12:07:03 +0100
Subject: [PATCH] Add function to request a resource synchronization

#100
---
 src/components/modals/ReasonModal.vue         |  7 +-
 .../ParameterSchemaFormComponent.vue          |  6 +-
 src/components/resources/ResourceCard.vue     | 52 ++++++++++---
 .../workflows/WorkflowWithVersionsCard.vue    |  8 +-
 src/stores/resources.ts                       | 53 +++++++------
 src/views/admin/AdminSyncRequestsView.vue     |  9 ++-
 src/views/resources/ListResourcesView.vue     | 78 ++++++++++++++++---
 src/views/resources/MyResourcesView.vue       | 44 +++++++++++
 src/views/resources/ReviewResourceView.vue    |  1 +
 9 files changed, 204 insertions(+), 54 deletions(-)

diff --git a/src/components/modals/ReasonModal.vue b/src/components/modals/ReasonModal.vue
index 38d4b2a..561c27a 100644
--- a/src/components/modals/ReasonModal.vue
+++ b/src/components/modals/ReasonModal.vue
@@ -2,10 +2,13 @@
 import BootstrapModal from "@/components/modals/BootstrapModal.vue";
 import { reactive, ref } from "vue";
 
+type reasonfor = "request" | "rejection";
+
 const props = defineProps<{
   modalId: string;
   modalLabel: string;
   loading: boolean;
+  purpose: reasonfor;
 }>();
 
 const formState = reactive<{
@@ -48,7 +51,7 @@ function sendSaveEvent() {
         :class="{ 'was-validated': formState.validated }"
       >
         <label :for="'reason-modal-input-' + randomIDSuffix" class="form-label"
-          >Reason for rejection</label
+          >Reason for {{ props.purpose }}</label
         >
         <textarea
           class="form-control"
@@ -56,7 +59,7 @@ function sendSaveEvent() {
           rows="3"
           minlength="16"
           maxlength="512"
-          placeholder="State your reason for rejection"
+          :placeholder="'State your reason for the ' + props.purpose"
           v-model="formState.reason"
         ></textarea>
       </form>
diff --git a/src/components/parameter-schema/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue
index b7947f7..3a77eaf 100644
--- a/src/components/parameter-schema/ParameterSchemaFormComponent.vue
+++ b/src/components/parameter-schema/ParameterSchemaFormComponent.vue
@@ -437,7 +437,7 @@ onMounted(() => {
             :checked="props.viewMode === 'simple'"
             @click="
               router.replace({
-                query: { viewMode: 'simple' },
+                query: { ...route.query, viewMode: 'simple' },
                 hash: route.hash,
               })
             "
@@ -454,7 +454,7 @@ onMounted(() => {
             :checked="props.viewMode === 'advanced'"
             @click="
               router.replace({
-                query: { viewMode: 'advanced' },
+                query: { ...route.query, viewMode: 'advanced' },
                 hash: route.hash,
               })
             "
@@ -471,7 +471,7 @@ onMounted(() => {
             :checked="props.viewMode === 'expert'"
             @click="
               router.replace({
-                query: { viewMode: 'expert' },
+                query: { ...route.query, viewMode: 'expert' },
                 hash: route.hash,
               })
             "
diff --git a/src/components/resources/ResourceCard.vue b/src/components/resources/ResourceCard.vue
index fbd7a4d..ddb1fb2 100644
--- a/src/components/resources/ResourceCard.vue
+++ b/src/components/resources/ResourceCard.vue
@@ -26,7 +26,7 @@ const props = defineProps<{
 let refreshTimeout: NodeJS.Timeout | undefined = undefined;
 
 const stateToUIMapping: Record<Status, string> = {
-  APPROVED: "",
+  APPROVED: "Resource approved",
   WAIT_FOR_REVIEW: "Wait for review",
   CLUSTER_DELETE_ERROR: "Error deleting resource on cluster",
   CLUSTER_DELETING: "Resource deletion on cluster in progress",
@@ -39,13 +39,14 @@ const stateToUIMapping: Record<Status, string> = {
   S3_DELETED: "Tarball deleted in S3",
   SYNCHRONIZED: "Resource available",
   SYNCHRONIZING: "Synchronizing to cluster in progress",
-  SYNC_REQUESTED: "Wait for download on cluster",
+  SYNC_REQUESTED: "Synchronization to cluster requested",
   LATEST: "Resource available (latest)",
 };
 
 const emit = defineEmits<{
   (e: "click-info", resourceVersion: ResourceVersionOut): void;
   (e: "click-update", resource: ResourceOut): void;
+  (e: "click-request-sync", resourceVersion: ResourceVersionOut): void;
 }>();
 
 const resourceVersionS3Ready = ref<Record<string, boolean>>({});
@@ -82,6 +83,10 @@ function requestReview(resourceVersion: ResourceVersionOut) {
 
 onMounted(() => {
   if (!props.loading) {
+    new Tooltip("#resource-name-" + props.resource.resource_id);
+    if (props.resource.private) {
+      new Tooltip("#resource-private-icon-" + props.resource.resource_id);
+    }
     for (const r of props.resource.versions) {
       if (r.status == Status.RESOURCE_REQUESTED) {
         checkS3Resource(r);
@@ -110,8 +115,21 @@ onMounted(() => {
         <div v-if="props.loading" class="placeholder-glow w-100">
           <span class="placeholder col-6"></span>
         </div>
-        <div v-else>
-          <span>{{ props.resource.name }}</span>
+        <div v-else class="d-inline-flex align-items-center text-truncate">
+          <span
+            :id="'resource-name-' + props.resource.resource_id"
+            data-bs-toggle="tooltip"
+            :data-bs-title="props.resource.name"
+            >{{ props.resource.name }}</span
+          >
+          <font-awesome-icon
+            v-if="props.resource.private"
+            :id="'resource-private-icon-' + props.resource.resource_id"
+            icon="fa-solid fa-lock"
+            class="fs-5 ms-2 tooltip-private-repository"
+            data-bs-toggle="tooltip"
+            data-bs-title="Private resource"
+          />
         </div>
         <button
           v-if="props.extended"
@@ -180,13 +198,27 @@ onMounted(() => {
               }"
               :data-bs-parent="'#accordion-' + props.resource.resource_id"
             >
-              <div class="accordion-body">
+              <div class="accordion-body d-flex flex-column">
                 <div>
                   Registered at:
                   {{
                     dayjs.unix(resourceVersion.created_at).format("DD MMM YYYY")
                   }}
                 </div>
+                <div
+                  v-if="resourceVersion.status == Status.APPROVED"
+                  class="d-grid gap-2"
+                >
+                  <button
+                    type="button"
+                    class="btn btn-primary"
+                    @click="emit('click-request-sync', resourceVersion)"
+                    data-bs-toggle="modal"
+                    data-bs-target="#request-synchronization-modal"
+                  >
+                    Request synchronization
+                  </button>
+                </div>
                 <div
                   v-if="
                     props.extended &&
@@ -231,7 +263,6 @@ onMounted(() => {
                     resourceVersion.status === Status.SYNCHRONIZED ||
                     resourceVersion.status === Status.LATEST
                   "
-                  class="my-1"
                 >
                   <label
                     :for="
@@ -241,7 +272,7 @@ onMounted(() => {
                     class="form-label"
                     >Nextflow Access Path:</label
                   >
-                  <div class="input-group fs-4 mb-3">
+                  <div class="input-group fs-4">
                     <div
                       class="input-group-text hover-info"
                       :id="
@@ -273,9 +304,8 @@ onMounted(() => {
                 <div
                   v-if="
                     props.extended &&
-                    resourceVersion.status !== Status.S3_DELETED
+                    resourceVersion.status === Status.RESOURCE_REQUESTED
                   "
-                  class="my-1"
                 >
                   <label
                     :for="
@@ -339,4 +369,8 @@ onMounted(() => {
   transform: translate(0, -5px);
   box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
 }
+
+.accordion-body > div:not(:last-child) {
+  margin-bottom: 0.5rem !important;
+}
 </style>
diff --git a/src/components/workflows/WorkflowWithVersionsCard.vue b/src/components/workflows/WorkflowWithVersionsCard.vue
index 2759731..dc30668 100644
--- a/src/components/workflows/WorkflowWithVersionsCard.vue
+++ b/src/components/workflows/WorkflowWithVersionsCard.vue
@@ -114,16 +114,16 @@ onMounted(() => {
         <div v-if="props.loading" class="placeholder-glow w-100">
           <span class="placeholder col-6"></span>
         </div>
-        <div v-else class="text-truncate">
+        <div v-else class="text-truncate d-inline-flex align-items-center">
+          <span>{{ props.workflow.name }}</span>
           <font-awesome-icon
             v-if="props.workflow.private"
             icon="fa-solid fa-lock"
-            class="fs-5 me-2 tooltip-private-repository"
+            class="fs-5 ms-2 tooltip-private-repository"
             :id="'tooltip-' + randomIDSuffix"
             data-bs-toggle="tooltip"
-            data-bs-title="Private Git Repository"
+            data-bs-title="Private Git repository"
           />
-          <span>{{ props.workflow.name }}</span>
         </div>
         <div class="btn-group">
           <button
diff --git a/src/stores/resources.ts b/src/stores/resources.ts
index 08e9338..9cf7688 100644
--- a/src/stores/resources.ts
+++ b/src/stores/resources.ts
@@ -202,32 +202,37 @@ export const useResourceStore = defineStore({
         resourceVersion.resource_id,
         resourceVersion.resource_version_id,
         request,
-      ).then((changedResourceVersion) => {
-        if (
-          this.ownResourceMapping[changedResourceVersion.resource_id] ==
-          undefined
-        ) {
-          this.fetchResource(resourceVersion.resource_id);
+      )
+        .then((changedResourceVersion) => {
+          const versionIndex = this.resourceMapping[
+            changedResourceVersion.resource_id
+          ]?.versions?.findIndex(
+            (version) =>
+              version.resource_version_id ==
+              changedResourceVersion.resource_version_id,
+          );
+          if (versionIndex != undefined && versionIndex > -1) {
+            this.resourceMapping[changedResourceVersion.resource_id].versions[
+              versionIndex
+            ] = changedResourceVersion;
+          }
           return changedResourceVersion;
-        }
-        const versionIndex = this.resourceMapping[
-          changedResourceVersion.resource_id
-        ].versions.findIndex(
-          (version) =>
-            version.resource_version_id ==
-            changedResourceVersion.resource_version_id,
-        );
-        if (versionIndex > -1) {
-          this.resourceMapping[changedResourceVersion.resource_id].versions[
-            versionIndex
-          ] = changedResourceVersion;
-        } else {
-          this.resourceMapping[
+        })
+        .then((changedResourceVersion) => {
+          const versionIndex = this.ownResourceMapping[
             changedResourceVersion.resource_id
-          ].versions.push(changedResourceVersion);
-        }
-        return changedResourceVersion;
-      });
+          ]?.versions?.findIndex(
+            (version) =>
+              version.resource_version_id ==
+              changedResourceVersion.resource_version_id,
+          );
+          if (versionIndex != undefined && versionIndex > -1) {
+            this.ownResourceMapping[
+              changedResourceVersion.resource_id
+            ].versions[versionIndex] = changedResourceVersion;
+          }
+          return changedResourceVersion;
+        });
     },
     requestReview(
       resourceVersion: ResourceVersionOut,
diff --git a/src/views/admin/AdminSyncRequestsView.vue b/src/views/admin/AdminSyncRequestsView.vue
index 856617d..5774737 100644
--- a/src/views/admin/AdminSyncRequestsView.vue
+++ b/src/views/admin/AdminSyncRequestsView.vue
@@ -152,6 +152,7 @@ onMounted(() => {
     modal-id="sync-request-reject-modal"
     modal-label="Resource Synchronization Request Reject Modal"
     :loading="resourceState.sendingRequest"
+    purpose="rejection"
     @save="(reason) => rejectSyncRequest(reason, resourceState.rejectResource)"
   >
     <template #header>
@@ -194,7 +195,7 @@ onMounted(() => {
   </div>
   <div v-else class="d-flex flex-column">
     <div
-      class="border p-2 pb-0 rounded mb-2 d-flex"
+      class="border p-2 pb-0 rounded mb-2 d-flex hover-card"
       v-for="request in resourceRepository.syncRequests"
       :key="request.resource_version_id"
     >
@@ -263,4 +264,8 @@ onMounted(() => {
   </div>
 </template>
 
-<style scoped></style>
+<style scoped>
+.hover-card:hover {
+  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
+}
+</style>
diff --git a/src/views/resources/ListResourcesView.vue b/src/views/resources/ListResourcesView.vue
index c083e09..c764868 100644
--- a/src/views/resources/ListResourcesView.vue
+++ b/src/views/resources/ListResourcesView.vue
@@ -5,27 +5,34 @@ import ResourceCard from "@/components/resources/ResourceCard.vue";
 import CardTransitionGroup from "@/components/transitions/CardTransitionGroup.vue";
 import { useAuthStore } from "@/stores/users";
 import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
-import type { ResourceOut } from "@/client/resource";
+import type { ResourceOut, ResourceVersionOut } from "@/client/resource";
+import ReasonModal from "@/components/modals/ReasonModal.vue";
+import { Modal, Toast } from "bootstrap";
+import BootstrapToast from "@/components/BootstrapToast.vue";
 
 const resourceRepository = useResourceStore();
 const userRepository = useAuthStore();
+let requestReasonModal: Modal | null = null;
+let syncRequestSuccessToast: Toast | null = null;
 
 const resourceState = reactive<{
   loading: boolean;
   filterString: string;
   sortDesc: boolean;
+  showPrivate: boolean;
+  syncResourceVersion?: ResourceVersionOut;
 }>({
   loading: true,
   filterString: "",
   sortDesc: true,
+  showPrivate: false,
+  syncResourceVersion: undefined,
 });
 
-const publicResources = computed<ResourceOut[]>(() =>
-  resourceRepository.resources.filter((resource) => !resource.private),
-);
-
 const sortedResourcesByName = computed<ResourceOut[]>(() => {
-  return [...publicResources.value].sort((a, b) => (a.name < b.name ? 1 : -1));
+  return [...resourceRepository.resources].sort((a, b) =>
+    a.name < b.name ? 1 : -1,
+  );
 });
 
 const sortedResources = computed<ResourceOut[]>(() => {
@@ -37,7 +44,7 @@ const sortedResources = computed<ResourceOut[]>(() => {
 
 const filteredSortedResources = computed<ResourceOut[]>(() => {
   return sortedResources.value
-    .filter((resource) => !resource.private)
+    .filter((resource) => !resource.private || resourceState.showPrivate)
     .filter((resource) => {
       return resourceState.filterString.length > 0
         ? resource.name.includes(resourceState.filterString)
@@ -45,7 +52,25 @@ const filteredSortedResources = computed<ResourceOut[]>(() => {
     });
 });
 
+function requestResourceSync(
+  reason: string,
+  resourceVersion?: ResourceVersionOut,
+) {
+  if (resourceVersion != undefined) {
+    resourceRepository
+      .requestSynchronization(resourceVersion, {
+        reason: reason,
+      })
+      .then(() => {
+        requestReasonModal?.hide();
+        syncRequestSuccessToast?.show();
+      });
+  }
+}
+
 onMounted(() => {
+  requestReasonModal = new Modal("#request-synchronization-modal");
+  syncRequestSuccessToast = new Toast("#request-sync-toast");
   resourceRepository
     .fetchPublicResources(() => {
       resourceState.loading = false;
@@ -57,6 +82,20 @@ onMounted(() => {
 </script>
 
 <template>
+  <bootstrap-toast toast-id="request-sync-toast" color-class="success">
+    Requested resource synchronization
+  </bootstrap-toast>
+  <reason-modal
+    modal-id="request-synchronization-modal"
+    modal-label=""
+    :loading="false"
+    purpose="request"
+    @save="
+      (reason) => requestResourceSync(reason, resourceState.syncResourceVersion)
+    "
+  >
+    <template #header> Request resource synchronization</template>
+  </reason-modal>
   <div class="row border-bottom mb-4">
     <h2 class="mb-2">Available Resources</h2>
   </div>
@@ -79,6 +118,17 @@ onMounted(() => {
         />
       </div>
     </div>
+    <div class="form-check fs-5 ms-auto">
+      <label class="form-check-label" for="public-resources-checkbox">
+        Show only public resources
+        <input
+          class="form-check-input"
+          type="checkbox"
+          v-model="resourceState.showPrivate"
+          id="public-resources-checkbox"
+        />
+      </label>
+    </div>
     <font-awesome-icon
       :icon="
         resourceState.sortDesc
@@ -86,18 +136,23 @@ onMounted(() => {
           : 'fa-solid fa-arrow-up-wide-short'
       "
       @click="resourceState.sortDesc = !resourceState.sortDesc"
-      class="fs-5 cursor-pointer ms-auto"
+      class="fs-5 cursor-pointer ms-2"
     />
   </div>
   <div v-if="!resourceState.loading">
-    <div v-if="publicResources.length === 0" class="text-center fs-2 mt-5">
+    <div
+      v-if="resourceRepository.resources.length === 0"
+      class="text-center fs-2 mt-5"
+    >
       <font-awesome-icon
         icon="fa-solid fa-x"
         class="my-5 fs-0"
         style="color: var(--bs-secondary)"
       />
       <p>
-        There are no public resources in the system. Please come again later.
+        There are no resources
+        <span v-if="resourceState.showPrivate">public</span> in the system.
+        Please come again later.
       </p>
     </div>
     <div
@@ -125,6 +180,9 @@ onMounted(() => {
         :resource="resource"
         :loading="false"
         style="min-width: 47%; max-width: 48%"
+        @click-request-sync="
+          (version) => (resourceState.syncResourceVersion = version)
+        "
       />
     </CardTransitionGroup>
   </div>
diff --git a/src/views/resources/MyResourcesView.vue b/src/views/resources/MyResourcesView.vue
index 72aaf6b..227cd37 100644
--- a/src/views/resources/MyResourcesView.vue
+++ b/src/views/resources/MyResourcesView.vue
@@ -9,17 +9,24 @@ import { useS3KeyStore } from "@/stores/s3keys";
 import type { ResourceVersionOut, ResourceOut } from "@/client/resource";
 import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
 import UpdateResourceModal from "@/components/resources/modals/UpdateResourceModal.vue";
+import ReasonModal from "@/components/modals/ReasonModal.vue";
+import BootstrapToast from "@/components/BootstrapToast.vue";
+import { Modal, Toast } from "bootstrap";
 
 const resourceRepository = useResourceStore();
 const s3KeyRepository = useS3KeyStore();
+let requestReasonModal: Modal | null = null;
+let syncRequestSuccessToast: Toast | null = null;
 
 const resourceState = reactive<{
   loading: boolean;
   resourceVersionInfo?: ResourceVersionOut;
   updateResource: ResourceOut;
+  syncResourceVersion?: ResourceVersionOut;
 }>({
   loading: true,
   resourceVersionInfo: undefined,
+  syncResourceVersion: undefined,
   updateResource: {
     name: "",
     description: "",
@@ -38,8 +45,30 @@ function setResourceUpdate(resource: ResourceOut) {
   resourceState.updateResource = resource;
 }
 
+function setResourceSync(version: ResourceVersionOut) {
+  resourceState.syncResourceVersion = version;
+}
+
+function requestResourceSync(
+  reason: string,
+  resourceVersion?: ResourceVersionOut,
+) {
+  if (resourceVersion != undefined) {
+    resourceRepository
+      .requestSynchronization(resourceVersion, {
+        reason: reason,
+      })
+      .then(() => {
+        requestReasonModal?.hide();
+        syncRequestSuccessToast?.show();
+      });
+  }
+}
+
 onMounted(() => {
   let fetchedResources = false;
+  requestReasonModal = new Modal("#request-synchronization-modal");
+  syncRequestSuccessToast = new Toast("#request-sync-toast");
   s3KeyRepository.fetchS3Keys(() => {
     if (!fetchedResources) {
       fetchedResources = true;
@@ -52,6 +81,20 @@ onMounted(() => {
 </script>
 
 <template>
+  <bootstrap-toast toast-id="request-sync-toast" color-class="success">
+    Requested resource synchronization
+  </bootstrap-toast>
+  <reason-modal
+    modal-id="request-synchronization-modal"
+    modal-label=""
+    :loading="false"
+    purpose="request"
+    @save="
+      (reason) => requestResourceSync(reason, resourceState.syncResourceVersion)
+    "
+  >
+    <template #header> Request resource synchronization</template>
+  </reason-modal>
   <create-resource-modal modal-id="createResourceModal" />
   <upload-resource-info-modal
     modal-id="uploadResourceInfoModal"
@@ -109,6 +152,7 @@ onMounted(() => {
         extended
         @click-info="setResourceVersionInfo"
         @click-update="setResourceUpdate"
+        @click-request-sync="setResourceSync"
       />
     </CardTransitionGroup>
   </div>
diff --git a/src/views/resources/ReviewResourceView.vue b/src/views/resources/ReviewResourceView.vue
index 6a8f00b..5c863c3 100644
--- a/src/views/resources/ReviewResourceView.vue
+++ b/src/views/resources/ReviewResourceView.vue
@@ -110,6 +110,7 @@ onMounted(() => {
     modal-id="review-reject-modal"
     modal-label="Resource Review Reject Modal"
     :loading="resourceState.sendingRequest"
+    purpose="rejection"
     @save="(reason) => rejectReview(reason, resourceState.rejectResource)"
   >
     <template #header>
-- 
GitLab