From 8ee7d104a0d81971163ce70a2cf0de5b64b6bd80 Mon Sep 17 00:00:00 2001 From: Jeremy Wharton Date: Thu, 8 Jun 2023 03:12:24 -0500 Subject: [PATCH] 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 --- web/satellite/src/api/projectMembers.ts | 14 +- .../src/components/common/TableItem.vue | 5 +- .../project/ProjectOwnershipTag.vue | 20 +- .../project/dashboard/ProjectDashboard.vue | 3 +- .../components/team/ProjectMemberListItem.vue | 35 +++- .../components/team/ProjectMembersArea.vue | 16 +- .../src/store/modules/projectMembersStore.ts | 29 ++- web/satellite/src/types/projectMembers.ts | 180 +++++++++++++++++- .../components/ProjectInvitationItem.vue | 3 +- .../all-dashboard/components/ProjectItem.vue | 3 +- .../components/ProjectTableInvitationItem.vue | 3 +- .../components/ProjectTableItem.vue | 5 +- .../tests/unit/store/projectMembers.spec.ts | 17 +- 13 files changed, 258 insertions(+), 75 deletions(-) diff --git a/web/satellite/src/api/projectMembers.ts b/web/satellite/src/api/projectMembers.ts index f4f0ea47b..dc003ddc2 100644 --- a/web/satellite/src/api/projectMembers.ts +++ b/web/satellite/src/api/projectMembers.ts @@ -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; diff --git a/web/satellite/src/components/common/TableItem.vue b/web/satellite/src/components/common/TableItem.vue index 48f3f2fb2..00d6d7f51 100644 --- a/web/satellite/src/components/common/TableItem.vue +++ b/web/satellite/src/components/common/TableItem.vue @@ -27,8 +27,7 @@

- - + {{ val }}

@@ -44,6 +43,8 @@ diff --git a/web/satellite/src/components/project/dashboard/ProjectDashboard.vue b/web/satellite/src/components/project/dashboard/ProjectDashboard.vue index 11df15f97..340599c87 100644 --- a/web/satellite/src/components/project/dashboard/ProjectDashboard.vue +++ b/web/satellite/src/components/project/dashboard/ProjectDashboard.vue @@ -5,7 +5,7 @@

{{ selectedProject.name }}

- +

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'; diff --git a/web/satellite/src/components/team/ProjectMemberListItem.vue b/web/satellite/src/components/team/ProjectMemberListItem.vue index c6fc19ac1..2e371f51e 100644 --- a/web/satellite/src/components/team/ProjectMemberListItem.vue +++ b/web/satellite/src/components/team/ProjectMemberListItem.vue @@ -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)" /> @@ -16,7 +16,7 @@ diff --git a/web/satellite/src/components/team/ProjectMembersArea.vue b/web/satellite/src/components/team/ProjectMembersArea.vue index 6ee10ab42..b23be2e7a 100644 --- a/web/satellite/src/components/team/ProjectMembersArea.vue +++ b/web/satellite/src/components/team/ProjectMembersArea.vue @@ -37,7 +37,7 @@ @@ -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(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); } } diff --git a/web/satellite/src/store/modules/projectMembersStore.ts b/web/satellite/src/store/modules/projectMembersStore.ts index 7ccca4c98..458e1f84a 100644 --- a/web/satellite/src/store/modules/projectMembersStore.ts +++ b/web/satellite/src/store/modules/projectMembersStore.ts @@ -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() { diff --git a/web/satellite/src/types/projectMembers.ts b/web/satellite/src/types/projectMembers.ts index b72d054e3..ed5228d16 100644 --- a/web/satellite/src/types/projectMembers.ts +++ b/web/satellite/src/types/projectMembers.ts @@ -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', +} diff --git a/web/satellite/src/views/all-dashboard/components/ProjectInvitationItem.vue b/web/satellite/src/views/all-dashboard/components/ProjectInvitationItem.vue index 7089a6f42..83d001b70 100644 --- a/web/satellite/src/views/all-dashboard/components/ProjectInvitationItem.vue +++ b/web/satellite/src/views/all-dashboard/components/ProjectInvitationItem.vue @@ -3,7 +3,7 @@