feat: MVP manual view positioning (#5500)

* init migration

* WIP position calculations

* view retrieval sorting

* WIP specific position resolution

* position calc

* new positioning draft

* workz?

* improved error

* even better logging

* moar

* troubleshooting

* works??

* delete junk

* fixed rebalancing

* some tests

* lint fix

* test fix

* more fixes and tests

* more tests and fixes

* fix testsss

* more tests

* moaar

* fix group drop

* errorToString updates
This commit is contained in:
Kristaps Fabians Geikins
2025-09-24 11:58:46 +02:00
committed by GitHub
parent 251d9a3297
commit 99c26db777
25 changed files with 1419 additions and 117 deletions
+4 -1
View File
@@ -85,4 +85,7 @@ bin/
multiregion.json
multiregion.test.json
packages/*/.tshy/
.vite-node
.vite-node
.nuxt
.output
@@ -3,10 +3,10 @@
<template>
<div
v-keyboard-clickable
:class="[wrapperClasses, draggableClasses]"
:class="[wrapperClasses, draggableClasses, draggableTargetClasses]"
:view-id="view.id"
draggable="true"
v-on="on"
v-on="{ ...on, ...targetOn }"
@click="apply"
>
<div class="flex items-center shrink-0">
@@ -124,7 +124,10 @@ import {
useCollectNewSavedViewViewerData,
useUpdateSavedView
} from '~/lib/viewer/composables/savedViews/management'
import { useDraggableView } from '~/lib/viewer/composables/savedViews/ui'
import {
useDraggableView,
useDraggableViewTargetView
} from '~/lib/viewer/composables/savedViews/ui'
import { useSavedViewValidationHelpers } from '~/lib/viewer/composables/savedViews/validation'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
@@ -197,6 +200,10 @@ const {
const { classes: draggableClasses, on } = useDraggableView({
view: computed(() => props.view)
})
const { classes: draggableTargetClasses, on: targetOn } = useDraggableViewTargetView({
view: computed(() => props.view)
})
const mp = useMixpanel()
const showMenu = ref(false)
@@ -14,6 +14,7 @@
:group="group"
:search="search"
:views-type="viewsType"
@view-count-updated="(count) => (viewCount = count)"
/>
<template #title-actions>
<div
@@ -142,6 +143,9 @@ const props = defineProps<{
search?: string
}>()
const open = defineModel<boolean>('open')
const viewCount = ref(0)
const { triggerNotification } = useGlobalToast()
const isLoading = useMutationLoading()
const createView = useCreateSavedView()
@@ -153,11 +157,11 @@ const { on, classes: dropZoneClasses } = useDraggableViewTargetGroup({
if (!open.value) {
open.value = true
}
}
},
enabled: computed(() => !open.value || !viewCount.value)
})
const renameMode = defineModel<boolean>('renameMode')
const open = defineModel<boolean>('open')
const showMenu = ref(false)
const menuId = useId()
@@ -77,6 +77,10 @@ const viewsQuery = graphql(`
}
`)
const emit = defineEmits<{
'view-count-updated': [count: number]
}>()
const props = defineProps<{
group: ViewerSavedViewsPanelViewsGroupInner_SavedViewGroupFragment
viewsType: ViewsType
@@ -119,4 +123,13 @@ const {
})
const views = computed(() => result.value?.project.savedViewGroup.views.items || [])
watch(
() => views.value.length,
(newVal, oldVal) => {
if (newVal === oldVal) return
emit('view-count-updated', newVal)
},
{ immediate: true }
)
</script>
@@ -446,7 +446,8 @@ type Documents = {
"\n fragment UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\n }\n": typeof types.UseDeleteSavedViewGroup_SavedViewGroupFragmentDoc,
"\n mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n }\n }\n }\n": typeof types.UpdateSavedViewGroupDocument,
"\n fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\n }\n": typeof types.UseUpdateSavedViewGroup_SavedViewGroupFragmentDoc,
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.UseDraggableView_SavedViewFragmentDoc,
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.UseDraggableView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": typeof types.UseDraggableViewTargetView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": typeof types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
@@ -979,7 +980,8 @@ const documents: Documents = {
"\n fragment UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\n }\n": types.UseDeleteSavedViewGroup_SavedViewGroupFragmentDoc,
"\n mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n }\n }\n }\n": types.UpdateSavedViewGroupDocument,
"\n fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\n }\n": types.UseUpdateSavedViewGroup_SavedViewGroupFragmentDoc,
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.UseDraggableView_SavedViewFragmentDoc,
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.UseDraggableView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": types.UseDraggableViewTargetView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
@@ -2825,7 +2827,11 @@ export function graphql(source: "\n fragment UseUpdateSavedViewGroup_SavedViewG
/**
* 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 UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n"): (typeof documents)["\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n"];
export function graphql(source: "\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n"): (typeof documents)["\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\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 UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n"): (typeof documents)["\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\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
+42 -42
View File
@@ -8,7 +8,7 @@ import { getMainDefinition, Observable } from '@apollo/client/utilities'
import { Kind } from 'graphql'
import type { GraphQLError, OperationDefinitionNode } from 'graphql'
import type { CookieRef, NuxtApp } from '#app'
import type { Optional } from '@speckle/shared'
import { errorToString, type Optional } from '@speckle/shared'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import {
buildAbstractCollectionMergeFunction,
@@ -21,7 +21,6 @@ import { useAppErrorState } from '~~/lib/core/composables/error'
import { isInvalidAuth } from '~~/lib/common/helpers/graphql'
import { intersection, isArray, isBoolean, omit } from 'lodash-es'
import { useRequestId } from '~/lib/core/composables/server'
import { BatchHttpLink } from '@apollo/client/link/batch-http'
const appName = 'frontend-2'
@@ -489,6 +488,7 @@ function createLink(params: {
)
const logContext = {
...omit(res, ['forward', 'response']),
networkError: res.networkError ? errorToString(res.networkError) : undefined,
networkErrorMessage: res.networkError?.message,
gqlErrorMessages: gqlErrors.map((e) => e.message),
errorMessage: errMsg,
@@ -515,47 +515,47 @@ function createLink(params: {
// TODO: Do we even need upload client?
// Prepare links
// Decide between upload link and batch link based on whether variables contain File/Blob/FileList
const hasUpload = (val: unknown): boolean => {
if (!val) return false
// Guard for SSR where File/Blob/FileList may be undefined
const isFile =
typeof File !== 'undefined' && typeof val === 'object' && val instanceof File
const isBlob =
typeof Blob !== 'undefined' && typeof val === 'object' && val instanceof Blob
const isFileList =
typeof FileList !== 'undefined' &&
typeof val === 'object' &&
val instanceof FileList
if (isFile || isBlob) return true
if (isFileList) return Array.from(val as FileList).some((v) => hasUpload(v))
if (Array.isArray(val)) return val.some((v) => hasUpload(v))
if (typeof val === 'object') {
for (const k in val as Record<string, unknown>) {
if (hasUpload((val as Record<string, unknown>)[k])) return true
}
}
return false
}
// const hasUpload = (val: unknown): boolean => {
// if (!val) return false
// // Guard for SSR where File/Blob/FileList may be undefined
// const isFile =
// typeof File !== 'undefined' && typeof val === 'object' && val instanceof File
// const isBlob =
// typeof Blob !== 'undefined' && typeof val === 'object' && val instanceof Blob
// const isFileList =
// typeof FileList !== 'undefined' &&
// typeof val === 'object' &&
// val instanceof FileList
// if (isFile || isBlob) return true
// if (isFileList) return Array.from(val as FileList).some((v) => hasUpload(v))
// if (Array.isArray(val)) return val.some((v) => hasUpload(v))
// if (typeof val === 'object') {
// for (const k in val as Record<string, unknown>) {
// if (hasUpload((val as Record<string, unknown>)[k])) return true
// }
// }
// return false
// }
const uploadHttpLink = createUploadLink({ uri: httpEndpoint })
const batchHttpLink = new BatchHttpLink({
uri: httpEndpoint,
batchMax: 10,
batchInterval: 20,
// Keep batches “compatible” (avoid mixing ops with different auth/headers)
batchKey: (op) =>
JSON.stringify({
uri: op.getContext().uri,
headers: op.getContext().headers,
credentials: op.getContext().credentials
})
})
const httpLink = split(
(operation) => hasUpload(operation.variables),
// If there's an upload in variables -> use upload link, else batch
uploadHttpLink,
batchHttpLink
)
// const batchHttpLink = new BatchHttpLink({
// uri: httpEndpoint,
// batchMax: 10,
// batchInterval: 20,
// // Keep batches “compatible” (avoid mixing ops with different auth/headers)
// batchKey: (op) =>
// JSON.stringify({
// uri: op.getContext().uri,
// headers: op.getContext().headers,
// credentials: op.getContext().credentials
// })
// })
// const httpLink = split(
// (operation) => hasUpload(operation.variables),
// // If there's an upload in variables -> use upload link, else batch
// uploadHttpLink,
// batchHttpLink
// )
const authLink = setContext((_, ctx) => {
const { headers } = ctx
@@ -571,7 +571,7 @@ function createLink(params: {
}
})
let link = authLink.concat(httpLink)
let link = authLink.concat(uploadHttpLink)
if (wsClient) {
const wsLink = new WebSocketLink(wsClient)
@@ -167,6 +167,9 @@ export function useViewerUserActivityBroadcasting(
response: { project }
}
} = options?.state || useInjectedViewerState()
const {
public: { disableViewerActivityBroadcasting }
} = useRuntimeConfig()
const { activeUser } = useActiveUser()
const { update, activity, status, activityId } = useViewerRealtimeActivityTracker()
const apollo = useApolloClient().client
@@ -186,7 +189,7 @@ export function useViewerUserActivityBroadcasting(
}
const invokeMutation = async () => {
if (!activeUser.value?.id) return false
if (!activeUser.value?.id || disableViewerActivityBroadcasting) return false
const result = await apollo
.mutate({
@@ -1,16 +1,17 @@
import { useMutation, type MutateResult } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type {
CreateSavedViewGroupInput,
CreateSavedViewInput,
UpdateSavedViewGroupInput,
UpdateSavedViewGroupMutationVariables,
UpdateSavedViewInput,
UpdateSavedViewMutation,
UseDeleteSavedView_SavedViewFragment,
UseDeleteSavedViewGroup_SavedViewGroupFragment,
UseUpdateSavedView_SavedViewFragment,
UseUpdateSavedViewGroup_SavedViewGroupFragment
import {
SortDirection,
type CreateSavedViewGroupInput,
type CreateSavedViewInput,
type UpdateSavedViewGroupInput,
type UpdateSavedViewGroupMutationVariables,
type UpdateSavedViewInput,
type UpdateSavedViewMutation,
type UseDeleteSavedView_SavedViewFragment,
type UseDeleteSavedViewGroup_SavedViewGroupFragment,
type UseUpdateSavedView_SavedViewFragment,
type UseUpdateSavedViewGroup_SavedViewGroupFragment
} from '~/lib/common/generated/gql/graphql'
import { useStateSerialization } from '~/lib/viewer/composables/serialization'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
@@ -20,7 +21,11 @@ import {
onNewGroupViewCacheUpdates
} from '~/lib/viewer/helpers/savedViews/cache'
import { isUngroupedGroup } from '@speckle/shared/saved-views'
import { getCachedObjectKeys } from '~/lib/common/helpers/graphql'
import {
getCachedObjectKeys,
parseObjectReference,
type CacheObjectReference
} from '~/lib/common/helpers/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
const createSavedViewMutation = graphql(`
@@ -297,7 +302,6 @@ export const useUpdateSavedView = () => {
groupId: oldGroupId,
projectId: params.view.projectId
})
// Update new group
onNewGroupViewCacheUpdates(cache, {
viewId: update.id,
@@ -344,6 +348,64 @@ export const useUpdateSavedView = () => {
)
}
}
// If position changed, recalculate it according to sort dir in vars
if (input.position) {
// Go through all SavedViewGroup.views, where this view exists and update array position
iterateObjectField(
cache,
getCacheId('Project', params.view.projectId),
'savedViewGroups',
({ value }) => {
const items = value.items
if (!items) return
items.forEach((groupRef) => {
const parsed = parseObjectReference(groupRef)
modifyObjectField(
cache,
getCacheId('SavedViewGroup', parsed.id),
'views',
({ helpers: { createUpdatedValue, readField }, variables }) => {
const sortDir =
variables.input.sortDirection || SortDirection.Desc
const sortBy = (variables.input.sortBy || 'position') as
| 'position'
| 'updatedAt'
return createUpdatedValue(({ update }) => {
update('items', (items) => {
const newItems = items.slice().sort((a, b) => {
const process = (
ref: CacheObjectReference<'SavedView'>
) => {
const val = readField(ref, sortBy)
if (!val) return -1
if (sortBy === 'updatedAt') {
return new Date(val).getTime()
}
return val as number
}
const aVal = process(a)
const bVal = process(b)
if (aVal < bVal)
return sortDir === SortDirection.Asc ? -1 : 1
if (aVal > bVal)
return sortDir === SortDirection.Asc ? 1 : -1
return 0
})
return newItems
})
})
}
)
})
}
)
}
}
}
).catch(convertThrowIntoFetchResult)
@@ -1,8 +1,10 @@
import { useMutationLoading } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type {
UseDraggableView_SavedViewFragment,
UseDraggableViewTargetGroup_SavedViewGroupFragment
import {
ViewPositionInputType,
type UseDraggableView_SavedViewFragment,
type UseDraggableViewTargetGroup_SavedViewGroupFragment,
type UseDraggableViewTargetView_SavedViewFragment
} from '~/lib/common/generated/gql/graphql'
import { ensureError, safeParse } from '@speckle/shared'
import { has, isObjectLike } from 'lodash-es'
@@ -17,6 +19,7 @@ graphql(`
id
projectId
name
position
group {
id
}
@@ -74,14 +77,18 @@ export const useDraggableView = (params: {
}
graphql(`
fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {
fragment UseDraggableViewTargetView_SavedView on SavedView {
id
title
name
position
group {
id
}
}
`)
export const useDraggableViewTargetGroup = (params: {
group: Ref<UseDraggableViewTargetGroup_SavedViewGroupFragment>
export const useDraggableViewTargetView = (params: {
view: Ref<UseDraggableViewTargetView_SavedViewFragment>
onMoved?: () => void
}) => {
const isDragOver = ref(false)
@@ -103,6 +110,125 @@ export const useDraggableViewTargetGroup = (params: {
isDragOver.value = false
dragCounter.value = 0
try {
const data = event.dataTransfer.getData('application/json')
const view = safeParse(data, isDraggableView)
if (!view) return
// check if same view
if (view.id === params.view.value.id) {
return
}
// See whether view was dropped closer to top or bottom to figure out
// whether to put it before or after the target view
const targetRect = (event.target as HTMLElement).getBoundingClientRect()
const dropPosition = event.clientY - targetRect.top
const dropInTopHalf = dropPosition < targetRect.height / 2
await updateView(
{
view,
input: {
id: view.id,
projectId: view.projectId,
groupId: params.view.value.group.id,
position: {
type: ViewPositionInputType.Between,
...(dropInTopHalf
? { beforeViewId: params.view.value.id }
: { afterViewId: params.view.value.id })
}
}
},
{
skipToast: true,
onFullResult: (res, success) => {
if (success) {
// no notification here, this can get noisy
params.onMoved?.()
} else {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to move view',
description: getFirstGqlErrorMessage(res?.errors)
})
}
}
}
)
} catch (e) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to move view',
description: ensureError(e).message
})
}
},
dragenter: (event: DragEvent) => {
event.preventDefault()
dragCounter.value++
isDragOver.value = true
},
dragleave: () => {
dragCounter.value--
if (dragCounter.value === 0) {
isDragOver.value = false
}
}
}
const classes = computed(() => {
const classParts: string[] = ['draggable-view-target']
if (isDragOver.value) {
// classParts.push('rounded-md ring-2 ring-primary ring-opacity-50 bg-primary/5')
classParts.push('bg-foundation-2')
}
return classParts.join(' ')
})
return {
on: vOn,
classes
}
}
graphql(`
fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {
id
title
}
`)
export const useDraggableViewTargetGroup = (params: {
group: Ref<UseDraggableViewTargetGroup_SavedViewGroupFragment>
onMoved?: () => void
enabled?: boolean | Ref<boolean>
}) => {
const enabled = computed(() => unref(params.enabled) ?? true)
const isDragOver = ref(false)
const dragCounter = ref(0)
const { triggerNotification } = useGlobalToast()
const updateView = useUpdateSavedView()
const vOn = {
dragover: (event: DragEvent) => {
if (!enabled.value) return
if (!event.dataTransfer) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
},
drop: async (event: DragEvent) => {
if (!enabled.value) return
if (!event.dataTransfer) return
event.preventDefault()
isDragOver.value = false
dragCounter.value = 0
try {
const data = event.dataTransfer.getData('application/json')
const view = safeParse(data, isDraggableView)
@@ -154,11 +280,15 @@ export const useDraggableViewTargetGroup = (params: {
}
},
dragenter: (event: DragEvent) => {
if (!enabled.value) return
event.preventDefault()
dragCounter.value++
isDragOver.value = true
},
dragleave: () => {
if (!enabled.value) return
dragCounter.value--
if (dragCounter.value === 0) {
isDragOver.value = false
@@ -169,13 +299,20 @@ export const useDraggableViewTargetGroup = (params: {
const classes = computed(() => {
const classParts: string[] = ['draggable-view-target']
if (isDragOver.value) {
if (isDragOver.value && enabled.value) {
classParts.push('rounded-md ring-2 ring-primary ring-opacity-50 bg-primary/5')
}
return classParts.join(' ')
})
watch(enabled, (newVal, oldVal) => {
if (newVal && !oldVal) {
dragCounter.value = 0
isDragOver.value = false
}
})
return {
on: vOn,
classes
@@ -37,10 +37,14 @@ export const onNewGroupViewCacheUpdates = (
getCacheId('Project', projectId),
'savedViewGroups',
({ helpers: { createUpdatedValue, ref, fromRef }, value }) => {
const isNewGroup = !value?.items?.some((group) => fromRef(group).id === groupId)
if (!isNewGroup) return
const isNewGroupUngrouped = isUngroupedGroup(groupId)
const alreadyExists = value?.items?.some((group) =>
isNewGroupUngrouped
? isUngroupedGroup(fromRef(group).id)
: fromRef(group).id === groupId
)
const isNewGroup = !alreadyExists
if (!isNewGroup) return
return createUpdatedValue(({ update }) => {
update('totalCount', (count) => count + 1)
+2 -1
View File
@@ -85,7 +85,8 @@ export default defineNuxtConfig({
datadogEnv: '',
intercomAppId: '',
dashboardsOrigin: '',
parallelMiddlewares: true
parallelMiddlewares: true,
disableViewerActivityBroadcasting: false
}
},
+32 -7
View File
@@ -1,4 +1,4 @@
import { collectLongTrace } from '@speckle/shared'
import { collectLongTrace, errorToString } from '@speckle/shared'
import type { LogType } from 'consola'
import dayjs from 'dayjs'
import { omit } from 'lodash-es'
@@ -31,6 +31,7 @@ const simpleStripHtml = (str: string) => str.replace(/<[^>]*>?/gm, '')
*/
export default defineNuxtPlugin(async (nuxtApp) => {
const runtimeConfig = useRuntimeConfig()
const {
public: {
logLevel: untypedLogLevel,
@@ -41,7 +42,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
serverName,
logCsrEmitProps
}
} = useRuntimeConfig()
} = runtimeConfig
const logLevel = untypedLogLevel as LogType
const route = useRoute()
@@ -313,12 +314,35 @@ export default defineNuxtPlugin(async (nuxtApp) => {
// More error logging hooks
nuxtApp.vueApp.config.errorHandler = (error, _vm, info) => {
logger.error(error, 'Unhandled error in Vue app', info)
logger.error(
{
err: error,
info,
isAppError: true,
vm: _vm?.$options.name,
errString: errorToString(error)
},
'Unhandled error in Vue app'
)
}
nuxtApp.hook('app:error', (error) => {
logger.error(error, 'Unhandled app error', {
isAppError: true
})
logger.error(
{ err: error, isAppError: true, errString: errorToString(error) },
'Unhandled app error'
)
})
nuxtApp.hook('vue:error', (error, _vm, info) => {
logger.error(
{
err: error,
info,
isAppError: true,
vm: _vm?.$options.name,
errString: errorToString(error)
},
'Unhandled Vue error'
)
})
// Hydrate server fatal error to CSR
@@ -361,7 +385,8 @@ export default defineNuxtPlugin(async (nuxtApp) => {
nuxtApp.hook('app:mounted', () => {
logger.info('App mounted in the client', {
important: true,
speckleServerVersion
speckleServerVersion,
runtimeConfig
})
})
} else {
@@ -72,8 +72,8 @@ input SavedViewGroupViewsInput {
"""
sortDirection: SortDirection
"""
Optionally specify sort by field. Default: updatedAt
Options: updatedAt, createdAt, name
Optionally specify sort by field. Default: position
Options: updatedAt, createdAt, name, position
"""
sortBy: String
limit: Int
@@ -163,6 +163,7 @@ input CreateSavedViewInput {
Set visibility of the view. Default: public
"""
visibility: SavedViewVisibility
position: ViewPositionInput
}
input CreateSavedViewGroupInput {
@@ -184,6 +185,26 @@ input DeleteSavedViewInput {
projectId: ID!
}
enum ViewPositionInputType {
between
}
"""
If only one is set, the other will be resolved automatically
If none are set, the view will be added to the end of the list
"""
input ViewPositionInput {
type: ViewPositionInputType!
"""
The ID of the view that should be after the new position
"""
afterViewId: ID
"""
The ID of the view that should be before the new position
"""
beforeViewId: ID
}
input UpdateSavedViewInput {
id: ID!
projectId: ID!
@@ -218,6 +239,7 @@ input UpdateSavedViewInput {
Optionally change visibility of the view
"""
visibility: SavedViewVisibility
position: ViewPositionInput
}
input UpdateSavedViewGroupInput {
@@ -11,6 +11,8 @@ const command: CommandModule<unknown, CommonDbArgs> = {
const { regionKey } = argv
const dbs = await getTargettedDbClients({ regionKey })
logger.info(`Found ${dbs.length} DB(s) to run latest on`)
for (const db of dbs) {
logger.info(`Running latest on DB ${db.regionKey}...`)
await db.client.migrate.latest()
@@ -1089,6 +1089,7 @@ export type CreateSavedViewInput = {
isHomeView?: InputMaybe<Scalars['Boolean']['input']>;
/** Auto-generated name, if not specified */
name?: InputMaybe<Scalars['String']['input']>;
position?: InputMaybe<ViewPositionInput>;
projectId: Scalars['ID']['input'];
resourceIdString: Scalars['String']['input'];
/** Encoded screenshot of the view */
@@ -3732,8 +3733,8 @@ export type SavedViewGroupViewsInput = {
/** Whether to only include views matching this search term */
search?: InputMaybe<Scalars['String']['input']>;
/**
* Optionally specify sort by field. Default: updatedAt
* Options: updatedAt, createdAt, name
* Optionally specify sort by field. Default: position
* Options: updatedAt, createdAt, name, position
*/
sortBy?: InputMaybe<Scalars['String']['input']>;
/** Optionally specify sort direction. Default: descending */
@@ -4648,6 +4649,7 @@ export type UpdateSavedViewInput = {
isHomeView?: InputMaybe<Scalars['Boolean']['input']>;
/** New name for the view */
name?: InputMaybe<Scalars['String']['input']>;
position?: InputMaybe<ViewPositionInput>;
projectId: Scalars['ID']['input'];
/** New resource targets, if necessary. Must be set together w/ viewerState & screenshot. */
resourceIdString?: InputMaybe<Scalars['String']['input']>;
@@ -5193,6 +5195,23 @@ export type VersionPermissionChecks = {
canUpdate: PermissionCheckResult;
};
/**
* If only one is set, the other will be resolved automatically
* If none are set, the view will be added to the end of the list
*/
export type ViewPositionInput = {
/** The ID of the view that should be after the new position */
afterViewId?: InputMaybe<Scalars['ID']['input']>;
/** The ID of the view that should be before the new position */
beforeViewId?: InputMaybe<Scalars['ID']['input']>;
type: ViewPositionInputType;
};
export const ViewPositionInputType = {
Between: 'between'
} as const;
export type ViewPositionInputType = typeof ViewPositionInputType[keyof typeof ViewPositionInputType];
export type ViewerResourceGroup = {
__typename?: 'ViewerResourceGroup';
/** Resource identifier used to refer to a collection of resource items */
@@ -6441,6 +6460,8 @@ export type ResolversTypes = {
VersionCreatedTriggerDefinition: ResolverTypeWrapper<AutomationRevisionTriggerDefinitionGraphQLReturn>;
VersionMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
VersionPermissionChecks: ResolverTypeWrapper<VersionPermissionChecksGraphQLReturn>;
ViewPositionInput: ViewPositionInput;
ViewPositionInputType: ViewPositionInputType;
ViewerResourceGroup: ResolverTypeWrapper<ViewerResourceGroup>;
ViewerResourceItem: ResolverTypeWrapper<ViewerResourceItem>;
ViewerUpdateTrackingTarget: ViewerUpdateTrackingTarget;
@@ -6813,6 +6834,7 @@ export type ResolversParentTypes = {
VersionCreatedTriggerDefinition: AutomationRevisionTriggerDefinitionGraphQLReturn;
VersionMutations: MutationsObjectGraphQLReturn;
VersionPermissionChecks: VersionPermissionChecksGraphQLReturn;
ViewPositionInput: ViewPositionInput;
ViewerResourceGroup: ViewerResourceGroup;
ViewerResourceItem: ViewerResourceItem;
ViewerUpdateTrackingTarget: ViewerUpdateTrackingTarget;
@@ -83,7 +83,10 @@ export type GetGroupSavedViewsPageParams = GetGroupSavedViewsBaseParams & {
limit?: MaybeNullOrUndefined<number>
cursor?: MaybeNullOrUndefined<string>
sortDirection?: MaybeNullOrUndefined<'asc' | 'desc'>
sortBy?: MaybeNullOrUndefined<'createdAt' | 'name' | 'updatedAt'>
/**
* Null means - manual positioning
*/
sortBy?: MaybeNullOrUndefined<'createdAt' | 'name' | 'updatedAt' | 'position'>
}
export type GetGroupSavedViewsTotalCount = (
@@ -176,6 +179,37 @@ export type SetNewHomeView = (params: {
newHomeViewId: string | null
}) => Promise<boolean>
/**
* Calculate new view position for the beginning or end of the group that it will be a part of
*/
export type GetNewViewBoundaryPosition = (params: {
projectId: string
resourceIdString: string
groupId: string | null
position: 'last' | 'first'
}) => Promise<number>
/**
* Calculate new view position for a specific position in the group that it will be a part of. Also
* returns whether rebalancing is needed (i.e. the gap between before and after positions is too small)
*/
export type GetNewViewSpecificPosition = (params: {
projectId: string
resourceIdString: string
groupId: string | null
beforeId: MaybeNullOrUndefined<string>
afterId: MaybeNullOrUndefined<string>
}) => Promise<{
newPosition: number
needsRebalancing: boolean
}>
export type RebalanceViewPositions = (params: {
projectId: string
resourceIdString: string
groupId: string | null
}) => Promise<number>
/////////////////////
// SERVICE OPERATIONS:
/////////////////////
@@ -198,6 +232,7 @@ export type CreateSavedViewParams = {
screenshot: string
isHomeView?: MaybeNullOrUndefined<boolean>
visibility?: MaybeNullOrUndefined<SavedViewVisibility>
position?: MaybeNullOrUndefined<ViewPositionInput>
}
authorId: string
}
@@ -231,6 +266,12 @@ export type DeleteSavedView = (params: {
userId: string
}) => Promise<void>
export type ViewPositionInput = {
type: 'first' | 'last' | 'between'
beforeViewId?: MaybeNullOrUndefined<string>
afterViewId?: MaybeNullOrUndefined<string>
}
export type UpdateSavedViewParams = {
id: string
projectId: string
@@ -242,6 +283,7 @@ export type UpdateSavedViewParams = {
viewerState?: MaybeNullOrUndefined<unknown>
resourceIdString?: MaybeNullOrUndefined<string>
screenshot?: MaybeNullOrUndefined<string>
position?: MaybeNullOrUndefined<ViewPositionInput>
}
export type UpdateSavedView = (params: {
@@ -21,11 +21,13 @@ import {
deleteSavedViewRecordFactory,
getGroupSavedViewsPageItemsFactory,
getGroupSavedViewsTotalCountFactory,
getNewViewSpecificPositionFactory,
getProjectSavedViewGroupsPageItemsFactory,
getProjectSavedViewGroupsTotalCountFactory,
getStoredViewCountFactory,
getStoredViewGroupCountFactory,
getUngroupedSavedViewsGroupFactory,
rebalancingViewPositionsFactory,
recalculateGroupResourceIdsFactory,
setNewHomeViewFactory,
storeSavedViewFactory,
@@ -310,7 +312,11 @@ const resolvers: Resolvers = {
}),
setNewHomeView: setNewHomeViewFactory({
db: projectDb
})
}),
getNewViewSpecificPosition: getNewViewSpecificPositionFactory({
db: projectDb
}),
rebalanceViewPositions: rebalancingViewPositionsFactory({ db: projectDb })
})
return await createSavedView({ input: args.input, authorId: ctx.userId! })
},
@@ -399,6 +405,10 @@ const resolvers: Resolvers = {
}),
setNewHomeView: setNewHomeViewFactory({
db: projectDb
}),
rebalanceViewPositions: rebalancingViewPositionsFactory({ db: projectDb }),
getNewViewSpecificPosition: getNewViewSpecificPositionFactory({
db: projectDb
})
})
@@ -0,0 +1,21 @@
import type { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
// Set initial manual positions based on the updatedAt timestamp
await knex.raw(`
WITH ordered AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY "updatedAt") AS rn
FROM saved_views
)
UPDATE saved_views
SET position = (ordered.rn * 1000)::double precision
FROM ordered
WHERE saved_views.id = ordered.id;
`)
}
export async function down(knex: Knex): Promise<void> {
// Set every position to 0
await knex('saved_views').update({ position: 0 })
}
@@ -1,5 +1,6 @@
import { Branches, buildTableHelper } from '@/modules/core/dbSchema'
import type { Model } from '@/modules/core/domain/branches/types'
import { LogicError } from '@/modules/shared/errors'
import {
compositeCursorTools,
formatJsonArrayRecords
@@ -27,7 +28,10 @@ import type {
UpdateSavedViewGroupRecord,
GetModelHomeSavedViews,
GetModelHomeSavedView,
SetNewHomeView
SetNewHomeView,
GetNewViewBoundaryPosition,
GetNewViewSpecificPosition,
RebalanceViewPositions
} from '@/modules/viewer/domain/operations/savedViews'
import {
SavedViewVisibility,
@@ -78,6 +82,8 @@ export const SavedViewGroups = buildTableHelper('saved_view_groups', [
'updatedAt'
])
export const MINIMUM_POSITION_GAP = 0.00001
const savedGroupCursorUtils = () =>
compositeCursorTools({
schema: SavedViewGroups,
@@ -404,7 +410,7 @@ export const getGroupSavedViewsTotalCountFactory =
export const getGroupSavedViewsPageItemsFactory =
(deps: { db: Knex }): GetGroupSavedViewsPageItems =>
async (params) => {
const sortByCol = params.sortBy || 'updatedAt'
const sortByCol = params.sortBy || 'position'
const sortDir = params.sortDirection || 'desc'
const q = getGroupSavedViewsBaseQueryFactory(deps)(params)
@@ -758,3 +764,233 @@ export const setNewHomeViewFactory =
return true
}
function applyViewPositionGroupFiltering(
q: Knex.QueryBuilder,
groupId: string | null,
projectId: string,
resourceIdString: string,
col = SavedViews.col
) {
const groupResourceIds = formatResourceIdsForGroup(resourceIdString)
if (!groupId && !groupResourceIds.length) {
throw new LogicError(
'Cannot determine new view position without resources or groupId'
)
}
q.andWhere(col.projectId, projectId).andWhere(col.groupId, groupId)
if (!groupId) {
q.andWhereRaw(
`cardinality(ARRAY(SELECT UNNEST(??::varchar[]) INTERSECT SELECT UNNEST(?::varchar[]))) > 0`,
[col.groupResourceIds, groupResourceIds]
)
}
}
export const getNewViewBoundaryPositionFactory =
(deps: { db: Knex }): GetNewViewBoundaryPosition =>
async (params) => {
const { projectId, resourceIdString, groupId } = params
const asLast = params.position === 'last'
const q = tables
.savedViews(deps.db)
.select<Array<{ newPosition: number }>>(
deps.db.raw(
asLast
? 'COALESCE(MAX(??), 0) + 1000 as "newPosition"'
: 'COALESCE(MIN(??), 0) - 1000 as "newPosition"',
[SavedViews.col.position]
)
)
applyViewPositionGroupFiltering(q, groupId, projectId, resourceIdString)
const [result] = await q
return result?.newPosition ?? 1000
}
const getNeighborViewFactory =
(deps: { db: Knex }) =>
async (params: {
projectId: string
resourceIdString: string
groupId: string | null
direction: 'before' | 'after'
anchorId: string
}) => {
const { direction, anchorId, projectId, groupId, resourceIdString } = params
const SubqCols = SavedViews.with({ withCustomTablePrefix: 'sqv' })
const MainCols = SavedViews.with({ withCustomTablePrefix: 'mv' })
// Subquery to find the anchor view
const subQ = tables.savedViews(deps.db).where(SavedViews.col.id, anchorId).first()
applyViewPositionGroupFiltering(subQ, groupId, projectId, resourceIdString)
// Main query: find neighbor
const cmpOperator = direction === 'before' ? '<' : '>'
const sortDir = direction === 'before' ? 'desc' : 'asc'
const resultQ = deps
.db(MainCols.name)
.select<Array<SavedView>>(MainCols.cols)
.join(subQ.as('sqv'), (j1) => {
j1
// same projectId
.on(MainCols.col.projectId, '=', SubqCols.col.projectId)
// same groupId (including null)
.andOn((o1) => {
o1.on(MainCols.col.groupId, '=', SubqCols.col.groupId).orOn((o2) => {
o2.onNull(MainCols.col.groupId).andOnNull(SubqCols.col.groupId)
})
})
// next positions
.andOn(MainCols.col.position, cmpOperator, SubqCols.col.position)
if (!groupId) {
// Check resource intersection too, if no groupId
const groupResourceIds = formatResourceIdsForGroup(resourceIdString)
j1.andOn(
deps.db.raw(
`cardinality(ARRAY(SELECT UNNEST(??::varchar[]) INTERSECT SELECT UNNEST(?::varchar[]))) > 0`,
[MainCols.col.groupResourceIds, groupResourceIds]
)
)
}
})
.orderBy(MainCols.col.position, sortDir)
.limit(1)
const result = await resultQ
return result.length > 0 ? result[0] : null
}
export const getNewViewSpecificPositionFactory =
(deps: { db: Knex }): GetNewViewSpecificPosition =>
async (params) => {
const { beforeId, afterId } = params
const boundaries = {
before: { id: beforeId, position: undefined as number | undefined },
after: { id: afterId, position: undefined as number | undefined }
}
const getNewViewBoundaryPosition = getNewViewBoundaryPositionFactory(deps)
const getNeighborViewId = getNeighborViewFactory(deps)
const getView = getSavedViewFactory(deps)
if (!boundaries.before.id && !boundaries.after.id) {
// end of list
return {
needsRebalancing: false,
newPosition: await getNewViewBoundaryPosition({ ...params, position: 'last' })
}
}
// One of the ids is undefined, try to resolve it
if (!boundaries.before.id || !boundaries.after.id) {
if (!boundaries.before.id) {
// only afterId - get the view before it
const beforeView = await getNeighborViewId({
...params,
direction: 'before',
anchorId: boundaries.after.id!
})
boundaries.before.position = beforeView?.position
boundaries.before.id = beforeView?.id
} else if (!boundaries.after.id) {
// only beforeId - get the view after it
const afterView = await getNeighborViewId({
...params,
direction: 'after',
anchorId: boundaries.before.id!
})
boundaries.after.position = afterView?.position
boundaries.after.id = afterView?.id
}
}
// If one of the ids is still undefined, it means we hit the list boundary
if (!boundaries.before.id || !boundaries.after.id) {
const hasNoBeforeView = !boundaries.before.id
return {
newPosition: await getNewViewBoundaryPosition({
...params,
position: hasNoBeforeView ? 'first' : 'last'
}),
needsRebalancing: false
}
}
// Both ids are defined - get their positions if we don't have them yet
if (!boundaries.before.position || !boundaries.after.position) {
const [beforePosition, afterPosition] = await Promise.all([
boundaries.before.position
? Promise.resolve(boundaries.before.position)
: getView({ id: boundaries.before.id!, projectId: params.projectId }).then(
(v) => v?.position
),
boundaries.after.position
? Promise.resolve(boundaries.after.position)
: getView({ id: boundaries.after.id!, projectId: params.projectId }).then(
(v) => v?.position
)
])
// These will only be undefined if the view ids are actually invalid
if (!beforePosition || !afterPosition) {
throw new Error('Either beforeId or afterId are invalid IDs')
}
boundaries.before.position = beforePosition
boundaries.after.position = afterPosition
}
// See if we need rebalancing
const gap = boundaries.after.position - boundaries.before.position
const needsRebalancing = gap < MINIMUM_POSITION_GAP
const newPosition = (boundaries.before.position + boundaries.after.position) / 2
return {
needsRebalancing,
newPosition
}
}
/**
* Rebalances the view positions within a group. This needs to happen when the gap between two view positions becomes so small
* that inserting a new view between them becomes problematic because of floating point precision limits.
*/
export const rebalancingViewPositionsFactory =
(deps: { db: Knex }): RebalanceViewPositions =>
async (params) => {
const { projectId, resourceIdString, groupId } = params
const cteRawQuery = deps.db.with('ordered', (q2) => {
q2.from(SavedViews.name).select(
SavedViews.col.id,
deps.db.raw('ROW_NUMBER() OVER (ORDER BY ??) AS rn', [SavedViews.col.position])
)
applyViewPositionGroupFiltering(q2, groupId, projectId, resourceIdString)
})
// need to strip the final select *. sort of hacky,
// but i dont want to write the CTE by hand as a raw string, cause
// then i cant reuse the applyViewPositionGroupFiltering util
const cteString = cteRawQuery.toQuery().replace(/\s+select\s+\*\s*$/i, '')
// knex .with().update() support sucks, gotta make this a raw query
const q = deps.db.raw(`
${cteString}
UPDATE ${SavedViews.with({ quoted: true }).name}
SET ${
SavedViews.with({ quoted: true, withoutTablePrefix: true }).col.position
} = ordered.rn * 1000
FROM ordered
WHERE ordered.id = ${SavedViews.with({ quoted: true }).col.id}
`)
const ret = (await q) as { rowCount: number }
return ret.rowCount
}
@@ -8,6 +8,7 @@ import type {
GetGroupSavedViews,
GetGroupSavedViewsPageItems,
GetGroupSavedViewsTotalCount,
GetNewViewSpecificPosition,
GetProjectSavedViewGroups,
GetProjectSavedViewGroupsPageItems,
GetProjectSavedViewGroupsTotalCount,
@@ -15,6 +16,7 @@ import type {
GetSavedViewGroup,
GetStoredViewCount,
GetStoredViewGroupCount,
RebalanceViewPositions,
RecalculateGroupResourceIds,
SetNewHomeView,
StoreSavedView,
@@ -227,11 +229,12 @@ export const createSavedViewFactory =
getSavedViewGroup: GetSavedViewGroup
recalculateGroupResourceIds: RecalculateGroupResourceIds
setNewHomeView: SetNewHomeView
getNewViewSpecificPosition: GetNewViewSpecificPosition
rebalanceViewPositions: RebalanceViewPositions
}): CreateSavedView =>
async ({ input, authorId }) => {
const { resourceIdString, projectId } = input
const { resourceIdString, projectId, position: positionInput } = input
const visibility = input.visibility || SavedViewVisibility.public // default to public
const position = 0 // TODO: Resolve based on existing views
let groupId = input.groupId?.trim() || null
const description = input.description?.trim() || null
const isHomeView = input.isHomeView || false
@@ -309,6 +312,16 @@ export const createSavedViewFactory =
resourceIds
})
// Resolve new position
const { newPosition: position, needsRebalancing } =
await deps.getNewViewSpecificPosition({
projectId,
groupId,
resourceIdString: resourceIds.toString(),
beforeId: positionInput?.beforeViewId,
afterId: positionInput?.afterViewId
})
const concreteResourceIds = resourceIds.toResources().map((r) => r.toString())
const ret = await deps.storeSavedView({
view: {
@@ -337,6 +350,15 @@ export const createSavedViewFactory =
newHomeViewId: ret.id
})
]
: []),
...(needsRebalancing
? [
deps.rebalanceViewPositions({
projectId,
groupId: ret!.groupId || null,
resourceIdString: ret!.resourceIds.join(',')
})
]
: [])
])
@@ -470,6 +492,8 @@ export const updateSavedViewFactory =
updateSavedViewRecord: UpdateSavedViewRecord
recalculateGroupResourceIds: RecalculateGroupResourceIds
setNewHomeView: SetNewHomeView
getNewViewSpecificPosition: GetNewViewSpecificPosition
rebalanceViewPositions: RebalanceViewPositions
} & DependenciesOf<typeof validateProjectResourceIdStringFactory>
): UpdateSavedView =>
async (params) => {
@@ -603,7 +627,25 @@ export const updateSavedViewFactory =
resourceIds: resourceIds || resourceBuilder().addResources(view.resourceIds)
})
const finalChanges = omit(changes, ['resourceIdString', 'viewerState'])
// Position
let position: number | undefined = undefined
let needsRebalancing = false
if ('position' in changes && changes.position) {
const posInput = changes.position
const newPos = await deps.getNewViewSpecificPosition({
projectId,
groupId: ('groupId' in changes ? changes.groupId : view.groupId) || null,
resourceIdString: resourceIds
? resourceIds.toString()
: view.resourceIds.join(','),
beforeId: posInput.type === 'between' ? posInput.beforeViewId || null : null,
afterId: posInput.type === 'between' ? posInput.afterViewId || null : null
})
position = newPos.newPosition
needsRebalancing = newPos.needsRebalancing
}
const finalChanges = omit(changes, ['resourceIdString', 'viewerState', 'position'])
const update = {
...finalChanges,
...(resourceIds
@@ -616,7 +658,8 @@ export const updateSavedViewFactory =
? {
viewerState
}
: {})
: {}),
...(!isUndefined(position) ? { position } : {})
}
// Check if there's any actual changes
@@ -670,6 +713,15 @@ export const updateSavedViewFactory =
modelId: homeViewModel.modelId
})
]
: []),
...(needsRebalancing
? [
deps.rebalanceViewPositions({
projectId,
groupId: updatedView!.groupId || null,
resourceIdString: updatedView!.resourceIds.join(',')
})
]
: [])
])
@@ -1,4 +1,5 @@
import { db } from '@/db/knex'
import { ViewPositionInputType } from '@/modules/core/graph/generated/graphql'
import {
getBranchesByIdsFactory,
getBranchLatestCommitsFactory,
@@ -16,9 +17,11 @@ import {
import { formatResourceIdsForGroup } from '@/modules/viewer/helpers/savedViews'
import {
getModelHomeSavedViewFactory,
getNewViewSpecificPositionFactory,
getSavedViewFactory,
getSavedViewGroupFactory,
getStoredViewCountFactory,
rebalancingViewPositionsFactory,
recalculateGroupResourceIdsFactory,
setNewHomeViewFactory,
storeSavedViewFactory
@@ -132,12 +135,19 @@ export const createTestSavedView = async (params?: {
}),
setNewHomeView: setNewHomeViewFactory({
db
})
}),
getNewViewSpecificPosition: getNewViewSpecificPositionFactory({
db
}),
rebalanceViewPositions: rebalancingViewPositionsFactory({ db })
})
const createdView = await createSavedView({
input: {
...view,
position: {
type: ViewPositionInputType.Between
},
resourceIdString: view.resourceIds.join(','),
viewerState: view.viewerState.state
},
@@ -1,3 +1,4 @@
import { db } from '@/db/knex'
import type {
BasicSavedViewFragment,
BasicSavedViewGroupFragment,
@@ -32,7 +33,8 @@ import {
GetProjectSavedViewIfExistsDocument,
GetProjectUngroupedViewGroupDocument,
UpdateSavedViewDocument,
UpdateSavedViewGroupDocument
UpdateSavedViewGroupDocument,
ViewPositionInputType
} from '@/modules/core/graph/generated/graphql'
import {
buildBasicTestModel,
@@ -51,6 +53,10 @@ import {
SavedViewInvalidResourceTargetError,
SavedViewUpdateValidationError
} from '@/modules/viewer/errors/savedViews'
import {
MINIMUM_POSITION_GAP,
updateSavedViewRecordFactory
} from '@/modules/viewer/repositories/savedViews'
import { createSavedViewFactory } from '@/modules/viewer/tests/helpers/graphql'
import {
fakeScreenshot,
@@ -84,11 +90,13 @@ import * as ViewerState from '@speckle/shared/viewer/state'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import dayjs from 'dayjs'
import { intersection, merge, times } from 'lodash-es'
import { intersection, isUndefined, merge, times } from 'lodash-es'
import type { PartialDeep } from 'type-fest'
const { FF_WORKSPACES_MODULE_ENABLED, FF_SAVED_VIEWS_ENABLED } = getFeatureFlags()
const TOO_SMALL_OF_A_GAP = MINIMUM_POSITION_GAP / 2
const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerState>) =>
merge(
{},
@@ -127,7 +135,11 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
viewerState?: ViewerState.SerializedViewerState
overrides?: PartialDeep<CreateSavedViewMutationVariables['input']>
}): CreateSavedViewMutationVariables => ({
input: merge(
input: merge<
{},
CreateSavedViewMutationVariables['input'],
PartialDeep<CreateSavedViewMutationVariables['input']>
>(
{},
{
projectId: params.projectId || myProject.id,
@@ -488,7 +500,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
expect(view!.visibility).to.equal(SavedViewVisibility.public) // default
expect(view!.viewerState).to.deep.equalInAnyOrder(viewerState)
expect(view!.screenshot).to.equal(fakeScreenshot)
expect(view!.position).to.equal(0) // default position
expect(view!.position).to.equal(1000)
})
it('setting a new home view unsets home view from old one', async () => {
@@ -841,6 +853,220 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
{ assertNoErrors: true }
)
})
itEach(
[
{ grouping: 'ungrouped', expectedPosition: 'before' },
{ grouping: 'grouped', expectedPosition: 'before' },
{ grouping: 'ungrouped', expectedPosition: 'after' },
{ grouping: 'grouped', expectedPosition: 'after' }
],
({ grouping, expectedPosition }) =>
`should add new view ${expectedPosition} the other view in the ${grouping} group`,
async ({ grouping, expectedPosition }) => {
const addBefore = expectedPosition === 'before'
const resourceIdString = model1ResourceIds().toString()
const res1 = await createSavedView(
buildCreateInput({
resourceIdString,
overrides: {
groupId: grouping === 'grouped' ? testGroup1.id : null
}
}),
{
assertNoErrors: true
}
)
const firstView = res1.data?.projectMutations.savedViewMutations.createView!
const res2 = await createSavedView(
buildCreateInput({
resourceIdString,
overrides: {
groupId: grouping === 'grouped' ? testGroup1.id : null,
...(expectedPosition
? {
position: {
type: ViewPositionInputType.Between,
beforeViewId: !addBefore ? firstView.id : null,
afterViewId: addBefore ? firstView.id : null
}
}
: {})
}
}),
{
assertNoErrors: true
}
)
const finalView = res2.data?.projectMutations.savedViewMutations.createView
if (addBefore) {
expect(finalView!.position).to.be.lessThan(firstView!.position)
} else {
expect(finalView!.position).to.be.greaterThan(firstView!.position)
}
}
)
itEach(
[
{ grouping: 'ungrouped', specificLastPosition: true },
{ grouping: 'grouped', specificLastPosition: true },
{ grouping: 'ungrouped', specificLastPosition: false },
{ grouping: 'grouped', specificLastPosition: false }
],
({ grouping, specificLastPosition }) =>
`should add new views after the${
specificLastPosition ? ' specifically specified' : ''
} last position in the ${grouping} group`,
async ({ grouping, specificLastPosition }) => {
const resourceIdString = model1ResourceIds().toString()
const res1 = await createSavedView(
buildCreateInput({
resourceIdString,
overrides: {
groupId: grouping === 'grouped' ? testGroup1.id : null
}
}),
{
assertNoErrors: true
}
)
const res2 = await createSavedView(
buildCreateInput({
resourceIdString,
overrides: {
groupId: grouping === 'grouped' ? testGroup1.id : null,
...(specificLastPosition
? {
position: {
type: ViewPositionInputType.Between,
beforeViewId:
res1.data?.projectMutations.savedViewMutations.createView!.id,
afterViewId: null
}
}
: {})
}
}),
{
assertNoErrors: true
}
)
const firstView = res1.data?.projectMutations.savedViewMutations.createView
const finalView = res2.data?.projectMutations.savedViewMutations.createView
expect(finalView!.position).to.equal(firstView!.position + 1000)
}
)
itEach(
['ungrouped', 'grouped'],
(grouping) =>
`should allow positioning between 2 other views and rebalance ${grouping} group when positions get too close`,
async (grouping) => {
const resourceIdString = model1ResourceIds().toString()
const beforeViewRes = await createSavedView(
buildCreateInput({
resourceIdString,
overrides: {
groupId: grouping === 'grouped' ? testGroup1.id : null
}
}),
{
assertNoErrors: true
}
)
const beforeView =
beforeViewRes.data?.projectMutations.savedViewMutations.createView!
expect(beforeView.position).to.be.ok
const afterViewRes = await createSavedView(
buildCreateInput({
resourceIdString,
overrides: {
groupId: grouping === 'grouped' ? testGroup1.id : null
}
}),
{
assertNoErrors: true
}
)
const afterView =
afterViewRes.data?.projectMutations.savedViewMutations.createView!
expect(afterView.position).to.be.ok
// API doesnt allow direct control over position, so
// we need to do this directly in DB
const updateView = updateSavedViewRecordFactory({ db })
const newFixablePos = beforeView.position! + TOO_SMALL_OF_A_GAP
await updateView({
id: afterView.id,
projectId: afterView.projectId,
update: {
position: newFixablePos
}
})
// Now lets insert new view in the middle, and recalculation should happen
const middleViewRes = await createSavedView(
buildCreateInput({
resourceIdString,
overrides: {
groupId: grouping === 'grouped' ? testGroup1.id : null,
position: {
type: ViewPositionInputType.Between,
beforeViewId: beforeView.id,
afterViewId: afterView.id
}
}
}),
{
assertNoErrors: true
}
)
const middleView =
middleViewRes.data?.projectMutations.savedViewMutations.createView!
expect(middleView.position).to.be.ok
// Now list that "group" again, check that all 3 views are there
// and have fixed positions
const groupWithViews =
grouping === 'grouped'
? await getGroup(
{
groupId: testGroup1.id,
projectId: myProject.id
},
{ assertNoErrors: true }
).then((r) => r.data?.project.savedViewGroup)
: await getProjectUngroupedViewGroup(
{
projectId: myProject.id,
input: { resourceIdString }
},
{ assertNoErrors: true }
).then((r) => r.data?.project.ungroupedViewGroup)
expect(groupWithViews).to.be.ok
expect(
groupWithViews?.views.items.filter((v) =>
[beforeView.id, afterView.id, middleView.id].includes(v.id)
).length
).to.be.eq(3)
let prevPosition: number | undefined = undefined
for (const view of groupWithViews?.views.items || []) {
if (!isUndefined(prevPosition)) {
expect(view.position).to.be.eq(prevPosition - 1000)
}
prevPosition = view.position
}
}
)
})
describe('updates', () => {
@@ -1064,6 +1290,186 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
}
)
itEach(
['ungrouped', 'grouped'],
(grouping) =>
`should update view to have the last position in the ${grouping} group w/ an empty position input`,
async (grouping) => {
const resourceIdString = models[0].id
const res1 = await createSavedView(
buildCreateInput({
resourceIdString,
projectId: updatablesProject.id,
overrides: {
groupId: grouping === 'grouped' ? optionalGroup.id : null
}
}),
{
assertNoErrors: true
}
)
const res2 = await createSavedView(
buildCreateInput({
resourceIdString,
projectId: updatablesProject.id,
overrides: {
groupId: grouping === 'grouped' ? optionalGroup.id : null
}
}),
{
assertNoErrors: true
}
)
const firstView = res1.data?.projectMutations.savedViewMutations.createView
const secondView = res2.data?.projectMutations.savedViewMutations.createView
expect(secondView!.position).to.equal(firstView!.position! + 1000)
const rest3 = await updateView(
{
input: {
id: firstView!.id,
projectId: updatablesProject.id,
position: {
// empty input means "move to end"
type: ViewPositionInputType.Between
}
}
},
{ assertNoErrors: true }
)
const firstViewAgain =
rest3.data?.projectMutations.savedViewMutations.updateView!
expect(firstViewAgain.position).to.equal(secondView!.position! + 1000)
}
)
itEach(
['ungrouped', 'grouped'],
(grouping) =>
`should allow updating position between 2 other views and rebalance ${grouping} group when positions get too close`,
async (grouping) => {
const resourceIdString = models[0].id
const projectId = updatablesProject.id
const firstViewRes = await createSavedView(
buildCreateInput({
resourceIdString,
projectId,
overrides: {
groupId: grouping === 'grouped' ? optionalGroup.id : null
}
}),
{
assertNoErrors: true
}
)
const firstView =
firstViewRes.data?.projectMutations.savedViewMutations.createView!
expect(firstView.position).to.be.ok
const secondViewRes = await createSavedView(
buildCreateInput({
resourceIdString,
projectId,
overrides: {
groupId: grouping === 'grouped' ? optionalGroup.id : null
}
}),
{
assertNoErrors: true
}
)
const secondView =
secondViewRes.data?.projectMutations.savedViewMutations.createView!
expect(secondView.position).to.be.eq(firstView.position! + 1000)
const thirdViewRes = await createSavedView(
buildCreateInput({
resourceIdString,
projectId,
overrides: {
groupId: grouping === 'grouped' ? optionalGroup.id : null
}
}),
{
assertNoErrors: true
}
)
const thirdView =
thirdViewRes.data?.projectMutations.savedViewMutations.createView!
expect(thirdView.position).to.be.eq(secondView.position! + 1000)
// API doesnt allow direct control over position, so
// we need to do this directly in DB
const updateViewDb = updateSavedViewRecordFactory({ db })
const newFixablePos = firstView.position! + TOO_SMALL_OF_A_GAP
await updateViewDb({
id: secondView.id,
projectId: secondView.projectId,
update: {
position: newFixablePos
}
})
// Now lets update the third view to be in the middle, and recalculation should happen
const thirdViewAgainRes = await updateView(
{
input: {
id: thirdView.id,
projectId: updatablesProject.id,
position: {
type: ViewPositionInputType.Between,
beforeViewId: firstView.id,
afterViewId: secondView.id
}
}
},
{
assertNoErrors: true
}
)
const middleView =
thirdViewAgainRes.data?.projectMutations.savedViewMutations.updateView!
expect(middleView.position).to.be.ok
// Now list that "group" again, check that all 3 views are there
// and have fixed positions
const groupWithViews =
grouping === 'grouped'
? await getGroup(
{
groupId: optionalGroup.id,
projectId: updatablesProject.id
},
{ assertNoErrors: true }
).then((r) => r.data?.project.savedViewGroup)
: await getProjectUngroupedViewGroup(
{
projectId: updatablesProject.id,
input: { resourceIdString }
},
{ assertNoErrors: true }
).then((r) => r.data?.project.ungroupedViewGroup)
expect(groupWithViews).to.be.ok
expect(
groupWithViews?.views.items.filter((v) =>
[firstView.id, secondView.id, middleView.id].includes(v.id)
).length
).to.be.eq(3)
let prevPosition: number | undefined = undefined
for (const view of groupWithViews?.views.items || []) {
if (!isUndefined(prevPosition)) {
expect(view.position).to.be.eq(prevPosition - 1000)
}
prevPosition = view.position
}
}
)
it('successfully updated everyting in a saved view', async () => {
const input: UpdateSavedViewInput = {
id: testView.id,
@@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest'
import { errorToString } from './error.js'
describe('errorToString', () => {
it('should stringify non-Error objects', () => {
const obj = { foo: 'bar', num: 42 }
const result = errorToString(obj)
expect(result).toBe('{"foo":"bar","num":42}')
})
it('should handle primitive values', () => {
expect(errorToString('string error')).toBe('"string error"')
expect(errorToString(123)).toBe('123')
expect(errorToString(true)).toBe('true')
expect(errorToString(null)).toBe('null')
expect(errorToString(undefined)).toBeUndefined()
})
it('should fallback to String() for non-serializable objects', () => {
const circular: Record<string, unknown> = { name: 'circular' }
circular.self = circular
const result = errorToString(circular)
expect(result).toBe('[object Object]')
})
it('should return stack trace for Error objects', () => {
const error = new Error('Test error')
const result = errorToString(error)
expect(result).toContain('Test error')
expect(result).toContain('Error: Test error')
})
it('should fallback to message if no stack', () => {
const error = new Error('Test message')
// Remove stack to test fallback
delete (error as Error & { stack?: string }).stack
const result = errorToString(error)
expect(result).toBe('Test message')
})
it('should fallback to String(error) if no stack or message', () => {
const error = new Error()
delete (error as Error & { stack?: string }).stack
// Use Reflect.deleteProperty to avoid TypeScript error
Reflect.deleteProperty(error, 'message')
const result = errorToString(error)
expect(result).toBe('Error')
})
it('should handle Error with cause property', () => {
const rootCause = new Error('Root cause')
const error = new Error('Main error') as Error & { cause?: Error }
error.cause = rootCause
const result = errorToString(error)
expect(result).toContain('Main error')
expect(result).toContain('Cause: ')
expect(result).toContain('Root cause')
})
it('should handle Error with jse_cause property', () => {
const rootCause = new Error('JSE root cause')
const error = new Error('Main error') as Error & { jse_cause?: Error }
error['jse_cause'] = rootCause
const result = errorToString(error)
expect(result).toContain('Main error')
expect(result).toContain('Cause: ')
expect(result).toContain('JSE root cause')
})
it('should handle nested causes recursively', () => {
const deepCause = new Error('Deep cause')
const midCause = new Error('Mid cause') as Error & { cause?: Error }
midCause.cause = deepCause
const error = new Error('Top error') as Error & { cause?: Error }
error.cause = midCause
const result = errorToString(error)
expect(result).toContain('Top error')
expect(result).toContain('Mid cause')
expect(result).toContain('Deep cause')
// Should have nested "Cause:" labels
const causeCount = (result.match(/Cause: /g) || []).length
expect(causeCount).toBe(2)
})
it('should prioritize jse_cause over cause when both are present', () => {
const jseCause = new Error('JSE cause')
const stdCause = new Error('Standard cause')
const error = new Error('Main error') as Error & {
cause?: Error
jse_cause?: Error
}
error.cause = stdCause
error['jse_cause'] = jseCause
const result = errorToString(error)
expect(result).toContain('Main error')
expect(result).toContain('JSE cause')
// Should NOT contain cause since jse_cause takes priority
expect(result).not.toContain('Standard cause')
// Should have only one "Cause:" label
const causeCount = (result.match(/Cause: /g) || []).length
expect(causeCount).toBe(1)
})
it('should handle cause when jse_cause is not present', () => {
const stdCause = new Error('Standard cause')
const error = new Error('Main error') as Error & { cause?: Error }
error.cause = stdCause
const result = errorToString(error)
expect(result).toContain('Main error')
expect(result).toContain('Standard cause')
// Should have one "Cause:" label
const causeCount = (result.match(/Cause: /g) || []).length
expect(causeCount).toBe(1)
})
it('should handle error with no cause properties', () => {
const error = new Error('Error without cause')
const result = errorToString(error)
expect(result).toContain('Error without cause')
expect(result).not.toContain('Cause:')
// Should have no "Cause:" labels
const causeCount = (result.match(/Cause: /g) || []).length
expect(causeCount).toBe(0)
})
it('should handle non-Error causes', () => {
const error = new Error('Main error') as Error & { cause?: unknown }
error.cause = { type: 'custom', message: 'Custom cause' }
const result = errorToString(error)
expect(result).toContain('Main error')
expect(result).toContain('Cause: {"type":"custom","message":"Custom cause"}')
})
it('should handle circular reference in causes', () => {
const error = new Error('Main error') as Error & { cause?: unknown }
const cause: Record<string, unknown> = { message: 'Circular cause' }
cause.self = cause
error.cause = cause
const result = errorToString(error)
expect(result).toContain('Main error')
expect(result).toContain('Cause: [object Object]')
})
})
+30
View File
@@ -1,3 +1,5 @@
import { get } from '#lodash'
class UnexpectedErrorStructureError extends Error {}
/**
@@ -46,3 +48,31 @@ export const collectLongTrace = (limit?: number) => {
Error.stackTraceLimit = originalLimit
return trace
}
/**
* When you need to log a full error representation, w/ full .cause() support
*/
export const errorToString = (e: unknown): string => {
if (!(e instanceof Error)) {
try {
return JSON.stringify(e)
} catch {
return String(e)
}
}
let ret = e.stack || e.message || String(e)
const causeProps = ['jse_cause', 'cause'] as const
for (const prop of causeProps) {
if (prop in e) {
const cause = get(e, prop)
if (!cause) continue
ret += `\nCause: ${errorToString(cause)}`
break // avoid chaining multiple causes
}
}
return ret
}