feat(fe2): invite + list workspace invites (#2629)

* list invites table

* invites list works

* update last reminded date on resend

* fix FE

* WIP invitedialog + updated debounced utility

* invite create works

* exclude users correctly

* more adjustments

* minor cleanup

* using workspace invite server role

* test fix

* fixed multiple root eslint issues

* minor adjustments
This commit is contained in:
Kristaps Fabians Geikins
2024-08-12 11:30:01 +03:00
committed by GitHub
parent 03db1cca94
commit 4dae1569cd
69 changed files with 1903 additions and 1327 deletions
@@ -10,10 +10,15 @@
<template #default="{ activeItem }">
<SettingsWorkspacesMembersTable
v-if="activeItem.id === 'members'"
:workspace="workspace"
:workspace-id="workspaceId"
/>
<div v-if="activeItem.id === 'guests'">Guests</div>
<div v-if="activeItem.id === 'invites'">Pending invites</div>
<SettingsWorkspacesMembersInvitesTable
v-if="activeItem.id === 'invites'"
:workspace-id="workspaceId"
:workspace="workspace"
/>
</template>
</LayoutTabsHorizontal>
</div>
@@ -21,17 +26,53 @@
</template>
<script setup lang="ts">
import type { LayoutTabItem } from '~~/lib/layout/helpers/components'
import { Roles } from '@speckle/shared'
import { useQuery } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import { settingsWorkspacesMembersQuery } from '~/lib/settings/graphql/queries'
import type { LayoutPageTabItem } from '~~/lib/layout/helpers/components'
defineProps<{
graphql(`
fragment SettingsWorkspacesMembers_Workspace on Workspace {
id
role
}
`)
const props = defineProps<{
workspaceId: string
}>()
const tabItems = ref<LayoutTabItem[]>([
const { result } = useQuery(
settingsWorkspacesMembersQuery,
() => ({
workspaceId: props.workspaceId
}),
() => ({
// Custom error policy so that a failing invitedTeam resolver (due to access rights)
// doesn't kill the entire query
errorPolicy: 'all',
context: {
skipLoggingErrors: (err) =>
err.graphQLErrors?.length === 1 &&
err.graphQLErrors.some((e) => e.path?.includes('invitedTeam'))
}
})
)
const isAdmin = computed(() => result.value?.workspace?.role === Roles.Workspace.Admin)
const tabItems = computed<LayoutPageTabItem[]>(() => [
{ title: 'Members', id: 'members' },
{ title: 'Guests', id: 'guests' },
{ title: 'Pending invites', id: 'invites' }
{
title: 'Pending invites',
id: 'invites',
disabled: !isAdmin.value,
disabledMessage: 'Only workspace admins can manage invites'
}
])
const activeTab = ref(tabItems.value[0])
const workspace = computed(() => result.value?.workspace)
</script>
@@ -0,0 +1,124 @@
<template>
<div>
<SettingsWorkspacesMembersTableHeader
v-model:search="search"
search-placeholder="Search pending invites..."
:workspace-id="workspaceId"
:workspace="workspace"
/>
<LayoutTable
class="mt-6 md:mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3' },
{ id: 'invitedBy', header: 'Invited by', classes: 'col-span-4' },
{ id: 'role', header: 'Role', classes: 'col-span-2' },
{ id: 'lastRemindedOn', header: 'Last reminded on', classes: 'col-span-3' }
]"
:buttons="buttons"
:items="invites"
:loading="searchResultLoading"
:empty-message="
search.length
? 'No invites with the specified filter found'
: 'No pending invites'
"
>
<template #name="{ item }">
<div class="flex items-center gap-2">
<UserAvatar v-if="item.user" :user="item.user" />
<span class="truncate text-body-xs text-foreground">{{ item.title }}</span>
</div>
</template>
<template #invitedBy="{ item }">
<div class="flex items-center gap-2">
<UserAvatar :user="item.invitedBy" />
<span class="truncate text-body-xs text-foreground">
{{ item.invitedBy.name }}
</span>
</div>
</template>
<template #role="{ item }">
<span class="text-body-xs text-foreground-2">
{{ roleDisplayName(item.role) }}
</span>
</template>
<template #lastRemindedOn="{ item }">
<span class="text-body-xs text-foreground-2">
{{ formattedFullDate(item.updatedAt) }}
</span>
</template>
</LayoutTable>
</div>
</template>
<script setup lang="ts">
import { EnvelopeIcon, XMarkIcon } from '@heroicons/vue/24/outline'
import { useQuery } from '@vue/apollo-composable'
import { capitalize } from 'lodash-es'
import { graphql } from '~/lib/common/generated/gql'
import type { SettingsWorkspacesMembersInvitesTable_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { settingsWorkspacesInvitesSearchQuery } from '~/lib/settings/graphql/queries'
graphql(`
fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
id
role
title
updatedAt
user {
id
...LimitedUserAvatar
}
invitedBy {
id
...LimitedUserAvatar
}
}
`)
graphql(`
fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {
id
...SettingsWorkspacesMembersTableHeader_Workspace
invitedTeam(filter: $invitesFilter) {
...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator
}
}
`)
const props = defineProps<{
workspaceId: string
workspace?: SettingsWorkspacesMembersInvitesTable_WorkspaceFragment
}>()
const search = ref('')
const { result: searchResult, loading: searchResultLoading } = useQuery(
settingsWorkspacesInvitesSearchQuery,
() => ({
invitesSearch: search.value,
workspaceId: props.workspaceId
}),
() => ({
enabled: !!search.value.length
})
)
const invites = computed(() =>
search.value.length
? searchResult.value?.workspace.invitedTeam || props.workspace?.invitedTeam
: props.workspace?.invitedTeam
)
const buttons = computed(() => [
{
label: 'Resend invite',
icon: EnvelopeIcon,
action: () => ({})
},
{
label: 'Delete invite',
icon: XMarkIcon,
action: () => ({})
}
])
const roleDisplayName = (role: string) => capitalize(role.split(':')[1])
</script>
@@ -0,0 +1,157 @@
<template>
<div>
<SettingsWorkspacesMembersTableHeader
search-placeholder="Search members..."
:workspace-id="workspaceId"
:workspace="workspace"
/>
<LayoutTable
class="mt-6 md:mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3' },
{ id: 'company', header: 'Company', classes: 'col-span-3' },
{ id: 'verified', header: 'Status', classes: 'col-span-3' },
{ id: 'role', header: 'Role', classes: 'col-span-2' }
]"
:items="members"
>
<template #name="{ item }">
<div class="flex items-center gap-2">
<UserAvatar :user="item" />
<span class="truncate text-body-xs text-foreground">{{ item.name }}</span>
</div>
</template>
<template #company="{ item }">
<span class="text-body-xs text-foreground">
{{ item.company ? item.company : '-' }}
</span>
</template>
<template #verified="{ item }">
<span class="text-body-xs text-foreground-2">
{{ item.verified ? 'Verified' : 'Unverified' }}
</span>
</template>
<template #role="{ item }">
<FormSelectWorkspaceRoles
:model-value="item.role as WorkspaceRoles"
fully-control-value
:disabled="!isCurrentUser(item.id)"
@update:model-value="
(newRoleValue) => openChangeUserRoleDialog(item, newRoleValue)
"
/>
</template>
</LayoutTable>
<SettingsSharedChangeRoleDialog
v-model:open="showChangeUserRoleDialog"
:name="userToModify?.name ?? ''"
:old-role="oldRole"
:new-role="newRole"
@update-role="onUpdateRole"
/>
</div>
</template>
<script setup lang="ts">
// Todo: Enable searching once supported
import type { WorkspaceRoles } from '@speckle/shared'
import { workspaceUpdateRoleMutation } from '~~/lib/workspaces/graphql/mutations'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useMutation } from '@vue/apollo-composable'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import type { SettingsWorkspacesMembersMembersTable_WorkspaceFragment } from '~~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql'
type UserItem = (typeof members)['value'][0]
graphql(`
fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {
id
role
user {
id
avatar
name
company
verified
}
}
`)
graphql(`
fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {
id
...SettingsWorkspacesMembersTableHeader_Workspace
team {
id
...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator
}
}
`)
const props = defineProps<{
workspace?: SettingsWorkspacesMembersMembersTable_WorkspaceFragment
workspaceId: string
}>()
const { triggerNotification } = useGlobalToast()
// const { on, bind, value: search } = useDebouncedTextInput()
const { activeUser } = useActiveUser()
const { mutate: updateChangeRole } = useMutation(workspaceUpdateRoleMutation)
const showChangeUserRoleDialog = ref(false)
const newRole = ref<WorkspaceRoles>()
const userToModify = ref<UserItem>()
const members = computed(() =>
(props.workspace?.team || []).map(({ user, ...rest }) => ({
...user,
...rest
}))
)
const oldRole = computed(() => userToModify.value?.role as WorkspaceRoles)
const isCurrentUser = (id: string) => id === activeUser.value?.id
const openChangeUserRoleDialog = (
user: UserItem,
newRoleValue?: WorkspaceRoles | WorkspaceRoles[]
) => {
if (!newRoleValue) return
userToModify.value = user
newRole.value = Array.isArray(newRoleValue) ? newRoleValue[0] : newRoleValue
showChangeUserRoleDialog.value = true
}
const onUpdateRole = async () => {
if (!userToModify.value || !newRole.value) return
const mutationResult = await updateChangeRole({
input: {
userId: userToModify.value.id,
role: newRole.value,
workspaceId: props.workspaceId
}
}).catch(convertThrowIntoFetchResult)
if (mutationResult?.data?.workspaceMutations?.updateRole) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'User role updated',
description: 'The user role has been updated'
})
} else {
const errorMessage = getFirstErrorMessage(mutationResult?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to update role',
description: errorMessage
})
}
}
</script>
@@ -1,160 +0,0 @@
<template>
<div class="flex flex-col-reverse md:justify-between md:flex-row md:gap-x-4">
<!--
Todo: Enable search once supported
<div class="relative w-full md:max-w-sm mt-6 md:mt-0">
<FormTextInput
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
:show-clear="!!search"
placeholder="Search members"
class="rounded-md border border-outline-3"
:model-value="bind.modelValue.value"
v-on="on"
/>
</div> -->
<!-- Todo: Make this button functional -->
<FormButton>Invite</FormButton>
</div>
<LayoutTable
class="mt-6 md:mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3' },
{ id: 'company', header: 'Company', classes: 'col-span-3' },
{ id: 'verified', header: 'Status', classes: 'col-span-3' },
{ id: 'role', header: 'Role', classes: 'col-span-2' }
]"
:items="members"
>
<template #name="{ item }">
<div class="flex items-center gap-2">
<UserAvatar :user="item" />
<span class="truncate text-body-xs text-foreground">{{ item.name }}</span>
</div>
</template>
<template #company="{ item }">
<span class="text-body-xs text-foreground">
{{ item.company ? item.company : '-' }}
</span>
</template>
<template #verified="{ item }">
<span class="text-body-xs text-foreground-2">
{{ item.verified ? 'Verified' : 'Unverified' }}
</span>
</template>
<template #role="{ item }">
<FormSelectWorkspaceRoles
:model-value="item.role as WorkspaceRoles"
fully-control-value
:disabled="!isCurrentUser(item.id)"
@update:model-value="
(newRoleValue) => openChangeUserRoleDialog(item, newRoleValue)
"
/>
</template>
</LayoutTable>
<SettingsSharedChangeRoleDialog
v-model:open="showChangeUserRoleDialog"
:name="userToModify?.name ?? ''"
:old-role="oldRole"
:new-role="newRole"
@update-role="onUpdateRole"
/>
</template>
<script setup lang="ts">
// Todo: Enable searching once supported
import type { WorkspaceRoles } from '@speckle/shared'
// import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
// import { useDebouncedTextInput } from '@speckle/ui-components'
import { settingsWorkspacesMembersQuery } from '~~/lib/settings/graphql/queries'
import { workspaceUpdateRoleMutation } from '~~/lib/workspaces/graphql/mutations'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useMutation, useQuery } from '@vue/apollo-composable'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import type { SettingsWorkspacesMembersQuery } from '~~/lib/common/generated/gql/graphql'
type UserItem = {
id: string
role: string
name: string
verified?: boolean | null
avatar?: string | null
company?: string | null
}
const props = defineProps<{
workspaceId: string
}>()
const { triggerNotification } = useGlobalToast()
// const { on, bind, value: search } = useDebouncedTextInput()
const { activeUser } = useActiveUser()
const { mutate: updateChangeRole } = useMutation(workspaceUpdateRoleMutation)
const { result } = useQuery<SettingsWorkspacesMembersQuery>(
settingsWorkspacesMembersQuery,
() => ({
workspaceId: props.workspaceId
})
)
const showChangeUserRoleDialog = ref(false)
const newRole = ref<WorkspaceRoles>()
const userToModify = ref<UserItem>()
const members = computed(() =>
(result.value?.workspace.team || []).map(({ user, ...rest }) => ({
...user,
...rest
}))
)
const oldRole = computed(() => userToModify.value?.role as WorkspaceRoles)
const isCurrentUser = (id: string) => id === activeUser.value?.id
const openChangeUserRoleDialog = (
user: UserItem,
newRoleValue?: WorkspaceRoles | WorkspaceRoles[]
) => {
if (!newRoleValue) return
userToModify.value = user
newRole.value = Array.isArray(newRoleValue) ? newRoleValue[0] : newRoleValue
showChangeUserRoleDialog.value = true
}
const onUpdateRole = async () => {
if (!userToModify.value || !newRole.value) return
const mutationResult = await updateChangeRole({
input: {
userId: userToModify.value.id,
role: newRole.value,
workspaceId: props.workspaceId
}
}).catch(convertThrowIntoFetchResult)
if (mutationResult?.data?.workspaceMutations?.updateRole) {
triggerNotification({
type: ToastNotificationType.Success,
title: 'User role updated',
description: 'The user role has been updated'
})
} else {
const errorMessage = getFirstErrorMessage(mutationResult?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to update role',
description: errorMessage
})
}
}
</script>
@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col-reverse md:justify-between md:flex-row md:gap-x-4">
<div class="relative w-full md:max-w-sm mt-6 md:mt-0">
<FormTextInput
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
show-clear
:placeholder="searchPlaceholder"
class="rounded-md border border-outline-3"
v-bind="bind"
v-on="on"
/>
</div>
<FormButton @click="() => (isInviteDialogOpen = !isInviteDialogOpen)">
Invite
</FormButton>
<WorkspaceInviteDialog
v-model:open="isInviteDialogOpen"
:workspace-id="workspaceId"
:workspace="workspace"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
import { useDebouncedTextInput } from '@speckle/ui-components'
import type { SettingsWorkspacesMembersTableHeader_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql'
graphql(`
fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {
id
...WorkspaceInviteDialog_Workspace
}
`)
defineProps<{
searchPlaceholder: string
workspaceId: string
workspace?: SettingsWorkspacesMembersTableHeader_WorkspaceFragment
}>()
const search = defineModel<string>('search')
const { on, bind } = useDebouncedTextInput({ model: search })
const isInviteDialogOpen = ref(false)
</script>