Files
speckle-server/packages/frontend-2/lib/projects/composables/versionManagement.ts
T
Kristaps Fabians Geikins 83d8035dc2 chore: upgrade to eslint 9 (#2348)
* root + server

* frontend

* frontend-2

* dui3

* dui3

* tailwind theme

* ui-components

* preview service

* viewer

* viewer-sandbox

* fileimport-service

* webhook service

* objectloader

* shared

* ui-components-nuxt

* WIP full config

* WIP full linter

* eslint projectwide util

* minor fix

* removing redundant ci

* clean up test errors

* fixed prettier formatting

* CI improvements

* TSC lint fix

* 'buildBatch' needs to be async since some batch types (like Text) require it. Removed a disabled liniting rule from ObjLoader

* removed unnecessary void

---------

Co-authored-by: AlexandruPopovici <alexandrupopoviciioan@gmail.com>
2024-06-12 14:38:02 +03:00

701 lines
22 KiB
TypeScript

import type { ApolloCache } from '@apollo/client/core'
import { useApolloClient, useQuery, useSubscription } from '@vue/apollo-composable'
import type { MaybeRef } from '@vueuse/core'
import type { Get } from 'type-fest'
import { SpeckleViewer } from '@speckle/shared'
import type { Nullable } from '@speckle/shared'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import type {
DeleteVersionsInput,
Model,
ModelPendingImportedVersionsArgs,
ModelVersionArgs,
ModelVersionsArgs,
MoveVersionsInput,
OnProjectPendingVersionsUpdatedSubscription,
OnProjectVersionsUpdateSubscription,
Project,
ProjectModelsArgs,
ProjectModelsTreeArgs,
ProjectPendingImportedModelsArgs,
ProjectVersionsArgs,
UpdateVersionInput
} from '~~/lib/common/generated/gql/graphql'
import {
ProjectPendingVersionsUpdatedMessageType,
ProjectVersionsUpdatedMessageType
} from '~~/lib/common/generated/gql/graphql'
import { modelRoute } from '~~/lib/common/helpers/route'
import {
onProjectPendingVersionsUpdatedSubscription,
onProjectVersionsUpdateSubscription
} from '~~/lib/projects/graphql/subscriptions'
import {
convertThrowIntoFetchResult,
evictObjectFields,
getCacheId,
getFirstErrorMessage,
getObjectReference,
modifyObjectFields
} from '~~/lib/common/helpers/graphql'
import { projectModelVersionsQuery } from '~~/lib/projects/graphql/queries'
import {
deleteVersionsMutation,
moveVersionsMutation,
updateVersionMutation
} from '~~/lib/projects/graphql/mutations'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useEvictProjectModelFields } from '~~/lib/projects/composables/modelManagement'
import { intersection, isUndefined, uniqBy } from 'lodash-es'
import { FileUploadConvertedStatus } from '~~/lib/core/api/fileImport'
import { useLock } from '~~/lib/common/composables/singleton'
export function useProjectVersionUpdateTracking(
projectId: MaybeRef<string>,
handler?: (
data: NonNullable<
Get<OnProjectVersionsUpdateSubscription, 'projectVersionsUpdated'>
>,
cache: ApolloCache<unknown>
) => void,
options?: Partial<{
silenceToast: boolean
}>
) {
const { silenceToast = false } = options || {}
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { hasLock } = useLock(
computed(() => `useProjectVersionUpdateTracking-${unref(projectId)}`)
)
const isEnabled = computed(() => !!(hasLock.value || handler))
const { onResult: onProjectVersionsUpdate } = useSubscription(
onProjectVersionsUpdateSubscription,
() => ({
id: unref(projectId)
}),
{ enabled: isEnabled }
)
// Cache updates that should only be invoked once
onProjectVersionsUpdate((res) => {
if (!res.data?.projectVersionsUpdated || !hasLock.value) return
const event = res.data.projectVersionsUpdated
const version = event.version
if (
[
ProjectVersionsUpdatedMessageType.Created,
ProjectVersionsUpdatedMessageType.Updated
].includes(event.type) &&
version
) {
// Added new model w/ versions OR updated model that now has versions (it might not have had them previously)
// So - add it to the list, if its not already there
modifyObjectFields<ProjectModelsArgs, Project['models']>(
apollo.cache,
getCacheId('Project', unref(projectId)),
(_fieldName, variables, value, { ref }) => {
if (variables.filter?.search) return
const limit = variables.limit
const newModelRef = ref('Model', version.model.id)
const newItems = (value?.items || []).slice()
let itemAdded = false
if (
!newItems.find((i) => i.__ref === newModelRef.__ref) &&
(isUndefined(limit) || newItems.length < limit)
) {
newItems.unshift(newModelRef)
itemAdded = true
}
return {
...(value || {}),
items: newItems,
totalCount: (value.totalCount || 0) + (itemAdded ? 1 : 0)
}
},
{ fieldNameWhitelist: ['models'] }
)
// + Evict modelsTree, if it doesnt have this model
evictObjectFields<ProjectModelsTreeArgs, Project['modelsTree']>(
apollo.cache,
getCacheId('Project', unref(projectId)),
(fieldName, variables, value, { readField }) => {
if (fieldName !== 'modelsTree') return false
if (variables.filter?.search) return false
if (variables.filter?.contributors?.length) return false
if (variables.filter?.sourceApps?.length) return false
const items = value?.items || []
for (const item of items) {
const fullName = readField('fullName', item)
if (fullName === version.model.name) return false
}
return true
}
)
if (event.type === ProjectVersionsUpdatedMessageType.Created) {
// Evict project.viewerResources
evictObjectFields(
apollo.cache,
getCacheId('Project', unref(projectId)),
(fieldName) => fieldName === 'viewerResources'
)
// Remove from pendingVersions, in case it's there
modifyObjectFields<
ModelPendingImportedVersionsArgs,
Model['pendingImportedVersions']
>(
apollo.cache,
getCacheId('Model', version.model.id),
(_fieldName, _variables, value, { readField }) => {
if (!value?.length) return
// Unfortunately message matching is the best we can do
const newMessage = version.message || ''
const pendingWithFittingMessageIdx = (value || []).findIndex((i) => {
const fileName = <string>readField('fileName', i) || ''
return newMessage.includes(fileName)
})
const newVersions = (value || []).slice()
if (pendingWithFittingMessageIdx !== -1) {
newVersions.splice(pendingWithFittingMessageIdx, 1)
}
return newVersions
},
{ fieldNameWhitelist: ['pendingImportedVersions'] }
)
// Add to model.versions
modifyObjectFields<ModelVersionsArgs, Model['versions']>(
apollo.cache,
getCacheId('Model', version.model.id),
(_fieldName, variables, value, { ref }) => {
if (
variables.filter?.priorityIdsOnly &&
variables.filter?.priorityIds &&
!variables.filter.priorityIds.includes(version.id)
) {
return
}
const limit = variables.limit
if (!limit) {
return // already updated through ProjectPageLatestItemsModelItem fragment in response
}
const newItems = (value?.items || []).slice()
if (isUndefined(limit) || newItems.length < limit) {
newItems.unshift(ref('Version', version.id))
}
return {
...(value || {}),
items: newItems,
totalCount: (value.totalCount || 0) + 1
}
},
{ fieldNameWhitelist: ['versions'] }
)
// Add to project.versions
modifyObjectFields<ProjectVersionsArgs, Project['versions']>(
apollo.cache,
getCacheId('Project', unref(projectId)),
(_fieldName, variables, value, { ref }) => {
const newVersionRef = ref('Version', version.id)
const limit = variables.limit
const newItems = (value?.items || []).slice()
if (
!newItems.find((i) => i.__ref === newVersionRef.__ref) &&
(isUndefined(limit) || newItems.length < limit)
) {
newItems.unshift(newVersionRef)
}
return {
...(value || {}),
items: newItems,
totalCount: (value.totalCount || 0) + 1
}
},
{ fieldNameWhitelist: ['versions'] }
)
// Potentially remove item from Project.pendingImportedModels?
// Remove from pending models?
modifyObjectFields<
ProjectPendingImportedModelsArgs,
Project['pendingImportedModels']
>(
apollo.cache,
getCacheId('Project', unref(projectId)),
(_fieldName, _variables, value, { readField }) => {
if (!value?.length) return
const versionModelName = version.model.name
const currentModels = (value || []).filter((i) => {
const itemModelName = readField('modelName', i)
return itemModelName !== versionModelName
})
return currentModels
},
{ fieldNameWhitelist: ['pendingImportedModels'] }
)
// Emit toast
if (!silenceToast) {
triggerNotification({
type: ToastNotificationType.Info,
title: 'A new version was created!',
cta: {
title: 'View Version',
url: modelRoute(
unref(projectId),
SpeckleViewer.ViewerRoute.resourceBuilder()
.addModel(version.model.id, version.id)
.toString()
)
}
})
}
}
} else if (event.type === ProjectVersionsUpdatedMessageType.Deleted) {
// Delete from cache
apollo.cache.evict({
id: getCacheId('Version', event.id)
})
if (event.modelId) {
// Evict stale model fields
evictObjectFields(
apollo.cache,
getCacheId('Model', event.modelId),
(fieldName) =>
['updatedAt', 'previewUrl', 'versionCount', 'versions'].includes(fieldName)
)
// Evict project.viewerResources
apollo.cache.evict({
id: getCacheId('Project', unref(projectId)),
fieldName: 'viewerResources'
})
}
}
})
onProjectVersionsUpdate((res) => {
if (!res.data?.projectVersionsUpdated) return
const event = res.data.projectVersionsUpdated
handler?.(event, apollo.cache)
})
}
export function useModelVersions(params: {
projectId: MaybeRef<string>
modelId: MaybeRef<string>
}) {
const { projectId, modelId } = params
const cursor = ref(null as Nullable<string>)
const { result, fetchMore, onResult } = useQuery(projectModelVersionsQuery, () => ({
projectId: unref(projectId),
modelId: unref(modelId),
versionsCursor: cursor.value
}))
onResult((res) => {
cursor.value = res.data.project?.model?.versions.cursor || null
})
const versions = computed(() => result.value?.project?.model?.versions)
const moreToLoad = computed(
() =>
(!versions.value || versions.value.items.length < versions.value.totalCount) &&
cursor.value
)
const loadMore = () => {
if (!moreToLoad.value) return
return fetchMore({ variables: { versionsCursor: cursor.value } })
}
return {
versions,
loadMore,
moreToLoad
}
}
export function useDeleteVersions() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { isLoggedIn } = useActiveUser()
return async (
input: DeleteVersionsInput,
/**
* Various options for better cache updates, set if possible
*/
options?: Partial<{
projectId: string
modelId: string
}>
) => {
if (!input.versionIds.length) return
if (!isLoggedIn.value) return
const { data, errors } = await apollo
.mutate({
mutation: deleteVersionsMutation,
variables: { input },
update: (cache, { data }) => {
if (!data?.versionMutations.delete) return
// Evict all versions from cache
for (const versionId of input.versionIds) {
cache.evict({
id: getCacheId('Version', versionId)
})
}
// Update totalCounts in project
if (options?.projectId) {
modifyObjectFields<ProjectVersionsArgs, Project['versions']>(
cache,
getCacheId('Project', options.projectId),
(_fieldName, _variables, data) => {
return {
...data,
...(!isUndefined(data.totalCount)
? {
totalCount: Math.max(
data.totalCount - input.versionIds.length,
0
)
}
: {})
}
},
{ fieldNameWhitelist: ['versions'] }
)
}
// Update totalCounts in model
if (options?.modelId) {
modifyObjectFields<ModelVersionsArgs, Model['versions']>(
cache,
getCacheId('Model', options.modelId),
(_fieldName, variables, data) => {
let removedCount = input.versionIds.length
if (
variables.filter?.priorityIdsOnly &&
variables.filter?.priorityIds
) {
const idIntersection = intersection(
variables.filter.priorityIds,
input.versionIds
)
if (idIntersection.length < 1) return
removedCount = idIntersection.length
}
return {
...data,
...(!isUndefined(data.totalCount)
? {
totalCount: Math.max(data.totalCount - removedCount, 0)
}
: {})
}
},
{ fieldNameWhitelist: ['versions'] }
)
}
}
})
.catch(convertThrowIntoFetchResult)
if (data?.versionMutations.delete) {
const deleteCount = input.versionIds.length
triggerNotification({
type: ToastNotificationType.Info,
title: `${deleteCount} version${deleteCount > 1 ? 's' : ''} deleted`
})
} else {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Version deletion failed',
description: errMsg
})
}
return !!data?.versionMutations.delete
}
}
export function useMoveVersions() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { isLoggedIn } = useActiveUser()
const evictProjectModels = useEvictProjectModelFields()
return async (
input: MoveVersionsInput,
options?: Partial<{
previousModelId: string
newModelCreated: boolean
projectId: string
}>
) => {
if (!input.versionIds.length || !input.targetModelName.trim()) return
if (!isLoggedIn.value) return
const { data, errors } = await apollo
.mutate({
mutation: moveVersionsMutation,
variables: { input },
update: (cache, { data }) => {
if (!data?.versionMutations.moveToModel.id) return
const newModelId = data.versionMutations.moveToModel.id
const previousModelId = options?.previousModelId
if (!previousModelId) return
// Remove from Model.version
modifyObjectFields<ModelVersionArgs>(
cache,
getCacheId('Model', previousModelId),
(_fieldName, variables) => {
if (!input.versionIds.includes(variables.id)) return
// Set to null
return null
},
{ fieldNameWhitelist: ['version'] }
)
// Remove from Model.versions
modifyObjectFields<ModelVersionsArgs, Model['versions']>(
cache,
getCacheId('Model', previousModelId),
(_fieldName, _variables, data) => {
const oldItems = data.items || []
const newItems = oldItems.filter(
(i) =>
!input.versionIds
.map((id) => getCacheId('Version', id))
.includes(i.__ref)
)
const removedItemsCount = Math.max(0, oldItems.length - newItems.length)
return {
...data,
...(data.items ? { items: newItems } : {}),
...(!isUndefined(data.totalCount)
? {
totalCount: data.totalCount - removedItemsCount
}
: {})
}
},
{ fieldNameWhitelist: ['versions'] }
)
// Add to new model's Model.version
modifyObjectFields<ModelVersionArgs>(
cache,
getCacheId('Model', newModelId),
(_fieldName, variables) => {
if (!input.versionIds.includes(variables.id)) return
return getObjectReference('Version', variables.id)
},
{ fieldNameWhitelist: ['version'] }
)
// Add to new model's Model.versions
modifyObjectFields<ModelVersionsArgs, Model['versions']>(
cache,
getCacheId('Model', newModelId),
(_fieldName, _variables, data) => {
const oldItems = data.items || []
const newItems = [
...input.versionIds.map((i) => getObjectReference('Version', i)),
...oldItems
]
const addedItemAmount = newItems.length - oldItems.length
return {
...data,
...(data.items ? { items: newItems } : {}),
...(!isUndefined(data.totalCount)
? {
totalCount: data.totalCount + addedItemAmount
}
: {})
}
},
{ fieldNameWhitelist: ['versions'] }
)
if (options?.newModelCreated && options?.projectId) {
evictProjectModels(options.projectId)
}
}
})
.catch(convertThrowIntoFetchResult)
if (data?.versionMutations.moveToModel.id) {
const deleteCount = input.versionIds.length
triggerNotification({
type: ToastNotificationType.Info,
title: `${deleteCount} version${deleteCount > 1 ? 's' : ''} moved`
})
} else {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Version move failed',
description: errMsg
})
}
return !!data?.versionMutations.moveToModel.id
}
}
export function useUpdateVersion() {
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
const { isLoggedIn } = useActiveUser()
return async (input: UpdateVersionInput) => {
if (!input.versionId) return
if (!isLoggedIn.value) return
const { data, errors } = await apollo
.mutate({
mutation: updateVersionMutation,
variables: { input }
})
.catch(convertThrowIntoFetchResult)
if (data?.versionMutations.update.id) {
triggerNotification({
type: ToastNotificationType.Success,
title: `Version successfully updated`
})
} else {
const errMsg = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Version update failed',
description: errMsg
})
}
return data?.versionMutations.update
}
}
export function useProjectPendingVersionUpdateTracking(
projectId: MaybeRef<string>,
handler?: (
data: NonNullable<
Get<OnProjectPendingVersionsUpdatedSubscription, 'projectPendingVersionsUpdated'>
>,
cache: ApolloCache<unknown>
) => void
) {
const { hasLock } = useLock(
computed(() => `useProjectPendingVersionUpdateTracking-${unref(projectId)}`)
)
const isEnabled = computed(() => !!(hasLock.value || handler))
const { onResult: onProjectPendingVersionsUpdate } = useSubscription(
onProjectPendingVersionsUpdatedSubscription,
() => ({
id: unref(projectId)
}),
{ enabled: isEnabled }
)
const apollo = useApolloClient().client
const { triggerNotification } = useGlobalToast()
onProjectPendingVersionsUpdate((res) => {
if (!res.data?.projectPendingVersionsUpdated.id || !hasLock.value) return
const event = res.data.projectPendingVersionsUpdated
const modelId = event.version.model?.id
if (!modelId) return
if (event.type === ProjectPendingVersionsUpdatedMessageType.Created) {
// Insert into model.pendingVersions
modifyObjectFields<
ModelPendingImportedVersionsArgs,
Model['pendingImportedVersions']
>(
apollo.cache,
getCacheId('Model', modelId),
(_fieldName, _variables, value, { ref }) => {
const currentVersions = (value || []).slice()
currentVersions.push(ref('FileUpload', event.id))
return uniqBy(currentVersions, (v) => v.__ref)
},
{ fieldNameWhitelist: ['pendingImportedVersions'] }
)
} else if (event.type === ProjectPendingVersionsUpdatedMessageType.Updated) {
const success =
event.version.convertedStatus === FileUploadConvertedStatus.Completed
const failure = event.version.convertedStatus === FileUploadConvertedStatus.Error
if (success) {
// Remove from model.pendingVersions
modifyObjectFields<
ModelPendingImportedVersionsArgs,
Model['pendingImportedVersions']
>(
apollo.cache,
getCacheId('Model', modelId),
(_fieldName, _variables, value, { ref }) => {
if (!value?.length) return
const currentVersions = (value || []).filter(
(i) => i.__ref !== ref('FileUpload', event.id).__ref
)
return currentVersions
},
{ fieldNameWhitelist: ['pendingImportedVersions'] }
)
} else if (failure) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'File import failed',
description:
event.version.convertedMessage ||
`${event.version.modelName} version could not be imported`
})
}
}
})
onProjectPendingVersionsUpdate((res) => {
if (!res.data?.projectPendingVersionsUpdated.id) return
const event = res.data.projectPendingVersionsUpdated
handler?.(event, apollo.cache)
})
}