feat: copy link to view + load original version (#5202)
* frontend nearly there * backend adjustment sort of there * WIP url busted issue * does it work? * how about now * loading seems to work now
This commit is contained in:
committed by
GitHub
parent
e1e1cded27
commit
0f5096fb2e
@@ -26,6 +26,7 @@
|
||||
:items="menuItems"
|
||||
:menu-id="menuId"
|
||||
mount-menu-on-body
|
||||
:size="230"
|
||||
@chosen="({ item: actionItem }) => onActionChosen(actionItem)"
|
||||
>
|
||||
<FormButton
|
||||
@@ -71,12 +72,11 @@ import { useMutationLoading } from '@vue/apollo-composable'
|
||||
import { Ellipsis, SquarePen } from 'lucide-vue-next'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { ViewerSavedViewsPanelView_SavedViewFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useEventBus } from '~/lib/core/composables/eventBus'
|
||||
import { useViewerSavedViewsUtils } from '~/lib/viewer/composables/savedViews/general'
|
||||
import { useDeleteSavedView } from '~/lib/viewer/composables/savedViews/management'
|
||||
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
|
||||
|
||||
const Menuitems = StringEnum(['Delete'])
|
||||
type MenuItems = StringEnumValues<typeof Menuitems>
|
||||
const MenuItems = StringEnum(['Delete', 'LoadOriginalVersions', 'CopyLink'])
|
||||
type MenuItems = StringEnumValues<typeof MenuItems>
|
||||
|
||||
graphql(`
|
||||
fragment ViewerSavedViewsPanelView_SavedView on SavedView {
|
||||
@@ -103,9 +103,9 @@ const props = defineProps<{
|
||||
view: ViewerSavedViewsPanelView_SavedViewFragment
|
||||
}>()
|
||||
|
||||
const eventBus = useEventBus()
|
||||
const deleteView = useDeleteSavedView()
|
||||
const isLoading = useMutationLoading()
|
||||
const { copyLink, applyView } = useViewerSavedViewsUtils()
|
||||
|
||||
const showEditDialog = ref(false)
|
||||
const showMenu = ref(false)
|
||||
@@ -115,7 +115,17 @@ const canUpdate = computed(() => props.view.permissions.canUpdate)
|
||||
const menuItems = computed((): LayoutMenuItem<MenuItems>[][] => [
|
||||
[
|
||||
{
|
||||
id: Menuitems.Delete,
|
||||
id: MenuItems.LoadOriginalVersions,
|
||||
title: 'Load with original model version'
|
||||
},
|
||||
{
|
||||
id: MenuItems.CopyLink,
|
||||
title: 'Copy link'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
id: MenuItems.Delete,
|
||||
title: 'Delete',
|
||||
disabled: !canUpdate.value?.authorized || isLoading.value,
|
||||
disabledTooltip: canUpdate.value.errorMessage
|
||||
@@ -125,19 +135,30 @@ const menuItems = computed((): LayoutMenuItem<MenuItems>[][] => [
|
||||
|
||||
const onActionChosen = async (item: LayoutMenuItem<MenuItems>) => {
|
||||
switch (item.id) {
|
||||
case Menuitems.Delete:
|
||||
case MenuItems.Delete:
|
||||
await deleteView({ view: props.view })
|
||||
break
|
||||
case MenuItems.CopyLink:
|
||||
await copyLink({
|
||||
settings: {
|
||||
id: props.view.id
|
||||
}
|
||||
})
|
||||
break
|
||||
case MenuItems.LoadOriginalVersions:
|
||||
applyView({
|
||||
id: props.view.id,
|
||||
loadOriginal: true
|
||||
})
|
||||
break
|
||||
default:
|
||||
throwUncoveredError(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const apply = async () => {
|
||||
// Force update, even if the view id is already set
|
||||
// (in case this is a frustration click w/ the state not applying)
|
||||
eventBus.emit(ViewerEventBusKeys.UpdateSavedView, {
|
||||
viewId: props.view.id
|
||||
applyView({
|
||||
id: props.view.id
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { until } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* Global state that tells you if the router is in the middle of a navigation
|
||||
*/
|
||||
export const useRouterNavigating = () => {
|
||||
const { $isNavigating } = useNuxtApp()
|
||||
const logger = useLogger()
|
||||
|
||||
const waitUntilReady = async () => {
|
||||
try {
|
||||
await until($isNavigating).toBe(false, { throwOnTimeout: true, timeout: 500 })
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Wait for router ready failed w/ timeout')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isNavigating: $isNavigating,
|
||||
/**
|
||||
* Wait for router to flush active navigations
|
||||
*/
|
||||
waitUntilReady
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,39 @@ export const writableAsyncComputed: typeof originalWritableAsyncComputed = (para
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Like normal watch() except handles async callbacks in an ordered manner - previous
|
||||
* watches must complete, before new ones can get processed
|
||||
*/
|
||||
export const watchAsync = ((...args: Parameters<typeof watch>) => {
|
||||
const [source, cb, options] = args
|
||||
const logger = useLogger()
|
||||
|
||||
const watches = shallowRef<Array<Promise<unknown>>>([])
|
||||
|
||||
const watchRet = watch(
|
||||
source,
|
||||
(newVal, oldVal, onCleanup) => {
|
||||
// 1. Wait for all active processing to finish
|
||||
// 2. Then run new processing
|
||||
// 3. At the end - clean up watches array
|
||||
const handlerPromise = Promise.allSettled(watches.value).finally(() =>
|
||||
Promise.resolve(cb(newVal, oldVal, onCleanup))
|
||||
.catch((e) => {
|
||||
logger.error(e, 'Error occurred in watchAsync callback')
|
||||
throw e
|
||||
})
|
||||
.finally(() => {
|
||||
watches.value = watches.value.filter((p) => p !== handlerPromise)
|
||||
})
|
||||
)
|
||||
|
||||
// Add handler to array
|
||||
watches.value = [...watches.value, handlerPromise]
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
return watchRet
|
||||
}) as unknown as typeof watch // ts typing difficulty
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type {
|
||||
OperationVariables,
|
||||
QueryOptions,
|
||||
WatchQueryFetchPolicy
|
||||
import {
|
||||
NetworkStatus,
|
||||
type OperationVariables,
|
||||
type QueryOptions,
|
||||
type WatchQueryFetchPolicy
|
||||
} from '@apollo/client/core'
|
||||
import type {
|
||||
DocumentParameter,
|
||||
@@ -265,3 +266,26 @@ export const usePageQueryStandardFetchPolicy = () => {
|
||||
return hasNavigatedInCSR.value ? 'cache-and-network' : undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* By default 'variables' off useQuery updates the moment variables are updated. This returns the variables
|
||||
* associated with the active result. So if the result is still loading, the variables are gonna be undefined too.
|
||||
*/
|
||||
export const useQueryResultVariables = <
|
||||
TResult = any,
|
||||
TVariables extends OperationVariables = OperationVariables
|
||||
>(
|
||||
useQueryRet: ReturnType<typeof useQuery<TResult, TVariables>>
|
||||
) => {
|
||||
const { variables, onResult } = useQueryRet
|
||||
|
||||
const currentVariables = shallowRef<(typeof variables)['value']>()
|
||||
onResult((res) => {
|
||||
if (res.networkStatus !== NetworkStatus.ready) return
|
||||
currentVariables.value = variables.value
|
||||
})
|
||||
|
||||
const resultVariables = computed(() => currentVariables.value)
|
||||
|
||||
return resultVariables
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function deserializeHashState(hashString: string) {
|
||||
export function useRouteHashState() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { waitUntilReady } = useRouterNavigating()
|
||||
|
||||
const hashState = writableAsyncComputed({
|
||||
get: () => {
|
||||
@@ -44,6 +45,8 @@ export function useRouteHashState() {
|
||||
},
|
||||
set: async (newVal) => {
|
||||
const hashString = serializeHashState(newVal)
|
||||
|
||||
await waitUntilReady()
|
||||
await router.push({
|
||||
query: route.query,
|
||||
hash: hashString
|
||||
@@ -55,3 +58,21 @@ export function useRouteHashState() {
|
||||
|
||||
return { hashState }
|
||||
}
|
||||
|
||||
export const useAppUrlUtils = () => {
|
||||
const {
|
||||
public: { baseUrl }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
const buildUrl = (relativeUrl: string | URL): string => {
|
||||
const url = new URL(relativeUrl, baseUrl)
|
||||
return decodeURI(url.toString()) // url encoded looks ugly
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Build full/absolute URL
|
||||
*/
|
||||
buildUrl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,8 +418,7 @@ type Documents = {
|
||||
"\n mutation CreateCommentThread($input: CreateCommentInput!) {\n commentMutations {\n create(input: $input) {\n ...ViewerCommentThread\n }\n }\n }\n": typeof types.CreateCommentThreadDocument,
|
||||
"\n mutation CreateCommentReply($input: CreateCommentReplyInput!) {\n commentMutations {\n reply(input: $input) {\n ...ViewerCommentsReplyItem\n }\n }\n }\n": typeof types.CreateCommentReplyDocument,
|
||||
"\n mutation ArchiveComment($input: ArchiveCommentInput!) {\n commentMutations {\n archive(input: $input)\n }\n }\n": typeof types.ArchiveCommentDocument,
|
||||
"\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: String\n ) {\n project(id: $projectId) {\n id\n viewerResources(resourceIdString: $resourceUrlString, savedViewId: $savedViewId) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n }\n }\n": typeof types.ProjectViewerResourcesDocument,
|
||||
"\n query ViewerActiveSavedView($projectId: String!, $savedViewId: ID!) {\n project(id: $projectId) {\n id\n savedView(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n": typeof types.ViewerActiveSavedViewDocument,
|
||||
"\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: ID\n $savedViewSettings: SavedViewsLoadSettings\n ) {\n project(id: $projectId) {\n id\n viewerResources(\n resourceIdString: $resourceUrlString\n savedViewId: $savedViewId\n savedViewSettings: $savedViewSettings\n ) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n savedViewIfExists(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n": typeof types.ProjectViewerResourcesDocument,
|
||||
"\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n ...UseCheckViewerCommentingAccess_Project\n ...UseViewerUserActivityBroadcasting_Project\n ...ViewerResourcesLimitAlert_Project\n ...ViewerSavedViewsPanel_Project\n }\n }\n": typeof types.ViewerLoadedResourcesDocument,
|
||||
"\n query ViewerModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n role\n model(id: $modelId) {\n id\n versions(cursor: $versionsCursor, limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n }\n": typeof types.ViewerModelVersionsDocument,
|
||||
"\n query ViewerDiffVersions(\n $projectId: String!\n $modelId: String!\n $versionAId: String!\n $versionBId: String!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n versionA: version(id: $versionAId) {\n ...ViewerModelVersionCardItem\n }\n versionB: version(id: $versionBId) {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n": typeof types.ViewerDiffVersionsDocument,
|
||||
@@ -912,8 +911,7 @@ const documents: Documents = {
|
||||
"\n mutation CreateCommentThread($input: CreateCommentInput!) {\n commentMutations {\n create(input: $input) {\n ...ViewerCommentThread\n }\n }\n }\n": types.CreateCommentThreadDocument,
|
||||
"\n mutation CreateCommentReply($input: CreateCommentReplyInput!) {\n commentMutations {\n reply(input: $input) {\n ...ViewerCommentsReplyItem\n }\n }\n }\n": types.CreateCommentReplyDocument,
|
||||
"\n mutation ArchiveComment($input: ArchiveCommentInput!) {\n commentMutations {\n archive(input: $input)\n }\n }\n": types.ArchiveCommentDocument,
|
||||
"\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: String\n ) {\n project(id: $projectId) {\n id\n viewerResources(resourceIdString: $resourceUrlString, savedViewId: $savedViewId) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n }\n }\n": types.ProjectViewerResourcesDocument,
|
||||
"\n query ViewerActiveSavedView($projectId: String!, $savedViewId: ID!) {\n project(id: $projectId) {\n id\n savedView(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n": types.ViewerActiveSavedViewDocument,
|
||||
"\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: ID\n $savedViewSettings: SavedViewsLoadSettings\n ) {\n project(id: $projectId) {\n id\n viewerResources(\n resourceIdString: $resourceUrlString\n savedViewId: $savedViewId\n savedViewSettings: $savedViewSettings\n ) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n savedViewIfExists(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n": types.ProjectViewerResourcesDocument,
|
||||
"\n query ViewerLoadedResources(\n $projectId: String!\n $modelIds: [String!]!\n $versionIds: [String!]\n ) {\n project(id: $projectId) {\n id\n role\n allowPublicComments\n models(filter: { ids: $modelIds }) {\n totalCount\n items {\n id\n name\n updatedAt\n loadedVersion: versions(\n filter: { priorityIds: $versionIds, priorityIdsOnly: true }\n ) {\n items {\n ...ViewerModelVersionCardItem\n automationsStatus {\n id\n automationRuns {\n ...AutomateViewerPanel_AutomateRun\n }\n }\n }\n }\n versions(limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n ...ProjectPageLatestItemsModels\n ...ModelPageProject\n ...HeaderNavShare_Project\n ...UseCheckViewerCommentingAccess_Project\n ...UseViewerUserActivityBroadcasting_Project\n ...ViewerResourcesLimitAlert_Project\n ...ViewerSavedViewsPanel_Project\n }\n }\n": types.ViewerLoadedResourcesDocument,
|
||||
"\n query ViewerModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n role\n model(id: $modelId) {\n id\n versions(cursor: $versionsCursor, limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n }\n": types.ViewerModelVersionsDocument,
|
||||
"\n query ViewerDiffVersions(\n $projectId: String!\n $modelId: String!\n $versionAId: String!\n $versionBId: String!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n versionA: version(id: $versionAId) {\n ...ViewerModelVersionCardItem\n }\n versionB: version(id: $versionBId) {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n": types.ViewerDiffVersionsDocument,
|
||||
@@ -2635,11 +2633,7 @@ export function graphql(source: "\n mutation ArchiveComment($input: ArchiveComm
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: String\n ) {\n project(id: $projectId) {\n id\n viewerResources(resourceIdString: $resourceUrlString, savedViewId: $savedViewId) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: String\n ) {\n project(id: $projectId) {\n id\n viewerResources(resourceIdString: $resourceUrlString, savedViewId: $savedViewId) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ViewerActiveSavedView($projectId: String!, $savedViewId: ID!) {\n project(id: $projectId) {\n id\n savedView(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n"): (typeof documents)["\n query ViewerActiveSavedView($projectId: String!, $savedViewId: ID!) {\n project(id: $projectId) {\n id\n savedView(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: ID\n $savedViewSettings: SavedViewsLoadSettings\n ) {\n project(id: $projectId) {\n id\n viewerResources(\n resourceIdString: $resourceUrlString\n savedViewId: $savedViewId\n savedViewSettings: $savedViewSettings\n ) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n savedViewIfExists(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n"): (typeof documents)["\n query ProjectViewerResources(\n $projectId: String!\n $resourceUrlString: String!\n $savedViewId: ID\n $savedViewSettings: SavedViewsLoadSettings\n ) {\n project(id: $projectId) {\n id\n viewerResources(\n resourceIdString: $resourceUrlString\n savedViewId: $savedViewId\n savedViewSettings: $savedViewSettings\n ) {\n identifier\n items {\n modelId\n versionId\n objectId\n }\n }\n savedViewIfExists(id: $savedViewId) {\n id\n ...UseViewerSavedViewSetup_SavedView\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -114,6 +114,8 @@ export const modelRoute = (
|
||||
`/projects/${projectId}/models/${resourceIdString}${
|
||||
hashState ? serializeHashState(hashState) || '' : ''
|
||||
}`
|
||||
export const viewerRoute = modelRoute
|
||||
|
||||
export const modelVersionsRoute = (projectId: string, modelId: string) =>
|
||||
`/projects/${projectId}/models/${modelId}/versions`
|
||||
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import { useAppUrlUtils } from '~/lib/common/composables/url'
|
||||
import { viewerRoute } from '~/lib/common/helpers/route'
|
||||
import { useEventBus } from '~/lib/core/composables/eventBus'
|
||||
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
|
||||
import { ViewerHashStateKeys } from '~/lib/viewer/composables/setup/urlHashState'
|
||||
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
|
||||
import {
|
||||
serializeSavedViewUrlSettings,
|
||||
type SavedViewUrlSettings
|
||||
} from '~/lib/viewer/helpers/savedViews'
|
||||
|
||||
export const useAreSavedViewsEnabled = () => {
|
||||
const {
|
||||
public: { FF_SAVED_VIEWS_ENABLED }
|
||||
@@ -5,3 +16,36 @@ export const useAreSavedViewsEnabled = () => {
|
||||
|
||||
return FF_SAVED_VIEWS_ENABLED
|
||||
}
|
||||
|
||||
export const useViewerSavedViewsUtils = () => {
|
||||
const {
|
||||
projectId,
|
||||
resources: {
|
||||
request: { resourceIdString }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
const { copy } = useClipboard()
|
||||
const { buildUrl } = useAppUrlUtils()
|
||||
const eventBus = useEventBus()
|
||||
|
||||
const copyLink = async (params: { settings: SavedViewUrlSettings }) => {
|
||||
const { settings } = params
|
||||
const relativeUrl = viewerRoute(projectId.value, resourceIdString.value, {
|
||||
[ViewerHashStateKeys.SavedView]: serializeSavedViewUrlSettings(settings)
|
||||
})
|
||||
await copy(buildUrl(relativeUrl), {
|
||||
successMessage: 'Copied link to view'
|
||||
})
|
||||
}
|
||||
|
||||
const applyView = (settings: SavedViewUrlSettings) => {
|
||||
// Force update, even if the view id is already set
|
||||
// (in case this is a frustration click w/ the state not applying)
|
||||
eventBus.emit(ViewerEventBusKeys.UpdateSavedView, settings)
|
||||
}
|
||||
|
||||
return {
|
||||
copyLink,
|
||||
applyView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,14 @@ import {
|
||||
} from '~~/lib/viewer/composables/ui'
|
||||
import { CameraController, ViewMode, VisualDiffMode } from '@speckle/viewer'
|
||||
import type { NumericPropertyInfo } from '@speckle/viewer'
|
||||
import type { PartialDeep } from 'type-fest'
|
||||
import type { Merge, PartialDeep } from 'type-fest'
|
||||
import type { SectionBoxData } from '@speckle/shared/viewer/state'
|
||||
import { useViewerRealtimeActivityTracker } from '~/lib/viewer/composables/activity'
|
||||
import {
|
||||
isModelResource,
|
||||
resourceBuilder,
|
||||
type ViewerResource
|
||||
} from '@speckle/shared/viewer/route'
|
||||
|
||||
type SerializedViewerState = SpeckleViewer.ViewerState.SerializedViewerState
|
||||
|
||||
@@ -138,6 +143,13 @@ export enum StateApplyMode {
|
||||
SavedView
|
||||
}
|
||||
|
||||
export type StateApplyOptions = Merge<
|
||||
Record<StateApplyMode, never>,
|
||||
{
|
||||
[StateApplyMode.SavedView]: { loadOriginal: boolean }
|
||||
}
|
||||
>
|
||||
|
||||
export function useApplySerializedState() {
|
||||
const {
|
||||
projectId,
|
||||
@@ -171,7 +183,11 @@ export function useApplySerializedState() {
|
||||
const logger = useLogger()
|
||||
const { update } = useViewerRealtimeActivityTracker()
|
||||
|
||||
return async (state: PartialDeep<SerializedViewerState>, mode: StateApplyMode) => {
|
||||
return async <Mode extends StateApplyMode>(
|
||||
state: PartialDeep<SerializedViewerState>,
|
||||
mode: Mode,
|
||||
options?: StateApplyOptions[Mode]
|
||||
) => {
|
||||
if (mode === StateApplyMode.Reset) {
|
||||
resetState()
|
||||
update() // Trigger activity update
|
||||
@@ -186,6 +202,34 @@ export function useApplySerializedState() {
|
||||
[StateApplyMode.Spotlight, StateApplyMode.ThreadFullContextOpen].includes(mode)
|
||||
) {
|
||||
await resourceIdString.update(state.resources?.request?.resourceIdString || '')
|
||||
} else if (mode === StateApplyMode.SavedView) {
|
||||
const { loadOriginal } = options || {}
|
||||
|
||||
const current = resourceBuilder().addResources(resourceIdString.value)
|
||||
const incoming = resourceBuilder().addResources(
|
||||
state.resources?.request?.resourceIdString || ''
|
||||
)
|
||||
|
||||
const finalItems: ViewerResource[] = []
|
||||
for (const incomingItem of incoming) {
|
||||
if (!isModelResource(incomingItem)) {
|
||||
finalItems.push(incomingItem)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update versionId based on loadOriginal
|
||||
incomingItem.versionId = loadOriginal
|
||||
? incomingItem.versionId
|
||||
: current
|
||||
.filter(isModelResource)
|
||||
.find((r) => r.modelId === incomingItem.modelId)?.versionId
|
||||
finalItems.push(incomingItem)
|
||||
}
|
||||
const newResourceIdString = resourceBuilder()
|
||||
.addResources(finalItems)
|
||||
.addNew(current) // keeping other federated models around
|
||||
.toString()
|
||||
await resourceIdString.update(newResourceIdString)
|
||||
}
|
||||
|
||||
position.value = new Vector3(
|
||||
|
||||
@@ -26,7 +26,6 @@ import { isNonNullable } from '@speckle/shared'
|
||||
import { useApolloClient, useLazyQuery, useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
projectViewerResourcesQuery,
|
||||
viewerActiveSavedViewQuery,
|
||||
viewerLoadedResourcesQuery,
|
||||
viewerLoadedThreadsQuery,
|
||||
viewerModelVersionsQuery
|
||||
@@ -79,7 +78,7 @@ import {
|
||||
ViewerModelResource,
|
||||
type ViewerResource
|
||||
} from '@speckle/shared/viewer/route'
|
||||
import { useAreSavedViewsEnabled } from '~/lib/viewer/composables/savedViews/general'
|
||||
import type { SavedViewUrlSettings } from '~/lib/viewer/helpers/savedViews'
|
||||
|
||||
export type LoadedModel = NonNullable<
|
||||
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
|
||||
@@ -98,11 +97,6 @@ export type InjectableViewerState = Readonly<{
|
||||
* The project which we're opening in the viewer (all loaded models should belong to it)
|
||||
*/
|
||||
projectId: AsyncWritableComputedRef<string>
|
||||
/**
|
||||
* Core source of truth for the view id (other is in hash state). This allows you to
|
||||
* set a view to load, without it showing up in the URL.
|
||||
*/
|
||||
savedViewId: Ref<Nullable<string>>
|
||||
/**
|
||||
* User viewer session ID. The same user will have different IDs in different tabs if multiple are open.
|
||||
* This is used to ignore user activity messages from the same tab.
|
||||
@@ -153,6 +147,17 @@ export type InjectableViewerState = Readonly<{
|
||||
* State of resource identifiers that should be loaded (tied to the URL param)
|
||||
*/
|
||||
request: {
|
||||
/**
|
||||
* Saved view parameters, that affect what resources we're loading and how
|
||||
*/
|
||||
savedView: {
|
||||
id: Ref<Nullable<string>>
|
||||
/**
|
||||
* By default we use latest or already loaded versions, but this allows
|
||||
* us to load the versions originally specified when creating the view
|
||||
*/
|
||||
loadOriginal: Ref<boolean>
|
||||
}
|
||||
/**
|
||||
* All currently requested identifiers. You
|
||||
* can write to this to change which resources should be loaded.
|
||||
@@ -173,19 +178,12 @@ export type InjectableViewerState = Readonly<{
|
||||
* Helper for switching model to a specific version (or just latest)
|
||||
*/
|
||||
switchModelToVersion: (modelId: string, versionId?: string) => Promise<void>
|
||||
// addModelVersion: (modelId: string, versionId: string) => void
|
||||
// removeModelVersion: (modelId: string, versionId?: string) => void
|
||||
// setModelVersions: (newResources: ViewerResource[]) => void
|
||||
}
|
||||
/**
|
||||
* State of resolved, validated & de-duplicated resources that are loaded in the viewer. These
|
||||
* are resolved from multiple GQL requests and update whenever resources.request updates.
|
||||
*/
|
||||
response: {
|
||||
/**
|
||||
* Resource id string w/ saved view applied, if any
|
||||
*/
|
||||
resolvedResourceIdString: ComputedRef<string>
|
||||
/**
|
||||
* Metadata about loaded items
|
||||
*/
|
||||
@@ -319,7 +317,11 @@ export type InjectableViewerState = Readonly<{
|
||||
urlHashState: {
|
||||
focusedThreadId: AsyncWritableComputedRef<Nullable<string>>
|
||||
diff: AsyncWritableComputedRef<Nullable<DiffStateCommand>>
|
||||
savedViewId: AsyncWritableComputedRef<Nullable<string>>
|
||||
/**
|
||||
* Core source of truth is under `resources.request.savedView`, but this allows
|
||||
* the saved view settings to be URL controlled
|
||||
*/
|
||||
savedView: AsyncWritableComputedRef<Nullable<SavedViewUrlSettings>>
|
||||
}
|
||||
}>
|
||||
|
||||
@@ -332,7 +334,7 @@ type CachedViewerState = Pick<
|
||||
|
||||
type InitialSetupState = Pick<
|
||||
InjectableViewerState,
|
||||
'projectId' | 'viewer' | 'sessionId' | 'urlHashState' | 'savedViewId'
|
||||
'projectId' | 'viewer' | 'sessionId' | 'urlHashState'
|
||||
>
|
||||
|
||||
type InitialStateWithRequest = InitialSetupState & {
|
||||
@@ -440,7 +442,6 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
|
||||
|
||||
return {
|
||||
projectId: params.projectId,
|
||||
savedViewId: ref<string | null>(null),
|
||||
sessionId,
|
||||
viewer: import.meta.server
|
||||
? ({
|
||||
@@ -478,12 +479,15 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
|
||||
function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { waitUntilReady } = useRouterNavigating()
|
||||
const getParam = computed(() => route.params.modelId as string)
|
||||
|
||||
const resources = writableAsyncComputed({
|
||||
get: () => parseUrlParameters(getParam.value),
|
||||
set: async (newResources) => {
|
||||
const modelId = createGetParamFromResources(newResources)
|
||||
|
||||
await waitUntilReady()
|
||||
await router.push({
|
||||
params: { modelId },
|
||||
query: route.query,
|
||||
@@ -553,6 +557,10 @@ function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest
|
||||
...state,
|
||||
resources: {
|
||||
request: {
|
||||
savedView: {
|
||||
id: ref<string | null>(null),
|
||||
loadOriginal: ref<boolean>(false)
|
||||
},
|
||||
items: resources,
|
||||
resourceIdString,
|
||||
threadFilters,
|
||||
@@ -570,21 +578,29 @@ function setupResponseResourceItems(
|
||||
state: InitialStateWithRequest
|
||||
): Pick<
|
||||
InjectableViewerState['resources']['response'],
|
||||
| 'resourceItems'
|
||||
| 'resourceItemsQueryVariables'
|
||||
| 'resourceItemsLoaded'
|
||||
| 'resolvedResourceIdString'
|
||||
'resourceItems' | 'resourceItemsQueryVariables' | 'resourceItemsLoaded' | 'savedView'
|
||||
> {
|
||||
const globalError = useError()
|
||||
const {
|
||||
projectId,
|
||||
savedViewId,
|
||||
resources: {
|
||||
request: { resourceIdString }
|
||||
request: {
|
||||
resourceIdString,
|
||||
savedView: { id: savedViewId, loadOriginal }
|
||||
}
|
||||
}
|
||||
} = state
|
||||
|
||||
const initLoadDone = ref(import.meta.server ? false : true)
|
||||
|
||||
/**
|
||||
* Resolves actual resources to load:
|
||||
* - Viewer Resource Groups and items
|
||||
* - Saved View that was used, if any
|
||||
*
|
||||
* Both must be loaded together to avoid race conditions. They both change
|
||||
* what exactly ends up being loaded, so its important they're in sync.
|
||||
*/
|
||||
const {
|
||||
result: resolvedResourcesResult,
|
||||
variables: resourceItemsQueryVariables,
|
||||
@@ -595,7 +611,10 @@ function setupResponseResourceItems(
|
||||
() => ({
|
||||
projectId: projectId.value,
|
||||
resourceUrlString: resourceIdString.value,
|
||||
savedViewId: savedViewId.value
|
||||
savedViewId: savedViewId.value,
|
||||
savedViewSettings: {
|
||||
loadOriginal: loadOriginal.value
|
||||
}
|
||||
}),
|
||||
{ keepPreviousResult: true }
|
||||
)
|
||||
@@ -680,18 +699,19 @@ function setupResponseResourceItems(
|
||||
|
||||
const resourceItemsLoaded = computed(() => initLoadDone.value)
|
||||
|
||||
const resolvedResourceIdString = computed(() =>
|
||||
resourceBuilder()
|
||||
// Combined group identifiers should result in the final resource id string
|
||||
.addFromString(resolvedResourceGroups.value.map((group) => group.identifier))
|
||||
.toString()
|
||||
// Shows only the one matching the savedViewId. If the query is still loading/stale, it will return undefined
|
||||
const savedView = computed(() =>
|
||||
savedViewId.value &&
|
||||
resolvedResourcesResult.value?.project?.savedViewIfExists?.id === savedViewId.value
|
||||
? resolvedResourcesResult.value?.project?.savedViewIfExists
|
||||
: undefined
|
||||
)
|
||||
|
||||
return {
|
||||
resourceItems,
|
||||
resourceItemsQueryVariables: computed(() => resourceItemsQueryVariables.value),
|
||||
resourceItemsLoaded,
|
||||
resolvedResourceIdString
|
||||
savedView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,19 +720,14 @@ function setupResponseResourceData(
|
||||
resourceItemsData: ReturnType<typeof setupResponseResourceItems>
|
||||
): Omit<
|
||||
InjectableViewerState['resources']['response'],
|
||||
| 'resourceItems'
|
||||
| 'resourceItemsQueryVariables'
|
||||
| 'resourceItemsLoaded'
|
||||
| 'resolvedResourceIdString'
|
||||
'resourceItems' | 'resourceItemsQueryVariables' | 'resourceItemsLoaded' | 'savedView'
|
||||
> {
|
||||
const apollo = useApolloClient().client
|
||||
const globalError = useError()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const logger = useLogger()
|
||||
const savedViewsEnabled = useAreSavedViewsEnabled()
|
||||
|
||||
const {
|
||||
savedViewId,
|
||||
projectId,
|
||||
resources: {
|
||||
request: { resourceIdString, threadFilters }
|
||||
@@ -915,36 +930,6 @@ function setupResponseResourceData(
|
||||
logger.error(err)
|
||||
})
|
||||
|
||||
// SAVED VIEW
|
||||
const { result: viewerActiveSavedViewResult, onError: onViewerActiveSavedViewError } =
|
||||
useQuery(
|
||||
viewerActiveSavedViewQuery,
|
||||
() => ({
|
||||
projectId: projectId.value,
|
||||
savedViewId: savedViewId.value!
|
||||
}),
|
||||
{
|
||||
enabled: computed(() => !!savedViewId.value && savedViewsEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
onViewerActiveSavedViewError((err) => {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Saved view loading failed',
|
||||
description: `${err.message}`
|
||||
})
|
||||
logger.error(err)
|
||||
})
|
||||
|
||||
// Shows only the one matching the savedViewId. If the query is still loading/stale, it will return undefined
|
||||
const savedView = computed(() =>
|
||||
savedViewId.value &&
|
||||
viewerActiveSavedViewResult.value?.project?.savedView.id === savedViewId.value
|
||||
? viewerActiveSavedViewResult.value?.project?.savedView
|
||||
: undefined
|
||||
)
|
||||
|
||||
onServerPrefetch(async () => {
|
||||
await Promise.all([serverResourcesLoadedPromise.promise])
|
||||
})
|
||||
@@ -960,8 +945,7 @@ function setupResponseResourceData(
|
||||
threadsQueryVariables: computed(() => threadsQueryVariables.value),
|
||||
loadMoreVersions,
|
||||
resourcesLoaded: computed(() => initLoadDone.value),
|
||||
resourcesLoading: computed(() => viewerLoadedResourcesLoading.value),
|
||||
savedView
|
||||
resourcesLoading: computed(() => viewerLoadedResourcesLoading.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,7 @@ import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import {
|
||||
isSerializedViewerState,
|
||||
type SectionBoxData,
|
||||
type SerializedViewerState
|
||||
type SectionBoxData
|
||||
} from '@speckle/shared/viewer/state'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import {
|
||||
@@ -70,10 +69,10 @@ import {
|
||||
useApplySerializedState
|
||||
} from '~/lib/viewer/composables/serialization'
|
||||
import { useViewerRealtimeActivityTracker } from '~/lib/viewer/composables/activity'
|
||||
import { resourceBuilder } from '@speckle/shared/viewer/route'
|
||||
import { useEventBus } from '~/lib/core/composables/eventBus'
|
||||
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
|
||||
import { useTreeManagement } from '~~/lib/viewer/composables/tree'
|
||||
import type { SavedViewUrlSettings } from '~/lib/viewer/helpers/savedViews'
|
||||
|
||||
function useViewerLoadCompleteEventHandler() {
|
||||
const state = useInjectedViewerState()
|
||||
@@ -938,12 +937,13 @@ graphql(`
|
||||
|
||||
const useViewerSavedViewSetup = () => {
|
||||
const {
|
||||
savedViewId,
|
||||
resources: {
|
||||
request: { resourceIdString },
|
||||
response: { savedView, resolvedResourceIdString }
|
||||
request: {
|
||||
savedView: { id: savedViewId, loadOriginal }
|
||||
},
|
||||
response: { savedView }
|
||||
},
|
||||
urlHashState: { savedViewId: urlHashSavedViewId }
|
||||
urlHashState: { savedView: urlHashStateSavedViewSettings }
|
||||
} = useInjectedViewerState()
|
||||
const applyState = useApplySerializedState()
|
||||
const { serializedStateId } = useViewerRealtimeActivityTracker()
|
||||
@@ -955,68 +955,76 @@ const useViewerSavedViewSetup = () => {
|
||||
|
||||
const validState = (state: unknown) => (isSerializedViewerState(state) ? state : null)
|
||||
|
||||
const apply = async (state: SerializedViewerState) => {
|
||||
// Combine resolved w/ old, resolved taking precedence - we dont want to unload
|
||||
// other federated resources that are not a part of the saved view
|
||||
const combinedIdString = resourceBuilder()
|
||||
.addResources(resolvedResourceIdString.value)
|
||||
.addNew(resourceIdString.value)
|
||||
.toString()
|
||||
const apply = async () => {
|
||||
const state = validState(savedView.value?.viewerState)
|
||||
if (!state) return
|
||||
|
||||
await resourceIdString.update(combinedIdString)
|
||||
await applyState(state, StateApplyMode.SavedView)
|
||||
await applyState(state, StateApplyMode.SavedView, {
|
||||
loadOriginal: loadOriginal.value
|
||||
})
|
||||
savedViewStateId.value = serializedStateId.value
|
||||
}
|
||||
|
||||
const update = (params: { viewId?: string }) => {
|
||||
const update = async (params: { settings: SavedViewUrlSettings }) => {
|
||||
const { settings } = params
|
||||
|
||||
let reapplyState = true
|
||||
|
||||
// If passing in viewId and it differs, apply and wait for that to finish
|
||||
if (params.viewId && params.viewId !== savedViewId.value) {
|
||||
savedViewId.value = params.viewId
|
||||
return
|
||||
if (settings.id && settings.id !== savedViewId.value) {
|
||||
// wipe hash state, if any exists, otherwise the state will be stale
|
||||
await resetUrlHashState()
|
||||
savedViewId.value = settings.id
|
||||
reapplyState = false
|
||||
}
|
||||
|
||||
// Re-apply current state
|
||||
const state = validState(savedView.value?.viewerState)
|
||||
if (!state) return
|
||||
apply(state)
|
||||
// If changing loadOriginal value, apply and wait for that to finish
|
||||
if ((settings.loadOriginal || false) !== loadOriginal.value) {
|
||||
loadOriginal.value = settings.loadOriginal || false
|
||||
}
|
||||
|
||||
// Re-apply current state, if queued
|
||||
if (reapplyState && settings.id === savedViewId.value) {
|
||||
const state = validState(savedView.value?.viewerState)
|
||||
if (!state) return
|
||||
await apply()
|
||||
}
|
||||
}
|
||||
|
||||
const resetUrlHashState = async () => {
|
||||
await urlHashStateSavedViewSettings.update(null)
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
savedViewId.value = null
|
||||
loadOriginal.value = false
|
||||
savedViewStateId.value = undefined
|
||||
await resetUrlHashState()
|
||||
}
|
||||
|
||||
// Allow force update
|
||||
on(ViewerEventBusKeys.UpdateSavedView, (params) => {
|
||||
update(params)
|
||||
on(ViewerEventBusKeys.UpdateSavedView, async (settings) => {
|
||||
await update({ settings })
|
||||
})
|
||||
|
||||
// Apply saved view state on initial load
|
||||
useOnViewerLoadComplete(async ({ isInitial }) => {
|
||||
const state = validState(savedView.value?.viewerState)
|
||||
|
||||
if (isInitial && state) {
|
||||
await apply(state)
|
||||
if (isInitial) {
|
||||
await apply()
|
||||
}
|
||||
})
|
||||
|
||||
// Saved view changed, apply
|
||||
watch(savedView, (newVal, oldVal) => {
|
||||
watch(savedView, async (newVal, oldVal) => {
|
||||
if (!newVal || newVal.id === oldVal?.id) return
|
||||
|
||||
const state = validState(newVal.viewerState)
|
||||
if (!state) return
|
||||
|
||||
// If the saved view has changed, apply it
|
||||
apply(state)
|
||||
await apply()
|
||||
})
|
||||
|
||||
// If the URL hash saved view ID has changed, update the saved view ID
|
||||
watch(
|
||||
urlHashSavedViewId,
|
||||
async (newVal, oldVal) => {
|
||||
if (newVal === oldVal) return
|
||||
|
||||
savedViewId.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Did state change after applying saved view? Undo view
|
||||
watch(
|
||||
serializedStateId,
|
||||
@@ -1025,9 +1033,22 @@ const useViewerSavedViewSetup = () => {
|
||||
|
||||
// If the saved view state ID is different from the current serialized state ID, reset the saved view
|
||||
if (savedViewStateId.value && newVal !== savedViewStateId.value) {
|
||||
savedViewId.value = null
|
||||
void urlHashSavedViewId.update(null)
|
||||
savedViewStateId.value = undefined
|
||||
void reset()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Url hash state -> core source of truth sync
|
||||
watch(
|
||||
urlHashStateSavedViewSettings,
|
||||
async (newVal) => {
|
||||
if ((newVal?.id || null) !== savedViewId.value) {
|
||||
savedViewId.value = newVal?.id || null
|
||||
}
|
||||
|
||||
if ((newVal?.loadOriginal || false) !== loadOriginal.value) {
|
||||
loadOriginal.value = newVal?.loadOriginal || false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import {
|
||||
parseSavedViewUrlSettings,
|
||||
serializeSavedViewUrlSettings,
|
||||
type SavedViewUrlSettings
|
||||
} from '~/lib/viewer/helpers/savedViews'
|
||||
import { writableAsyncComputed } from '~~/lib/common/composables/async'
|
||||
import { useRouteHashState } from '~~/lib/common/composables/url'
|
||||
import type { InjectableViewerState } from '~~/lib/viewer/composables/setup'
|
||||
@@ -7,7 +13,7 @@ export enum ViewerHashStateKeys {
|
||||
FocusedThreadId = 'threadId',
|
||||
Diff = 'diff',
|
||||
EmbedOptions = 'embed',
|
||||
SavedViewId = 'savedViewId'
|
||||
SavedView = 'savedView'
|
||||
}
|
||||
|
||||
export function setupUrlHashState(): InjectableViewerState['urlHashState'] {
|
||||
@@ -41,12 +47,16 @@ export function setupUrlHashState(): InjectableViewerState['urlHashState'] {
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
const savedViewId = writableAsyncComputed({
|
||||
get: () => hashState.value[ViewerHashStateKeys.SavedViewId] || null,
|
||||
const savedView = writableAsyncComputed<Nullable<SavedViewUrlSettings>>({
|
||||
get: () => {
|
||||
const urlVal = hashState.value[ViewerHashStateKeys.SavedView]
|
||||
return parseSavedViewUrlSettings(urlVal)
|
||||
},
|
||||
set: async (newVal) => {
|
||||
const serialized = newVal ? serializeSavedViewUrlSettings(newVal) : null
|
||||
await hashState.update({
|
||||
...hashState.value,
|
||||
[ViewerHashStateKeys.SavedViewId]: newVal
|
||||
[ViewerHashStateKeys.SavedView]: serialized
|
||||
})
|
||||
},
|
||||
initialState: null,
|
||||
@@ -56,6 +66,6 @@ export function setupUrlHashState(): InjectableViewerState['urlHashState'] {
|
||||
return {
|
||||
focusedThreadId,
|
||||
diff,
|
||||
savedViewId
|
||||
savedView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ export const projectViewerResourcesQuery = graphql(`
|
||||
query ProjectViewerResources(
|
||||
$projectId: String!
|
||||
$resourceUrlString: String!
|
||||
$savedViewId: String
|
||||
$savedViewId: ID
|
||||
$savedViewSettings: SavedViewsLoadSettings
|
||||
) {
|
||||
project(id: $projectId) {
|
||||
id
|
||||
viewerResources(resourceIdString: $resourceUrlString, savedViewId: $savedViewId) {
|
||||
viewerResources(
|
||||
resourceIdString: $resourceUrlString
|
||||
savedViewId: $savedViewId
|
||||
savedViewSettings: $savedViewSettings
|
||||
) {
|
||||
identifier
|
||||
items {
|
||||
modelId
|
||||
@@ -16,15 +21,7 @@ export const projectViewerResourcesQuery = graphql(`
|
||||
objectId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const viewerActiveSavedViewQuery = graphql(`
|
||||
query ViewerActiveSavedView($projectId: String!, $savedViewId: ID!) {
|
||||
project(id: $projectId) {
|
||||
id
|
||||
savedView(id: $savedViewId) {
|
||||
savedViewIfExists(id: $savedViewId) {
|
||||
id
|
||||
...UseViewerSavedViewSetup_SavedView
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ViewerSavedViewEventBusPayloads } from '~/lib/viewer/helpers/savedViews'
|
||||
|
||||
export enum ViewerEventBusKeys {
|
||||
UpdateSavedView = 'aaa'
|
||||
}
|
||||
|
||||
// Add mappings between event keys and expected payloads here
|
||||
export type ViewerEventBusKeyPayloadMap = {
|
||||
[ViewerEventBusKeys.UpdateSavedView]: { viewId?: string }
|
||||
[ViewerEventBusKeys.UpdateSavedView]: ViewerSavedViewEventBusPayloads[ViewerEventBusKeys.UpdateSavedView]
|
||||
} & { [k in ViewerEventBusKeys]: unknown } & Record<string, unknown>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { StringEnumValues } from '@speckle/shared'
|
||||
import { isObjectLike, isString } from 'lodash-es'
|
||||
import type { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
|
||||
|
||||
export const ViewsType = {
|
||||
All: 'all-views',
|
||||
@@ -12,3 +14,38 @@ export const viewsTypeLabels: Record<ViewsType, string> = {
|
||||
[ViewsType.My]: 'My Views',
|
||||
[ViewsType.Connector]: 'From connectors'
|
||||
}
|
||||
|
||||
/**
|
||||
* Url hash state struct for saved views
|
||||
*/
|
||||
export type SavedViewUrlSettings = {
|
||||
id: string
|
||||
loadOriginal?: boolean
|
||||
}
|
||||
|
||||
export type ViewerSavedViewEventBusPayloads = {
|
||||
[ViewerEventBusKeys.UpdateSavedView]: SavedViewUrlSettings
|
||||
}
|
||||
|
||||
export const parseSavedViewUrlSettings = (
|
||||
settingsString: string | null
|
||||
): SavedViewUrlSettings | null => {
|
||||
if (!settingsString) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(settingsString)
|
||||
if (isObjectLike(parsed) && isString(parsed.id)) {
|
||||
return parsed as SavedViewUrlSettings
|
||||
}
|
||||
} catch {
|
||||
// suppress
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const serializeSavedViewUrlSettings = (
|
||||
settings: SavedViewUrlSettings
|
||||
): string => {
|
||||
return JSON.stringify(settings)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { until } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* Global state for if vue-router is in a pending navigation
|
||||
* (helps avoid race conditions in environments w/ many concurrent navigations like the viewer)
|
||||
*/
|
||||
export default defineNuxtPlugin(() => {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const logger = useLogger()
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
// Only drive on client
|
||||
if (import.meta.client) {
|
||||
router.beforeEach(() => {
|
||||
isNavigating.value = true
|
||||
})
|
||||
router.afterEach(async (to) => {
|
||||
const newPath = to.fullPath
|
||||
|
||||
try {
|
||||
await until(() => route.fullPath === newPath).toBeTruthy({
|
||||
timeout: 500,
|
||||
throwOnTimeout: true
|
||||
})
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Waiting for navigation to finalize failed')
|
||||
}
|
||||
|
||||
isNavigating.value = false
|
||||
})
|
||||
router.onError(() => {
|
||||
isNavigating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
provide: {
|
||||
isNavigating: computed(() => isNavigating.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -25,17 +25,6 @@ extend type Project {
|
||||
"""
|
||||
modelChildrenTree(fullName: String!): [ModelsTreeItem!]!
|
||||
"""
|
||||
Return metadata about resources being requested in the viewer
|
||||
"""
|
||||
viewerResources(
|
||||
resourceIdString: String!
|
||||
"""
|
||||
If a saved view ID is specified, the returned resources will be adjusted to return the view's resources instead
|
||||
"""
|
||||
savedViewId: String
|
||||
loadedVersionsOnly: Boolean = true
|
||||
): [ViewerResourceGroup!]!
|
||||
"""
|
||||
Returns a flat list of all project versions
|
||||
"""
|
||||
versions(limit: Int! = 25, cursor: String): VersionCollection!
|
||||
@@ -77,29 +66,6 @@ input ProjectModelsTreeFilter {
|
||||
contributors: [String!]
|
||||
}
|
||||
|
||||
type ViewerResourceGroup {
|
||||
"""
|
||||
Resource identifier used to refer to a collection of resource items
|
||||
"""
|
||||
identifier: String!
|
||||
"""
|
||||
Viewer resources that the identifier refers to
|
||||
"""
|
||||
items: [ViewerResourceItem!]!
|
||||
}
|
||||
|
||||
type ViewerResourceItem {
|
||||
"""
|
||||
Null if resource represents an object
|
||||
"""
|
||||
modelId: String
|
||||
"""
|
||||
Null if resource represents an object
|
||||
"""
|
||||
versionId: String
|
||||
objectId: String!
|
||||
}
|
||||
|
||||
type Model {
|
||||
id: ID!
|
||||
"""
|
||||
|
||||
@@ -125,6 +125,10 @@ extend type Project {
|
||||
savedViewGroup(id: ID!): SavedViewGroup!
|
||||
ungroupedViewGroup(input: GetUngroupedViewGroupInput!): SavedViewGroup!
|
||||
savedView(id: ID!): SavedView!
|
||||
"""
|
||||
Same as savedView(), but won't throw if view isn't found
|
||||
"""
|
||||
savedViewIfExists(id: ID): SavedView
|
||||
}
|
||||
|
||||
input CreateSavedViewInput {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
input SavedViewsLoadSettings {
|
||||
"""
|
||||
If true, load versions originally specified in the view, rather than the latest ones
|
||||
or ones already being loaded otherwise
|
||||
"""
|
||||
loadOriginal: Boolean
|
||||
}
|
||||
|
||||
type ViewerResourceGroup {
|
||||
"""
|
||||
Resource identifier used to refer to a collection of resource items
|
||||
"""
|
||||
identifier: String!
|
||||
"""
|
||||
Viewer resources that the identifier refers to
|
||||
"""
|
||||
items: [ViewerResourceItem!]!
|
||||
}
|
||||
|
||||
type ViewerResourceItem {
|
||||
"""
|
||||
Null if resource represents an object
|
||||
"""
|
||||
modelId: String
|
||||
"""
|
||||
Null if resource represents an object
|
||||
"""
|
||||
versionId: String
|
||||
objectId: String!
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
"""
|
||||
Return metadata about resources being requested in the viewer
|
||||
"""
|
||||
viewerResources(
|
||||
resourceIdString: String!
|
||||
"""
|
||||
If a saved view ID is specified, the returned resources will be adjusted to return the view's resources instead
|
||||
"""
|
||||
savedViewId: ID
|
||||
savedViewSettings: SavedViewsLoadSettings
|
||||
loadedVersionsOnly: Boolean = true
|
||||
): [ViewerResourceGroup!]!
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
getViewerResourceGroupsFactory,
|
||||
getViewerResourceItemsUngroupedFactory
|
||||
} from '@/modules/viewer/services/viewerResources'
|
||||
import { getSavedViewFactory } from '@/modules/viewer/repositories/savedViews'
|
||||
|
||||
const command: CommandModule<
|
||||
unknown,
|
||||
@@ -118,7 +119,8 @@ const command: CommandModule<
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db: projectDb }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDb }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db: projectDb }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb })
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb }),
|
||||
getSavedView: getSavedViewFactory({ db: projectDb })
|
||||
})
|
||||
})
|
||||
const getViewerResourcesFromLegacyIdentifiers =
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
getViewerResourceGroupsFactory,
|
||||
getViewerResourceItemsUngroupedFactory
|
||||
} from '@/modules/viewer/services/viewerResources'
|
||||
import { getSavedViewFactory } from '@/modules/viewer/repositories/savedViews'
|
||||
|
||||
const command: CommandModule<
|
||||
unknown,
|
||||
@@ -146,7 +147,8 @@ const command: CommandModule<
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db: projectDb }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDb }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db: projectDb }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb })
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb }),
|
||||
getSavedView: getSavedViewFactory({ db: projectDb })
|
||||
})
|
||||
})
|
||||
const getViewerResourcesFromLegacyIdentifiers =
|
||||
|
||||
@@ -96,6 +96,8 @@ import {
|
||||
getViewerResourceGroupsFactory,
|
||||
getViewerResourceItemsUngroupedFactory
|
||||
} from '@/modules/viewer/services/viewerResources'
|
||||
import type { RequestDataLoaders } from '@/modules/core/loaders'
|
||||
import { getSavedViewFactory } from '@/modules/viewer/repositories/dataLoaders/savedViews'
|
||||
|
||||
// We can use the main DB for these
|
||||
const getStream = getStreamFactory({ db })
|
||||
@@ -114,7 +116,10 @@ const buildGetViewerResourcesFromLegacyIdentifiers = (deps: { db: Knex }) => {
|
||||
return getViewerResourcesFromLegacyIdentifiers
|
||||
}
|
||||
|
||||
const buildGetViewerResourceItemsUngrouped = (deps: { db: Knex }) =>
|
||||
const buildGetViewerResourceItemsUngrouped = (deps: {
|
||||
db: Knex
|
||||
loaders: RequestDataLoaders
|
||||
}) =>
|
||||
getViewerResourceItemsUngroupedFactory({
|
||||
getViewerResourceGroups: getViewerResourceGroupsFactory({
|
||||
getStreamObjects: getStreamObjectsFactory(deps),
|
||||
@@ -122,7 +127,8 @@ const buildGetViewerResourceItemsUngrouped = (deps: { db: Knex }) =>
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory(deps),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory(deps),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory(deps),
|
||||
getBranchesByIds: getBranchesByIdsFactory(deps)
|
||||
getBranchesByIds: getBranchesByIdsFactory(deps),
|
||||
getSavedView: getSavedViewFactory(deps)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -540,7 +546,8 @@ export default {
|
||||
const projectDb = await getProjectDbClient({ projectId })
|
||||
|
||||
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
|
||||
db: projectDb
|
||||
db: projectDb,
|
||||
loaders: ctx.loaders
|
||||
})
|
||||
|
||||
const validateInputAttachments = validateInputAttachmentsFactory({
|
||||
@@ -709,7 +716,8 @@ export default {
|
||||
|
||||
const projectDb = await getProjectDbClient({ projectId })
|
||||
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
|
||||
db: projectDb
|
||||
db: projectDb,
|
||||
loaders: context.loaders
|
||||
})
|
||||
|
||||
await publish(ViewerSubscriptions.UserActivityBroadcasted, {
|
||||
@@ -1058,7 +1066,8 @@ export default {
|
||||
|
||||
const projectDb = await getProjectDbClient({ projectId: payload.projectId })
|
||||
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
|
||||
db: projectDb
|
||||
db: projectDb,
|
||||
loaders: context.loaders
|
||||
})
|
||||
const requestedResourceItems = await getViewerResourceItemsUngrouped(target)
|
||||
|
||||
@@ -1094,7 +1103,8 @@ export default {
|
||||
|
||||
const projectDb = await getProjectDbClient({ projectId: payload.projectId })
|
||||
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
|
||||
db: projectDb
|
||||
db: projectDb,
|
||||
loaders: context.loaders
|
||||
})
|
||||
|
||||
const requestedResourceItems = await getViewerResourceItemsUngrouped(target)
|
||||
|
||||
@@ -2362,6 +2362,8 @@ export type Project = {
|
||||
savedView: SavedView;
|
||||
savedViewGroup: SavedViewGroup;
|
||||
savedViewGroups: SavedViewGroupCollection;
|
||||
/** Same as savedView(), but won't throw if view isn't found */
|
||||
savedViewIfExists?: Maybe<SavedView>;
|
||||
/** Source apps used in any models of this project */
|
||||
sourceApps: Array<Scalars['String']['output']>;
|
||||
team: Array<ProjectCollaborator>;
|
||||
@@ -2504,6 +2506,11 @@ export type ProjectSavedViewGroupsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type ProjectSavedViewIfExistsArgs = {
|
||||
id?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type ProjectUngroupedViewGroupArgs = {
|
||||
input: GetUngroupedViewGroupInput;
|
||||
};
|
||||
@@ -2523,7 +2530,8 @@ export type ProjectVersionsArgs = {
|
||||
export type ProjectViewerResourcesArgs = {
|
||||
loadedVersionsOnly?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
resourceIdString: Scalars['String']['input'];
|
||||
savedViewId?: InputMaybe<Scalars['String']['input']>;
|
||||
savedViewId?: InputMaybe<Scalars['ID']['input']>;
|
||||
savedViewSettings?: InputMaybe<SavedViewsLoadSettings>;
|
||||
};
|
||||
|
||||
|
||||
@@ -3550,6 +3558,14 @@ export const SavedViewVisibility = {
|
||||
} as const;
|
||||
|
||||
export type SavedViewVisibility = typeof SavedViewVisibility[keyof typeof SavedViewVisibility];
|
||||
export type SavedViewsLoadSettings = {
|
||||
/**
|
||||
* If true, load versions originally specified in the view, rather than the latest ones
|
||||
* or ones already being loaded otherwise
|
||||
*/
|
||||
loadOriginal?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
/** Available scopes. */
|
||||
export type Scope = {
|
||||
__typename?: 'Scope';
|
||||
@@ -6027,6 +6043,7 @@ export type ResolversTypes = {
|
||||
SavedViewMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
SavedViewPermissionChecks: ResolverTypeWrapper<SavedViewPermissionChecksGraphQLReturn>;
|
||||
SavedViewVisibility: SavedViewVisibility;
|
||||
SavedViewsLoadSettings: SavedViewsLoadSettings;
|
||||
Scope: ResolverTypeWrapper<Scope>;
|
||||
ServerApp: ResolverTypeWrapper<ServerAppGraphQLReturn>;
|
||||
ServerAppListItem: ResolverTypeWrapper<ServerAppListItemGraphQLReturn>;
|
||||
@@ -6384,6 +6401,7 @@ export type ResolversParentTypes = {
|
||||
SavedViewGroupsInput: SavedViewGroupsInput;
|
||||
SavedViewMutations: MutationsObjectGraphQLReturn;
|
||||
SavedViewPermissionChecks: SavedViewPermissionChecksGraphQLReturn;
|
||||
SavedViewsLoadSettings: SavedViewsLoadSettings;
|
||||
Scope: Scope;
|
||||
ServerApp: ServerAppGraphQLReturn;
|
||||
ServerAppListItem: ServerAppListItemGraphQLReturn;
|
||||
@@ -7428,6 +7446,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
|
||||
savedView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<ProjectSavedViewArgs, 'id'>>;
|
||||
savedViewGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<ProjectSavedViewGroupArgs, 'id'>>;
|
||||
savedViewGroups?: Resolver<ResolversTypes['SavedViewGroupCollection'], ParentType, ContextType, RequireFields<ProjectSavedViewGroupsArgs, 'input'>>;
|
||||
savedViewIfExists?: Resolver<Maybe<ResolversTypes['SavedView']>, ParentType, ContextType, Partial<ProjectSavedViewIfExistsArgs>>;
|
||||
sourceApps?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
team?: Resolver<Array<ResolversTypes['ProjectCollaborator']>, ParentType, ContextType>;
|
||||
ungroupedViewGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<ProjectUngroupedViewGroupArgs, 'input'>>;
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
createBranchFactory,
|
||||
deleteBranchByIdFactory,
|
||||
getBranchByIdFactory,
|
||||
getBranchesByIdsFactory,
|
||||
getBranchLatestCommitsFactory,
|
||||
getModelTreeItemsFactory,
|
||||
getModelTreeItemsFilteredFactory,
|
||||
getModelTreeItemsFilteredTotalCountFactory,
|
||||
@@ -31,14 +29,11 @@ import {
|
||||
getPaginatedProjectModelsItemsFactory,
|
||||
getPaginatedProjectModelsTotalCountFactory,
|
||||
getStreamBranchByNameFactory,
|
||||
getStreamBranchesByNameFactory,
|
||||
updateBranchFactory
|
||||
} from '@/modules/core/repositories/branches'
|
||||
import { BranchNotFoundError } from '@/modules/core/errors/branch'
|
||||
import { CommitNotFoundError } from '@/modules/core/errors/commit'
|
||||
import { getStreamObjectsFactory } from '@/modules/core/repositories/objects'
|
||||
import {
|
||||
getAllBranchCommitsFactory,
|
||||
getBranchCommitsTotalCountFactory,
|
||||
getPaginatedBranchCommitsItemsFactory,
|
||||
getSpecificBranchCommitsFactory,
|
||||
@@ -59,13 +54,6 @@ import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
|
||||
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
|
||||
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
|
||||
import { withOperationLogging } from '@/observability/domain/businessLogging'
|
||||
import { getViewerResourceGroupsFactory } from '@/modules/viewer/services/viewerResources'
|
||||
import { NotFoundError } from '@/modules/shared/errors'
|
||||
import {
|
||||
isModelResource,
|
||||
resourceBuilder,
|
||||
ViewerModelResource
|
||||
} from '@speckle/shared/viewer/route'
|
||||
|
||||
export default {
|
||||
User: {
|
||||
@@ -170,63 +158,6 @@ export default {
|
||||
}
|
||||
)
|
||||
},
|
||||
async viewerResources(
|
||||
parent,
|
||||
{ resourceIdString, loadedVersionsOnly, savedViewId },
|
||||
ctx
|
||||
) {
|
||||
const projectDB = await getProjectDbClient({ projectId: parent.id })
|
||||
const getStreamObjects = getStreamObjectsFactory({ db: projectDB })
|
||||
const getViewerResourceGroups = getViewerResourceGroupsFactory({
|
||||
getStreamObjects,
|
||||
getBranchLatestCommits: getBranchLatestCommitsFactory({ db: projectDB }),
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db: projectDB }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDB }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db: projectDB }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDB })
|
||||
})
|
||||
|
||||
// Saved View: By default load already specified versions were available,
|
||||
// otherwise load latest versions
|
||||
if (savedViewId) {
|
||||
const savedView = await ctx.loaders
|
||||
.forRegion({ db: projectDB })
|
||||
.savedViews.getSavedView.load({ viewId: savedViewId, projectId: parent.id })
|
||||
if (!savedView) {
|
||||
throw new NotFoundError(
|
||||
`Saved view with ID ${savedViewId} not found in project ${parent.id}`
|
||||
)
|
||||
}
|
||||
|
||||
const savedViewResources = resourceBuilder().addFromString(
|
||||
savedView.resourceIds
|
||||
)
|
||||
const baseResources = resourceBuilder().addFromString(resourceIdString)
|
||||
const finalSavedViewResources = savedViewResources.map((r) => {
|
||||
if (!isModelResource(r) || !r.versionId) {
|
||||
return r
|
||||
}
|
||||
|
||||
const matchingBaseResource = baseResources
|
||||
.filter(isModelResource)
|
||||
.find((r2) => {
|
||||
return r2.modelId === r.modelId
|
||||
})
|
||||
|
||||
return new ViewerModelResource(r.modelId, matchingBaseResource?.versionId)
|
||||
})
|
||||
|
||||
resourceIdString = resourceBuilder()
|
||||
.addResources(finalSavedViewResources)
|
||||
.toString()
|
||||
}
|
||||
|
||||
return await getViewerResourceGroups({
|
||||
projectId: parent.id,
|
||||
resourceIdString,
|
||||
loadedVersionsOnly
|
||||
})
|
||||
},
|
||||
async versions(parent, args, ctx) {
|
||||
const projectDB = await getProjectDbClient({ projectId: parent.id })
|
||||
// If limit=0, short-cut full execution and use data loader
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
getViewerResourceGroupsFactory,
|
||||
getViewerResourceItemsUngroupedFactory
|
||||
} from '@/modules/viewer/services/viewerResources'
|
||||
import { getSavedViewFactory } from '@/modules/viewer/repositories/savedViews'
|
||||
|
||||
const crossServerSyncModule: SpeckleModule = {
|
||||
init() {
|
||||
@@ -100,7 +101,8 @@ const crossServerSyncModule: SpeckleModule = {
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db })
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db }),
|
||||
getSavedView: getSavedViewFactory({ db })
|
||||
})
|
||||
})
|
||||
const getViewerResourcesFromLegacyIdentifiers =
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import type { ViewerUpdateTrackingTarget } from '@/modules/core/graph/generated/graphql'
|
||||
import type {
|
||||
SavedViewsLoadSettings,
|
||||
ViewerUpdateTrackingTarget
|
||||
} from '@/modules/core/graph/generated/graphql'
|
||||
import type {
|
||||
ViewerResourceGroup,
|
||||
ViewerResourceItem
|
||||
} from '@/modules/viewer/domain/types/resources'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
export type GetViewerResourceGroupsParams = ViewerUpdateTrackingTarget & {
|
||||
/**
|
||||
* By default this only returns groups w/ resources in them. W/ this flag set, it will also
|
||||
* return valid model groups that have no resources in them
|
||||
*/
|
||||
allowEmptyModels?: boolean
|
||||
/**
|
||||
* Saved view being applied makes the resources be loaded differently
|
||||
*/
|
||||
savedViewId?: MaybeNullOrUndefined<string>
|
||||
savedViewSettings?: MaybeNullOrUndefined<SavedViewsLoadSettings>
|
||||
}
|
||||
|
||||
export type GetViewerResourceGroups = (
|
||||
target: ViewerUpdateTrackingTarget & { allowEmptyModels?: boolean }
|
||||
target: GetViewerResourceGroupsParams
|
||||
) => Promise<ViewerResourceGroup[]>
|
||||
|
||||
export type GetViewerResourceItemsUngrouped = (
|
||||
target: ViewerUpdateTrackingTarget
|
||||
target: GetViewerResourceGroupsParams
|
||||
) => Promise<ViewerResourceItem[]>
|
||||
|
||||
@@ -48,8 +48,12 @@ import {
|
||||
getSavedViewFactory,
|
||||
getSavedViewGroupFactory
|
||||
} from '@/modules/viewer/repositories/dataLoaders/savedViews'
|
||||
import type { RequestDataLoaders } from '@/modules/core/loaders'
|
||||
|
||||
const buildGetViewerResourceGroups = (params: { projectDb: Knex }) => {
|
||||
const buildGetViewerResourceGroups = (params: {
|
||||
projectDb: Knex
|
||||
loaders: RequestDataLoaders
|
||||
}) => {
|
||||
const { projectDb } = params
|
||||
return getViewerResourceGroupsFactory({
|
||||
getStreamObjects: getStreamObjectsFactory({ db: projectDb }),
|
||||
@@ -57,7 +61,8 @@ const buildGetViewerResourceGroups = (params: { projectDb: Knex }) => {
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db: projectDb }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDb }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db: projectDb }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb })
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb }),
|
||||
getSavedView: getSavedViewFactory({ loaders: params.loaders })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,6 +130,19 @@ const resolvers: Resolvers = {
|
||||
)
|
||||
}
|
||||
|
||||
return view
|
||||
},
|
||||
savedViewIfExists: async (parent, args, ctx) => {
|
||||
if (!args.id?.length) return null
|
||||
|
||||
const projectDb = await getProjectDbClient({ projectId: parent.id })
|
||||
const view = await ctx.loaders
|
||||
.forRegion({ db: projectDb })
|
||||
.savedViews.getSavedView.load({
|
||||
viewId: args.id,
|
||||
projectId: parent.id
|
||||
})
|
||||
|
||||
return view
|
||||
}
|
||||
},
|
||||
@@ -221,7 +239,10 @@ const resolvers: Resolvers = {
|
||||
|
||||
const projectDb = await getProjectDbClient({ projectId })
|
||||
const createSavedView = createSavedViewFactory({
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({ projectDb }),
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({
|
||||
projectDb,
|
||||
loaders: ctx.loaders
|
||||
}),
|
||||
getStoredViewCount: getStoredViewCountFactory({ db: projectDb }),
|
||||
storeSavedView: storeSavedViewFactory({ db: projectDb }),
|
||||
getSavedViewGroup: getSavedViewGroupFactory({ loaders: ctx.loaders }),
|
||||
@@ -278,7 +299,10 @@ const resolvers: Resolvers = {
|
||||
throwIfAuthNotOk(canUpdate)
|
||||
|
||||
const updateSavedView = updateSavedViewFactory({
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({ projectDb }),
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({
|
||||
projectDb,
|
||||
loaders: ctx.loaders
|
||||
}),
|
||||
getSavedView: getSavedViewFactory({ loaders: ctx.loaders }),
|
||||
getSavedViewGroup: getSavedViewGroupFactory({ loaders: ctx.loaders }),
|
||||
updateSavedViewRecord: updateSavedViewRecordFactory({
|
||||
@@ -325,7 +349,10 @@ const resolvers: Resolvers = {
|
||||
const projectDb = await getProjectDbClient({ projectId })
|
||||
const createSavedViewGroup = createSavedViewGroupFactory({
|
||||
storeSavedViewGroup: storeSavedViewGroupFactory({ db: projectDb }),
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({ projectDb })
|
||||
getViewerResourceGroups: buildGetViewerResourceGroups({
|
||||
projectDb,
|
||||
loaders: ctx.loaders
|
||||
})
|
||||
})
|
||||
return await createSavedViewGroup({
|
||||
input: args.input,
|
||||
@@ -359,6 +386,9 @@ const disabledResolvers: Resolvers = {
|
||||
},
|
||||
savedView: () => {
|
||||
throw new NotImplementedError(disabledMessage)
|
||||
},
|
||||
savedViewIfExists: () => {
|
||||
return null // intentional - so we dont have to FF guard the query
|
||||
}
|
||||
},
|
||||
ProjectMutations: {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
import {
|
||||
getBranchesByIdsFactory,
|
||||
getBranchLatestCommitsFactory,
|
||||
getStreamBranchesByNameFactory
|
||||
} from '@/modules/core/repositories/branches'
|
||||
import {
|
||||
getAllBranchCommitsFactory,
|
||||
getSpecificBranchCommitsFactory
|
||||
} from '@/modules/core/repositories/commits'
|
||||
import { getStreamObjectsFactory } from '@/modules/core/repositories/objects'
|
||||
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
|
||||
import { getSavedViewFactory } from '@/modules/viewer/repositories/dataLoaders/savedViews'
|
||||
import { getViewerResourceGroupsFactory } from '@/modules/viewer/services/viewerResources'
|
||||
|
||||
const resolvers: Resolvers = {
|
||||
Project: {
|
||||
async viewerResources(
|
||||
parent,
|
||||
{ resourceIdString, loadedVersionsOnly, savedViewId, savedViewSettings },
|
||||
ctx
|
||||
) {
|
||||
const projectDB = await getProjectDbClient({ projectId: parent.id })
|
||||
const getStreamObjects = getStreamObjectsFactory({ db: projectDB })
|
||||
const getViewerResourceGroups = getViewerResourceGroupsFactory({
|
||||
getStreamObjects,
|
||||
getBranchLatestCommits: getBranchLatestCommitsFactory({ db: projectDB }),
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db: projectDB }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDB }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db: projectDB }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDB }),
|
||||
getSavedView: getSavedViewFactory({ loaders: ctx.loaders })
|
||||
})
|
||||
|
||||
return await getViewerResourceGroups({
|
||||
projectId: parent.id,
|
||||
resourceIdString,
|
||||
loadedVersionsOnly,
|
||||
savedViewId,
|
||||
savedViewSettings
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default resolvers
|
||||
@@ -16,7 +16,8 @@ import type {
|
||||
StoreSavedViewGroup,
|
||||
GetSavedViews,
|
||||
DeleteSavedViewRecord,
|
||||
UpdateSavedViewRecord
|
||||
UpdateSavedViewRecord,
|
||||
GetSavedView
|
||||
} from '@/modules/viewer/domain/operations/savedViews'
|
||||
import {
|
||||
SavedViewVisibility,
|
||||
@@ -486,6 +487,14 @@ export const getSavedViewsFactory =
|
||||
return viewsMap
|
||||
}
|
||||
|
||||
export const getSavedViewFactory =
|
||||
(deps: { db: Knex }): GetSavedView =>
|
||||
async ({ id, projectId }) => {
|
||||
const getSavedViews = getSavedViewsFactory(deps)
|
||||
const savedViews = await getSavedViews({ viewIds: [{ viewId: id, projectId }] })
|
||||
return savedViews[id]
|
||||
}
|
||||
|
||||
export const deleteSavedViewRecordFactory =
|
||||
(deps: { db: Knex }): DeleteSavedViewRecord =>
|
||||
async (params) => {
|
||||
|
||||
@@ -9,18 +9,25 @@ import type {
|
||||
} from '@/modules/core/domain/commits/operations'
|
||||
import type { GetStreamObjects } from '@/modules/core/domain/objects/operations'
|
||||
import type {
|
||||
SavedViewsLoadSettings,
|
||||
ViewerResourceGroup,
|
||||
ViewerResourceItem,
|
||||
ViewerUpdateTrackingTarget
|
||||
ViewerResourceItem
|
||||
} from '@/modules/core/graph/generated/graphql'
|
||||
import type { CommitRecord } from '@/modules/core/helpers/types'
|
||||
import { NotFoundError } from '@/modules/shared/errors'
|
||||
import type { DependenciesOf } from '@/modules/shared/helpers/factory'
|
||||
import type {
|
||||
GetViewerResourceGroups,
|
||||
GetViewerResourceItemsUngrouped
|
||||
} from '@/modules/viewer/domain/operations/resources'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import type { GetSavedView } from '@/modules/viewer/domain/operations/savedViews'
|
||||
import type { MaybeNullOrUndefined, Optional } from '@speckle/shared'
|
||||
import { SpeckleViewer } from '@speckle/shared'
|
||||
import type { ViewerModelResource } from '@speckle/shared/viewer/route'
|
||||
import {
|
||||
isModelResource,
|
||||
resourceBuilder,
|
||||
ViewerModelResource
|
||||
} from '@speckle/shared/viewer/route'
|
||||
import { flatten, keyBy, uniq, uniqWith } from 'lodash-es'
|
||||
|
||||
export function isResourceItemEqual(a: ViewerResourceItem, b: ViewerResourceItem) {
|
||||
@@ -324,24 +331,78 @@ const getVersionResourceGroupsFactory =
|
||||
return [...(allModelsGroup ? [allModelsGroup] : []), ...groups]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve final resourceIdString based on the saved view and its load settings
|
||||
*/
|
||||
const adjustResourceIdStringWithSavedViewSettingsFactory =
|
||||
(deps: { getSavedView: GetSavedView }) =>
|
||||
async (params: {
|
||||
projectId: string
|
||||
resourceIdString: string
|
||||
savedViewId: string
|
||||
savedViewSettings: MaybeNullOrUndefined<SavedViewsLoadSettings>
|
||||
}): Promise<string> => {
|
||||
const { resourceIdString, projectId, savedViewId, savedViewSettings } = params
|
||||
const { loadOriginal } = savedViewSettings || {}
|
||||
|
||||
const savedView = await deps.getSavedView({
|
||||
id: savedViewId,
|
||||
projectId
|
||||
})
|
||||
if (!savedView) {
|
||||
throw new NotFoundError(
|
||||
`Saved view with ID ${savedViewId} not found in project ${projectId}`
|
||||
)
|
||||
}
|
||||
|
||||
const savedViewResources = resourceBuilder().addFromString(savedView.resourceIds)
|
||||
const baseResources = resourceBuilder().addFromString(resourceIdString)
|
||||
const finalSavedViewResources = savedViewResources.map((r) => {
|
||||
if (!isModelResource(r) || !r.versionId) {
|
||||
return r
|
||||
}
|
||||
|
||||
const matchingBaseResource = baseResources.filter(isModelResource).find((r2) => {
|
||||
return r2.modelId === r.modelId
|
||||
})
|
||||
const versionId = loadOriginal ? r.versionId : matchingBaseResource?.versionId
|
||||
return new ViewerModelResource(r.modelId, versionId)
|
||||
})
|
||||
|
||||
return resourceBuilder().addResources(finalSavedViewResources).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate requested resource identifiers and build viewer resource groups & items with
|
||||
* the metadata that the viewer needs to work with these
|
||||
*/
|
||||
export const getViewerResourceGroupsFactory =
|
||||
(
|
||||
deps: GetObjectResourceGroupsDeps & GetVersionResourceGroupsDeps
|
||||
deps: GetObjectResourceGroupsDeps &
|
||||
GetVersionResourceGroupsDeps &
|
||||
DependenciesOf<typeof adjustResourceIdStringWithSavedViewSettingsFactory>
|
||||
): GetViewerResourceGroups =>
|
||||
async (
|
||||
target: ViewerUpdateTrackingTarget & {
|
||||
/**
|
||||
* By default this only returns groups w/ resources in them. W/ this flag set, it will also
|
||||
* return valid model groups that have no resources in them
|
||||
*/
|
||||
allowEmptyModels?: boolean
|
||||
async (params): Promise<ViewerResourceGroup[]> => {
|
||||
const {
|
||||
projectId,
|
||||
loadedVersionsOnly,
|
||||
allowEmptyModels,
|
||||
savedViewId,
|
||||
savedViewSettings
|
||||
} = params
|
||||
|
||||
let resourceIdString = params.resourceIdString
|
||||
if (savedViewId) {
|
||||
resourceIdString = await adjustResourceIdStringWithSavedViewSettingsFactory(deps)(
|
||||
{
|
||||
resourceIdString,
|
||||
projectId,
|
||||
savedViewId,
|
||||
savedViewSettings
|
||||
}
|
||||
)
|
||||
}
|
||||
): Promise<ViewerResourceGroup[]> => {
|
||||
const { resourceIdString, projectId, loadedVersionsOnly, allowEmptyModels } = target
|
||||
|
||||
if (!resourceIdString?.trim().length) return []
|
||||
const resources = SpeckleViewer.ViewerRoute.parseUrlParameters(resourceIdString)
|
||||
|
||||
@@ -374,12 +435,12 @@ export const getViewerResourceItemsUngroupedFactory =
|
||||
(deps: {
|
||||
getViewerResourceGroups: GetViewerResourceGroups
|
||||
}): GetViewerResourceItemsUngrouped =>
|
||||
async (target: ViewerUpdateTrackingTarget): Promise<ViewerResourceItem[]> => {
|
||||
const { resourceIdString } = target
|
||||
async (params): Promise<ViewerResourceItem[]> => {
|
||||
const { resourceIdString } = params
|
||||
if (!resourceIdString?.trim().length) return []
|
||||
|
||||
let results: ViewerResourceItem[] = []
|
||||
const groups = await deps.getViewerResourceGroups(target)
|
||||
const groups = await deps.getViewerResourceGroups(params)
|
||||
for (const group of groups) {
|
||||
results = results.concat(group.items)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/modules/core/repositories/commits'
|
||||
import { getStreamObjectsFactory } from '@/modules/core/repositories/objects'
|
||||
import { buildBasicTestProject } from '@/modules/core/tests/helpers/creation'
|
||||
import { getSavedViewFactory } from '@/modules/viewer/repositories/savedViews'
|
||||
import {
|
||||
doViewerResourcesFit,
|
||||
getViewerResourceGroupsFactory,
|
||||
@@ -52,7 +53,8 @@ describe('Viewer Resources Collection Service', () => {
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db })
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db }),
|
||||
getSavedView: getSavedViewFactory({ db })
|
||||
})
|
||||
|
||||
const allVersions = (): BasicTestCommit[] => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/modules/core/repositories/commits'
|
||||
import { getStreamObjectsFactory } from '@/modules/core/repositories/objects'
|
||||
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
|
||||
import { getSavedViewFactory } from '@/modules/viewer/repositories/savedViews'
|
||||
import {
|
||||
getViewerResourceGroupsFactory,
|
||||
getViewerResourceItemsUngroupedFactory
|
||||
@@ -44,7 +45,8 @@ export const createTestComment = async (
|
||||
getStreamBranchesByName: getStreamBranchesByNameFactory({ db: projectDb }),
|
||||
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDb }),
|
||||
getAllBranchCommits: getAllBranchCommitsFactory({ db: projectDb }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb })
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb }),
|
||||
getSavedView: getSavedViewFactory({ db: projectDb })
|
||||
})
|
||||
}),
|
||||
validateInputAttachments: validateInputAttachmentsFactory({
|
||||
|
||||
@@ -256,6 +256,12 @@ class ViewerResourceBuilder implements Iterable<ViewerResource> {
|
||||
get length() {
|
||||
return this.#resources.length
|
||||
}
|
||||
|
||||
isEqualTo(resource: ViewerResourcesTarget) {
|
||||
const incomingBuilder = resourceBuilder().addResources(resource)
|
||||
return this.toString() === incomingBuilder.toString()
|
||||
}
|
||||
|
||||
forEach(callback: (resource: ViewerResource) => void) {
|
||||
this.#resources.forEach(callback)
|
||||
return this
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dirname, join } from 'path'
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite'
|
||||
import { get, isObjectLike } from 'lodash'
|
||||
import { get, isObjectLike } from '#lodash'
|
||||
|
||||
function getAbsolutePath<V extends string = string>(value: V): V {
|
||||
return dirname(require.resolve(join(value, 'package.json'))) as V
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import tseslint from 'typescript-eslint'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'
|
||||
import { omit } from 'lodash-es'
|
||||
import { omit } from '#lodash'
|
||||
|
||||
const tsParserOptions = {
|
||||
tsconfigRootDir: getESMDirname(import.meta.url),
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
InformationCircleIcon,
|
||||
ExclamationCircleIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { noop } from 'lodash'
|
||||
import { noop } from '#lodash'
|
||||
import { computed, useSlots, type SetupContext } from 'vue'
|
||||
import FormButton from '~~/src/components/form/Button.vue'
|
||||
import type {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</Component>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isObjectLike } from 'lodash'
|
||||
import { isObjectLike } from '#lodash'
|
||||
import type { PropAnyComponent } from '~~/src/helpers/common/components'
|
||||
import { computed, resolveDynamicComponent } from 'vue'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { wait } from '@speckle/shared'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { times } from 'lodash'
|
||||
import { times } from '#lodash'
|
||||
import { useForm } from 'vee-validate'
|
||||
import FormButton from '~~/src/components/form/Button.vue'
|
||||
import FormSelectTags from '~~/src/components/form/Tags.vue'
|
||||
|
||||
@@ -147,7 +147,7 @@ import {
|
||||
TransitionRoot
|
||||
} from '@headlessui/vue'
|
||||
import { CheckIcon, XMarkIcon, ExclamationCircleIcon } from '@heroicons/vue/20/solid'
|
||||
import { debounce, uniq } from 'lodash'
|
||||
import { debounce, uniq } from '#lodash'
|
||||
import { useTextInputCore } from '~~/src/composables/form/textInput'
|
||||
import type { InputColor } from '~~/src/composables/form/textInput'
|
||||
import type { RuleExpression } from 'vee-validate'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { wait } from '@speckle/shared'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { omit } from 'lodash'
|
||||
import { omit } from '#lodash'
|
||||
import FormSelectBase from '~~/src/components/form/select/Base.vue'
|
||||
import { isRequired } from '~~/src/helpers/common/validation'
|
||||
import LayoutDialog from '~~/src/components/layout/Dialog.vue'
|
||||
|
||||
@@ -243,7 +243,7 @@ import {
|
||||
XMarkIcon,
|
||||
ExclamationCircleIcon
|
||||
} from '@heroicons/vue/20/solid'
|
||||
import { debounce, isArray, isObjectLike } from 'lodash'
|
||||
import { debounce, isArray, isObjectLike } from '#lodash'
|
||||
import type { CSSProperties, PropType, Ref } from 'vue'
|
||||
import { computed, onMounted, ref, unref, watch } from 'vue'
|
||||
import type { MaybeAsync, Nullable, Optional } from '@speckle/shared'
|
||||
|
||||
@@ -219,7 +219,7 @@ import {
|
||||
XMarkIcon,
|
||||
ExclamationCircleIcon
|
||||
} from '@heroicons/vue/20/solid'
|
||||
import { debounce, isArray, isObjectLike } from 'lodash'
|
||||
import { debounce, isArray, isObjectLike } from '#lodash'
|
||||
import type { CSSProperties, PropType, Ref } from 'vue'
|
||||
import { computed, onMounted, ref, unref, watch, nextTick } from 'vue'
|
||||
import type { MaybeAsync, Nullable, Optional } from '@speckle/shared'
|
||||
|
||||
@@ -139,7 +139,7 @@ import { FormButton, type LayoutDialogButton } from '~~/src/lib'
|
||||
import { XMarkIcon, ChevronLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { isClient, useResizeObserver, type ResizeObserverCallback } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref, useSlots, watch, type SetupContext } from 'vue'
|
||||
import { throttle } from 'lodash'
|
||||
import { throttle } from '#lodash'
|
||||
import { directive as vTippy } from 'vue-tippy'
|
||||
|
||||
type MaxWidthValue = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import type { LayoutMenuItem } from '~~/src/helpers/layout/components'
|
||||
import { useElementBounding, useEventListener } from '@vueuse/core'
|
||||
import { useBodyMountedMenuPositioning } from '~~/src/composables/layout/menu'
|
||||
import { isNumber } from '#lodash'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', val: boolean): void
|
||||
@@ -66,7 +67,7 @@ const props = defineProps<{
|
||||
* 2D array so that items can be grouped with dividers between them
|
||||
*/
|
||||
items: LayoutMenuItem<MenuIds>[][]
|
||||
size?: 'base' | 'lg'
|
||||
size?: 'base' | 'lg' | number
|
||||
menuId?: string
|
||||
/**
|
||||
* Preferable menu position/directed. This can change depending on available space.
|
||||
@@ -108,6 +109,8 @@ const { menuStyle } = useBodyMountedMenuPositioning({
|
||||
menuOpenDirection: menuDirection,
|
||||
buttonBoundingBox: menuButtonBounding,
|
||||
menuWidth: computed(() => {
|
||||
if (isNumber(props.size)) return props.size
|
||||
|
||||
switch (props.size) {
|
||||
case 'lg':
|
||||
return 208
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" generic="T extends {id: string}, C extends string">
|
||||
import { noop, isString } from 'lodash'
|
||||
import { noop, isString } from '#lodash'
|
||||
import { computed } from 'vue'
|
||||
import type { PropAnyComponent } from '~~/src/helpers/common/components'
|
||||
import { CommonLoadingIcon, FormButton } from '~~/src/lib'
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
HorizontalOrVertical,
|
||||
StepCoreType
|
||||
} from '~~/src/helpers/common/components'
|
||||
import { clamp } from 'lodash'
|
||||
import { clamp } from '#lodash'
|
||||
import { TailwindBreakpoints, markClassesUsed } from '~~/src/helpers/tailwind'
|
||||
|
||||
export type StepsPadding = 'base' | 'xs' | 'sm'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { isClient } from '@vueuse/core'
|
||||
import type { MaybeRef } from '@vueuse/core'
|
||||
import { debounce, isUndefined, throttle } from 'lodash'
|
||||
import { debounce, isUndefined, throttle } from '#lodash'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, unref, watch } from 'vue'
|
||||
|
||||
export enum ThrottleOrDebounce {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import { isArray } from 'lodash'
|
||||
import { isArray } from '#lodash'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref, ToRefs } from 'vue'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computed, onMounted, ref, unref, watch } from 'vue'
|
||||
import type { Ref, ToRefs } from 'vue'
|
||||
import type { MaybeNullOrUndefined, Nullable } from '@speckle/shared'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { debounce, isArray, isBoolean, isString, isUndefined, noop } from 'lodash'
|
||||
import { debounce, isArray, isBoolean, isString, isUndefined, noop } from '#lodash'
|
||||
import type { LabelPosition } from './input'
|
||||
|
||||
export type InputColor = 'page' | 'foundation' | 'transparent'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isClient, type UseElementBoundingReturn } from '@vueuse/core'
|
||||
import { isUndefined } from 'lodash-es'
|
||||
import { isUndefined } from '#lodash'
|
||||
import { computed, unref, type ComputedRef, type CSSProperties } from 'vue'
|
||||
import { HorizontalDirection } from '~~/src/composables/common/window'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Nullable, Optional } from '@speckle/shared'
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { isUndefined } from '#lodash'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isString, isUndefined } from 'lodash'
|
||||
import { isString, isUndefined } from '#lodash'
|
||||
import type { GenericValidateFunction } from 'vee-validate'
|
||||
import { isNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { difference, intersection } from 'lodash'
|
||||
import { difference, intersection } from '#lodash'
|
||||
import { md5 } from '@speckle/shared'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { BaseError } from '~~/src/helpers/common/error'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { merge } from 'lodash'
|
||||
import { merge } from '#lodash'
|
||||
import type { StoryObj } from '@storybook/vue3'
|
||||
import type { Get } from 'type-fest'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user