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:
parent
0421ef2fa1
commit
79eb71841d
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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;
|
||||||
|
@ -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.
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
*
|
*
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user