web/satellite: show project invitations in dashboard area

This change allows users to view and interact with their project member
invitations through a banner in the dashboard area. The banner only
appears if the All Projects Dashboard is disabled, as it provides the
same functionality in a different way.

References #5855

Change-Id: Ia0771e2af52c40a72f1cacf72bc9098cc68f0dcd
This commit is contained in:
Jeremy Wharton 2023-06-02 02:31:34 -05:00
parent cafa6db971
commit c858479ef0
4 changed files with 220 additions and 27 deletions

View File

@ -0,0 +1,171 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div v-if="invite" class="banner">
<VLoader v-if="isLoading" class="banner__loader" width="40px" height="40px" />
<span v-if="invites.length > 1" class="banner__count">{{ invites.length }}</span>
<div class="banner__left">
<UsersIcon class="banner__left__icon" />
<span>{{ invite.inviterEmail }} has invited you to the project "{{ invite.projectName }}".</span>
</div>
<div class="banner__right">
<div class="banner__right__links">
<a @click="onJoinClicked">Join Project</a>
<a @click="onDeclineClicked">Decline</a>
</div>
<CloseIcon class="banner__right__close" @click="onCloseClicked" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ProjectInvitation, ProjectInvitationResponse } from '@/types/projects';
import { useAppStore } from '@/store/modules/appStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useNotify } from '@/utils/hooks';
import { MODALS } from '@/utils/constants/appStatePopUps';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import VLoader from '@/components/common/VLoader.vue';
import UsersIcon from '@/../static/images/notifications/usersIcon.svg';
import CloseIcon from '@/../static/images/notifications/closeSmall.svg';
const appStore = useAppStore();
const projectsStore = useProjectsStore();
const notify = useNotify();
const isLoading = ref<boolean>(false);
const hidden = ref<Set<ProjectInvitation>>(new Set<ProjectInvitation>());
/**
* Returns a sorted list of non-hidden project member invitation from the store.
*/
const invites = computed((): ProjectInvitation[] => {
return projectsStore.state.invitations
.filter(invite => !hidden.value.has(invite))
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
});
/**
* Returns the first non-hidden project member invitation from the store.
*/
const invite = computed((): ProjectInvitation | null => {
return !invites.value.length ? null : invites.value[0];
});
/**
* Hides the active project member invitation.
* Closes the notification if there are no more invitations.
*/
function onCloseClicked(): void {
if (isLoading.value || !invite.value) return;
hidden.value.add(invite.value);
}
/**
* Displays the Join Project modal.
*/
function onJoinClicked(): void {
if (isLoading.value || !invite.value) return;
projectsStore.selectInvitation(invite.value);
appStore.updateActiveModal(MODALS.joinProject);
}
/**
* Declines the project member invitation.
*/
async function onDeclineClicked(): Promise<void> {
if (isLoading.value || !invite.value) return;
isLoading.value = true;
try {
await projectsStore.respondToInvitation(invite.value.projectID, ProjectInvitationResponse.Decline);
} catch (error) {
notify.error(`Failed to decline project invitation. ${error.message}`, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
}
try {
await projectsStore.getUserInvitations();
await projectsStore.getProjects();
} catch (error) {
notify.error(`Failed to reload projects and invitations list. ${error.message}`, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
}
isLoading.value = false;
}
</script>
<style scoped lang="scss">
.banner {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--c-white);
border: 1px solid var(--c-grey-3);
border-radius: 10px;
box-shadow: 0 7px 20px rgb(0 0 0 / 15%);
font-family: 'font_regular', sans-serif;
font-size: 14px;
line-height: 20px;
&__count {
padding: 2px 8px;
position: absolute;
top: -8px;
left: -8px;
border-radius: 8px;
background-color: var(--c-blue-3);
color: var(--c-white);
}
&__loader {
position: absolute;
inset: 0;
align-items: center;
border-radius: 10px;
background-color: rgb(255 255 255 / 66%);
}
&__left {
display: flex;
align-items: center;
gap: 16px;
&__icon {
flex-shrink: 0;
}
}
&__right {
display: flex;
align-items: center;
gap: 16px;
&__links {
display: flex;
align-items: center;
gap: 23px;
text-align: center;
& a {
color: var(--c-black);
line-height: 22px;
text-decoration: underline !important;
}
}
&__close {
flex-shrink: 0;
cursor: pointer;
}
}
}
</style>

View File

@ -35,7 +35,6 @@ const props = defineProps<{
background-color: #fff;
border: 1px solid rgb(56 75 101 / 40%);
padding: 16px;
margin-bottom: 48px;
font-family: 'font_regular', sans-serif;
font-size: 14px;
border-radius: 16px;

View File

@ -15,7 +15,9 @@
<div ref="dashboardContent" class="dashboard__wrap__main-area__content-wrap__container">
<BetaSatBar v-if="isBetaSatellite" />
<MFARecoveryCodeBar v-if="showMFARecoveryCodeBar" :open-generate-modal="generateNewMFARecoveryCodes" />
<div class="banner-container dashboard__wrap__main-area__content-wrap__container__content">
<div class="dashboard__wrap__main-area__content-wrap__container__content banners">
<ProjectInvitationBanner v-if="isProjectInvitationBannerShown" />
<UpdateSessionTimeoutBanner
v-if="isUpdateSessionTimeoutBanner && dashboardContent"
:dashboard-ref="dashboardContent"
@ -78,7 +80,7 @@
</v-banner>
</div>
<router-view class="dashboard__wrap__main-area__content-wrap__container__content" />
<div class="banner-container__bottom dashboard__wrap__main-area__content-wrap__container__content">
<div class="dashboard__wrap__main-area__content-wrap__container__content banners-bottom">
<UploadNotification
v-if="isLargeUploadNotificationShown && !isLargeUploadWarningNotificationShown && isBucketsView"
wording-bold="The web browser is best for uploads up to 1GB."
@ -173,6 +175,7 @@ import LimitWarningModal from '@/components/modals/LimitWarningModal.vue';
import VBanner from '@/components/common/VBanner.vue';
import UpgradeNotification from '@/components/notifications/UpgradeNotification.vue';
import ProjectLimitBanner from '@/components/notifications/ProjectLimitBanner.vue';
import ProjectInvitationBanner from '@/components/notifications/ProjectInvitationBanner.vue';
import BrandedLoader from '@/components/common/BrandedLoader.vue';
import UpdateSessionTimeoutBanner from '@/components/notifications/UpdateSessionTimeoutBanner.vue';
import ObjectsUploadingModal from '@/components/modals/objectUpload/ObjectsUploadingModal.vue';
@ -434,6 +437,13 @@ const isLargeUploadWarningNotificationShown = computed((): boolean => {
return appStore.state.isLargeUploadWarningNotificationShown;
});
/**
* Indicates whether the project member invitation banner should be shown.
*/
const isProjectInvitationBannerShown = computed((): boolean => {
return !configStore.state.config.allProjectsDashboard;
});
/**
* Indicates if current route is create project page.
*/
@ -783,6 +793,12 @@ onMounted(async () => {
notify.error(`Unable to get credit cards. ${error.message}`, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
}
try {
await projectsStore.getUserInvitations();
} catch (error) {
notify.error(`Unable to get project invitations. ${error.message}`, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
}
let projects: Project[] = [];
try {
@ -824,25 +840,6 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="scss">
:deep(.notification-wrap) {
margin-top: 1rem;
}
.banner-container {
padding-top: 0 !important;
&:empty {
display: none;
}
&__bottom {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
}
.dashboard {
height: 100%;
background-color: #f5f6fa;
@ -881,6 +878,30 @@ onBeforeUnmount(() => {
padding: 48px 48px 0;
box-sizing: border-box;
width: 100%;
&.banners {
display: flex;
flex-direction: column;
gap: 16px;
&:empty {
display: none;
}
}
&.banners-bottom {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 16px;
padding-bottom: 48px;
flex-grow: 1;
justify-content: flex-end;
&:empty {
padding-top: 0;
}
}
}
}
}
@ -926,11 +947,7 @@ onBeforeUnmount(() => {
@media screen and (width <= 800px) {
.dashboard__wrap__main-area__content-wrap__container__content {
padding: 32px 24px 50px;
}
.banner-container {
padding-bottom: 0;
padding: 32px 24px 0;
}
}

View File

@ -0,0 +1,6 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.1231 25.2746C17.9508 26.9128 17.2608 28.9197 17.2608 31.0878V34.0008H2V32.24C2 26.7291 6.46741 22.2617 11.9782 22.2617C14.7793 22.2617 17.3108 23.4159 19.1231 25.2746Z" fill="#376FFF"/>
<circle cx="12.2244" cy="14.1571" r="7.15706" fill="#00E075"/>
<path d="M18.4346 32.24C18.4346 26.7291 22.902 22.2617 28.4128 22.2617C33.9236 22.2617 38.391 26.7291 38.391 32.24V34.0008H18.4346V32.24Z" fill="#FF598B"/>
<circle cx="28.5828" cy="14.1571" r="7.15706" fill="#FFC700"/>
</svg>

After

Width:  |  Height:  |  Size: 628 B