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
@@ -8,8 +8,8 @@
color="foundation"
placeholder="Search Functions..."
show-clear
:model-value="bind.modelValue.value"
full-width
v-bind="bind"
v-on="on"
/>
<div class="mt-4">
@@ -13,8 +13,8 @@
name="search"
placeholder="Search functions..."
show-clear
:model-value="bind.modelValue.value"
color="foundation"
v-bind="bind"
v-on="on"
/>
<FormButton
@@ -54,7 +54,7 @@ const { on, bind } = useDebouncedTextInput({
debouncedBy: 2000,
isBasicHtmlInput: true
})
const visibleDescription = computed(() => bind.modelValue.value)
const visibleDescription = computed(() => bind.value.modelValue)
const descriptionInputClasses = computed(() => [
'normal placeholder:text-foreground-2 text-foreground-2 focus:text-foreground',
@@ -46,7 +46,7 @@ const { on, bind } = useDebouncedTextInput({
isBasicHtmlInput: true,
submitOnEnter: true
})
const visibleTitle = computed(() => bind.modelValue.value)
const visibleTitle = computed(() => bind.value.modelValue)
const titleInputClasses = computed(() => {
const classParts = [
@@ -22,7 +22,7 @@
<PortalTarget name="primary-actions"></PortalTarget>
</ClientOnly>
<!-- Notifications dropdown -->
<HeaderNavNotifications />
<HeaderNavNotifications v-if="hasNotifications" />
<FormButton
v-if="!activeUser"
:to="loginUrl.fullPath"
@@ -59,4 +59,10 @@ const loginUrl = computed(() =>
}
})
)
const hasNotifications = computed(() => {
if (!activeUser.value) return false
if (!activeUser.value?.verified) return true
return false
})
</script>
@@ -1,11 +1,11 @@
<template>
<div v-if="hasNotifications">
<div>
<Menu as="div" class="flex items-center">
<MenuButton :id="menuButtonId" v-slot="{ open: menuOpen }" as="div">
<div class="cursor-pointer">
<span class="sr-only">Open notifications menu</span>
<div class="relative">
<div v-if="hasNotifications && !menuOpen" class="scale-75">
<div v-if="!menuOpen" class="scale-75">
<div
class="absolute top-1 right-1 w-3 h-3 rounded-full bg-primary animate-ping"
></div>
@@ -45,14 +45,6 @@
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
const { activeUser } = useActiveUser()
const menuButtonId = useId()
const hasNotifications = computed(() => {
if (!activeUser.value) return false
if (!activeUser.value?.verified) return true
return false
})
</script>
@@ -24,7 +24,7 @@
</template>
</FormTextInput>
<div
v-if="searchUsers.length || selectedEmails?.length"
v-if="hasTargets"
class="flex flex-col border bg-foundation border-primary-muted mt-2 rounded-md"
>
<template v-if="searchUsers.length">
@@ -53,21 +53,21 @@
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { ServerRoles, StreamRoles } from '@speckle/shared'
import { useUserSearch } from '~~/lib/common/composables/users'
import type { UserSearchItem } from '~~/lib/common/composables/users'
import type {
ProjectInviteCreateInput,
ProjectPageInviteDialog_ProjectFragment
} from '~~/lib/common/generated/gql/graphql'
import type { SetFullyRequired } from '~~/lib/common/helpers/type'
import { isEmail } from '~~/lib/common/helpers/validation'
import { isArray, isString } from 'lodash-es'
import { isString } from 'lodash-es'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
import { useTeamInternals } from '~~/lib/projects/composables/team'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useServerInfo } from '~~/lib/core/composables/server'
import { graphql } from '~/lib/common/generated/gql/gql'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useResolveInviteTargets } from '~/lib/server/composables/invites'
import { filterInvalidInviteTargets } from '~/lib/workspaces/helpers/invites'
graphql(`
fragment ProjectPageInviteDialog_Project on Project {
@@ -85,6 +85,11 @@ const props = defineProps<{
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const mp = useMixpanel()
const projectId = computed(() => props.projectId as string)
const projectData = computed(() => props.project)
const { collaboratorListItems } = useTeamInternals(projectData)
const loading = ref(false)
const search = ref('')
@@ -92,19 +97,19 @@ const role = ref<StreamRoles>(Roles.Stream.Contributor)
const { isGuestMode } = useServerInfo()
const createInvite = useInviteUserToProject()
const { userSearch, searchVariables } = useUserSearch({
variables: computed(() => ({
query: search.value,
limit: 5
}))
const {
users: searchUsers,
emails: selectedEmails,
hasTargets
} = useResolveInviteTargets({
search,
excludeUserIds: computed(() =>
collaboratorListItems.value
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
.map((t) => t.user.id)
)
})
const projectId = computed(() => props.projectId as string)
const projectData = computed(() => props.project)
const { collaboratorListItems } = useTeamInternals(projectData)
const dialogButtons = computed<LayoutDialogButton[]>(() => [
{
text: 'Cancel',
@@ -115,58 +120,29 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
}
])
const selectedEmails = computed(() => {
const query = searchVariables.value?.query || ''
if (isValidEmail(query)) return [query]
const multipleEmails = query.split(',').map((i) => i.trim())
const validEmails = multipleEmails.filter((e) => isValidEmail(e))
return validEmails.length ? validEmails : null
})
const isOwnerSelected = computed(() => role.value === Roles.Stream.Owner)
const searchUsers = computed(() => {
const searchResults = userSearch.value?.userSearch.items || []
const collaboratorIds = new Set(
collaboratorListItems.value
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
.map((t) => t.user.id)
)
return searchResults.filter((r) => !collaboratorIds.has(r.id))
})
const isValidEmail = (val: string) =>
isEmail(val || '', {
field: '',
value: '',
form: {}
}) === true
? true
: false
const mp = useMixpanel()
const onInviteUser = async (
user: InvitableUser | InvitableUser[],
serverRole?: ServerRoles
) => {
const users = (isArray(user) ? user : [user]).filter(
(u) => !isOwnerSelected.value || isString(u) || u.role !== Roles.Server.Guest
)
serverRole = serverRole || Roles.Server.User
const users = filterInvalidInviteTargets(user, {
isTargetResourceOwner: isOwnerSelected.value,
emailTargetServerRole: serverRole
})
const inputs: ProjectInviteCreateInput[] = users
.filter((u) => (isString(u) ? isValidEmail(u) : u))
.map((u) => ({
role: role.value,
...(isString(u)
? {
email: u,
serverRole
}
: {
userId: u.id
})
}))
const inputs: ProjectInviteCreateInput[] = users.map((u) => ({
role: role.value,
...(isString(u)
? {
email: u,
serverRole
}
: {
userId: u.id
})
}))
if (!inputs.length) return
const isEmail = !!inputs.find((u) => !!u.email)
@@ -10,7 +10,7 @@
placeholder="Search automations..."
wrapper-classes="shrink-0"
show-clear
:model-value="bind.modelValue.value"
v-bind="bind"
v-on="on"
/>
<FormButton
@@ -1,35 +1,37 @@
<template>
<template v-if="itemsCount">
<ProjectModelsBasicCardView
:items="items"
:project="project"
:project-id="projectId"
:small-view="smallView"
:show-actions="showActions"
:show-versions="showVersions"
:disable-default-links="disableDefaultLinks"
@model-clicked="$emit('model-clicked', $event)"
<div>
<template v-if="itemsCount">
<ProjectModelsBasicCardView
:items="items"
:project="project"
:project-id="projectId"
:small-view="smallView"
:show-actions="showActions"
:show-versions="showVersions"
:disable-default-links="disableDefaultLinks"
@model-clicked="$emit('model-clicked', $event)"
/>
<FormButtonSecondaryViewAll
v-if="showViewAll"
class="mt-4"
:to="allProjectModelsRoute(projectId)"
/>
</template>
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="isFiltering"
@clear-search="() => $emit('clear-search')"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="items?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
<FormButtonSecondaryViewAll
v-if="showViewAll"
class="mt-4"
:to="allProjectModelsRoute(projectId)"
/>
</template>
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="isFiltering"
@clear-search="() => $emit('clear-search')"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="items?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
</div>
</template>
<script setup lang="ts">
import type {
@@ -1,43 +1,45 @@
<template>
<div v-if="topLevelItems.length && project" class="space-y-2 max-w-full">
<div v-for="item in topLevelItems" :key="item.id">
<ProjectPageModelsStructureItem
:item="item"
:project="project"
:can-contribute="canContribute"
:is-search-result="isUsingSearch"
@model-updated="onModelUpdated"
@create-submodel="onCreateSubmodel"
<div>
<div v-if="topLevelItems.length && project" class="space-y-2 max-w-full">
<div v-for="item in topLevelItems" :key="item.id">
<ProjectPageModelsStructureItem
:item="item"
:project="project"
:can-contribute="canContribute"
:is-search-result="isUsingSearch"
@model-updated="onModelUpdated"
@create-submodel="onCreateSubmodel"
/>
</div>
<FormButtonSecondaryViewAll
v-if="showViewAll"
:to="allProjectModelsRoute(projectId)"
/>
</div>
<FormButtonSecondaryViewAll
v-if="showViewAll"
:to="allProjectModelsRoute(projectId)"
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="
!topLevelItems.length &&
isFiltering &&
(baseResult?.project?.modelsTree.items || []).length === 0
"
@clear-search="$emit('clear-search')"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="topLevelItems?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
<ProjectPageModelsNewDialog
v-model:open="showNewDialog"
:project-id="projectId"
:parent-model-name="newSubmodelParent || undefined"
/>
</div>
<template v-else-if="!areQueriesLoading">
<CommonEmptySearchState
v-if="
!topLevelItems.length &&
isFiltering &&
(baseResult?.project?.modelsTree.items || []).length === 0
"
@clear-search="$emit('clear-search')"
/>
<div v-else>
<ProjectCardImportFileArea :project-id="projectId" class="h-36 col-span-4" />
</div>
</template>
<InfiniteLoading
v-if="topLevelItems?.length && !disablePagination"
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
<ProjectPageModelsNewDialog
v-model:open="showNewDialog"
:project-id="projectId"
:parent-model-name="newSubmodelParent || undefined"
/>
</template>
<script setup lang="ts">
import type {
@@ -13,7 +13,7 @@
:show-clear="!!search"
placeholder="Search users"
class="rounded-md border border-outline-3"
:model-value="bind.modelValue.value"
v-bind="bind"
v-on="on"
/>
</div>
@@ -16,7 +16,7 @@
:show-clear="!!search"
placeholder="Search invitations"
class="rounded-md border border-outline-3"
:model-value="bind.modelValue.value"
v-bind="bind"
v-on="on"
/>
</div>
@@ -16,7 +16,7 @@
:show-clear="!!search"
placeholder="Search projects"
class="rounded-md border border-outline-3"
:model-value="bind.modelValue.value"
v-bind="bind"
v-on="on"
/>
</div>
@@ -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>
@@ -239,6 +239,7 @@
</div>
</div>
</div>
<div v-else />
</template>
<script setup lang="ts">
import {
@@ -1,6 +1,5 @@
<template>
<div
v-if="hasAnyFiltersApplied"
class="bg-pink-300/0 flex justify-center items-center pointer-events-none transition-all duration-300 ease-in overflow-hidden h-8"
>
<FormButton class="pointer-events-auto" @click="trackAndResetFilters">
@@ -12,10 +11,7 @@
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useFilterUtilities } from '~~/lib/viewer/composables/ui'
const {
resetFilters,
filters: { hasAnyFiltersApplied }
} = useFilterUtilities()
const { resetFilters } = useFilterUtilities()
const mp = useMixpanel()
const trackAndResetFilters = () => {
@@ -1,5 +1,5 @@
<template>
<slot />
<div><slot /></div>
</template>
<script setup lang="ts">
import { useViewerPostSetup } from '~~/lib/viewer/composables/setup/postSetup'
@@ -74,7 +74,11 @@
<div class="flex gap-3">
<PortalTarget name="pocket-actions"></PortalTarget>
<!-- Shows up when filters are applied for an easy return to normality -->
<ViewerGlobalFilterReset class="z-20" :embed="!!isEmbedEnabled" />
<ViewerGlobalFilterReset
v-if="hasAnyFiltersApplied"
class="z-20"
:embed="!!isEmbedEnabled"
/>
</div>
</div>
<div class="flex items-end justify-center sm:justify-end">
@@ -103,6 +107,7 @@ import dayjs from 'dayjs'
import { graphql } from '~~/lib/common/generated/gql'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useViewerTour } from '~/lib/viewer/composables/tour'
import { useFilterUtilities } from '~/lib/viewer/composables/ui'
const emit = defineEmits<{
setup: [InjectableViewerState]
@@ -110,6 +115,9 @@ const emit = defineEmits<{
const route = useRoute()
const { showTour, showControls } = useViewerTour()
const {
filters: { hasAnyFiltersApplied }
} = useFilterUtilities()
const modelId = computed(() => route.params.modelId as string)
@@ -1,7 +1,6 @@
<template>
<slot v-if="!wrapper" />
<div v-else>
<slot />
<div>
<slot v-if="!wrapper" />
</div>
</template>
<script setup lang="ts">
@@ -1,5 +1,5 @@
<template>
<div v-if="filter" class="px-3 flex flex-col space-y-2 pb-2">
<div class="px-3 flex flex-col space-y-2 pb-2">
<div class="flex w-full space-x-1">
<div class="text-xs">Range:</div>
<div class="text-xs truncate">[{{ props.filter.min.toFixed(2) }},</div>
@@ -1,5 +1,5 @@
<template>
<div v-if="filter" class="pr-3 pl-2 flex flex-col space-y-2 pb-2">
<div class="pr-3 pl-2 flex flex-col space-y-2 pb-2">
<ViewerExplorerStringFilterItem
v-for="(vg, index) in groupsLimited"
:key="index"
@@ -44,6 +44,7 @@
</div>
</div>
</div>
<div v-else />
</template>
<script setup lang="ts">
import { useQuery, useSubscription } from '@vue/apollo-composable'
@@ -65,6 +65,7 @@
</template>
</ViewerSidebar>
</ViewerCommentsPortalOrDiv>
<div v-else />
</template>
<script setup lang="ts">
import {
@@ -0,0 +1,175 @@
<template>
<LayoutDialog
v-model:open="open"
max-width="sm"
title="Invite people to workspace"
:buttons="buttons"
buttons-wrapper-classes="flex-row-reverse"
>
<div>
<FormTextInput
name="search"
size="lg"
placeholder="Search by email or username..."
:disabled="disabled"
input-classes="pr-[85px] text-sm"
color="foundation"
label="Add people"
show-label
v-bind="bind"
v-on="on"
>
<template #input-right>
<div
class="absolute inset-y-0 right-0 flex items-center pr-2"
:class="disabled ? 'pointer-events-none' : ''"
>
<WorkspacePermissionSelect v-model="role" hide-remove />
</div>
</template>
</FormTextInput>
<div
v-if="hasTargets"
class="flex flex-col mt-2 border rounded-md border-outline-3"
>
<template v-if="users.length">
<WorkspaceInviteDialogUserRow
v-for="user in users"
:key="user.id"
:user="user"
:disabled="disabled"
:is-owner-role="isOwnerRole"
@invite-user="() => onInviteUser(user)"
/>
</template>
<WorkspaceInviteDialogEmailsRow
v-else-if="emails.length"
:selected-emails="emails"
:disabled="disabled"
:is-owner-role="isOwnerRole"
:is-guest-mode="isGuestMode"
class="p-2"
@invite-emails="({ serverRole }) => onInviteUser(emails, serverRole)"
/>
</div>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import {
isNonNullable,
Roles,
type ServerRoles,
type WorkspaceRoles
} from '@speckle/shared'
import { useDebouncedTextInput, type LayoutDialogButton } from '@speckle/ui-components'
import { isString } from 'lodash-es'
import { graphql } from '~/lib/common/generated/gql'
import type {
WorkspaceInviteCreateInput,
WorkspaceInviteDialog_WorkspaceFragment
} from '~/lib/common/generated/gql/graphql'
import { mapServerRoleToGqlServerRole } from '~/lib/common/helpers/roles'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useServerInfo } from '~/lib/core/composables/server'
import { useResolveInviteTargets } from '~/lib/server/composables/invites'
import { useInviteUserToWorkspace } from '~/lib/workspaces/composables/management'
import {
filterInvalidInviteTargets,
type UserSearchItemOrEmail
} from '~/lib/workspaces/helpers/invites'
import { mapMainRoleToGqlWorkspaceRole } from '~/lib/workspaces/helpers/roles'
graphql(`
fragment WorkspaceInviteDialog_Workspace on Workspace {
id
team {
id
user {
id
}
}
invitedTeam(filter: $invitesFilter) {
title
user {
id
}
}
}
`)
const props = defineProps<{
workspaceId: string
workspace?: WorkspaceInviteDialog_WorkspaceFragment
}>()
const open = defineModel<boolean>('open', { required: true })
const mp = useMixpanel()
const inviteToWorkspace = useInviteUserToWorkspace()
const { on, bind, value: search } = useDebouncedTextInput({ debouncedBy: 500 })
const { users, emails, hasTargets } = useResolveInviteTargets({
search,
excludeUserIds: computed(() => [
...(props.workspace?.team.map((c) => c.user.id) || []),
...(props.workspace?.invitedTeam?.map((c) => c.user?.id).filter(isNonNullable) ||
[])
]),
excludeEmails: computed(() => props.workspace?.invitedTeam?.map((c) => c.title))
})
const { isGuestMode } = useServerInfo()
const disabled = ref(false)
const role = ref<WorkspaceRoles>(Roles.Workspace.Member)
const buttons = computed((): LayoutDialogButton[] => [
{
text: 'Done',
props: { color: 'primary' },
onClick: () => {
open.value = false
}
}
])
const isOwnerRole = computed(() => role.value === Roles.Workspace.Admin)
const onInviteUser = async (
user: UserSearchItemOrEmail | UserSearchItemOrEmail[],
serverRole: ServerRoles = Roles.Server.User
) => {
const users = filterInvalidInviteTargets(user, {
isTargetResourceOwner: isOwnerRole.value,
emailTargetServerRole: serverRole
})
const inputs: WorkspaceInviteCreateInput[] = users.map((u) => ({
role: mapMainRoleToGqlWorkspaceRole(role.value),
...(isString(u)
? {
email: u,
serverRole: mapServerRoleToGqlServerRole(serverRole)
}
: {
userId: u.id
})
}))
if (!inputs.length) return
disabled.value = true
await inviteToWorkspace(props.workspaceId, inputs)
const isEmail = !!inputs.find((u) => !!u.email)
mp.track('Invite Action', {
type: 'workspace invite',
name: 'send',
multiple: inputs.length !== 1,
count: inputs.length,
hasProject: true,
to: isEmail ? 'email' : 'existing user'
})
disabled.value = false
}
</script>
@@ -0,0 +1,90 @@
<template>
<FormSelectBase
v-model="modelWrapper"
:items="Object.values(items)"
label="Workspace access level"
button-style="simple"
:show-label="showLabel"
:name="name || 'role'"
:allow-unset="false"
:disabled="disabled"
:label-id="labelId"
:button-id="buttonId"
hide-checkmarks
by="id"
class="min-w-[85px]"
mount-menu-on-body
>
<template #something-selected="{ value }">
<div class="text-normal text-right">
{{ isArray(value) ? value[0].title : value.title }}
</div>
</template>
<template #option="{ item, selected }">
<div class="flex flex-col">
<div
:class="[
'text-normal',
selected ? 'text-primary' : '',
item.id === 'delete' ? 'text-danger' : ''
]"
>
{{ item.title }}
</div>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { WorkspaceRoles } from '@speckle/shared'
import { reduce, isArray } from 'lodash-es'
import { roleSelectItems } from '~/lib/workspaces/helpers/roles'
const emit = defineEmits<{
(e: 'delete'): void
}>()
const props = defineProps<{
label?: string
showLabel?: boolean
name?: string
disabled?: boolean
hideRemove?: boolean
hideOwner?: boolean
}>()
const labelId = useId()
const buttonId = useId()
const items = ref(
reduce(
roleSelectItems,
(results, item) => {
if (item.id === 'delete') {
if (!props.hideRemove) {
results[item.id] = item
}
} else if (item.id === Roles.Workspace.Admin) {
if (!props.hideOwner) {
results[item.id] = item
}
} else {
results[item.id] = item
}
return results
},
{} as typeof roleSelectItems
)
)
const model = defineModel<WorkspaceRoles>({ required: true })
const modelWrapper = computed({
get: () => items.value[model.value],
set: (newVal) => {
if (newVal.id === 'delete') return emit('delete')
model.value = newVal.id
}
})
</script>
@@ -0,0 +1,63 @@
<template>
<div class="flex px-4 py-3 items-center space-x-2">
<UserAvatar />
<span class="grow truncate text-body-sm">{{ selectedEmails.join(', ') }}</span>
<div class="flex items-center space-x-2">
<FormSelectServerRoles
v-if="showServerRoleSelect"
v-model="serverRole"
:allow-guest="isGuestMode"
:allow-admin="isAdmin"
fixed-height
/>
<span
v-tippy="
isTryingToSetGuestOwner ? `Server guests can't be project owners` : undefined
"
>
<FormButton
:disabled="isButtonDisabled"
color="outline"
@click="() => $emit('invite-emails', { serverRole })"
>
Invite
</FormButton>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { ServerRoles } from '@speckle/shared'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
defineEmits<{
(e: 'invite-emails', payload: { serverRole: ServerRoles }): void
}>()
const props = defineProps<{
selectedEmails: string[]
disabled?: boolean
isGuestMode: boolean
isOwnerRole: boolean
}>()
const { isAdmin } = useActiveUser()
const serverRole = ref<ServerRoles>(Roles.Server.User)
const showServerRoleSelect = computed(() => props.isGuestMode || isAdmin.value)
const isTryingToSetGuestOwner = computed(() => {
if (!showServerRoleSelect.value) return false
if (serverRole.value === Roles.Server.Guest && props.isOwnerRole) return true
return false
})
const isButtonDisabled = computed(() => {
if (props.disabled) return true
if (isTryingToSetGuestOwner.value) return true
if (!props.selectedEmails.length) return true
return false
})
</script>
@@ -0,0 +1,47 @@
<template>
<div
class="flex px-4 py-3 items-center space-x-2 border-b last:border-0 border-outline-3"
>
<UserAvatar :user="user" />
<span class="grow truncate text-body-sm">{{ user.name }}</span>
<span v-tippy="isTryingToSetGuestOwner ? settingGuestOwnerErrorMessage : undefined">
<FormButton
:disabled="isButtonDisabled"
size="sm"
color="outline"
@click="() => $emit('invite-user')"
>
Invite
</FormButton>
</span>
</div>
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { UserSearchItem } from '~~/lib/common/composables/users'
defineEmits<{
(e: 'invite-user'): void
}>()
const props = withDefaults(
defineProps<{
isOwnerRole: boolean
user: UserSearchItem
disabled?: boolean
settingGuestOwnerErrorMessage?: string
}>(),
{
settingGuestOwnerErrorMessage: "Server guests can't be workspace owners"
}
)
const isTryingToSetGuestOwner = computed(
() => props.user.role === Roles.Server.Guest && props.isOwnerRole
)
const isButtonDisabled = computed(() => {
if (props.disabled) return true
if (isTryingToSetGuestOwner.value) return true
return false
})
</script>
@@ -55,3 +55,9 @@ export const useDevLogger = () => {
const info = logger.info.bind(logger)
return info as (...args: unknown[]) => void
}
/**
* console.log replacement for development mode. Calls to this are skipped outside of dev mode
* and it ensures that the real structured logger is used.
*/
export const devLog = (...args: unknown[]) => useDevLogger()(...args)
+3 -1
View File
@@ -97,7 +97,9 @@ const configs = withNuxt([
}
}
],
'vue/html-self-closing': 'off' // messes with prettier
'vue/html-self-closing': 'off', // messes with prettier
'vue/no-multiple-template-root': 'error',
'vue/no-root-v-if': 'error'
}
},
{
@@ -92,10 +92,17 @@ const documents = {
"\n fragment UserProfileEditDialogDeleteAccount_User on User {\n id\n email\n }\n": types.UserProfileEditDialogDeleteAccount_UserFragmentDoc,
"\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n": types.UserProfileEditDialogBio_UserFragmentDoc,
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersMembersTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n ...WorkspaceInviteDialog_Workspace\n }\n": types.SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n id\n user {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n": types.WorkspaceInviteDialog_WorkspaceFragmentDoc,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": types.CreateOnboardingProjectDocument,
"\n mutation FinishOnboarding {\n activeUserMutations {\n finishOnboarding\n }\n }\n": types.FinishOnboardingDocument,
@@ -136,7 +143,7 @@ const documents = {
"\n query GendoAIRenders($versionId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n version(id: $versionId) {\n id\n gendoAIRenders {\n totalCount\n items {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n }\n }\n }\n": types.GendoAiRendersDocument,
"\n subscription ProjectVersionGendoAIRenderCreated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderCreated(id: $id, versionId: $versionId) {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n": types.ProjectVersionGendoAiRenderCreatedDocument,
"\n subscription ProjectVersionGendoAIRenderUpdated($id: String!, $versionId: String!) {\n projectVersionGendoAIRenderUpdated(id: $id, versionId: $versionId) {\n id\n projectId\n modelId\n versionId\n createdAt\n updatedAt\n gendoGenerationId\n status\n prompt\n camera\n responseImage\n }\n }\n": types.ProjectVersionGendoAiRenderUpdatedDocument,
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
"\n fragment ProjectDashboardItem on Project {\n id\n ...ProjectDashboardItemNoModels\n models(limit: 4, filter: { onlyWithVersions: true }) {\n totalCount\n items {\n ...ProjectPageLatestItemsModelItem\n }\n }\n pendingImportedModels(limit: 4) {\n ...PendingFileUpload\n }\n }\n": types.ProjectDashboardItemFragmentDoc,
"\n fragment PendingFileUpload on FileUpload {\n id\n projectId\n modelName\n convertedStatus\n convertedMessage\n uploadDate\n convertedLastUpdate\n fileType\n fileName\n }\n": types.PendingFileUploadFragmentDoc,
@@ -215,7 +222,8 @@ const documents = {
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
"\n mutation SettingsNewEmailVerification($input: EmailVerificationRequestInput!) {\n activeUserMutations {\n emailMutations {\n requestNewEmailVerification(input: $input)\n }\n }\n }\n": types.SettingsNewEmailVerificationDocument,
"\n query SettingsSidebarWorkspaces {\n activeUser {\n workspaces {\n items {\n id\n name\n }\n }\n }\n }\n": types.SettingsSidebarWorkspacesDocument,
"\n query SettingsWorkspacesMembers($workspaceId: String!) {\n workspace(id: $workspaceId) {\n team {\n role\n id\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n }\n }\n": types.SettingsWorkspacesMembersDocument,
"\n query SettingsWorkspacesMembers(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembers_Workspace\n ...SettingsWorkspacesMembersMembersTable_Workspace\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesMembersDocument,
"\n query SettingsWorkspacesInvitesSearch(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesInvitesSearchDocument,
"\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n": types.SettingsUserEmailsQueryDocument,
"\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": types.AppAuthorAvatarFragmentDoc,
"\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": types.LimitedUserAvatarFragmentDoc,
@@ -242,6 +250,7 @@ const documents = {
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument,
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc,
"\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\n }\n }\n }\n }\n": types.UpdateRoleDocument,
"\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n": types.InviteToWorkspaceDocument,
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
"\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n": types.LegacyViewerStreamRedirectMetadataDocument,
@@ -585,6 +594,30 @@ export function graphql(source: "\n fragment UserProfileEditDialogBio_User on U
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam(filter: $invitesFilter) {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersMembersTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n ...WorkspaceInviteDialog_Workspace\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n ...WorkspaceInviteDialog_Workspace\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -601,6 +634,10 @@ export function graphql(source: "\n fragment ViewerCommentsListItem on Comment
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"): (typeof documents)["\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n id\n user {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDialog_Workspace on Workspace {\n id\n team {\n id\n user {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n title\n user {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -764,7 +801,7 @@ export function graphql(source: "\n subscription ProjectVersionGendoAIRenderUpd
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n role\n ...LimitedUserAvatar\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n role\n ...LimitedUserAvatar\n }\n }\n }\n"];
export function graphql(source: "\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1080,7 +1117,11 @@ export function graphql(source: "\n query SettingsSidebarWorkspaces {\n acti
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query SettingsWorkspacesMembers($workspaceId: String!) {\n workspace(id: $workspaceId) {\n team {\n role\n id\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesMembers($workspaceId: String!) {\n workspace(id: $workspaceId) {\n team {\n role\n id\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n }\n }\n"];
export function graphql(source: "\n query SettingsWorkspacesMembers(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembers_Workspace\n ...SettingsWorkspacesMembersMembersTable_Workspace\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesMembers(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembers_Workspace\n ...SettingsWorkspacesMembersMembersTable_Workspace\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query SettingsWorkspacesInvitesSearch(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesInvitesSearch(\n $workspaceId: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspace(id: $workspaceId) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1185,6 +1226,10 @@ export function graphql(source: "\n fragment LinkableComment on Comment {\n
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateRole($input: WorkspaceRoleUpdateInput!) {\n workspaceMutations {\n updateRole(input: $input) {\n id\n team {\n id\n role\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation InviteToWorkspace(\n $workspaceId: String!\n $input: [WorkspaceInviteCreateInput!]!\n ) {\n workspaceMutations {\n invites {\n batchCreate(workspaceId: $workspaceId, input: $input) {\n id\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -313,23 +313,23 @@ const revolveFieldNameAndVariables = <
* full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references
*/
export function modifyObjectFields<
V extends Optional<Record<string, unknown>> = undefined,
D = unknown
Variables extends Optional<Record<string, unknown>> = undefined,
FieldData = unknown
>(
cache: ApolloCache<unknown>,
id: string,
updater: (
fieldName: string,
variables: V,
value: ModifyFnCacheData<D>,
details: Parameters<Modifier<ModifyFnCacheData<D>>>[1] & {
variables: Variables,
value: ModifyFnCacheData<FieldData>,
details: Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1] & {
ref: typeof getObjectReference
revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables
}
) =>
| Optional<ModifyFnCacheData<D>>
| Parameters<Modifier<ModifyFnCacheData<D>>>[1]['DELETE']
| Parameters<Modifier<ModifyFnCacheData<D>>>[1]['INVALIDATE'],
| Optional<ModifyFnCacheData<FieldData>>
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['DELETE']
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['INVALIDATE'],
options?: Partial<{
fieldNameWhitelist: string[]
debug: boolean
@@ -364,13 +364,16 @@ export function modifyObjectFields<
return fieldValue as unknown
}
const { variables } = revolveFieldNameAndVariables<V>(storeFieldName, fieldName)
const { variables } = revolveFieldNameAndVariables<Variables>(
storeFieldName,
fieldName
)
log('invoking updater', { fieldName, variables, fieldValue })
const res = updater(
fieldName,
(variables || {}) as V,
fieldValue as ModifyFnCacheData<D>,
(variables || {}) as Variables,
fieldValue as ModifyFnCacheData<FieldData>,
{
...details,
ref: getObjectReference,
@@ -31,3 +31,16 @@ export function mapServerRoleToValue(graphqlServerRole: ServerRole): ServerRoles
return Roles.Server.Guest
}
}
export const mapServerRoleToGqlServerRole = (role: ServerRoles): ServerRole => {
switch (role) {
case Roles.Server.User:
return ServerRole.ServerUser
case Roles.Server.Admin:
return ServerRole.ServerAdmin
case Roles.Server.ArchivedUser:
return ServerRole.ServerArchivedUser
case Roles.Server.Guest:
return ServerRole.ServerGuest
}
}
@@ -288,6 +288,13 @@ function createCache(): InMemoryCache {
merge: buildAbstractCollectionMergeFunction('AutomateRunCollection')
}
}
},
Workspace: {
fields: {
invitedTeam: {
merge: (_existing, incoming) => incoming
}
}
}
}
})
@@ -1,2 +1,6 @@
export { GridListToggleValue } from '@speckle/ui-components'
export type { LayoutTabItem, LayoutMenuItem } from '@speckle/ui-components'
export type {
LayoutTabItem,
LayoutMenuItem,
LayoutPageTabItem
} from '@speckle/ui-components'
@@ -25,6 +25,7 @@ graphql(`
team {
role
user {
id
role
...LimitedUserAvatar
}
@@ -12,6 +12,10 @@ import {
modifyObjectFields
} from '~~/lib/common/helpers/graphql'
import { inviteServerUserMutation } from '../graphql/mutations'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { useUserSearch } from '~/lib/common/composables/users'
import { isValidEmail } from '~/lib/workspaces/helpers/invites'
import { uniq } from 'lodash-es'
export function useInviteUserToServer() {
const { triggerNotification } = useGlobalToast()
@@ -67,3 +71,48 @@ export function useInviteUserToServer() {
loading
}
}
export const useResolveInviteTargets = (params: {
search: Ref<MaybeNullOrUndefined<string>>
/**
* For excluding already invited/added users from search results.
*/
excludeUserIds?: Ref<MaybeNullOrUndefined<string[]>>
excludeEmails?: Ref<MaybeNullOrUndefined<string[]>>
}) => {
const { search, excludeUserIds, excludeEmails } = params
const { userSearch, searchVariables } = useUserSearch({
variables: computed(() => ({
query: search.value || '',
limit: 5
}))
})
const emails = computed(() => {
const query = searchVariables.value?.query || ''
const multipleEmails = isValidEmail(query)
? [query]
: query.split(',').map((i) => i.trim())
const validEmails = multipleEmails.filter((e) => isValidEmail(e))
const uniqueEmails = uniq(validEmails)
const finalEmails = uniqueEmails.length ? uniqueEmails : []
const invitedEmails = new Set(excludeEmails?.value || [])
if (!invitedEmails.size) return finalEmails
return finalEmails.filter((e) => !invitedEmails.has(e))
})
const users = computed(() => {
const searchResults = userSearch.value?.userSearch.items || []
const collaboratorIds = new Set(excludeUserIds?.value || [])
if (!collaboratorIds.size) return searchResults
return searchResults.filter((r) => !collaboratorIds.has(r.id))
})
const hasTargets = computed(() => users.value?.length || emails.value?.length)
return { users, emails, hasTargets }
}
@@ -14,19 +14,25 @@ export const settingsSidebarWorkspacesQuery = graphql(`
`)
export const settingsWorkspacesMembersQuery = graphql(`
query SettingsWorkspacesMembers($workspaceId: String!) {
query SettingsWorkspacesMembers(
$workspaceId: String!
$invitesFilter: PendingWorkspaceCollaboratorsFilter
) {
workspace(id: $workspaceId) {
team {
role
id
user {
id
avatar
name
company
verified
}
}
...SettingsWorkspacesMembers_Workspace
...SettingsWorkspacesMembersMembersTable_Workspace
...SettingsWorkspacesMembersInvitesTable_Workspace
}
}
`)
export const settingsWorkspacesInvitesSearchQuery = graphql(`
query SettingsWorkspacesInvitesSearch(
$workspaceId: String!
$invitesFilter: PendingWorkspaceCollaboratorsFilter
) {
workspace(id: $workspaceId) {
...SettingsWorkspacesMembersInvitesTable_Workspace
}
}
`)
@@ -0,0 +1,78 @@
import { useMutation } from '@vue/apollo-composable'
import type {
Workspace,
WorkspaceInviteCreateInput,
WorkspaceInvitedTeamArgs
} from '~/lib/common/generated/gql/graphql'
import {
evictObjectFields,
getCacheId,
getFirstErrorMessage,
getObjectReference,
modifyObjectFields
} from '~/lib/common/helpers/graphql'
import { inviteToWorkspaceMutation } from '~/lib/workspaces/graphql/mutations'
export const useInviteUserToWorkspace = () => {
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const { mutate } = useMutation(inviteToWorkspaceMutation)
return async (workspaceId: string, inputs: WorkspaceInviteCreateInput[]) => {
const userId = activeUser.value?.id
if (!userId) return
const { data, errors } =
(await mutate(
{ workspaceId, input: inputs },
{
update: (cache, { data }) => {
if (!data?.workspaceMutations.invites.batchCreate.id) return
const invitedTeam = data.workspaceMutations.invites.batchCreate.invitedTeam
if (!invitedTeam) return
modifyObjectFields<WorkspaceInvitedTeamArgs, Workspace['invitedTeam']>(
cache,
getCacheId('Workspace', workspaceId),
(_fieldName, vars) => {
if (vars.filter?.search?.length) return
return invitedTeam.map((i) =>
getObjectReference('PendingWorkspaceCollaborator', i.id)
)
},
{
fieldNameWhitelist: ['invitedTeam']
}
)
// Evict the cache for the invited team if the search filter is active
evictObjectFields<WorkspaceInvitedTeamArgs, Workspace['invitedTeam']>(
cache,
getCacheId('Workspace', workspaceId),
(fieldName, vars) => {
if (fieldName !== 'invitedTeam') return false
return vars.filter?.search?.length !== 0
}
)
}
}
).catch(convertThrowIntoFetchResult)) || {}
if (!data?.workspaceMutations.invites.batchCreate.id) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Invitation failed',
description: err
})
} else {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Invite successfully sent'
})
}
return data?.workspaceMutations.invites.batchCreate
}
}
@@ -13,3 +13,21 @@ export const workspaceUpdateRoleMutation = graphql(`
}
}
`)
export const inviteToWorkspaceMutation = graphql(`
mutation InviteToWorkspace(
$workspaceId: String!
$input: [WorkspaceInviteCreateInput!]!
) {
workspaceMutations {
invites {
batchCreate(workspaceId: $workspaceId, input: $input) {
id
invitedTeam {
...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator
}
}
}
}
}
`)
@@ -0,0 +1,42 @@
import { Roles, type ServerRoles } from '@speckle/shared'
import { isArray, isString } from 'lodash-es'
import type { UserSearchItem } from '~/lib/common/composables/users'
import { isEmail } from '~/lib/common/helpers/validation'
export type UserSearchItemOrEmail = UserSearchItem | string
export const isValidEmail = (val: string) =>
isEmail(val || '', {
field: '',
value: '',
form: {}
}) === true
? true
: false
export const filterInvalidInviteTargets = (
targets: UserSearchItemOrEmail | UserSearchItemOrEmail[],
params: {
isTargetResourceOwner: boolean
emailTargetServerRole: ServerRoles
}
) => {
const { isTargetResourceOwner } = params
const isTargetServerGuest = (i: UserSearchItemOrEmail) => {
if (isString(i)) {
return params.emailTargetServerRole === Roles.Server.Guest
} else {
return i.role === Roles.Server.Guest
}
}
return (isArray(targets) ? targets : [targets]).filter((u) => {
if (isTargetServerGuest(u) && isTargetResourceOwner) return false
if (isString(u)) {
return isValidEmail(u)
} else {
return true
}
})
}
@@ -0,0 +1,37 @@
import { Roles, type WorkspaceRoles } from '@speckle/shared'
import { WorkspaceRole } from '~/lib/common/generated/gql/graphql'
export type SelectableWorkspaceRole = WorkspaceRoles | 'delete'
export const roleSelectItems: Record<
SelectableWorkspaceRole | string,
{ id: SelectableWorkspaceRole; title: string }
> = {
[Roles.Workspace.Admin]: {
id: Roles.Workspace.Admin,
title: 'Admin'
},
[Roles.Workspace.Member]: {
id: Roles.Workspace.Member,
title: 'Can edit'
},
[Roles.Workspace.Guest]: {
id: Roles.Workspace.Guest,
title: 'Can view'
},
['delete']: {
id: 'delete',
title: 'Remove'
}
}
export const mapMainRoleToGqlWorkspaceRole = (role: WorkspaceRoles): WorkspaceRole => {
switch (role) {
case Roles.Workspace.Admin:
return WorkspaceRole.Admin
case Roles.Workspace.Member:
return WorkspaceRole.Member
case Roles.Workspace.Guest:
return WorkspaceRole.Guest
}
}
-4
View File
@@ -30,10 +30,6 @@ export default defineNuxtConfig({
shim: false,
strict: true
},
features: {
// while nuxt's implementation is broken, we disable this: https://github.com/nuxt/nuxt/issues/26369
devLogs: false
},
modules: [
'@nuxt/eslint',
'@nuxt/devtools',
+1 -1
View File
@@ -91,7 +91,7 @@
"@eslint/config-inspector": "^0.4.10",
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/client-preset": "^4.3.0",
"@nuxt/devtools": "^0.2.5",
"@nuxt/devtools": "^1.3.9",
"@nuxt/eslint": "^0.3.13",
"@nuxtjs/tailwindcss": "^6.3.0",
"@parcel/watcher": "^2.4.1",
@@ -103,6 +103,10 @@ input WorkspaceInviteCreateInput {
Defaults to the member role, if not specified
"""
role: WorkspaceRole
"""
Defaults to User, if not specified
"""
serverRole: ServerRole
}
input WorkspaceInviteUseInput {
@@ -123,6 +127,10 @@ type WorkspaceInviteMutations {
@hasServerRole(role: SERVER_USER)
}
input PendingWorkspaceCollaboratorsFilter {
search: String
}
type Workspace {
id: ID!
name: String!
@@ -141,7 +149,9 @@ type Workspace {
"""
Only available to workspace owners
"""
invitedTeam: [PendingWorkspaceCollaborator!] @hasWorkspaceRole(role: ADMIN)
invitedTeam(
filter: PendingWorkspaceCollaboratorsFilter
): [PendingWorkspaceCollaborator!] @hasWorkspaceRole(role: ADMIN)
projects(
limit: Int! = 25
cursor: String
@@ -175,6 +185,7 @@ type WorkspaceCollaborator {
type PendingWorkspaceCollaborator {
id: ID!
updatedAt: DateTime!
inviteId: String!
workspaceId: String!
workspaceName: String!
+1
View File
@@ -338,6 +338,7 @@ export const ServerInvites = buildTableHelper('server_invites', [
'target',
'inviterId',
'createdAt',
'updatedAt',
'message',
'resource',
'token'
@@ -1734,12 +1734,17 @@ export type PendingWorkspaceCollaborator = {
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
token?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
workspaceId: Scalars['String']['output'];
workspaceName: Scalars['String']['output'];
};
export type PendingWorkspaceCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type Project = {
__typename?: 'Project';
allowPublicComments: Scalars['Boolean']['output'];
@@ -3831,6 +3836,11 @@ export type Workspace = {
};
export type WorkspaceInvitedTeamArgs = {
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
};
export type WorkspaceProjectsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceProjectsFilter>;
@@ -3866,6 +3876,8 @@ export type WorkspaceInviteCreateInput = {
email?: InputMaybe<Scalars['String']['input']>;
/** Defaults to the member role, if not specified */
role?: InputMaybe<WorkspaceRole>;
/** Defaults to User, if not specified */
serverRole?: InputMaybe<ServerRole>;
/** Either this or email must be filled */
userId?: InputMaybe<Scalars['String']['input']>;
};
@@ -4167,6 +4179,7 @@ export type ResolversTypes = {
PasswordStrengthCheckResults: ResolverTypeWrapper<PasswordStrengthCheckResults>;
PendingStreamCollaborator: ResolverTypeWrapper<PendingStreamCollaboratorGraphQLReturn>;
PendingWorkspaceCollaborator: ResolverTypeWrapper<PendingWorkspaceCollaboratorGraphQLReturn>;
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
Project: ResolverTypeWrapper<ProjectGraphQLReturn>;
ProjectAccessRequest: ResolverTypeWrapper<ProjectAccessRequestGraphQLReturn>;
ProjectAccessRequestMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
@@ -4409,6 +4422,7 @@ export type ResolversParentTypes = {
PasswordStrengthCheckResults: PasswordStrengthCheckResults;
PendingStreamCollaborator: PendingStreamCollaboratorGraphQLReturn;
PendingWorkspaceCollaborator: PendingWorkspaceCollaboratorGraphQLReturn;
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
Project: ProjectGraphQLReturn;
ProjectAccessRequest: ProjectAccessRequestGraphQLReturn;
ProjectAccessRequestMutations: MutationsObjectGraphQLReturn;
@@ -5196,6 +5210,7 @@ export type PendingWorkspaceCollaboratorResolvers<ContextType = GraphQLContext,
role?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
token?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
workspaceId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
workspaceName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -5848,7 +5863,7 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType>;
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType, Partial<WorkspaceInvitedTeamArgs>>;
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
projects?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<WorkspaceProjectsArgs, 'limit'>>;
@@ -1723,12 +1723,17 @@ export type PendingWorkspaceCollaborator = {
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
token?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
workspaceId: Scalars['String']['output'];
workspaceName: Scalars['String']['output'];
};
export type PendingWorkspaceCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type Project = {
__typename?: 'Project';
allowPublicComments: Scalars['Boolean']['output'];
@@ -3820,6 +3825,11 @@ export type Workspace = {
};
export type WorkspaceInvitedTeamArgs = {
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
};
export type WorkspaceProjectsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceProjectsFilter>;
@@ -3855,6 +3865,8 @@ export type WorkspaceInviteCreateInput = {
email?: InputMaybe<Scalars['String']['input']>;
/** Defaults to the member role, if not specified */
role?: InputMaybe<WorkspaceRole>;
/** Defaults to User, if not specified */
serverRole?: InputMaybe<ServerRole>;
/** Either this or email must be filled */
userId?: InputMaybe<Scalars['String']['input']>;
};
@@ -9,7 +9,10 @@ import { ServerInviteResourceFilter } from '@/modules/serverinvites/repositories
export type FindUserByTarget = (target: string) => Promise<UserWithOptionalRole | null>
export type ServerInviteRecordInsertModel = Omit<ServerInviteRecord, 'createdAt'>
export type ServerInviteRecordInsertModel = Omit<
ServerInviteRecord,
'createdAt' | 'updatedAt'
>
export type InsertInviteAndDeleteOld = (
invite: ServerInviteRecordInsertModel,
@@ -57,7 +60,7 @@ export type QueryAllResourceInvites = <
filter: Pick<
InviteResourceTarget<TargetType, RoleType>,
'resourceId' | 'resourceType'
>
> & { search?: string }
) => Promise<ServerInviteRecord<InviteResourceTarget<TargetType, RoleType>>[]>
export type DeleteAllResourceInvites = <
@@ -104,3 +107,5 @@ export type CreateInviteParams = {
message?: string | null
primaryResourceTarget: PrimaryInviteResourceTarget
}
export type MarkInviteUpdated = (params: { inviteId: string }) => Promise<boolean>
@@ -54,6 +54,7 @@ export type ServerInviteRecord<
target: string
inviterId: string
createdAt: Date
updatedAt: Date
message: Nullable<string>
resource: PrimaryInviteResourceTarget<Resource>
token: string
@@ -28,7 +28,8 @@ import {
insertInviteAndDeleteOldFactory,
deleteInviteFactory as deleteInviteFromDbFactory,
queryAllUserResourceInvitesFactory,
queryAllResourceInvitesFactory
queryAllResourceInvitesFactory,
markInviteUpdatedfactory
} from '@/modules/serverinvites/repositories/serverInvites'
import {
createProjectInviteFactory,
@@ -301,7 +302,8 @@ export = {
getStream
}),
findUserByTarget: findUserByTargetFactory(),
findInvite: findInviteFactory({ db })
findInvite: findInviteFactory({ db }),
markInviteUpdated: markInviteUpdatedfactory({ db })
})
await resendInviteEmail({ inviteId })
@@ -0,0 +1,21 @@
import { Knex } from 'knex'
const TABLE_NAME = 'server_invites'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TABLE_NAME, (table) => {
table
.timestamp('updatedAt', { precision: 3, useTz: true })
.defaultTo(knex.fn.now())
.notNullable()
})
// set updatedAt to be same value as createdAt
await knex(TABLE_NAME).update({ updatedAt: knex.raw('??', ['createdAt']) })
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(TABLE_NAME, (table) => {
table.dropColumn('updatedAt')
})
}
@@ -1,4 +1,4 @@
import { knex, ServerInvites, Streams } from '@/modules/core/dbSchema'
import { knex, ServerInvites, Streams, Users } from '@/modules/core/dbSchema'
import {
getUserByEmail,
getUser,
@@ -24,6 +24,7 @@ import {
FindServerInvite,
FindServerInvites,
InsertInviteAndDeleteOld,
MarkInviteUpdated,
QueryAllResourceInvites,
QueryAllUserResourceInvites,
QueryInvites,
@@ -91,7 +92,7 @@ const buildInvitesBaseQuery =
const q = db(ServerInvites.name)
.select<Result>(ServerInvites.cols)
.orderBy(ServerInvites.col.createdAt, sort)
.orderBy(ServerInvites.col.updatedAt, sort)
// single built in filter
projectInviteValidityFilter(q)
@@ -241,13 +242,31 @@ export const queryAllResourceInvitesFactory =
filter: Pick<
InviteResourceTarget<TargetType, RoleType>,
'resourceId' | 'resourceType'
>
> & { search?: string }
) => {
if (!filter.resourceId) return []
return await buildInvitesBaseQuery({ db })<
const q = buildInvitesBaseQuery({ db })<
ServerInviteRecord<InviteResourceTarget<TargetType, RoleType>>[]
>({ filterQuery }).where((q) => filterByResource(q, filter))
>({ filterQuery })
q.where((q) => filterByResource(q, filter))
if (filter.search) {
q.leftJoin(
Users.name,
Users.col.id,
knex.raw('SUBSTRING(?? FROM 2)', [ServerInvites.col.target])
).where((w1) => {
w1.where(ServerInvites.col.target, 'ILIKE', `%${filter.search}%`).orWhere(
Users.col.name,
'ILIKE',
`%${filter.search}%`
)
})
}
return await q
}
export const deleteAllResourceInvitesFactory =
@@ -481,3 +500,13 @@ export const findInviteByTokenFactory =
return (await q) || null
}
export const markInviteUpdatedfactory =
({ db }: { db: Knex }): MarkInviteUpdated =>
async ({ inviteId }) => {
const cols = ServerInvites.with({ withoutTablePrefix: true }).col
const ret = await db(ServerInvites.name)
.where(ServerInvites.col.id, inviteId)
.update(cols.updatedAt, new Date())
return !!ret
}
@@ -13,6 +13,7 @@ import {
FindInvite,
FindUserByTarget,
InsertInviteAndDeleteOld,
MarkInviteUpdated,
ServerInviteRecordInsertModel
} from '@/modules/serverinvites/domain/operations'
import {
@@ -188,11 +189,13 @@ export const resendInviteEmailFactory =
({
buildInviteEmailContents,
findUserByTarget,
findInvite
findInvite,
markInviteUpdated
}: {
buildInviteEmailContents: BuildInviteEmailContents
findUserByTarget: FindUserByTarget
findInvite: FindInvite
markInviteUpdated: MarkInviteUpdated
}): ResendInviteEmail =>
async (params: { inviteId: string }) => {
const sendInviteEmail = sendInviteEmailFactory({ buildInviteEmailContents })
@@ -221,4 +224,6 @@ export const resendInviteEmailFactory =
targetUser,
targetData
})
await markInviteUpdated({ inviteId })
}
@@ -41,6 +41,8 @@ import {
UseStreamInviteDocument
} from '@/test/graphql/generated/graphql'
import { grantStreamPermissions } from '@/modules/core/repositories/streams'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import { reduce } from 'lodash'
async function cleanup() {
await truncateTables([ServerInvites.name, Streams.name, Users.name])
@@ -503,6 +505,17 @@ describe('[Stream & Server Invites]', () => {
)
const inviteIds = invites.map((i) => i.inviteId)
const inviteLastRemindedDates = reduce(
await ServerInvites.knex<ServerInviteRecord[]>().whereIn(
ServerInvites.col.id,
inviteIds
),
(res, item) => {
res[item.id] = item.updatedAt
return res
},
{} as Record<string, Date>
)
const results = await Promise.all(
inviteIds.map((inviteId) =>
@@ -516,6 +529,22 @@ describe('[Stream & Server Invites]', () => {
}
expect(sendEmailInvocations.length()).to.eq(inviteIds.length)
const newInviteLastRemindedDates = reduce(
await ServerInvites.knex<ServerInviteRecord[]>().whereIn(
ServerInvites.col.id,
inviteIds
),
(res, item) => {
res[item.id] = item.updatedAt
return res
},
{} as Record<string, Date>
)
for (const [id, newDate] of Object.entries(newInviteLastRemindedDates)) {
expect(newDate).to.be.greaterThan(inviteLastRemindedDates[id])
}
})
it('they can delete pre-existing invites irregardless of type', async () => {
@@ -41,13 +41,15 @@ export const onProjectCreatedFactory =
const workspaceMembers = await getWorkspaceRoles({ workspaceId })
await Promise.all(
workspaceMembers.map(({ userId, role: workspaceRole }) =>
grantStreamPermissions({
workspaceMembers.map(({ userId, role: workspaceRole }) => {
if (workspaceRole === Roles.Workspace.Guest) return
return grantStreamPermissions({
streamId: projectId,
userId,
role: mapWorkspaceRoleToProjectRole(workspaceRole)
})
)
})
)
}
@@ -435,7 +435,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
return collaborators
},
invitedTeam: async (parent) => {
invitedTeam: async (parent, args) => {
const getPendingTeam = getPendingWorkspaceCollaboratorsFactory({
queryAllResourceInvites: queryAllResourceInvitesFactory({
db,
@@ -444,7 +444,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
getInvitationTargetUsers: getInvitationTargetUsersFactory({ getUsers })
})
return await getPendingTeam({ workspaceId: parent.id })
return await getPendingTeam({ workspaceId: parent.id, filter: args.filter })
},
projects: async (parent, args) => {
const getWorkspaceProjects = getWorkspaceProjectsFactory({ getStreams })
@@ -1,13 +1,16 @@
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import {
PendingWorkspaceCollaboratorsFilter,
TokenResourceIdentifierType,
WorkspaceInviteCreateInput
} from '@/modules/core/graph/generated/graphql'
import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes'
import { getWorkspaceRoute } from '@/modules/core/helpers/routeHelper'
import { isResourceAllowed } from '@/modules/core/helpers/token'
import { LimitedUserRecord } from '@/modules/core/helpers/types'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import { getUser } from '@/modules/core/repositories/users'
import { ServerInviteResourceType } from '@/modules/serverinvites/domain/constants'
import {
FindInvite,
QueryAllResourceInvites,
@@ -82,7 +85,12 @@ export const createWorkspaceInviteFactory =
role:
(input.role ? mapGqlWorkspaceRoleToMainRole(input.role) : null) ||
Roles.Workspace.Member,
primary: true
primary: true,
secondaryResourceRoles: {
...(input.serverRole
? { [ServerInviteResourceType]: mapServerRoleToValue(input.serverRole) }
: {})
}
}
return await deps.createAndSendInvite(
@@ -241,7 +249,8 @@ function buildPendingWorkspaceCollaboratorModel(
title: resolveInviteTargetTitle(invite, targetUser),
role: invite.resource.role || Roles.Workspace.Member,
invitedById: invite.inviterId,
user: targetUser
user: targetUser,
updatedAt: invite.updatedAt
}
}
@@ -306,8 +315,9 @@ export const getPendingWorkspaceCollaboratorsFactory =
}) =>
async (params: {
workspaceId: string
filter?: MaybeNullOrUndefined<PendingWorkspaceCollaboratorsFilter>
}): Promise<PendingWorkspaceCollaboratorGraphQLReturn[]> => {
const { workspaceId } = params
const { workspaceId, filter } = params
// Get all pending invites
const invites = await deps.queryAllResourceInvites<
@@ -315,7 +325,8 @@ export const getPendingWorkspaceCollaboratorsFactory =
WorkspaceRoles
>({
resourceId: workspaceId,
resourceType: WorkspaceInviteResourceType
resourceType: WorkspaceInviteResourceType,
search: filter?.search || undefined
})
// Get all target users, if any
@@ -7,7 +7,7 @@ import { expect } from 'chai'
describe('Event handlers', () => {
describe('onProjectCreatedFactory creates a function, that', () => {
it('grants project roles for all workspace members', async () => {
it('grants project roles for all workspace members, except guests', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
@@ -48,7 +48,7 @@ describe('Event handlers', () => {
project: { workspaceId, id: projectId } as StreamRecord
})
expect(projectRoles.length).to.equal(3)
expect(projectRoles.length).to.equal(2)
})
})
})
@@ -16,6 +16,7 @@ export type PendingWorkspaceCollaboratorGraphQLReturn = {
role: WorkspaceRoles
invitedById: string
user: LimitedUserRecord | null
updatedAt: Date
}
export type WorkspaceCollaboratorGraphQLReturn = UserWithRole<LimitedUserRecord> & {
@@ -1724,12 +1724,17 @@ export type PendingWorkspaceCollaborator = {
title: Scalars['String']['output'];
/** Only available if the active user is the pending workspace collaborator */
token?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
workspaceId: Scalars['String']['output'];
workspaceName: Scalars['String']['output'];
};
export type PendingWorkspaceCollaboratorsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type Project = {
__typename?: 'Project';
allowPublicComments: Scalars['Boolean']['output'];
@@ -3821,6 +3826,11 @@ export type Workspace = {
};
export type WorkspaceInvitedTeamArgs = {
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
};
export type WorkspaceProjectsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceProjectsFilter>;
@@ -3856,6 +3866,8 @@ export type WorkspaceInviteCreateInput = {
email?: InputMaybe<Scalars['String']['input']>;
/** Defaults to the member role, if not specified */
role?: InputMaybe<WorkspaceRole>;
/** Defaults to User, if not specified */
serverRole?: InputMaybe<ServerRole>;
/** Either this or email must be filled */
userId?: InputMaybe<Scalars['String']['input']>;
};
@@ -92,7 +92,7 @@
</div>
<div
v-if="hasButtons"
class="relative z-50 flex px-6 py-3 space-x-3 shrink-0 bg-foundation-page border-t border-outline-2"
class="relative z-50 flex px-6 py-3 space-x-3 shrink-0 bg-foundation-page"
:class="{
'shadow-t': !scrolledToBottom,
[buttonsWrapperClasses || '']: true
@@ -5,7 +5,6 @@
class="w-full text-sm overflow-x-auto overflow-y-visible simple-scrollbar border border-outline-3 rounded-lg"
>
<div
v-if="items.length > 0"
class="grid z-10 grid-cols-12 items-center space-x-6 font-medium bg-foundation-page rounded-t-lg w-full border-b border-outline-3 pb-2 pt-4 px-4 min-w-[750px]"
:style="{ paddingRight: paddingRightStyle }"
>
@@ -21,7 +20,17 @@
class="divide-y divide-outline-3 h-full overflow-visible"
:class="{ 'pb-32': overflowCells }"
>
<template v-if="items.length">
<div
v-if="loading || !items"
tabindex="0"
:style="{ paddingRight: paddingRightStyle }"
:class="rowsWrapperClasses"
>
<div :class="getClasses(undefined, 0, { noPadding: true })" tabindex="0">
<CommonLoadingBar loading />
</div>
</div>
<template v-else-if="items?.length">
<div
v-for="item in items"
:key="item.id"
@@ -39,17 +48,19 @@
</div>
</template>
<div class="absolute right-1.5 space-x-1 flex items-center p-0 h-full">
<div v-for="button in buttons" :key="button.label">
<FormButton
:icon-left="button.icon"
size="sm"
color="outline"
hide-text
:class="button.class"
:to="isString(button.action) ? button.action : undefined"
@click.stop="!isString(button.action) ? button.action(item) : noop"
/>
</div>
<template v-if="buttons">
<div v-for="button in buttons" :key="button.label">
<FormButton
:icon-left="button.icon"
size="sm"
color="outline"
hide-text
:class="button.class"
:to="isString(button.action) ? button.action : undefined"
@click.stop="!isString(button.action) ? button.action(item) : noop"
/>
</div>
</template>
</div>
</div>
</template>
@@ -71,12 +82,11 @@
</div>
</div>
</template>
<script setup lang="ts" generic="T extends {id: string}, C extends string">
import { noop, isString } from 'lodash'
import { computed } from 'vue'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import { FormButton } from '~~/src/lib'
import { CommonLoadingBar, FormButton } from '~~/src/lib'
export type TableColumn<I> = {
id: I
@@ -84,31 +94,34 @@ export type TableColumn<I> = {
classes: string
}
export interface RowButton<T = unknown> {
export type RowButton<T = unknown> = {
icon: PropAnyComponent
label: string
action: (item: T) => void | string
action: (item: T) => unknown
class?: string
}
const props = withDefaults(
defineProps<{
items: T[]
items: T[] | undefined | null
buttons?: RowButton<T>[]
columns: TableColumn<C>[]
overflowCells?: boolean
onRowClick?: (item: T) => void
rowItemsAlign?: 'center' | 'stretch'
emptyMessage?: string
loading?: boolean
}>(),
{ rowItemsAlign: 'center', emptyMessage: 'No data found' }
)
const buttonCount = computed(() => {
return (props.buttons || []).length
})
const paddingRightStyle = computed(() => {
const buttonCount = (props.buttons || []).length
let padding = 16
if (buttonCount > 0) {
padding = 48 + (buttonCount - 1) * 42
if (buttonCount.value > 0) {
padding = 48 + (buttonCount.value - 1) * 42
}
return `${padding}px`
})
@@ -118,7 +131,7 @@ const rowsWrapperClasses = computed(() => {
'relative grid grid-cols-12 items-center space-x-6 px-4 py-0.5 min-w-[750px] bg-foundation text-body-xs'
]
if (props.onRowClick && props.items.length) {
if (props.onRowClick && props.items?.length) {
classParts.push('cursor-pointer hover:bg-primary-muted')
}
@@ -134,22 +147,36 @@ const rowsWrapperClasses = computed(() => {
return classParts.join(' ')
})
const getHeaderClasses = (column: C | undefined, colIndex: number): string => {
const getHeaderClasses = (
column: C | undefined,
colIndex: number,
options?: Partial<{
noPadding: boolean
}>
): string => {
const classParts = [
column ? props.columns.find((c) => c.id === column)?.classes : '' || ''
]
if (colIndex === 0) {
classParts.push('px-1')
} else {
classParts.push('lg:p-0 px-1')
if (!options?.noPadding) {
if (colIndex === 0) {
classParts.push('px-1')
} else {
classParts.push('lg:p-0 px-1')
}
}
return classParts.join(' ')
}
const getClasses = (column: C | undefined, colIndex: number): string => {
const classParts = [getHeaderClasses(column, colIndex)]
const getClasses = (
column: C | undefined,
colIndex: number,
options?: Partial<{
noPadding: boolean
}>
): string => {
const classParts = [getHeaderClasses(column, colIndex, options)]
if (colIndex === 0) {
classParts.push(`bg-transparent py-2 ${column ? 'pr-5' : 'col-span-full'}`)
@@ -268,9 +268,9 @@ export function useDebouncedTextInput(params?: {
}
}
}
const bind = {
modelValue: computed(() => model.value || '')
}
const bind = computed(() => ({
modelValue: model.value || ''
}))
watch(value, (newVal, oldVal) => {
if (oldVal === newVal && !oldVal && !newVal) return
+371 -908
View File
File diff suppressed because it is too large Load Diff