Skip to content

Commit

Permalink
web/satellite: show project invitations in all projects dashboard
Browse files Browse the repository at this point in the history
This change allows users to view and interact with their project member
invitations from within the All Projects Dashboard.

References #5855

Change-Id: If5b4af46924530c91f8a5c16bfb5134de313dc90
  • Loading branch information
jewharton committed Jun 6, 2023
1 parent 6359c53 commit cafa6db
Show file tree
Hide file tree
Showing 19 changed files with 583 additions and 79 deletions.
49 changes: 44 additions & 5 deletions web/satellite/src/api/projects.ts
Expand Up @@ -6,11 +6,13 @@ import {
DataStamp,
Project,
ProjectFields,
ProjectInvitation,
ProjectLimits,
ProjectsApi,
ProjectsCursor,
ProjectsPage,
ProjectsStorageBandwidthDaily,
ProjectInvitationResponse,
} from '@/types/projects';
import { HttpClient } from '@/utils/httpClient';
import { Time } from '@/utils/time';
Expand Down Expand Up @@ -137,7 +139,7 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {
* Get project limits.
*
* @param projectId- project ID
* throws Error
* @throws Error
*/
public async getLimits(projectId: string): Promise<ProjectLimits> {
const path = `${this.ROOT_PATH}/${projectId}/usage-limits`;
Expand Down Expand Up @@ -165,7 +167,7 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {
/**
* Get total limits for all the projects that user owns.
*
* throws Error
* @throws Error
*/
public async getTotalLimits(): Promise<ProjectLimits> {
const path = `${this.ROOT_PATH}/usage-limits`;
Expand All @@ -192,7 +194,7 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {
* @param projectId- project ID
* @param start- since date
* @param end- before date
* throws Error
* @throws Error
*/
public async getDailyUsage(projectId: string, start: Date, end: Date): Promise<ProjectsStorageBandwidthDaily> {
const since = Time.toUnixTimestamp(start).toString();
Expand All @@ -202,7 +204,6 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {

if (!response.ok) {
throw new Error('Can not get project daily usage');

}

const usage = await response.json();
Expand Down Expand Up @@ -272,6 +273,45 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {
return this.getProjectsPage(response.data.ownedProjects);
}

/**
* Returns a user's pending project member invitations.
*
* @throws Error
*/
public async getUserInvitations(): Promise<ProjectInvitation[]> {
const path = `${this.ROOT_PATH}/invitations`;
const response = await this.http.get(path);
const result = await response.json();

if (response.ok) {
return result.map(jsonInvite => new ProjectInvitation(
jsonInvite.projectID,
jsonInvite.projectName,
jsonInvite.projectDescription,
jsonInvite.inviterEmail,
new Date(jsonInvite.createdAt),
));
}

throw new Error(result.error || 'Failed to get project invitations');
}

/**
* Handles accepting or declining a user's project member invitation.
*
* @throws Error
*/
public async respondToInvitation(projectID: string, response: ProjectInvitationResponse): Promise<void> {
const path = `${this.ROOT_PATH}/invitations/${projectID}/respond`;
const body = { projectID, response };
const httpResponse = await this.http.post(path, JSON.stringify(body));

if (httpResponse.ok) return;

const result = await httpResponse.json();
throw new Error(result.error || 'Failed to respond to project invitation');
}

/**
* Method for mapping projects page from json to ProjectsPage type.
*
Expand All @@ -294,5 +334,4 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {

return new ProjectsPage(projects, page.limit, page.offset, page.pageCount, page.currentPage, page.totalCount);
}

}
3 changes: 2 additions & 1 deletion web/satellite/src/components/common/VButton.vue
Expand Up @@ -41,6 +41,7 @@
<span class="label" :class="{uppercase: isUppercase}">
<component :is="iconComponent" v-if="iconComponent" />
<span v-if="icon !== 'none'">&nbsp;&nbsp;</span>
<slot />
{{ label }}
</span>
<div class="icon-wrapper-right">
Expand Down Expand Up @@ -86,7 +87,7 @@ const props = withDefaults(defineProps<{
onPress?: () => void;
}>(), {
link: undefined,
label: 'Default',
label: '',
width: 'inherit',
height: 'inherit',
fontSize: '16px',
Expand Down
172 changes: 172 additions & 0 deletions web/satellite/src/components/modals/JoinProjectModal.vue
@@ -0,0 +1,172 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.

<template>
<VModal :on-close="closeModal">
<template #content>
<div class="modal">
<div class="modal__header">
<Icon />
<span class="modal__header__title">Join project</span>
</div>
<hr>
<div class="modal__info">
Join the {{ invite.projectName }} team project.
</div>
<hr>
<div class="modal__buttons">
<VButton
class="modal__buttons__button"
width="calc(50% - 8px)"
border-radius="8px"
font-size="14px"
:is-transparent="true"
:is-disabled="isLoading"
:on-press="() => respondToInvitation(ProjectInvitationResponse.Decline)"
label="Decline"
/>
<VButton
class="modal__buttons__button"
width="calc(50% - 8px)"
border-radius="8px"
font-size="14px"
:is-disabled="isLoading"
:on-press="() => respondToInvitation(ProjectInvitationResponse.Accept)"
label="Join Project"
/>
</div>
</div>
</template>
</VModal>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAppStore } from '@/store/modules/appStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useNotify } from '@/utils/hooks';
import { ProjectInvitation, ProjectInvitationResponse } from '@/types/projects';
import { AnalyticsHttpApi } from '@/api/analytics';
import { LocalData } from '@/utils/localData';
import { RouteConfig } from '@/router';
import VModal from '@/components/common/VModal.vue';
import VButton from '@/components/common/VButton.vue';
import Icon from '@/../static/images/modals/boxesIcon.svg';
const appStore = useAppStore();
const projectsStore = useProjectsStore();
const notify = useNotify();
const router = useRouter();
const analytics: AnalyticsHttpApi = new AnalyticsHttpApi();
const isLoading = ref<boolean>(false);
/**
* Returns selected project member invitation from the store.
*/
const invite = computed((): ProjectInvitation => {
return projectsStore.state.selectedInvitation;
});
/**
* Handles accepting or declining the project member invitation.
*/
async function respondToInvitation(response: ProjectInvitationResponse): Promise<void> {
if (isLoading.value) return;
isLoading.value = true;
let success = false;
try {
await projectsStore.respondToInvitation(invite.value.projectID, response);
success = true;
} catch (error) {
const action = response === ProjectInvitationResponse.Accept ? 'accept' : 'decline';
notify.error(`Failed to ${action} project invitation. ${error.message}`, null);
}
try {
await projectsStore.getUserInvitations();
await projectsStore.getProjects();
} catch (error) {
notify.error(`Failed to reload projects and invitations list. ${error.message}`, null);
}
if (!success) {
isLoading.value = false;
return;
}
if (response === ProjectInvitationResponse.Accept) {
projectsStore.selectProject(invite.value.projectID);
LocalData.setSelectedProjectId(invite.value.projectID);
analytics.pageVisit(RouteConfig.ProjectDashboard.path);
router.push(RouteConfig.ProjectDashboard.path);
}
closeModal();
}
/**
* Closes modal.
*/
function closeModal(): void {
appStore.removeActiveModal();
}
</script>

<style scoped lang="scss">
.modal {
width: 410px;
padding: 32px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
@media screen and (width <= 460px) {
width: calc(100vw - 48px);
}
&__header {
display: flex;
gap: 16px;
align-items: center;
&__title {
font-family: 'font_bold', sans-serif;
font-size: 24px;
line-height: 31px;
}
}
&__info {
font-family: 'font_regular', sans-serif;
font-size: 14px;
line-height: 20px;
text-align: left;
}
&__buttons {
display: flex;
gap: 16px;
justify-content: space-between;
&__button {
padding: 12px 0;
line-height: 24px;
}
}
& > hr {
height: 1px;
border: none;
background-color: var(--c-grey-2);
}
}
</style>
53 changes: 43 additions & 10 deletions web/satellite/src/components/project/ProjectOwnershipTag.vue
Expand Up @@ -2,24 +2,39 @@
// See LICENSE for copying information.

<template>
<div class="tag" :class="{member: !isOwner}">
<box-icon v-if="!noIcon" class="tag__icon" />
<div class="tag" :class="{owner: isOwner, invited: isInvited}">
<component :is="icon" v-if="!noIcon" class="tag__icon" />

<span class="tag__text"> {{ isOwner ? 'Owner': 'Member' }} </span>
<span class="tag__text">{{ label }}</span>
</div>
</template>

<script setup lang="ts">
import BoxIcon from '@/../static/images/allDashboard/box.svg';
import { computed, Component } from 'vue';
import BoxIcon from '@/../static/images/navigation/project.svg';
import InviteIcon from '@/../static/images/navigation/quickStart.svg';
const props = withDefaults(defineProps<{
isOwner: boolean,
isInvited: boolean,
noIcon?: boolean,
}>(), {
isOwner: false,
isInvited: false,
noIcon: false,
});
const icon = computed((): string => {
return props.isInvited ? InviteIcon : BoxIcon;
});
const label = computed((): string => {
if (props.isOwner) return 'Owner';
if (props.isInvited) return 'Invited';
return 'Member';
});
</script>

<style scoped lang="scss">
Expand All @@ -29,21 +44,39 @@ const props = withDefaults(defineProps<{
align-items: center;
gap: 5px;
padding: 4px 8px;
border: 1px solid var(--c-purple-2);
border: 1px solid var(--c-yellow-2);
border-radius: 24px;
color: var(--c-purple-4);
color: var(--c-yellow-5);
:deep(path) {
fill: var(--c-yellow-5);
}
&__icon {
width: 12px;
height: 12px;
}
&__text {
font-size: 12px;
font-family: 'font_regular', sans-serif;
}
&.member {
color: var(--c-yellow-5);
border-color: var(--c-yellow-2);
&.owner {
color: var(--c-purple-4);
border-color: var(--c-purple-2);
:deep(path) {
fill: var(--c-purple-4);
}
}
&.invited {
color: var(--c-grey-6);
border-color: var(--c-grey-4);
:deep(path) {
fill: var(--c-yellow-5);
fill: var(--c-yellow-3);
}
}
}
Expand Down

0 comments on commit cafa6db

Please sign in to comment.