Files
speckle-server/packages/frontend-2/lib/projects/composables/projectManagement.ts
T
2025-02-13 17:11:54 +01:00

618 lines
18 KiB
TypeScript

import type { ApolloCache } from '@apollo/client/core'
import type { Optional } from '@speckle/shared'
import { useApolloClient, useMutation, useSubscription } from '@vue/apollo-composable'
import type { MaybeRef } from '@vueuse/core'
import type { Get } from 'type-fest'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useLock } from '~~/lib/common/composables/singleton'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { ProjectUpdatedMessageType } from '~~/lib/common/generated/gql/graphql'
import type {
OnProjectUpdatedSubscription,
ProjectCreateInput,
ProjectInviteCreateInput,
ProjectInviteUseInput,
ProjectUpdateInput,
ProjectUpdateRoleInput,
UpdateProjectMetadataMutation,
WorkspaceProjectInviteCreateInput,
InviteProjectUserMutation,
Project,
WorkspaceProjectCreateInput,
CreateWorkspaceProjectMutation,
CreateProjectMutation,
AdminPanelProjectsListQuery
} from '~~/lib/common/generated/gql/graphql'
import {
convertThrowIntoFetchResult,
getCacheId,
getFirstErrorMessage,
modifyObjectField,
modifyObjectFields
} from '~~/lib/common/helpers/graphql'
import { useNavigateToHome, workspaceRoute } from '~~/lib/common/helpers/route'
import {
cancelProjectInviteMutation,
createProjectMutation,
deleteProjectMutation,
inviteProjectUserMutation,
inviteWorkspaceProjectUserMutation,
leaveProjectMutation,
updateProjectMetadataMutation,
updateProjectRoleMutation,
updateWorkspaceProjectRoleMutation,
useProjectInviteMutation,
useMoveProjectToWorkspaceMutation,
createWorkspaceProjectMutation
} from '~~/lib/projects/graphql/mutations'
import { onProjectUpdatedSubscription } from '~~/lib/projects/graphql/subscriptions'
import { projectRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useRouter } from 'vue-router'
export function useProjectUpdateTracking(
projectId: MaybeRef<string>,
handler?: (
data: NonNullable<Get<OnProjectUpdatedSubscription, 'projectUpdated'>>,
cache: ApolloCache<unknown>
) => void,
options?: Partial<{
redirectOnDeletion: boolean
notifyOnUpdate?: boolean
}>
) {
const { redirectOnDeletion, notifyOnUpdate } = options || {}
const goHome = useNavigateToHome()
const { triggerNotification } = useGlobalToast()
const apollo = useApolloClient().client
const { hasLock } = useLock(
computed(() => `useProjectUpdateTracking-${unref(projectId)}`)
)
const isEnabled = computed(() => !!(hasLock.value || handler))
const { onResult: onProjectUpdated } = useSubscription(
onProjectUpdatedSubscription,
() => ({
id: unref(projectId)
}),
{ enabled: isEnabled }
)
onProjectUpdated((res) => {
if (!res.data?.projectUpdated || !hasLock.value) return
const event = res.data.projectUpdated
const cache = apollo.cache
const isDeleted = event.type === ProjectUpdatedMessageType.Deleted
if (isDeleted) {
cache.evict({
id: getCacheId('Project', event.id)
})
if (redirectOnDeletion) {
goHome()
}
if (redirectOnDeletion || notifyOnUpdate) {
triggerNotification({
type: ToastNotificationType.Info,
title: isDeleted ? 'Project deleted' : 'Project updated'
})
}
}
})
onProjectUpdated((res) => {
if (!res.data?.projectUpdated) return
const event = res.data.projectUpdated
handler?.(event, apollo.cache)
})
}
export function useCreateProject() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { activeUser } = useActiveUser()
return async (input: ProjectCreateInput | WorkspaceProjectCreateInput) => {
const userId = activeUser.value?.id
if (!userId) return
const res = await apollo
.mutate({
...('workspaceId' in input
? {
mutation: createWorkspaceProjectMutation,
variables: { input }
}
: {
mutation: createProjectMutation,
variables: { input }
}),
update: (cache, { data }) => {
const typedData = data as
| CreateWorkspaceProjectMutation
| CreateProjectMutation
if (!typedData) return
modifyObjectFields<undefined, { [key: string]: AdminPanelProjectsListQuery }>(
cache,
ROOT_QUERY,
(_fieldName, _variables, value, details) => {
const projectListFields = Object.keys(value).filter(
(k) =>
details.revolveFieldNameAndVariables(k).fieldName === 'projectList'
)
const newVal: typeof value = { ...value }
for (const field of projectListFields) {
delete newVal[field]
}
return newVal
},
{ fieldNameWhitelist: ['admin'] }
)
}
})
.catch(convertThrowIntoFetchResult)
// not sure why this isn't happening automatically
const typedData = res.data as Optional<
CreateWorkspaceProjectMutation | CreateProjectMutation
>
const newProject = typedData
? 'projectMutations' in typedData
? typedData.projectMutations.create
: typedData.workspaceMutations.projects.create
: undefined
if (!newProject?.id) {
const err = getFirstErrorMessage(res.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Project creation failed',
description: err
})
}
return newProject
}
}
export function useUpdateUserRole(
project?: Ref<Pick<Project, 'workspaceId'> | undefined>
) {
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const updateProjectRole = async (input: ProjectUpdateRoleInput) => {
const userId = activeUser.value?.id
if (!userId) return
const { data, errors } = await apollo
.mutate({
mutation: updateProjectRoleMutation,
variables: { input }
})
.catch(convertThrowIntoFetchResult)
if (!data?.projectMutations.updateRole.id) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Permission update failed',
description: err
})
} else {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Project permissions updated'
})
}
return data?.projectMutations.updateRole
}
const updateWorkspaceProjectRole = async (input: ProjectUpdateRoleInput) => {
const userId = activeUser.value?.id
if (!userId) return
const { data, errors } = await apollo
.mutate({
mutation: updateWorkspaceProjectRoleMutation,
variables: { input },
update: (cache) => {
cache.evict({ id: getCacheId('Project', input.projectId) })
cache.evict({
id: getCacheId('WorkspaceCollaborator', input.userId)
})
}
})
.catch(convertThrowIntoFetchResult)
if (!data?.workspaceMutations.projects.updateRole.id) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Permission update failed',
description: err
})
} else {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Workspace project permissions updated'
})
}
return data?.workspaceMutations.projects
}
const isWorkspaceProject =
isWorkspacesEnabled.value && project?.value?.workspaceId?.length
return isWorkspaceProject ? updateWorkspaceProjectRole : updateProjectRole
}
export function useInviteUserToProject() {
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
return async (
projectId: string,
input: ProjectInviteCreateInput[] | WorkspaceProjectInviteCreateInput[],
options?: { hideToasts?: boolean }
) => {
const userId = activeUser.value?.id
const { hideToasts } = options || {}
if (!userId) return
const isWorkspaceInput = (
input: ProjectInviteCreateInput[] | WorkspaceProjectInviteCreateInput[]
): input is WorkspaceProjectInviteCreateInput[] => {
return input.some((i) => 'workspaceRole' in i)
}
let res: Optional<
InviteProjectUserMutation['projectMutations']['invites']['batchCreate']
> = undefined
let err: Optional<string> = undefined
if (isWorkspaceInput(input)) {
const { data, errors } = await apollo
.mutate({
mutation: inviteWorkspaceProjectUserMutation,
variables: { inputs: input, projectId }
})
.catch(convertThrowIntoFetchResult)
res = data?.projectMutations.invites.createForWorkspace
err = !res?.id ? getFirstErrorMessage(errors) : undefined
} else {
const { data, errors } = await apollo
.mutate({
mutation: inviteProjectUserMutation,
variables: { input, projectId }
})
.catch(convertThrowIntoFetchResult)
res = data?.projectMutations.invites.batchCreate
err = !res?.id ? getFirstErrorMessage(errors) : undefined
}
if (err && !hideToasts) {
triggerNotification({
type: ToastNotificationType.Danger,
title: input.length > 1 ? "Couldn't send invites" : "Couldn't send invite",
description: err
})
} else {
if (!hideToasts) {
triggerNotification({
type: ToastNotificationType.Success,
title:
input.length > 1 ? 'Invites successfully send' : 'Invite successfully sent'
})
}
}
return res
}
}
export function useCancelProjectInvite() {
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
return async (input: { projectId: string; inviteId: string }) => {
const userId = activeUser.value?.id
if (!userId) return
const { data, errors } = await apollo
.mutate({
mutation: cancelProjectInviteMutation,
variables: input
})
.catch(convertThrowIntoFetchResult)
if (!data?.projectMutations.invites.cancel) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Invitation cancelation failed',
description: err
})
} else {
triggerNotification({
type: ToastNotificationType.Info,
title: 'Invitation canceled'
})
}
return data?.projectMutations.invites.cancel
}
}
export function useUpdateProject() {
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
return async (
update: ProjectUpdateInput,
options?: Partial<{
customSuccessMessage?: string
optimisticResponse: UpdateProjectMetadataMutation
}>
) => {
if (!activeUser.value) return
const successMessage = options?.customSuccessMessage || 'Project updated'
const result = await apollo
.mutate({
mutation: updateProjectMetadataMutation,
variables: { update },
optimisticResponse: options?.optimisticResponse
})
.catch(convertThrowIntoFetchResult)
if (result?.data?.projectMutations.update?.id) {
triggerNotification({
type: ToastNotificationType.Success,
title: successMessage
})
} else {
const errMsg = getFirstErrorMessage(result.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Project update failed',
description: errMsg
})
}
return result.data?.projectMutations.update
}
}
export function useDeleteProject() {
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const navigateHome = useNavigateToHome()
const router = useRouter()
return async (
id: string,
options?: Partial<{ goHome: boolean; workspaceSlug?: string }>
) => {
if (!activeUser.value) return
const { goHome, workspaceSlug } = options || {}
const result = await apollo
.mutate({
mutation: deleteProjectMutation,
variables: {
id
}
})
.catch(convertThrowIntoFetchResult)
if (result?.data?.projectMutations.delete) {
if (goHome) {
if (workspaceSlug) {
router.push(workspaceRoute(workspaceSlug))
} else {
navigateHome()
}
}
// evict project from cache
apollo.cache.evict({
id: getCacheId('Project', id)
})
} else {
const errMsg = getFirstErrorMessage(result.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Project deletion failed',
description: errMsg
})
}
return !!result.data?.projectMutations.delete
}
}
export function useProcessProjectInvite() {
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
return async (
input: ProjectInviteUseInput,
options?: Partial<{ inviteId: string; skipToast: boolean }>
) => {
if (!activeUser.value) return
const { data, errors } = await apollo
.mutate({
mutation: useProjectInviteMutation,
variables: { input },
update: (cache, { data }) => {
if (!data?.projectMutations.invites.use) return
if (options?.inviteId) {
// Evict PendingStreamCollaborator
cache.evict({
id: getCacheId('PendingStreamCollaborator', options.inviteId)
})
}
}
})
.catch(convertThrowIntoFetchResult)
if (!options?.skipToast) {
if (data?.projectMutations.invites.use) {
triggerNotification({
type: input.accept
? ToastNotificationType.Success
: ToastNotificationType.Info,
title: input.accept ? 'Invite accepted' : 'Invite dismissed'
})
} else {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: "Couldn't process invite",
description: errMsg
})
}
}
return data?.projectMutations.invites.use
}
}
export function useLeaveProject() {
const apollo = useApolloClient().client
const { activeUser } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const navigateHome = useNavigateToHome()
return async (projectId: string, options?: Partial<{ goHome: boolean }>) => {
if (!activeUser.value) return
const { data, errors } = await apollo
.mutate({
mutation: leaveProjectMutation,
variables: { projectId },
update: (cache, { data }) => {
if (!data?.projectMutations.leave) return
cache.evict({
id: getCacheId('Project', projectId)
})
}
})
.catch(convertThrowIntoFetchResult)
if (data?.projectMutations.leave) {
triggerNotification({
type: ToastNotificationType.Info,
title: "You've left the project"
})
if (options?.goHome) navigateHome()
} else {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: "Couldn't leave project",
description: errMsg
})
}
}
}
export function useMoveProjectToWorkspace() {
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
const { mutate } = useMutation(useMoveProjectToWorkspaceMutation)
return async (params: {
projectId: string
workspaceId: string
workspaceName: string
eventSource?: string
}) => {
const { projectId, workspaceId, workspaceName, eventSource } = params
const res = await mutate(
{ projectId, workspaceId },
{
update: (cache, { data }) => {
if (!data?.workspaceMutations.projects.moveToWorkspace) return
if (!workspaceId) return
modifyObjectField(
cache,
getCacheId('Workspace', workspaceId),
'projects',
({ helpers: { createUpdatedValue, ref } }) => {
return createUpdatedValue(({ update }) => {
update('items', (items) => [ref('Project', projectId), ...items])
})
}
)
}
}
).catch(convertThrowIntoFetchResult)
if (res?.data?.workspaceMutations.projects.moveToWorkspace.id) {
triggerNotification({
type: ToastNotificationType.Success,
title: `Moved project to ${workspaceName}`
})
mixpanel.track('Project Moved To Workspace', {
projectId,
// eslint-disable-next-line camelcase
workspace_id: workspaceId,
source: eventSource
})
} else {
const errMsg = getFirstErrorMessage(res?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: "Couldn't move project",
description: errMsg
})
}
return res?.data?.workspaceMutations.projects.moveToWorkspace
}
}
export function useCopyProjectLink() {
const { copy } = useClipboard()
const { triggerNotification } = useGlobalToast()
return async (projectId: string) => {
if (import.meta.server) {
throw new Error('Not supported in SSR')
}
const path = projectRoute(projectId)
const url = new URL(path, window.location.toString()).toString()
await copy(url)
triggerNotification({
type: ToastNotificationType.Success,
title: 'Project link copied to clipboard'
})
}
}