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:
committed by
GitHub
parent
251d9a3297
commit
99c26db777
+4
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -85,7 +85,8 @@ export default defineNuxtConfig({
|
||||
datadogEnv: '',
|
||||
intercomAppId: '',
|
||||
dashboardsOrigin: '',
|
||||
parallelMiddlewares: true
|
||||
parallelMiddlewares: true,
|
||||
disableViewerActivityBroadcasting: false
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user