satellite/{web,analytics}: add row actions to project members

This change adds row actions (delete,reinvite,copy) to the project
members table. It also adds analytics events for the actions.

Issue: #5762
Also fixes: #5941

Change-Id: I7fb7f88c7bd5ac2ce3e3d00530af4708ff220bd7
This commit is contained in:
Wilfred Asomani 2023-06-20 10:50:05 +00:00
parent 0421ef2fa1
commit 79eb71841d
7 changed files with 256 additions and 8 deletions

View File

@ -90,6 +90,8 @@ const (
eventProjectInvitationDeclined = "Project Invitation Declined" eventProjectInvitationDeclined = "Project Invitation Declined"
eventGalleryViewClicked = "Gallery View Clicked" eventGalleryViewClicked = "Gallery View Clicked"
eventResendInviteClicked = "Resend Invite Clicked" eventResendInviteClicked = "Resend Invite Clicked"
eventCopyInviteLinkClicked = "Copy Invite Link Clicked"
eventRemoveProjectMemberCLicked = "Remove Member Clicked"
) )
var ( var (
@ -159,8 +161,7 @@ func NewService(log *zap.Logger, config Config, satelliteName string) *Service {
eventApplyNewCouponClicked, eventCreditCardRemoved, eventCouponCodeApplied, eventInvoiceDownloaded, eventCreditCardAddedFromBilling, eventApplyNewCouponClicked, eventCreditCardRemoved, eventCouponCodeApplied, eventInvoiceDownloaded, eventCreditCardAddedFromBilling,
eventStorjTokenAddedFromBilling, eventAddFundsClicked, eventProjectMembersInviteSent, eventError, eventProjectNameUpdated, eventProjectDescriptionUpdated, eventStorjTokenAddedFromBilling, eventAddFundsClicked, eventProjectMembersInviteSent, eventError, eventProjectNameUpdated, eventProjectDescriptionUpdated,
eventProjectStorageLimitUpdated, eventProjectBandwidthLimitUpdated, eventProjectInvitationAccepted, eventProjectInvitationDeclined, eventProjectStorageLimitUpdated, eventProjectBandwidthLimitUpdated, eventProjectInvitationAccepted, eventProjectInvitationDeclined,
eventGalleryViewClicked, eventResendInviteClicked, eventGalleryViewClicked, eventResendInviteClicked, eventRemoveProjectMemberCLicked, eventCopyInviteLinkClicked} {
} {
service.clientEvents[name] = true service.clientEvents[name] = true
} }

View File

@ -107,6 +107,22 @@ export class ProjectMembersApiGql extends BaseGql implements ProjectMembersApi {
throw new Error(result.error || 'Failed to send project invitations'); throw new Error(result.error || 'Failed to send project invitations');
} }
/**
* Get invite link for the specified project and email.
*
* @throws Error
*/
public async getInviteLink(projectID: string, email: string): Promise<string> {
const path = `${this.ROOT_PATH}/${projectID}/invite-link?email=${email}`;
const httpResponse = await this.http.get(path);
if (httpResponse.ok) {
return await httpResponse.json();
}
throw new Error('Can not get invite link');
}
/** /**
* Method for mapping project members page from json to ProjectMembersPage type. * Method for mapping project members page from json to ProjectMembersPage type.
* *

View File

@ -8,33 +8,96 @@
:selectable="true" :selectable="true"
:select-disabled="isProjectOwner" :select-disabled="isProjectOwner"
:selected="model.isSelected()" :selected="model.isSelected()"
:on-click="(_) => $emit('memberClick', model)" :on-click="(_) => emit('memberClick', model)"
@selectClicked="($event) => $emit('selectClicked', $event)" class="project-member-item"
/> @selectClicked="(_) => emit('selectClick', model)"
>
<template #options>
<th class="project-member-item__menu options overflow-visible" @click.stop="toggleDropDown">
<div v-if="!isProjectOwner" class="project-member-item__menu__icon">
<div class="project-member-item__menu__icon__content" :class="{open: isDropdownOpen}">
<menu-icon />
</div>
</div>
<div v-if="isDropdownOpen" v-click-outside="closeDropDown" class="project-member-item__menu__dropdown">
<div v-if="model.isPending() && !isExpired" class="project-member-item__menu__dropdown__item" @click.stop="copyLinkClicked">
<copy-icon />
<p class="project-member-item__menu__dropdown__item__label">Copy invite link</p>
</div>
<div v-if="model.isPending() && isExpired" class="project-member-item__menu__dropdown__item" @click.stop="resendClicked">
<upload-icon />
<p class="project-member-item__menu__dropdown__item__label">Resend invite</p>
</div>
<div class="project-member-item__menu__dropdown__item" @click.stop="deleteClicked">
<delete-icon />
<p class="project-member-item__menu__dropdown__item__label">Remove member</p>
</div>
</div>
</th>
</template>
</table-item>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { ProjectInvitationItemModel, ProjectMember, ProjectMemberItemModel, ProjectRole } from '@/types/projectMembers'; import { ProjectInvitationItemModel, ProjectMember, ProjectMemberItemModel, ProjectRole } from '@/types/projectMembers';
import { useResize } from '@/composables/resize'; import { useResize } from '@/composables/resize';
import { useProjectsStore } from '@/store/modules/projectsStore'; import { useProjectsStore } from '@/store/modules/projectsStore';
import { useAppStore } from '@/store/modules/appStore';
import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { useLoading } from '@/composables/useLoading';
import { AnalyticsHttpApi } from '@/api/analytics';
import TableItem from '@/components/common/TableItem.vue'; import TableItem from '@/components/common/TableItem.vue';
import UploadIcon from '@/../static/images/common/upload.svg';
import DeleteIcon from '@/../static/images/browser/galleryView/delete.svg';
import MenuIcon from '@/../static/images/common/horizontalDots.svg';
import CopyIcon from '@/../static/images/accessGrants/newCreateFlow/copy.svg';
const { isMobile, isTablet } = useResize(); const { isMobile, isTablet } = useResize();
const { withLoading } = useLoading();
const appStore = useAppStore();
const notify = useNotify();
const pmStore = useProjectMembersStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const analytics: AnalyticsHttpApi = new AnalyticsHttpApi();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
model: ProjectMemberItemModel; model: ProjectMemberItemModel;
}>(), { }>(), {
model: () => new ProjectMember('', '', '', new Date(), ''), model: () => new ProjectMember('', '', '', new Date(), ''),
}); });
const emit = defineEmits<{
(e: 'selectClick', model: ProjectMemberItemModel): void
(e: 'memberClick', model: ProjectMemberItemModel): void
(e: 'removeClick', model: ProjectMemberItemModel): void
(e: 'resendClick', model: ProjectMemberItemModel): void
}>();
const inviteLink = ref('');
const isProjectOwner = computed((): boolean => { const isProjectOwner = computed((): boolean => {
return props.model.getUserID() === projectsStore.state.selectedProject.ownerId; return props.model.getUserID() === projectsStore.state.selectedProject.ownerId;
}); });
const isExpired = computed((): boolean => {
if (!props.model.isPending()) {
return false;
}
const invite = props.model as ProjectInvitationItemModel;
return invite.expired;
});
const itemToRender = computed((): { [key: string]: unknown } => { const itemToRender = computed((): { [key: string]: unknown } => {
let role: ProjectRole = ProjectRole.Member; let role: ProjectRole = ProjectRole.Member;
if (props.model.isPending()) { if (props.model.isPending()) {
@ -63,9 +126,119 @@ const itemToRender = computed((): { [key: string]: unknown } => {
// TODO: change after adding actions button to list item // TODO: change after adding actions button to list item
return { name: props.model.getName(), email: props.model.getEmail() }; return { name: props.model.getName(), email: props.model.getEmail() };
}); });
/**
* isDropdownOpen if dropdown is open.
*/
const isDropdownOpen = computed((): boolean => {
return appStore.state.activeDropdown === props.model.getEmail();
});
function copyLinkClicked() {
analytics.eventTriggered(AnalyticsEvent.COPY_INVITE_LINK_CLICKED);
closeDropDown();
if (inviteLink.value) {
navigator.clipboard.writeText(inviteLink.value);
return;
}
withLoading(async () => {
try {
inviteLink.value = await pmStore.getInviteLink(props.model.getEmail(), projectsStore.state.selectedProject.id);
} catch (_) {
notify.error(`Error getting invite link.`, AnalyticsErrorEventSource.PROJECT_MEMBERS_PAGE);
}
navigator.clipboard.writeText(inviteLink.value);
notify.notify('Invite copied!');
});
}
function resendClicked() {
emit('resendClick', props.model);
closeDropDown();
}
function deleteClicked() {
emit('removeClick', props.model);
closeDropDown();
}
function toggleDropDown() {
appStore.toggleActiveDropdown(props.model.getEmail());
}
function closeDropDown() {
appStore.closeDropdowns();
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.project-member-item {
&__menu {
padding: 0 10px;
position: relative;
cursor: pointer;
&__icon {
&__content {
height: 32px;
width: 32px;
margin-left: auto;
margin-right: 0;
padding: 12px 5px;
border-radius: 5px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
&.open {
background: var(--c-grey-3);
}
}
}
&__dropdown {
position: absolute;
top: 50px;
right: 10px;
background: var(--c-white);
box-shadow: 0 7px 20px rgb(0 0 0 / 15%);
border: 1px solid var(--c-grey-2);
border-radius: 8px;
z-index: 100;
overflow: hidden;
&__item {
display: flex;
align-items: center;
width: 200px;
padding: 15px;
color: var(--c-grey-6);
cursor: pointer;
&__label {
font-family: 'font_regular', sans-serif;
margin: 0 0 0 10px;
}
&:hover {
font-family: 'font_medium', sans-serif;
color: var(--c-blue-3);
background-color: var(--c-grey-1);
svg :deep(path) {
fill: var(--c-blue-3);
}
}
}
}
}
}
:deep(.primary) { :deep(.primary) {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;

View File

@ -36,8 +36,10 @@
v-for="(member, key) in projectMembers" v-for="(member, key) in projectMembers"
:key="key" :key="key"
:model="member" :model="member"
@removeClick="onRemoveClick"
@resendClick="onResendClick"
@memberClick="onMemberCheckChange" @memberClick="onMemberCheckChange"
@selectClicked="(_) => onMemberCheckChange(member)" @selectClick="onMemberCheckChange"
/> />
</template> </template>
</v-table> </v-table>
@ -48,10 +50,14 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { ProjectMemberItemModel } from '@/types/projectMembers'; import { ProjectMemberItemModel } from '@/types/projectMembers';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames'; import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { useNotify } from '@/utils/hooks'; import { useNotify } from '@/utils/hooks';
import { useProjectMembersStore } from '@/store/modules/projectMembersStore'; import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
import { useProjectsStore } from '@/store/modules/projectsStore'; import { useProjectsStore } from '@/store/modules/projectsStore';
import { AnalyticsHttpApi } from '@/api/analytics';
import { useLoading } from '@/composables/useLoading';
import { MODALS } from '@/utils/constants/appStatePopUps';
import { useAppStore } from '@/store/modules/appStore';
import VLoader from '@/components/common/VLoader.vue'; import VLoader from '@/components/common/VLoader.vue';
import HeaderArea from '@/components/team/HeaderArea.vue'; import HeaderArea from '@/components/team/HeaderArea.vue';
@ -60,10 +66,15 @@ import VTable from '@/components/common/VTable.vue';
import EmptySearchResultIcon from '@/../static/images/common/emptySearchResult.svg'; import EmptySearchResultIcon from '@/../static/images/common/emptySearchResult.svg';
const appStore = useAppStore();
const pmStore = useProjectMembersStore(); const pmStore = useProjectMembersStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const notify = useNotify(); const notify = useNotify();
const analytics: AnalyticsHttpApi = new AnalyticsHttpApi();
const { withLoading } = useLoading();
const FIRST_PAGE = 1; const FIRST_PAGE = 1;
const areMembersFetching = ref<boolean>(true); const areMembersFetching = ref<boolean>(true);
@ -139,6 +150,36 @@ async function onPageChange(index: number, limit: number): Promise<void> {
} }
} }
function onResendClick(member: ProjectMemberItemModel) {
withLoading(async () => {
analytics.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED);
try {
await pmStore.inviteMembers([member.getEmail()], projectsStore.state.selectedProject.id);
} catch (_) {
await notify.error(`Error resending invite.`, AnalyticsErrorEventSource.PROJECT_MEMBERS_PAGE);
}
await notify.notify('Invite resent!');
pmStore.setSearchQuery('');
try {
await pmStore.getProjectMembers(FIRST_PAGE, projectsStore.state.selectedProject.id);
} catch (error) {
await notify.error(`Unable to fetch project members. ${error.message}`, AnalyticsErrorEventSource.PROJECT_MEMBERS_PAGE);
}
});
}
async function onRemoveClick(member: ProjectMemberItemModel) {
if (projectsStore.state.selectedProject.ownerId !== member.getUserID()) {
if (!member.isSelected()) {
pmStore.toggleProjectMemberSelection(member);
}
appStore.updateActiveModal(MODALS.removeTeamMember);
}
analytics.eventTriggered(AnalyticsEvent.REMOVE_PROJECT_MEMBER_CLICKED);
}
/** /**
* Lifecycle hook after initial render. * Lifecycle hook after initial render.
* Fetches first page of team members list of current project. * Fetches first page of team members list of current project.

View File

@ -32,6 +32,10 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
await api.invite(projectID, emails); await api.invite(projectID, emails);
} }
async function getInviteLink(email: string, projectID: string): Promise<string> {
return await api.getInviteLink(projectID, email);
}
async function deleteProjectMembers(projectID: string): Promise<void> { async function deleteProjectMembers(projectID: string): Promise<void> {
await api.delete(projectID, state.selectedProjectMembersEmails); await api.delete(projectID, state.selectedProjectMembersEmails);
@ -112,6 +116,7 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
return { return {
state, state,
inviteMembers, inviteMembers,
getInviteLink,
deleteProjectMembers, deleteProjectMembers,
getProjectMembers, getProjectMembers,
setSearchQuery, setSearchQuery,

View File

@ -32,6 +32,16 @@ export interface ProjectMembersApi {
*/ */
invite(projectId: string, emails: string[]): Promise<void>; invite(projectId: string, emails: string[]): Promise<void>;
/**
* Get invite link for the specified project and email.
*
* @param projectId
* @param email
*
* @throws Error
*/
getInviteLink(projectId: string, email: string): Promise<string>;
/** /**
* Deletes ProjectMembers from project by project member emails * Deletes ProjectMembers from project by project member emails
* *

View File

@ -56,6 +56,8 @@ export enum AnalyticsEvent {
PROJECT_INVITATION_DECLINED = 'Project Invitation Declined', PROJECT_INVITATION_DECLINED = 'Project Invitation Declined',
PASSPHRASE_CREATED = 'Passphrase Created', PASSPHRASE_CREATED = 'Passphrase Created',
RESEND_INVITE_CLICKED = 'Resend Invite Clicked', RESEND_INVITE_CLICKED = 'Resend Invite Clicked',
COPY_INVITE_LINK_CLICKED = 'Copy Invite Link Clicked',
REMOVE_PROJECT_MEMBER_CLICKED = 'Remove Member Clicked',
} }
export enum AnalyticsErrorEventSource { export enum AnalyticsErrorEventSource {