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"
|
||||
eventGalleryViewClicked = "Gallery View Clicked"
|
||||
eventResendInviteClicked = "Resend Invite Clicked"
|
||||
eventCopyInviteLinkClicked = "Copy Invite Link Clicked"
|
||||
eventRemoveProjectMemberCLicked = "Remove Member Clicked"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -159,8 +161,7 @@ func NewService(log *zap.Logger, config Config, satelliteName string) *Service {
|
||||
eventApplyNewCouponClicked, eventCreditCardRemoved, eventCouponCodeApplied, eventInvoiceDownloaded, eventCreditCardAddedFromBilling,
|
||||
eventStorjTokenAddedFromBilling, eventAddFundsClicked, eventProjectMembersInviteSent, eventError, eventProjectNameUpdated, eventProjectDescriptionUpdated,
|
||||
eventProjectStorageLimitUpdated, eventProjectBandwidthLimitUpdated, eventProjectInvitationAccepted, eventProjectInvitationDeclined,
|
||||
eventGalleryViewClicked, eventResendInviteClicked,
|
||||
} {
|
||||
eventGalleryViewClicked, eventResendInviteClicked, eventRemoveProjectMemberCLicked, eventCopyInviteLinkClicked} {
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
@ -8,33 +8,96 @@
|
||||
:selectable="true"
|
||||
:select-disabled="isProjectOwner"
|
||||
:selected="model.isSelected()"
|
||||
:on-click="(_) => $emit('memberClick', model)"
|
||||
@selectClicked="($event) => $emit('selectClicked', $event)"
|
||||
/>
|
||||
:on-click="(_) => emit('memberClick', model)"
|
||||
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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ProjectInvitationItemModel, ProjectMember, ProjectMemberItemModel, ProjectRole } from '@/types/projectMembers';
|
||||
import { useResize } from '@/composables/resize';
|
||||
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 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 { withLoading } = useLoading();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const notify = useNotify();
|
||||
const pmStore = useProjectMembersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const analytics: AnalyticsHttpApi = new AnalyticsHttpApi();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
model: ProjectMemberItemModel;
|
||||
}>(), {
|
||||
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 => {
|
||||
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 } => {
|
||||
let role: ProjectRole = ProjectRole.Member;
|
||||
if (props.model.isPending()) {
|
||||
@ -63,9 +126,119 @@ const itemToRender = computed((): { [key: string]: unknown } => {
|
||||
// TODO: change after adding actions button to list item
|
||||
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>
|
||||
|
||||
<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) {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
@ -36,8 +36,10 @@
|
||||
v-for="(member, key) in projectMembers"
|
||||
:key="key"
|
||||
:model="member"
|
||||
@removeClick="onRemoveClick"
|
||||
@resendClick="onResendClick"
|
||||
@memberClick="onMemberCheckChange"
|
||||
@selectClicked="(_) => onMemberCheckChange(member)"
|
||||
@selectClick="onMemberCheckChange"
|
||||
/>
|
||||
</template>
|
||||
</v-table>
|
||||
@ -48,10 +50,14 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { ProjectMemberItemModel } from '@/types/projectMembers';
|
||||
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
|
||||
import { useNotify } from '@/utils/hooks';
|
||||
import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
|
||||
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 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';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const pmStore = useProjectMembersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const notify = useNotify();
|
||||
|
||||
const analytics: AnalyticsHttpApi = new AnalyticsHttpApi();
|
||||
|
||||
const { withLoading } = useLoading();
|
||||
|
||||
const FIRST_PAGE = 1;
|
||||
|
||||
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.
|
||||
* Fetches first page of team members list of current project.
|
||||
|
@ -32,6 +32,10 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
|
||||
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> {
|
||||
await api.delete(projectID, state.selectedProjectMembersEmails);
|
||||
|
||||
@ -112,6 +116,7 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
|
||||
return {
|
||||
state,
|
||||
inviteMembers,
|
||||
getInviteLink,
|
||||
deleteProjectMembers,
|
||||
getProjectMembers,
|
||||
setSearchQuery,
|
||||
|
@ -32,6 +32,16 @@ export interface ProjectMembersApi {
|
||||
*/
|
||||
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
|
||||
*
|
||||
|
@ -56,6 +56,8 @@ export enum AnalyticsEvent {
|
||||
PROJECT_INVITATION_DECLINED = 'Project Invitation Declined',
|
||||
PASSPHRASE_CREATED = 'Passphrase Created',
|
||||
RESEND_INVITE_CLICKED = 'Resend Invite Clicked',
|
||||
COPY_INVITE_LINK_CLICKED = 'Copy Invite Link Clicked',
|
||||
REMOVE_PROJECT_MEMBER_CLICKED = 'Remove Member Clicked',
|
||||
}
|
||||
|
||||
export enum AnalyticsErrorEventSource {
|
||||
|
Loading…
Reference in New Issue
Block a user