web/satellite: show project invitations in the Team page

The Team page now displays project member invitations alongside project
members. These invitations can be removed in the same manner that
members can.

References #5855

Change-Id: Iade690757d4430deee3378066d7dc19766f53936
This commit is contained in:
Jeremy Wharton 2023-06-08 03:12:24 -05:00 committed by Storj Robot
parent ed37d72e3f
commit 8ee7d104a0
13 changed files with 258 additions and 75 deletions

View File

@ -2,7 +2,7 @@
// See LICENSE for copying information.
import { BaseGql } from '@/api/baseGql';
import { ProjectMember, ProjectMemberCursor, ProjectMembersApi, ProjectMembersPage } from '@/types/projectMembers';
import { ProjectInvitationItemModel, ProjectMember, ProjectMemberCursor, ProjectMembersApi, ProjectMembersPage } from '@/types/projectMembers';
import { HttpClient } from '@/utils/httpClient';
export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
@ -44,7 +44,7 @@ export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
project (
publicId: $projectId,
) {
members (
membersAndInvitations (
cursor: {
limit: $limit,
search: $search,
@ -62,6 +62,10 @@ export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
},
joinedAt
},
projectInvitations {
email,
createdAt
},
search,
limit,
order,
@ -83,7 +87,7 @@ export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
const response = await this.query(query, variables);
return this.getProjectMembersList(response.data.project.members);
return this.getProjectMembersList(response.data.project.membersAndInvitations);
}
/**
@ -120,6 +124,10 @@ export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
new Date(key.joinedAt),
key.user.id,
));
projectMembersPage.projectInvitations = projectMembers.projectInvitations.map(key => new ProjectInvitationItemModel(
key.email,
new Date(key.createdAt),
));
projectMembersPage.search = projectMembers.search;
projectMembersPage.limit = projectMembers.limit;

View File

@ -27,8 +27,7 @@
</p>
<p v-else :class="{primary: index === 0}" :title="val" @click.stop="(e) => cellContentClicked(index, e)">
<middle-truncate v-if="keyVal === 'fileName'" :text="val" />
<project-ownership-tag v-else-if="keyVal === 'owner'" :no-icon="itemType !== 'project'" :is-owner="val" />
<project-ownership-tag v-else-if="keyVal === 'invited'" :is-invited="val" />
<project-ownership-tag v-else-if="keyVal === 'role'" :no-icon="itemType !== 'project' && val !== ProjectRole.Invited" :role="val" />
<span v-else>{{ val }}</span>
</p>
<div v-if="showBucketGuide(index)" class="animation">
@ -44,6 +43,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProjectRole } from '@/types/projectMembers';
import VTableCheckbox from '@/components/common/VTableCheckbox.vue';
import BucketGuide from '@/components/objects/BucketGuide.vue';
import MiddleTruncate from '@/components/browser/MiddleTruncate.vue';

View File

@ -2,37 +2,31 @@
// See LICENSE for copying information.
<template>
<div class="tag" :class="{owner: isOwner, invited: isInvited}">
<div class="tag" :class="{[role.toLowerCase()]: true}">
<component :is="icon" v-if="!noIcon" class="tag__icon" />
<span class="tag__text">{{ label }}</span>
<span class="tag__text">{{ role }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, Component } from 'vue';
import { ProjectRole } from '@/types/projectMembers';
import BoxIcon from '@/../static/images/navigation/project.svg';
import InviteIcon from '@/../static/images/navigation/quickStart.svg';
const props = withDefaults(defineProps<{
isOwner: boolean,
isInvited: boolean,
role: ProjectRole,
noIcon?: boolean,
}>(), {
isOwner: false,
isInvited: false,
role: ProjectRole.Member,
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';
return props.role === ProjectRole.Invited ? InviteIcon : BoxIcon;
});
</script>

View File

@ -5,7 +5,7 @@
<div class="project-dashboard">
<div class="project-dashboard__heading">
<h1 class="project-dashboard__heading__title" aria-roledescription="title">{{ selectedProject.name }}</h1>
<project-ownership-tag :is-owner="selectedProject.ownerId === user.id" />
<project-ownership-tag :role="(selectedProject.ownerId === user.id) ? ProjectRole.Owner : ProjectRole.Member" />
</div>
<p class="project-dashboard__message">
Expect a delay of a few hours between network activity and the latest dashboard stats.
@ -191,6 +191,7 @@ import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
import { useAccessGrantsStore } from '@/store/modules/accessGrantsStore';
import { centsToDollars } from '@/utils/strings';
import { User } from '@/types/users';
import { ProjectRole } from '@/types/projectMembers';
import VLoader from '@/components/common/VLoader.vue';
import InfoContainer from '@/components/project/dashboard/InfoContainer.vue';

View File

@ -7,8 +7,8 @@
:item="itemToRender"
:selectable="true"
:select-disabled="isProjectOwner"
:selected="itemData.isSelected"
:on-click="(_) => $emit('memberClick', itemData)"
:selected="model.isSelected()"
:on-click="(_) => $emit('memberClick', model)"
@selectClicked="($event) => $emit('selectClicked', $event)"
/>
</template>
@ -16,7 +16,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProjectMember } from '@/types/projectMembers';
import { ProjectMember, ProjectMemberItemModel, ProjectRole } from '@/types/projectMembers';
import { useResize } from '@/composables/resize';
import { useProjectsStore } from '@/store/modules/projectsStore';
@ -26,23 +26,38 @@ const { isMobile, isTablet } = useResize();
const projectsStore = useProjectsStore();
const props = withDefaults(defineProps<{
itemData: ProjectMember;
model: ProjectMemberItemModel;
}>(), {
itemData: () => new ProjectMember('', '', '', new Date(), ''),
model: () => new ProjectMember('', '', '', new Date(), ''),
});
const isProjectOwner = computed((): boolean => {
return props.itemData.user.id === projectsStore.state.selectedProject.ownerId;
return props.model.getUserID() === projectsStore.state.selectedProject.ownerId;
});
const itemToRender = computed((): { [key: string]: unknown | string[] } => {
if (!isMobile.value && !isTablet.value) return { name: props.itemData.name, email: props.itemData.email, owner: isProjectOwner.value, date: props.itemData.localDate() };
const itemToRender = computed((): { [key: string]: unknown } => {
let role: ProjectRole = ProjectRole.Member;
if (props.model.isPending()) {
role = ProjectRole.Invited;
} else if (isProjectOwner.value) {
role = ProjectRole.Owner;
}
if (!isMobile.value && !isTablet.value) {
const dateStr = props.model.getJoinDate().toLocaleDateString('en-US', { day:'numeric', month:'short', year:'numeric' });
return {
name: props.model.getName(),
email: props.model.getEmail(),
role: role,
date: dateStr,
};
}
if (isTablet.value) {
return { name: props.itemData.name, email: props.itemData.email, owner: isProjectOwner.value };
return { name: props.model.getName(), email: props.model.getEmail(), role: role };
}
// TODO: change after adding actions button to list item
return { name: props.itemData.name, email: props.itemData.email };
return { name: props.model.getName(), email: props.model.getEmail() };
});
</script>

View File

@ -37,7 +37,7 @@
<ProjectMemberListItem
v-for="(member, key) in projectMembers"
:key="key"
:item-data="member"
:model="member"
@memberClick="onMemberCheckChange"
@selectClicked="(_) => onMemberCheckChange(member)"
/>
@ -50,8 +50,8 @@
import { computed, onMounted, ref } from 'vue';
import {
ProjectMember,
ProjectMemberHeaderState,
ProjectMemberItemModel,
} from '@/types/projectMembers';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { useNotify } from '@/utils/hooks';
@ -77,10 +77,10 @@ const areMembersFetching = ref<boolean>(true);
* Returns team members of current page from store.
* With project owner pinned to top
*/
const projectMembers = computed((): ProjectMember[] => {
const projectMembers = pmStore.state.page.projectMembers;
const projectOwner = projectMembers.find((member) => member.user.id === projectsStore.state.selectedProject.ownerId);
const projectMembersToReturn = projectMembers.filter((member) => member.user.id !== projectsStore.state.selectedProject.ownerId);
const projectMembers = computed((): ProjectMemberItemModel[] => {
const projectMembers = pmStore.state.page.getAllItems();
const projectOwner = projectMembers.find((member) => member.getUserID() === projectsStore.state.selectedProject.ownerId);
const projectMembersToReturn = projectMembers.filter((member) => member.getUserID() !== projectsStore.state.selectedProject.ownerId);
// if the project owner exists, place at the front of the members list
projectOwner && projectMembersToReturn.unshift(projectOwner);
@ -129,8 +129,8 @@ const isEmptySearchResultShown = computed((): boolean => {
* Selects team member if this user has no owner status.
* @param member
*/
function onMemberCheckChange(member: ProjectMember): void {
if (projectsStore.state.selectedProject.ownerId !== member.user.id) {
function onMemberCheckChange(member: ProjectMemberItemModel): void {
if (projectsStore.state.selectedProject.ownerId !== member.getUserID()) {
pmStore.toggleProjectMemberSelection(member);
}
}

View File

@ -7,6 +7,7 @@ import { defineStore } from 'pinia';
import {
ProjectMember,
ProjectMemberCursor,
ProjectMemberItemModel,
ProjectMemberOrderBy,
ProjectMembersApi,
ProjectMembersPage,
@ -43,12 +44,8 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
const projectMembersPage: ProjectMembersPage = await api.get(projectID, state.cursor);
state.page = projectMembersPage;
state.page.projectMembers = state.page.projectMembers.map(member => {
if (state.selectedProjectMembersEmails.includes(member.user.email)) {
member.isSelected = true;
}
return member;
state.page.getAllItems().forEach(item => {
item.setSelected(state.selectedProjectMembersEmails.includes(item.getEmail()));
});
return projectMembersPage;
@ -78,27 +75,25 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
state.cursor.orderDirection = direction;
}
function toggleProjectMemberSelection(projectMember: ProjectMember) {
if (!state.selectedProjectMembersEmails.includes(projectMember.user.email)) {
projectMember.isSelected = true;
state.selectedProjectMembersEmails.push(projectMember.user.email);
function toggleProjectMemberSelection(projectMember: ProjectMemberItemModel) {
const email = projectMember.getEmail();
if (!state.selectedProjectMembersEmails.includes(email)) {
projectMember.setSelected(true);
state.selectedProjectMembersEmails.push(email);
return;
}
projectMember.isSelected = false;
projectMember.setSelected(false);
state.selectedProjectMembersEmails = state.selectedProjectMembersEmails.filter(projectMemberEmail => {
return projectMemberEmail !== projectMember.user.email;
return projectMemberEmail !== email;
});
}
function clearProjectMemberSelection() {
state.selectedProjectMembersEmails = [];
state.page.projectMembers = state.page.projectMembers.map((projectMember: ProjectMember) => {
projectMember.isSelected = false;
return projectMember;
});
state.page.getAllItems().forEach(member => member.setSelected(false));
}
function clear() {

View File

@ -84,6 +84,7 @@ export class ProjectMemberCursor {
export class ProjectMembersPage {
public constructor(
public projectMembers: ProjectMember[] = [],
public projectInvitations: ProjectInvitationItemModel[] = [],
public search: string = '',
public order: ProjectMemberOrderBy = ProjectMemberOrderBy.NAME,
public orderDirection: SortDirection = SortDirection.ASCENDING,
@ -92,14 +93,73 @@ export class ProjectMembersPage {
public currentPage: number = 1,
public totalCount: number = 0,
) {}
/**
* Returns all project members and invitations as ProjectMemberItemModel.
*/
public getAllItems(): ProjectMemberItemModel[] {
const items = (this.projectMembers as ProjectMemberItemModel[]).concat(this.projectInvitations);
return items.sort((a, b) => {
let cmp: (a: ProjectMemberItemModel, b: ProjectMemberItemModel) => number;
if (this.order === ProjectMemberOrderBy.CREATED_AT) {
cmp = (a, b) => a.getJoinDate().getTime() - b.getJoinDate().getTime();
} else {
cmp = (a, b) => a.getName().toLowerCase().localeCompare(b.getName().toLowerCase());
}
const result = (this.orderDirection === SortDirection.DESCENDING) ? cmp(b, a) : cmp(a, b);
return (result !== 0) ? result : a.getEmail().toLowerCase().localeCompare(b.getEmail().toLowerCase());
});
}
}
/**
* ProjectInvitationItemModel represents the view model for project member list items.
*/
export interface ProjectMemberItemModel {
/**
* Returns the member's user ID if it exists.
*/
getUserID(): string | null;
/**
* Returns the member's name.
*/
getName(): string;
/**
* Returns the member's email address.
*/
getEmail(): string,
/**
* Returns the date that the member joined the project.
*/
getJoinDate(): Date;
/**
* Sets whether the member item has been selected.
*/
setSelected(selected: boolean): void;
/**
* Returns whether the member item has been selected.
*/
isSelected(): boolean;
/**
* Returns whether the member has yet to accept its invitation.
*/
isPending(): boolean;
}
/**
* ProjectMember is a type, used to describe project member.
*/
export class ProjectMember {
export class ProjectMember implements ProjectMemberItemModel {
public user: User;
public isSelected: boolean;
public _isSelected = false;
public constructor(
public fullName: string = '',
@ -109,20 +169,126 @@ export class ProjectMember {
public id: string = '',
) {
this.user = new User(this.id, this.fullName, this.shortName, this.email);
this.isSelected = false;
}
/**
* Returns the user's ID.
*/
public getUserID(): string | null {
return this.id;
}
/**
* Returns user's full name.
*/
public get name(): string {
public getName(): string {
return this.user.getFullName();
}
/**
* Returns joined at date as a local date string.
* Returns user's email address.
*/
public localDate(): string {
return this.joinedAt.toLocaleDateString('en-US', { day:'numeric', month:'short', year:'numeric' });
public getEmail(): string {
return this.email;
}
/**
* Returns the date that the member joined the project.
*/
public getJoinDate(): Date {
return this.joinedAt;
}
/**
* Sets whether the member item has been selected.
*/
public setSelected(selected: boolean): void {
this._isSelected = selected;
}
/**
* Returns whether the member item has been selected.
*/
public isSelected(): boolean {
return this._isSelected;
}
/**
* Returns whether the member has yet to accept its invitation.
* Always false. Required for implementing ProjectMemberItemModel.
*/
public isPending(): boolean {
return false;
}
}
/**
* ProjectInvitationItemModel represents the view model for project member invitation list items.
*/
export class ProjectInvitationItemModel implements ProjectMemberItemModel {
private _isSelected = false;
public constructor(
public email: string,
public createdAt: Date,
) {}
/**
* Returns a null user ID. Required for implementing ProjectMemberItemModel.
*/
public getUserID(): string | null {
return null;
}
/**
* Returns the placeholder name of the invitation recipient.
*/
public getName(): string {
return this.getEmail().split('@')[0];
}
/**
* Returns the invitation recipient's email address.
*/
public getEmail(): string {
return this.email.toLowerCase();
}
/**
* Returns the date that the invitation was created.
*/
public getJoinDate(): Date {
return this.createdAt;
}
/**
* Sets whether the invitation item has been selected.
*/
public setSelected(selected: boolean): void {
this._isSelected = selected;
}
/**
* Returns whether the invitation item has been selected.
*/
public isSelected(): boolean {
return this._isSelected;
}
/**
* Returns whether the member has yet to accept its invitation.
* Always true. Required for implementing ProjectMemberItemModel.
*/
public isPending(): boolean {
return true;
}
}
/**
* ProjectRole represents a project member's role.
*/
export enum ProjectRole {
Member = 'Member',
Owner = 'Owner',
Invited = 'Invited',
}

View File

@ -3,7 +3,7 @@
<template>
<div class="invite-item">
<project-ownership-tag class="invite-item__tag" :is-invited="true" />
<project-ownership-tag class="invite-item__tag" :role="ProjectRole.Invited" />
<div class="invite-item__info">
<p class="invite-item__info__name">{{ invitation.projectName }}</p>
@ -38,6 +38,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ProjectRole } from '@/types/projectMembers';
import { ProjectInvitation, ProjectInvitationResponse } from '@/types/projects';
import { useNotify } from '@/utils/hooks';
import { AnalyticsHttpApi } from '@/api/analytics';

View File

@ -4,7 +4,7 @@
<template>
<div v-if="project.id" class="project-item">
<div class="project-item__header">
<project-ownership-tag :is-owner="isOwner" />
<project-ownership-tag :role="isOwner ? ProjectRole.Owner : ProjectRole.Member" />
<a
v-click-outside="closeDropDown" href="" class="project-item__header__menu"
@ -47,6 +47,7 @@ import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Project } from '@/types/projects';
import { ProjectRole } from '@/types/projectMembers';
import { AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { User } from '@/types/users';
import { AnalyticsHttpApi } from '@/api/analytics';

View File

@ -50,6 +50,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ProjectRole } from '@/types/projectMembers';
import { ProjectInvitation, ProjectInvitationResponse } from '@/types/projects';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
@ -85,7 +86,7 @@ const itemToRender = computed((): { [key: string]: unknown | string[] } => {
multi: { title: props.invitation.projectName, subtitle: props.invitation.projectDescription },
date: props.invitation.invitedDate,
memberCount: '',
invited: true,
role: ProjectRole.Invited,
};
}

View File

@ -37,8 +37,8 @@
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { ProjectRole } from '@/types/projectMembers';
import { Project } from '@/types/projects';
import { useNotify } from '@/utils/hooks';
import { AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { User } from '@/types/users';
import { AnalyticsHttpApi } from '@/api/analytics';
@ -63,7 +63,6 @@ const appStore = useAppStore();
const pmStore = useProjectMembersStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const notify = useNotify();
const router = useRouter();
const analytics = new AnalyticsHttpApi();
@ -80,7 +79,7 @@ const itemToRender = computed((): { [key: string]: unknown | string[] } => {
multi: { title: props.project.name, subtitle: props.project.description },
date: props.project.createdDate(),
memberCount: props.project.memberCount.toString(),
owner: isOwner.value,
role: isOwner.value ? ProjectRole.Owner : ProjectRole.Member,
};
}

View File

@ -1,7 +1,7 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { vi } from 'vitest';
import { describe, beforeEach, it, expect, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { ProjectMembersApiGql } from '@/api/projectMembers';
@ -15,7 +15,7 @@ selectedProject.id = '1';
const FIRST_PAGE = 1;
const TEST_ERROR = new Error('testError');
const UNREACHABLE_ERROR = 'should be unreachable';
const UNREACHABLE_ERROR = new Error('should be unreachable');
const date = new Date(0);
const projectMember1 = new ProjectMember('testFullName1', 'testShortName1', 'test1@example.com', date, '1');
@ -93,7 +93,7 @@ describe('actions', () => {
store.setPage(testProjectMembersPage);
store.toggleProjectMemberSelection(projectMember1);
expect(store.state.page.projectMembers[0].isSelected).toBe(true);
expect(store.state.page.projectMembers[0].isSelected()).toBe(true);
expect(store.state.selectedProjectMembersEmails.length).toBe(1);
vi.spyOn(ProjectMembersApiGql.prototype, 'get')
@ -105,7 +105,7 @@ describe('actions', () => {
store.toggleProjectMemberSelection(projectMember1);
expect(store.state.page.projectMembers[0].isSelected).toBe(false);
expect(store.state.page.projectMembers[0].isSelected()).toBe(false);
expect(store.state.selectedProjectMembersEmails.length).toBe(0);
});
@ -122,7 +122,7 @@ describe('actions', () => {
store.clearProjectMemberSelection();
store.state.page.projectMembers.forEach((pm: ProjectMember) => {
expect(pm.isSelected).toBe(false);
expect(pm.isSelected()).toBe(false);
});
expect(store.state.selectedProjectMembersEmails.length).toBe(0);
@ -159,7 +159,7 @@ describe('actions', () => {
return;
}
fail(UNREACHABLE_ERROR);
throw UNREACHABLE_ERROR;
});
it('fetch project members', async function () {
@ -168,6 +168,7 @@ describe('actions', () => {
vi.spyOn(ProjectMembersApiGql.prototype, 'get').mockReturnValue(
Promise.resolve(new ProjectMembersPage(
[projectMember1],
[],
'',
ProjectMemberOrderBy.NAME,
SortDirection.ASCENDING,
@ -179,7 +180,7 @@ describe('actions', () => {
await store.getProjectMembers(FIRST_PAGE, selectedProject.id);
expect(store.state.page.projectMembers[0].isSelected).toBe(false);
expect(store.state.page.projectMembers[0].isSelected()).toBe(false);
expect(store.state.page.projectMembers[0].joinedAt).toBe(projectMember1.joinedAt);
expect(store.state.page.projectMembers[0].user.email).toBe(projectMember1.user.email);
expect(store.state.page.projectMembers[0].user.id).toBe(projectMember1.user.id);
@ -205,6 +206,6 @@ describe('actions', () => {
return;
}
fail(UNREACHABLE_ERROR);
throw UNREACHABLE_ERROR;
});
});