fix: various presentations mode fixes related to resetting (#5635)
* better workspace feature flag ops * user activity is correctly tracked * more fixes
This commit is contained in:
committed by
GitHub
parent
42d0237952
commit
d394e1cd9b
@@ -74,7 +74,6 @@ import { SpeckleViewer } from '@speckle/shared'
|
||||
import { keyboardClick } from '@speckle/ui-components'
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import type { HeaderNavShare_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useCopyModelLink } from '~~/lib/projects/composables/modelManagement'
|
||||
|
||||
graphql(`
|
||||
fragment HeaderNavShare_Project on Project {
|
||||
@@ -90,7 +89,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { copy } = useClipboard()
|
||||
const copyModelLink = useCopyModelLink()
|
||||
const menuButtonId = useId()
|
||||
|
||||
const embedDialogOpen = ref(false)
|
||||
@@ -99,37 +97,16 @@ const parsedResourceIds = computed(() =>
|
||||
SpeckleViewer.ViewerRoute.parseUrlParameters(props.resourceIdString)
|
||||
)
|
||||
|
||||
const firstResource = computed(() => parsedResourceIds.value[0] || {})
|
||||
|
||||
const versionId = computed(() => {
|
||||
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
|
||||
return firstResource.value.versionId
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const modelId = computed(() => {
|
||||
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
|
||||
return firstResource.value.modelId // Assuming your firstResource object has a modelId property
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isFederated = computed(() => parsedResourceIds.value.length > 1)
|
||||
|
||||
const handleCopyId = () => {
|
||||
copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
|
||||
const handleCopyId = async () => {
|
||||
await copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const modelIdValue = modelId.value
|
||||
const versionIdValue = versionId.value ? versionId.value : undefined
|
||||
void copyModelLink({
|
||||
model: {
|
||||
projectId: props.project.id,
|
||||
id: modelIdValue
|
||||
},
|
||||
versionId: versionIdValue
|
||||
const handleCopyLink = async () => {
|
||||
if (import.meta.server) return
|
||||
await copy(window.location.href, {
|
||||
successMessage: 'Copied link to clipboard'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LucideChevronLeft, LucideChevronRight, LucideRotateCcw } from 'lucide-v
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { clamp } from 'lodash-es'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
|
||||
|
||||
defineProps<{
|
||||
hideUi?: boolean
|
||||
@@ -36,8 +37,9 @@ defineProps<{
|
||||
|
||||
const {
|
||||
ui: { slideIdx: currentVisibleIndex, slideCount },
|
||||
viewer: { resetView, hasViewChanged }
|
||||
viewer: { hasViewChanged }
|
||||
} = useInjectedPresentationState()
|
||||
const { resetView } = useResetViewUtils()
|
||||
|
||||
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
|
||||
const disableNext = computed(() =>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { PresentationSlideListSlide_SavedViewFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { useAuthManager } from '~~/lib/auth/composables/auth'
|
||||
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
|
||||
|
||||
graphql(`
|
||||
fragment PresentationSlideListSlide_SavedView on SavedView {
|
||||
@@ -42,10 +43,10 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const {
|
||||
ui: { slideIdx: currentSlideIdx, slide: currentSlide },
|
||||
viewer: { resetView }
|
||||
ui: { slideIdx: currentSlideIdx, slide: currentSlide }
|
||||
} = useInjectedPresentationState()
|
||||
const { presentationToken } = useAuthManager()
|
||||
const { resetView } = useResetViewUtils()
|
||||
|
||||
const isCurrentSlide = computed(() => currentSlide.value?.id === props.slide.id)
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="presentation-viewer-post-setup h-full"><slot /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { usePresentationViewerPostSetup } from '~/lib/presentations/composables/viewerPostSetup'
|
||||
|
||||
/**
|
||||
* The only point of this component is to get around the stupid limitation where a component that injects() also can't provide() the same stuff back...
|
||||
*/
|
||||
|
||||
usePresentationViewerPostSetup()
|
||||
</script>
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<ViewerStateSetup :init-params="initParams">
|
||||
<PresentationViewerSetup
|
||||
@loading-change="onLoadingChange"
|
||||
@progress-change="onProgressChange"
|
||||
/>
|
||||
<PresentationViewerPostSetup>
|
||||
<PresentationViewerSetup
|
||||
@loading-change="onLoadingChange"
|
||||
@progress-change="onProgressChange"
|
||||
/>
|
||||
</PresentationViewerPostSetup>
|
||||
</ViewerStateSetup>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -159,7 +159,7 @@ const parentEl = ref(null as Nullable<HTMLElement>)
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const viewerState = useInjectedViewerState()
|
||||
const { sessionId } = viewerState
|
||||
const { users } = useViewerUserActivityTracking({ parentEl })
|
||||
const { users } = useViewerUserActivityTracking({ anchoredPointsParentEl: parentEl })
|
||||
const { isOpenThread, open, closeAllThreads } = useThreadUtilities()
|
||||
const {
|
||||
filters: { hasAnyFiltersApplied },
|
||||
|
||||
@@ -135,9 +135,11 @@ export type AddDomainToWorkspaceInput = {
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
/** Either the ID or slug must be set */
|
||||
export type AdminAccessToWorkspaceFeatureInput = {
|
||||
featureFlagName: WorkspaceFeatureFlagName;
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
workspaceId?: InputMaybe<Scalars['ID']['input']>;
|
||||
workspaceSlug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AdminInviteList = {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function wrapRefWithTracking<R extends Ref<unknown>>(
|
||||
},
|
||||
set: (newVal) => {
|
||||
if (!readsOnly) {
|
||||
logger().debug(`debugging: '${name}' written to`, newVal, getTrace())
|
||||
logger().debug(`debugging: '${name}' written to`, { newVal }, getTrace())
|
||||
}
|
||||
|
||||
ref.value = newVal
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
SavedViewVisibility
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { projectPresentationPageQuery } from '~/lib/presentations/graphql/queries'
|
||||
import { useEventBus } from '~/lib/core/composables/eventBus'
|
||||
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
|
||||
import { useProjectSavedViewsUpdateTracking } from '~/lib/viewer/composables/savedViews/subscriptions'
|
||||
|
||||
type ResponseProject = Optional<Get<ProjectPresentationPageQuery, 'project'>>
|
||||
@@ -44,10 +42,6 @@ export type InjectablePresentationState = Readonly<{
|
||||
* active slide etc.
|
||||
*/
|
||||
resourceIdString: ComputedRef<string>
|
||||
/**
|
||||
* Reset the current view to the saved view state of the current slide
|
||||
*/
|
||||
resetView: () => void
|
||||
/**
|
||||
* Whether the current view has been changed from the saved view state
|
||||
*/
|
||||
@@ -101,8 +95,6 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
|
||||
ui: { slideIdx }
|
||||
} = initState
|
||||
|
||||
const { emit, on } = useEventBus()
|
||||
|
||||
const hasViewChanged = ref(false)
|
||||
|
||||
const resourceIdString = computed(() => {
|
||||
@@ -113,32 +105,9 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
|
||||
.toString()
|
||||
})
|
||||
|
||||
const resetView = () => {
|
||||
const slides = presentation.value?.views.items || []
|
||||
const currentSlide = slides.at(slideIdx.value)
|
||||
|
||||
if (!currentSlide?.id) return
|
||||
|
||||
emit(ViewerEventBusKeys.ApplySavedView, {
|
||||
id: currentSlide.id,
|
||||
loadOriginal: false
|
||||
})
|
||||
|
||||
hasViewChanged.value = false
|
||||
}
|
||||
|
||||
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
|
||||
hasViewChanged.value = true
|
||||
})
|
||||
|
||||
watch(slideIdx, () => {
|
||||
hasViewChanged.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
viewer: {
|
||||
resourceIdString,
|
||||
resetView,
|
||||
hasViewChanged
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
|
||||
export const useResetViewUtils = () => {
|
||||
const {
|
||||
response: { presentation },
|
||||
ui: { slideIdx },
|
||||
viewer: { hasViewChanged }
|
||||
} = useInjectedPresentationState()
|
||||
|
||||
const { emit } = useEventBus()
|
||||
|
||||
const resetView = () => {
|
||||
const slides = presentation.value?.views.items || []
|
||||
const currentSlide = slides.at(slideIdx.value)
|
||||
|
||||
if (!currentSlide?.id) return
|
||||
|
||||
emit(ViewerEventBusKeys.ApplySavedView, {
|
||||
id: currentSlide.id,
|
||||
loadOriginal: false
|
||||
})
|
||||
|
||||
hasViewChanged.value = false
|
||||
}
|
||||
|
||||
return { resetView }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { useViewerUserActivityTracking } from '~/lib/viewer/composables/activity'
|
||||
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
|
||||
|
||||
const useActivityTrackingIntegration = () => {
|
||||
useViewerUserActivityTracking({
|
||||
trackInternallyOnly: true
|
||||
})
|
||||
}
|
||||
|
||||
const useResetTrackingIntegration = () => {
|
||||
const { on } = useEventBus()
|
||||
const {
|
||||
ui: { slideIdx },
|
||||
viewer: { hasViewChanged }
|
||||
} = useInjectedPresentationState()
|
||||
const {
|
||||
ui: {
|
||||
savedViews: { savedViewStateId }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
|
||||
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
|
||||
hasViewChanged.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
slideIdx,
|
||||
() => {
|
||||
savedViewStateId.value = undefined
|
||||
hasViewChanged.value = false
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Post setup work to run after the viewer (and presentation) states have been set up
|
||||
*/
|
||||
export const usePresentationViewerPostSetup = () => {
|
||||
if (import.meta.server) return
|
||||
useActivityTrackingIntegration()
|
||||
useResetTrackingIntegration()
|
||||
}
|
||||
@@ -261,10 +261,19 @@ export type UserActivityModel = Merge<
|
||||
/**
|
||||
* Track other user activity and emit viewing/disconnected updates
|
||||
*/
|
||||
export function useViewerUserActivityTracking(params: {
|
||||
parentEl: Ref<Nullable<HTMLElement>>
|
||||
}) {
|
||||
const { parentEl } = params
|
||||
export function useViewerUserActivityTracking(
|
||||
params?: Partial<{
|
||||
/**
|
||||
* Set if you need users to be positioned correctly in viewer world space in an overlaid anchored points element
|
||||
*/
|
||||
anchoredPointsParentEl: Ref<Nullable<HTMLElement>>
|
||||
/**
|
||||
* Whether to only track viewer state changes, without broadcasting it to other users or getting updates from them
|
||||
*/
|
||||
trackInternallyOnly: boolean
|
||||
}>
|
||||
) {
|
||||
const { anchoredPointsParentEl: parentEl, trackInternallyOnly } = params || {}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -275,10 +284,27 @@ export function useViewerUserActivityTracking(params: {
|
||||
} = useInjectedViewerState()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { update } = useViewerRealtimeActivityTracker()
|
||||
const sendUpdate = useViewerUserActivityBroadcasting()
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const processViewerViewing = async () => {
|
||||
if (trackInternallyOnly) {
|
||||
update({ status: ViewerUserActivityStatus.Viewing })
|
||||
} else {
|
||||
await sendUpdate.emitViewing()
|
||||
}
|
||||
}
|
||||
|
||||
const processViewerDisconnected = async () => {
|
||||
if (trackInternallyOnly) {
|
||||
update({ status: ViewerUserActivityStatus.Disconnected })
|
||||
} else {
|
||||
await sendUpdate.emitDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: For some reason subscription is set up twice? Vue Apollo bug?
|
||||
const { onResult: onUserActivity } = useSubscription(
|
||||
onViewerUserActivityBroadcastedSubscription,
|
||||
@@ -291,7 +317,7 @@ export function useViewerUserActivityTracking(params: {
|
||||
sessionId: sessionId.value
|
||||
}),
|
||||
() => ({
|
||||
enabled: isLoggedIn.value
|
||||
enabled: isLoggedIn.value && !trackInternallyOnly
|
||||
})
|
||||
)
|
||||
|
||||
@@ -378,53 +404,61 @@ export function useViewerUserActivityTracking(params: {
|
||||
}
|
||||
})
|
||||
|
||||
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>({
|
||||
parentEl,
|
||||
points: computed(() => Object.values(users.value)),
|
||||
pointLocationGetter: (user) => {
|
||||
const selection = user.state.ui.selection
|
||||
const selectionVector = selection
|
||||
? new Vector3(selection[0], selection[1], selection[2])
|
||||
: null
|
||||
if (parentEl) {
|
||||
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>(
|
||||
{
|
||||
parentEl,
|
||||
points: computed(() => Object.values(users.value)),
|
||||
pointLocationGetter: (user) => {
|
||||
const selection = user.state.ui.selection
|
||||
const selectionVector = selection
|
||||
? new Vector3(selection[0], selection[1], selection[2])
|
||||
: null
|
||||
|
||||
function getPointInBetweenByPerc(
|
||||
pointA: Vector3,
|
||||
pointB: Vector3,
|
||||
percentage: number
|
||||
) {
|
||||
let dir = pointB.clone().sub(pointA)
|
||||
const len = dir.length()
|
||||
dir = dir.normalize().multiplyScalar(len * percentage)
|
||||
return pointA.clone().add(dir)
|
||||
}
|
||||
function getPointInBetweenByPerc(
|
||||
pointA: Vector3,
|
||||
pointB: Vector3,
|
||||
percentage: number
|
||||
) {
|
||||
let dir = pointB.clone().sub(pointA)
|
||||
const len = dir.length()
|
||||
dir = dir.normalize().multiplyScalar(len * percentage)
|
||||
return pointA.clone().add(dir)
|
||||
}
|
||||
|
||||
// If there is no selection location, return to a blended location based on the camera's target and location.
|
||||
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
|
||||
if (!selectionVector) {
|
||||
const camPos = user.state.ui.camera.position
|
||||
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
|
||||
// If there is no selection location, return to a blended location based on the camera's target and location.
|
||||
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
|
||||
if (!selectionVector) {
|
||||
const camPos = user.state.ui.camera.position
|
||||
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
|
||||
|
||||
const camTarget = user.state.ui.camera.target
|
||||
const camTargetVector = new Vector3(camTarget[0], camTarget[1], camTarget[2])
|
||||
const camTarget = user.state.ui.camera.target
|
||||
const camTargetVector = new Vector3(
|
||||
camTarget[0],
|
||||
camTarget[1],
|
||||
camTarget[2]
|
||||
)
|
||||
|
||||
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
|
||||
}
|
||||
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
|
||||
}
|
||||
|
||||
return selectionVector.clone()
|
||||
},
|
||||
updatePositionCallback: (user, result, options) => {
|
||||
user.isOccluded = result.isOccluded
|
||||
user.style = {
|
||||
...user.style,
|
||||
target: {
|
||||
...user.style.target,
|
||||
...result.style,
|
||||
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
|
||||
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
|
||||
return selectionVector.clone()
|
||||
},
|
||||
updatePositionCallback: (user, result, options) => {
|
||||
user.isOccluded = result.isOccluded
|
||||
user.style = {
|
||||
...user.style,
|
||||
target: {
|
||||
...user.style.target,
|
||||
...result.style,
|
||||
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
|
||||
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const hideStaleUsers = () => {
|
||||
if (!Object.values(users.value).length) return
|
||||
@@ -451,7 +485,7 @@ export function useViewerUserActivityTracking(params: {
|
||||
// Debounced disconnect function - 30 second delay
|
||||
const debouncedDisconnect = debounce(
|
||||
async () => {
|
||||
await sendUpdate.emitDisconnected()
|
||||
await processViewerDisconnected()
|
||||
},
|
||||
30 * 1000 // 30 seconds
|
||||
)
|
||||
@@ -463,13 +497,13 @@ export function useViewerUserActivityTracking(params: {
|
||||
} else {
|
||||
// Window regained focus - cancel any pending disconnect and emit viewing
|
||||
debouncedDisconnect.cancel()
|
||||
await sendUpdate.emitViewing()
|
||||
await processViewerViewing()
|
||||
}
|
||||
})
|
||||
const sendUpdateAndHideStaleUsers = () => {
|
||||
if (!focused.value) return
|
||||
hideStaleUsers()
|
||||
sendUpdate.emitViewing()
|
||||
processViewerViewing()
|
||||
}
|
||||
|
||||
useIntervalFn(sendUpdateAndHideStaleUsers, OWN_ACTIVITY_UPDATE_INTERVAL)
|
||||
@@ -484,22 +518,22 @@ export function useViewerUserActivityTracking(params: {
|
||||
doubleClickCallback: selectionCallback
|
||||
})
|
||||
|
||||
useViewerCameraControlEndTracker(() => sendUpdate.emitViewing())
|
||||
useViewerCameraControlEndTracker(() => processViewerViewing())
|
||||
|
||||
useOnBeforeWindowUnload(async () => {
|
||||
// Cancel any pending debounced disconnect since we're actually leaving
|
||||
debouncedDisconnect.cancel()
|
||||
await sendUpdate.emitDisconnected()
|
||||
await processViewerDisconnected()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
sendUpdate.emitViewing()
|
||||
processViewerViewing()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Cancel any pending debounced disconnect
|
||||
debouncedDisconnect.cancel()
|
||||
sendUpdate.emitDisconnected()
|
||||
void processViewerDisconnected()
|
||||
})
|
||||
|
||||
const state = useInjectedViewerState()
|
||||
@@ -519,7 +553,7 @@ export function useViewerUserActivityTracking(params: {
|
||||
|
||||
watch(resourceIdString, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
sendUpdate.emitViewing()
|
||||
void processViewerViewing()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -24,16 +24,15 @@ export const useViewerSavedViewIntegration = () => {
|
||||
},
|
||||
response: { savedView }
|
||||
},
|
||||
urlHashState: { savedView: urlHashStateSavedViewSettings }
|
||||
urlHashState: { savedView: urlHashStateSavedViewSettings },
|
||||
ui: {
|
||||
savedViews: { savedViewStateId }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
const applyState = useApplySerializedState()
|
||||
const { serializedStateId } = useViewerRealtimeActivityTracker()
|
||||
const { on, emit } = useEventBus()
|
||||
|
||||
// Saved View ID will be unset, once the user does anything to the viewer that
|
||||
// changes it from the saved view
|
||||
const savedViewStateId = ref<string>()
|
||||
|
||||
const validState = (state: unknown) => (isSerializedViewerState(state) ? state : null)
|
||||
|
||||
const apply = async () => {
|
||||
@@ -131,6 +130,7 @@ export type SavedViewsUIState = ReturnType<typeof useBuildSavedViewsUIState>
|
||||
|
||||
export const useBuildSavedViewsUIState = () => {
|
||||
const openedGroupState = ref<Map<string, true>>(new Map())
|
||||
const savedViewStateId = ref<string>()
|
||||
|
||||
onUnmounted(() => {
|
||||
openedGroupState.value = new Map()
|
||||
@@ -140,7 +140,12 @@ export const useBuildSavedViewsUIState = () => {
|
||||
/**
|
||||
* Groups that should currently be expanded/open
|
||||
*/
|
||||
openedGroupState
|
||||
openedGroupState,
|
||||
/**
|
||||
* A kind of a "viewer snapshot" ID associated w/ the saved view being loaded. Helps track
|
||||
* if user has changed the view since loading the saved view
|
||||
*/
|
||||
savedViewStateId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -342,7 +342,6 @@ export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
{
|
||||
err: error,
|
||||
info,
|
||||
isAppError: true,
|
||||
vm: _vm?.$options.name,
|
||||
errString: errorToString(error),
|
||||
vueErrorHandler: true,
|
||||
|
||||
@@ -698,8 +698,12 @@ enum WorkspaceFeatureFlagName {
|
||||
presentations
|
||||
}
|
||||
|
||||
"""
|
||||
Either the ID or slug must be set
|
||||
"""
|
||||
input AdminAccessToWorkspaceFeatureInput {
|
||||
workspaceId: ID!
|
||||
workspaceId: ID
|
||||
workspaceSlug: String
|
||||
featureFlagName: WorkspaceFeatureFlagName!
|
||||
}
|
||||
|
||||
|
||||
@@ -157,9 +157,11 @@ export type AddDomainToWorkspaceInput = {
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
/** Either the ID or slug must be set */
|
||||
export type AdminAccessToWorkspaceFeatureInput = {
|
||||
featureFlagName: WorkspaceFeatureFlagName;
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
workspaceId?: InputMaybe<Scalars['ID']['input']>;
|
||||
workspaceSlug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AdminInviteList = {
|
||||
|
||||
@@ -26,9 +26,13 @@ export type {
|
||||
GetWorkspaceRolesAndSeats
|
||||
} from '@/modules/workspacesCore/domain/operations'
|
||||
|
||||
export type GetWorkspacePlan = (args: {
|
||||
workspaceId: string
|
||||
}) => Promise<WorkspacePlan | null>
|
||||
export type GetWorkspacePlan = (
|
||||
args:
|
||||
| {
|
||||
workspaceId: string
|
||||
}
|
||||
| { workspaceSlug: string }
|
||||
) => Promise<WorkspacePlan | null>
|
||||
|
||||
export type GetWorkspacePlansByWorkspaceId = (args: {
|
||||
workspaceIds: string[]
|
||||
|
||||
@@ -83,13 +83,24 @@ export const getWorkspaceWithPlanFactory =
|
||||
|
||||
export const getWorkspacePlanFactory =
|
||||
({ db }: { db: Knex }): GetWorkspacePlan =>
|
||||
async ({ workspaceId }) => {
|
||||
const workspacePlan = await tables
|
||||
async (params) => {
|
||||
const q = tables
|
||||
.workspacePlans(db)
|
||||
.select()
|
||||
.where({ workspaceId })
|
||||
.select<WorkspacePlan[]>(WorkspacePlans.cols)
|
||||
.first()
|
||||
return workspacePlan ?? null
|
||||
|
||||
if ('workspaceId' in params) {
|
||||
q.where({ workspaceId: params.workspaceId })
|
||||
} else {
|
||||
q.innerJoin(
|
||||
Workspaces.name,
|
||||
Workspaces.col.id,
|
||||
WorkspacePlans.col.workspaceId
|
||||
).where({ [Workspaces.col.slug]: params.workspaceSlug })
|
||||
}
|
||||
|
||||
const ret = await q
|
||||
return ret ?? null
|
||||
}
|
||||
|
||||
export const getWorkspacePlansByWorkspaceIdFactory =
|
||||
|
||||
@@ -243,6 +243,7 @@ import {
|
||||
} from '@/modules/core/services/projects'
|
||||
import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing'
|
||||
import { deleteProjectCommitsFactory } from '@/modules/core/repositories/commits'
|
||||
import { UserInputError } from '@/modules/core/errors/userinput'
|
||||
|
||||
const getServerInfo = getServerInfoFactory({ db })
|
||||
const getUser = getUserFactory({ db })
|
||||
@@ -623,13 +624,20 @@ export default FF_WORKSPACES_MODULE_ENABLED
|
||||
return true
|
||||
},
|
||||
giveAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
|
||||
const { workspaceId, featureFlagName } = input
|
||||
const { workspaceId, featureFlagName, workspaceSlug } = input
|
||||
const userId = ctx.userId
|
||||
if (!userId) throw new UnauthorizedError()
|
||||
if (!workspaceId && !workspaceSlug) {
|
||||
throw new UserInputError(
|
||||
'Either workspaceId or workspaceSlug must be provided'
|
||||
)
|
||||
}
|
||||
|
||||
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
|
||||
|
||||
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
|
||||
const getWorkspacePlan = getWorkspacePlanFactory({ db })
|
||||
const workspacePlan = await (workspaceId
|
||||
? getWorkspacePlan({ workspaceId })
|
||||
: getWorkspacePlan({ workspaceSlug: workspaceSlug! }))
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
|
||||
workspacePlan.featureFlags |= featureFlag
|
||||
@@ -641,13 +649,21 @@ export default FF_WORKSPACES_MODULE_ENABLED
|
||||
return true
|
||||
},
|
||||
removeAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
|
||||
const { workspaceId, featureFlagName } = input
|
||||
const { workspaceId, featureFlagName, workspaceSlug } = input
|
||||
const userId = ctx.userId
|
||||
if (!userId) throw new UnauthorizedError()
|
||||
if (!workspaceId && !workspaceSlug) {
|
||||
throw new UserInputError(
|
||||
'Either workspaceId or workspaceSlug must be provided'
|
||||
)
|
||||
}
|
||||
|
||||
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
|
||||
|
||||
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
|
||||
const getWorkspacePlan = getWorkspacePlanFactory({ db })
|
||||
const workspacePlan = await (workspaceId
|
||||
? getWorkspacePlan({ workspaceId })
|
||||
: getWorkspacePlan({ workspaceSlug: workspaceSlug! }))
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
|
||||
workspacePlan.featureFlags ^= featureFlag
|
||||
|
||||
@@ -139,7 +139,8 @@
|
||||
|
||||
"vue.complete.casing.props": "kebab",
|
||||
"vue.inlayHints.missingProps": true,
|
||||
"circleci.persistedProjectSelection": ["gh/specklesystems/speckle-server"]
|
||||
"circleci.persistedProjectSelection": ["gh/specklesystems/speckle-server"],
|
||||
"vue.suggest.componentNameCasing": "alwaysPascalCase"
|
||||
// "vitest.nodeEnv": {
|
||||
// "DISABLE_ALL_FFS": "1"
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user