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:
Kristaps Fabians Geikins
2025-08-11 16:02:35 +03:00
committed by GitHub
parent e1e1cded27
commit 0f5096fb2e
55 changed files with 803 additions and 332 deletions
@@ -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)
}
+43
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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'