diff --git a/src/components/modals/ReasonModal.vue b/src/components/modals/ReasonModal.vue index 38d4b2a3575f0497810923da642ddef785dd6d8e..561c27a546e54c5f1e17f3feef84f11d68eee376 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 b7947f789f6001bd9501d5da8fdec4b83026938a..3a77eafb79c39ada5d2a545da4137f5f0433cd97 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 fbd7a4dc960ead3f4edc9c034e1636b88ea7e3d2..ddb1fb2f7d5b24a22305c4dfeb26b720bce7ef8b 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 27597317ca4b4d50ce498051958ced304352ba0c..dc3066883b4619182cae6fa4c29e1a28d1d95734 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 08e9338afd3eb0a2f02b73d41f8c809229fc00e4..9cf76887aa3cb7021eef57a1bf56cbf0787a0c2c 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 856617da6bcf045486645346ee26a110882c1cce..5774737d37c760b8ee6f3118abaa4cad73d601b2 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 c083e0956b2f036b41bb81b08d9c9b0e7a48cc99..c76486884da6ae1d96db1753aca680d7615b2bd0 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 72aaf6be23fed41c6ff539143c69318cee8ba670..227cd378c70601ea4f768d8f03436cf62c620767 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 6a8f00b0f09a21ddfa835f759a94f1dc4d1c38cc..5c863c390e96327c6fe0e563855e8ca684ff8aa4 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>