Merge branch 'main' into iain/file-import-more-fixes

This commit is contained in:
Iain Sproat
2025-03-14 12:20:09 +00:00
17 changed files with 203 additions and 71 deletions
@@ -15,7 +15,7 @@
Looking for V2 connectors? Get them
<NuxtLink
class="text-foreground-3 hover:text-foreground-2 underline"
to="https://releases.speckle.systems"
to="https://releases.speckle.systems/legacy-connectors"
>
here.
</NuxtLink>
@@ -97,7 +97,7 @@
</MenuItem>
</div>
</div>
<div class="p-2 pt-1">
<div class="p-2 pt-1 max-h-96 overflow-y-auto simple-scrollbar">
<LayoutSidebarMenuGroup
title="Workspaces"
:icon-click="isGuest ? undefined : handlePlusClick"
@@ -1,5 +1,9 @@
<template>
<div ref="rendererparent" class="absolute w-full h-full"></div>
<div
ref="rendererparent"
class="absolute w-full h-full"
data-dd-action-name="Viewer Canvas"
></div>
</template>
<script setup lang="ts">
import { useInjectedViewer } from '~~/lib/viewer/composables/setup'
@@ -97,7 +97,11 @@
:url="route.path"
/>
<Portal to="primary-actions">
<HeaderNavShare v-if="project" :resource-id-string="modelId" :project="project" />
<HeaderNavShare
v-if="project"
:resource-id-string="resourceIdString"
:project="project"
/>
</Portal>
</div>
</template>
@@ -113,17 +117,28 @@ import { useFilterUtilities } from '~/lib/viewer/composables/ui'
import { projectsRoute } from '~~/lib/common/helpers/route'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
import { writableAsyncComputed } from '~/lib/common/composables/async'
const emit = defineEmits<{
setup: [InjectableViewerState]
}>()
const router = useRouter()
const route = useRoute()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const modelId = computed(() => route.params.modelId as string)
const projectId = computed(() => route.params.id as string)
const resourceIdString = computed(() => route.params.modelId as string)
const projectId = writableAsyncComputed({
get: () => route.params.id as string,
set: async (value: string) => {
// Just rewrite route id param
await router.push({
params: { id: value }
})
},
initialState: route.params.id as string,
asyncRead: false
})
const state = useSetupViewer({
projectId
@@ -48,9 +48,13 @@ export const useBillingActions = () => {
const { mutate: cancelCheckoutSessionMutation } = useMutation(
settingsBillingCancelCheckoutSessionMutation
)
const logger = useLogger()
const billingPortalRedirect = async (workspaceId: MaybeNullOrUndefined<string>) => {
if (!workspaceId) return
if (!workspaceId) {
logger.error('[Billing Portal] No workspaceId provided, returning early')
return
}
mixpanel.track('Workspace Billing Portal Button Clicked', {
// eslint-disable-next-line camelcase
@@ -66,6 +70,11 @@ export const useBillingActions = () => {
if (result.data?.workspace.customerPortalUrl) {
window.open(result.data.workspace.customerPortalUrl, '_blank')
} else {
logger.warn(
'[Billing Portal] No portal URL returned, full response:',
result.data
)
}
}
@@ -13,8 +13,8 @@ import {
useSelectionEvents,
useViewerCameraControlEndTracker
} from '~~/lib/viewer/composables/viewer'
import { SpeckleViewer } from '@speckle/shared'
import type { Nullable } from '@speckle/shared'
import { SpeckleViewer, xor } from '@speckle/shared'
import type { Nullable, Optional } from '@speckle/shared'
import { Vector3 } from 'three'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { broadcastViewerUserActivityMutation } from '~~/lib/viewer/graphql/mutations'
@@ -82,8 +82,16 @@ export function useViewerUserActivityBroadcasting(
const apollo = useApolloClient().client
const { isEnabled: isEmbedEnabled } = useEmbed()
const isSameMessage = (
previousSerializedMessage: Optional<string>,
newMessage: ViewerUserActivityMessageInput
) => {
if (xor(previousSerializedMessage, newMessage)) return false
if (!previousSerializedMessage && !newMessage) return false
return previousSerializedMessage === JSON.stringify(newMessage)
}
const invokeMutation = async (message: ViewerUserActivityMessageInput) => {
if (!isLoggedIn.value || isEmbedEnabled.value) return false
const result = await apollo
.mutate({
mutation: broadcastViewerUserActivityMutation,
@@ -98,14 +106,33 @@ export function useViewerUserActivityBroadcasting(
return result.data?.broadcastViewerUserActivity || false
}
let serializedPreviousMessage: Optional<string> = undefined
const invokeObservabilityEvent = async (message: ViewerUserActivityMessageInput) => {
const dd = window.DD_RUM
if (!dd || !('addAction' in dd)) return
if (isSameMessage(serializedPreviousMessage, message)) return
serializedPreviousMessage = JSON.stringify(message)
dd.addAction('Viewer User Activity', { message })
}
const invoke = async (message: ViewerUserActivityMessageInput) => {
if (!isLoggedIn.value || isEmbedEnabled.value) return false
return await Promise.all([
invokeMutation(message),
invokeObservabilityEvent(message)
])
}
return {
emitDisconnected: async () =>
invokeMutation({
await invoke({
...getMainMetadata(),
status: ViewerUserActivityStatus.Disconnected
}),
emitViewing: async () => {
await invokeMutation({
await invoke({
...getMainMetadata(),
status: ViewerUserActivityStatus.Viewing
})
@@ -10,8 +10,9 @@ import {
useFilterUtilities,
useSelectionUtilities
} from '~~/lib/viewer/composables/ui'
import { CameraController, ViewMode } from '@speckle/viewer'
import { CameraController, ViewMode, VisualDiffMode } from '@speckle/viewer'
import type { NumericPropertyInfo } from '@speckle/viewer'
import type { PartialDeep } from 'type-fest'
type SerializedViewerState = SpeckleViewer.ViewerState.SerializedViewerState
@@ -136,6 +137,7 @@ export enum StateApplyMode {
export function useApplySerializedState() {
const {
projectId,
ui: {
camera: { position, target, isOrthoProjection },
sectionBox,
@@ -165,61 +167,71 @@ export function useApplySerializedState() {
const { setSelectionFromObjectIds } = useSelectionUtilities()
const logger = useLogger()
return async (state: SerializedViewerState, mode: StateApplyMode) => {
return async (state: PartialDeep<SerializedViewerState>, mode: StateApplyMode) => {
if (mode === StateApplyMode.Reset) {
resetState()
return
}
if (state.projectId && state.projectId !== projectId.value) {
await projectId.update(state.projectId)
}
if (
[StateApplyMode.Spotlight, StateApplyMode.TheadFullContextOpen].includes(mode)
) {
await resourceIdString.update(state.resources?.request?.resourceIdString || '')
}
position.value = new Vector3(
state.ui.camera.position[0],
state.ui.camera.position[1],
state.ui.camera.position[2]
state.ui?.camera?.position?.[0],
state.ui?.camera?.position?.[1],
state.ui?.camera?.position?.[2]
)
target.value = new Vector3(
state.ui.camera.target[0],
state.ui.camera.target[1],
state.ui.camera.target[2]
state.ui?.camera?.target?.[0],
state.ui?.camera?.target?.[1],
state.ui?.camera?.target?.[2]
)
isOrthoProjection.value = state.ui.camera.isOrthoProjection
isOrthoProjection.value = !!state.ui?.camera?.isOrthoProjection
sectionBox.value = state.ui.sectionBox
sectionBox.value = state.ui?.sectionBox
? new Box3(
new Vector3(
state.ui.sectionBox.min[0],
state.ui.sectionBox.min[1],
state.ui.sectionBox.min[2]
state.ui.sectionBox.min?.[0],
state.ui.sectionBox.min?.[1],
state.ui.sectionBox.min?.[2]
),
new Vector3(
state.ui.sectionBox.max[0],
state.ui.sectionBox.max[1],
state.ui.sectionBox.max[2]
state.ui.sectionBox.max?.[0],
state.ui.sectionBox.max?.[1],
state.ui.sectionBox.max?.[2]
)
)
: null
const filters = state.ui.filters
if (filters.hiddenObjectIds.length) {
const filters = state.ui?.filters || {}
if (filters.hiddenObjectIds?.length) {
resetFilters()
hideObjects(filters.hiddenObjectIds, { replace: true })
} else if (filters.isolatedObjectIds.length) {
} else if (filters.isolatedObjectIds?.length) {
resetFilters()
isolateObjects(filters.isolatedObjectIds, { replace: true })
} else {
resetFilters()
}
const propertyFilterApplied = state.ui.filters.propertyFilter.isApplied
const propertyFilterApplied = filters.propertyFilter?.isApplied
if (propertyFilterApplied) {
applyPropertyFilter()
} else {
unApplyPropertyFilter()
}
const propertyInfoKey = state.ui.filters.propertyFilter.key
const passMin = state.viewer.metadata.filteringState?.passMin
const passMax = state.viewer.metadata.filteringState?.passMax
const propertyInfoKey = filters.propertyFilter?.key
const passMin = state.viewer?.metadata?.filteringState?.passMin
const passMax = state.viewer?.metadata?.filteringState?.passMax
if (propertyInfoKey) {
removePropertyFilter()
@@ -249,30 +261,26 @@ export function useApplySerializedState() {
}
if (mode === StateApplyMode.Spotlight) {
highlightedObjectIds.value = filters.selectedObjectIds.slice()
highlightedObjectIds.value = (filters.selectedObjectIds || []).slice()
} else {
if (filters.selectedObjectIds.length) {
if (filters.selectedObjectIds?.length) {
setSelectionFromObjectIds(filters.selectedObjectIds)
}
}
if (
[StateApplyMode.Spotlight, StateApplyMode.TheadFullContextOpen].includes(mode)
) {
await resourceIdString.update(state.resources.request.resourceIdString)
}
if ([StateApplyMode.Spotlight].includes(mode)) {
await urlHashState.focusedThreadId.update(state.ui.threads.openThread.threadId)
await urlHashState.focusedThreadId.update(
state.ui?.threads?.openThread?.threadId || null
)
}
const command = state.ui.diff.command
const command = state.ui?.diff?.command
? deserializeDiffCommand(state.ui.diff.command)
: null
const activeDiffEnabled = !!diff.enabled.value
if (command && command.diffs.length) {
diff.time.value = state.ui.diff.time
diff.mode.value = state.ui.diff.mode
if (command && command.diffs.length && state.ui?.diff) {
diff.time.value = state.ui.diff.time || 0.5
diff.mode.value = state.ui?.diff.mode || VisualDiffMode.COLORED
const instruction = command.diffs[0]
await diffModelVersions(
@@ -285,16 +293,16 @@ export function useApplySerializedState() {
}
// Restore view mode
if (state.ui.viewMode) {
if (state.ui?.viewMode) {
viewMode.value = state.ui.viewMode
} else {
viewMode.value = ViewMode.DEFAULT
}
explodeFactor.value = state.ui.explodeFactor
explodeFactor.value = state.ui?.explodeFactor || 0
lightConfig.value = {
...lightConfig.value,
...state.ui.lightConfig
...(state.ui?.lightConfig || {})
}
}
}
@@ -18,7 +18,6 @@ import {
type VisualDiffMode,
ViewMode
} from '@speckle/viewer'
import type { MaybeRef } from '@vueuse/shared'
import { inject, ref, provide } from 'vue'
import type { ComputedRef, WritableComputedRef, Raw, Ref, ShallowRef } from 'vue'
import { useScopedState } from '~~/lib/common/composables/scopedState'
@@ -82,7 +81,7 @@ export type InjectableViewerState = Readonly<{
/**
* The project which we're opening in the viewer (all loaded models should belong to it)
*/
projectId: ComputedRef<string>
projectId: AsyncWritableComputedRef<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.
@@ -400,8 +399,6 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
public: { viewerDebug }
} = useRuntimeConfig()
const projectId = computed(() => unref(params.projectId))
const sessionId = computed(() => nanoid())
const isInitialized = ref(false)
const { instance, initPromise, container } = useScopedState(
@@ -412,7 +409,7 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
const hasDoneInitialLoad = ref(false)
return {
projectId,
projectId: params.projectId,
sessionId,
viewer: import.meta.server
? ({
@@ -1030,7 +1027,7 @@ function setupInterfaceState(
}
}
type UseSetupViewerParams = { projectId: MaybeRef<string> }
type UseSetupViewerParams = { projectId: AsyncWritableComputedRef<string> }
export function useSetupViewer(params: UseSetupViewerParams): InjectableViewerState {
// Initialize full state object - each subsequent state initialization depends on
@@ -1,6 +1,13 @@
import { ViewerEvent } from '@speckle/viewer'
import {
StateApplyMode,
useApplySerializedState,
useStateSerialization
} from '~/lib/viewer/composables/serialization'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useViewerEventListener } from '~~/lib/viewer/composables/viewer'
import type { SpeckleViewer } from '@speckle/shared'
import { get, isString } from 'lodash-es'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function useDebugViewerEvents() {
@@ -12,22 +19,67 @@ function useDebugViewerEvents() {
}
function useDebugViewer() {
const state = useInjectedViewerState()
const fullViewerState = useInjectedViewerState()
const apply = useApplySerializedState()
const { serialize } = useStateSerialization()
const {
viewer: { instance }
} = state
} = fullViewerState
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const ensureObj = <O>(obj: O | string): O => {
return isString(obj) ? JSON.parse(obj) : obj
}
const applyState = (
state: SpeckleViewer.ViewerState.SerializedViewerState | string
) => {
return apply(ensureObj(state), StateApplyMode.TheadFullContextOpen)
}
// Get current viewer instance
window.VIEWER = instance
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.VIEWER_STATE = () => state
// Get current viewer state
window.VIEWER_STATE = () => fullViewerState
// Get serialized version of current state
window.VIEWER_SERIALIZED_STATE = (...args: Parameters<typeof serialize>) => {
const serialized = serialize(...args)
return JSON.stringify(serialized)
}
// Apply viewer state
window.APPLY_VIEWER_STATE = (
state: SpeckleViewer.ViewerState.SerializedViewerState
) => applyState(state)
// Apply DD user activity event
window.APPLY_VIEWER_DD_EVENT = (
event:
| {
content: {
attributes: {
context: {
message: { state: SpeckleViewer.ViewerState.SerializedViewerState }
}
}
}
}
| string
) => {
event = ensureObj(event)
const path = 'content.attributes.context.message.state'
const state = get(event, path)
if (!state) {
throw new Error('Cant find serialized state at path: ' + path)
}
return applyState(state)
}
}
export function setupDebugMode() {
if (import.meta.server) return
if (!import.meta.dev) return
// useDebugViewerEvents()
useDebugViewer()
+7
View File
@@ -7,6 +7,13 @@ declare global {
* Start a new DD RUM view. Function is idempotent and can be safely called multiple times.
*/
DD_RUM_START_VIEW?: (path: string, name: string) => void
// Debug keys, don't need to type properly cause we only use them manually from dev tools
VIEWER?: any
VIEWER_STATE?: any
VIEWER_SERIALIZED_STATE?: any
APPLY_VIEWER_STATE?: any
APPLY_VIEWER_DD_EVENT?: any
}
}
@@ -21,4 +21,6 @@ extend type WorkspaceProjectMutations {
- TODO: Eventually delete data in previous region
"""
moveToRegion(projectId: String!, regionKey: String!): String!
@hasServerRole(role: SERVER_ADMIN)
@hasStreamRole(role: STREAM_OWNER)
}
@@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'
import { RelativeURL } from '@speckle/shared'
import { expect } from 'chai'
import type { Express } from 'express'
import { has, isString, random } from 'lodash'
import { has, isString } from 'lodash'
import request from 'supertest'
export const appId = 'spklwebapp' // same values as on FE
@@ -233,7 +233,7 @@ export type LocalAuthRestApiHelpers = ReturnType<typeof localAuthRestApi>
export const generateRegistrationParams = (): RegisterParams => ({
challenge: faker.string.uuid(),
user: {
email: `${random(0, 1000)}@example.org`.toLowerCase(),
email: faker.internet.email().toLowerCase(),
password: faker.internet.password(),
name: faker.person.fullName()
}
@@ -18,7 +18,7 @@ import {
getAvailableRegionsFactory
} from '@/modules/workspaces/services/regions'
import { Roles } from '@speckle/shared'
import { getFeatureFlags, isTestEnv } from '@/modules/shared/helpers/envHelper'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { WorkspacesNotYetImplementedError } from '@/modules/workspaces/errors/workspace'
import { scheduleJob } from '@/modules/multiregion/services/queue'
@@ -61,7 +61,7 @@ export default {
},
WorkspaceProjectMutations: {
moveToRegion: async (_parent, args, context) => {
if (!FF_MOVE_PROJECT_REGION_ENABLED && !isTestEnv()) {
if (!FF_MOVE_PROJECT_REGION_ENABLED) {
throw new WorkspacesNotYetImplementedError()
}
@@ -522,7 +522,8 @@ isMultiRegionTestMode()
const adminUser: BasicTestUser = {
id: '',
name: 'John Speckle',
email: createRandomEmail()
email: createRandomEmail(),
role: Roles.Server.Admin
}
const testWorkspace: SetOptional<BasicTestWorkspace, 'slug'> = {
@@ -572,6 +572,9 @@ Generate the environment variables for Speckle server and Speckle objects deploy
- name: FF_WORKSPACES_NEW_PLANS_ENABLED
value: {{ .Values.featureFlags.workspacesNewPlanEnabled | quote }}
- name: FF_MOVE_PROJECT_REGION_ENABLED
value: {{ .Values.featureFlags.moveProjectRegionEnabled | quote }}
{{- if .Values.featureFlags.workspacesModuleEnabled }}
- name: LICENSE_TOKEN
valueFrom:
@@ -99,6 +99,11 @@
"type": "boolean",
"description": "Toggles whether the new (Q1 2025) plans for workspaces are available. workspacesModuleEnabled must also be enabled for this to take effect.",
"default": false
},
"moveProjectRegionEnabled": {
"type": "boolean",
"description": "Enables the ability to move a project region (manually or automatically)",
"default": false
}
}
},
+2
View File
@@ -61,6 +61,8 @@ featureFlags:
noPersonalEmailsEnabled: false
## @param featureFlags.workspacesNewPlanEnabled Toggles whether the new (Q1 2025) plans for workspaces are available. workspacesModuleEnabled must also be enabled for this to take effect.
workspacesNewPlanEnabled: false
## @param featureFlags.moveProjectRegionEnabled Enables the ability to move a project region (manually or automatically)
moveProjectRegionEnabled: false
analytics:
## @param analytics.enabled Enable or disable analytics