Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-workspace-as-a-setting

This commit is contained in:
andrewwallacespeckle
2025-05-23 09:44:50 +02:00
29 changed files with 487 additions and 155 deletions
+1 -2
View File
@@ -2,7 +2,7 @@
# shellcheck disable=SC1091
set -e
if [ -n "$CI" ]
if [ -n "$CI" ]
then
echo "running eslint"
yarn eslint:projectwide
@@ -16,7 +16,6 @@ else
yarn lint-staged
fi
echo "🔍 looking for additional linter dependencies"
check_dependencies_available() {
+7 -7
View File
@@ -9,9 +9,9 @@
"node": "^22.6.0"
},
"scripts": {
"build": "yarn workspaces foreach -ptvW run build",
"build:public": "yarn workspaces foreach -ptvW --no-private run build",
"build:tailwind-deps": "yarn workspaces foreach -ivW -j unlimited --include '{@speckle/shared,@speckle/tailwind-theme,@speckle/ui-components}' run build",
"build": "yarn workspaces foreach --parallel --topological --verbose --worktree run build",
"build:public": "yarn ensure:tailwind-deps && yarn workspace @speckle/frontend-2 build:postinstall && yarn workspaces foreach --parallel --topological --verbose --worktree --no-private run build",
"build:tailwind-deps": "yarn workspaces foreach --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/shared,@speckle/tailwind-theme,@speckle/ui-components}' run build",
"ensure:tailwind-deps": "node ./utils/ensure-tailwind-deps.mjs",
"helm:readme:generate": "./utils/helm/update-schema-json.sh",
"prettier:check": "prettier --check .",
@@ -28,10 +28,10 @@
"dev:kind:helm:up": "yarn dev:kind:up && tilt up --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server",
"dev:kind:helm:down": "tilt down --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server",
"dev:kind:helm:ci": "tilt ci --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server --timeout 10m",
"dev": "yarn workspaces foreach -pivW -j unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server -pivW -j unlimited run dev",
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2}' run gqlgen",
"dev": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server --parallel --interlaced --verbose --worktree --jobs unlimited run dev",
"dev:minimal": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/server,@speckle/frontend-2}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev",
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
"dev:shared": "yarn workspace @speckle/shared dev",
@@ -65,6 +65,59 @@
<span>{{ option.label }}</span>
</label>
</div>
<div v-if="isWorkspacesEnabled">
<label
:for="`option-hide-logo`"
class="flex items-center gap-1 cursor-pointer max-w-max"
>
<FormCheckbox
id="option-hide-logo"
v-model="hideSpeckleBranding"
name="Hide Speckle logo"
hide-label
class="cursor-pointer"
:disabled="
workspaceHideSpeckleBrandingEnabled ||
!canEditEmbedOptions?.authorized
"
/>
<div class="flex flex-col gap-0.5">
<span
:key="`hide-branding-tooltip-${workspaceHideSpeckleBrandingEnabled}`"
v-tippy="hideSpeckleBrandingTooltip"
>
Hide Speckle logo
</span>
<span
v-if="
!canEditEmbedOptions?.authorized &&
canEditEmbedOptions?.code === 'WorkspaceNoFeatureAccess'
"
class="text-body-2xs text-foreground-2"
>
This feature is only available on the business plan
<NuxtLink
:to="settingsWorkspaceRoutes.billing.route(workspaceSlug)"
class="underline"
>
upgrade now
</NuxtLink>
</span>
<span
v-if="hideSpeckleBranding && !workspaceHideSpeckleBrandingEnabled"
class="text-body-2xs text-foreground-2"
>
Tip: You can also hide the logo for all embeds in
<NuxtLink
:to="settingsWorkspaceRoutes.billing.route(workspaceSlug)"
class="underline"
>
workspace settings.
</NuxtLink>
</span>
</div>
</label>
</div>
</div>
</LayoutDialogSection>
<LayoutDialogSection
@@ -100,11 +153,24 @@ import {
castToSupportedVisibility,
SupportedProjectVisibility
} from '~/lib/projects/helpers/visibility'
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
graphql(`
fragment ProjectsModelPageEmbed_Project on Project {
id
...ProjectsPageTeamDialogManagePermissions_Project
workspace {
id
slug
embedOptions {
hideSpeckleBranding
}
permissions {
canEditEmbedOptions {
...FullPermissionCheckResult
}
}
}
}
`)
@@ -123,6 +189,7 @@ const {
public: { baseUrl }
} = useRuntimeConfig()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const updateProject = useUpdateProject()
const mp = useMixpanel()
@@ -134,6 +201,7 @@ const disableModelLink = ref(false)
const preventScrolling = ref(false)
const manuallyLoadModel = ref(false)
const projectVisibility = ref(props.project.visibility)
const hideSpeckleBranding = ref(false)
const routeModelId = computed(() => route.params.modelId as string)
@@ -176,6 +244,14 @@ const updatedUrl = computed(() => {
}
})
if (
hideSpeckleBranding.value &&
isWorkspacesEnabled.value &&
!workspaceHideSpeckleBrandingEnabled.value
) {
embedOptions['hideSpeckleBranding'] = true
}
// Serialize the embedOptions into a hash fragment
const hashFragment = encodeURIComponent(JSON.stringify(embedOptions))
url.hash = `embed=${hashFragment}`
@@ -228,6 +304,25 @@ const nonDiscoverableButtons = computed((): LayoutDialogButton[] => [
}
])
const workspaceSlug = computed(() => {
return props.project.workspace?.slug
})
const canEditEmbedOptions = computed(() => {
return props.project.workspace?.permissions?.canEditEmbedOptions
})
const workspaceHideSpeckleBrandingEnabled = computed(() => {
if (!isWorkspacesEnabled.value) return false
return props.project.workspace?.embedOptions?.hideSpeckleBranding
})
const hideSpeckleBrandingTooltip = computed(() => {
if (!isWorkspacesEnabled.value) return ''
if (workspaceHideSpeckleBrandingEnabled.value) {
return 'Speckle branding is disabled for all embeds in this workspace'
}
return ''
})
const handleEmbedCodeCopy = async (value: string) => {
await copy(value, {
successMessage: 'Embed code copied to clipboard',
@@ -292,4 +387,15 @@ const embedDialogOptions = [
value: manuallyLoadModel
}
]
watch(
() => props.project.workspace?.embedOptions?.hideSpeckleBranding,
() => {
if (isWorkspacesEnabled.value) {
hideSpeckleBranding.value =
props.project.workspace?.embedOptions?.hideSpeckleBranding ?? false
}
},
{ immediate: true }
)
</script>
@@ -102,7 +102,7 @@
:name="modelName || 'Loading...'"
:date="lastUpdate"
:url="route.path"
:hide-speckle-branding="hideSpeckleBranding"
:hide-speckle-branding="hideSpeckleLogo"
:disable-model-link="disableModelLink"
/>
<Portal to="primary-actions">
@@ -142,6 +142,7 @@ graphql(`
embedOptions {
hideSpeckleBranding
}
hasAccessToFeature(featureName: hideSpeckleBranding)
}
`)
@@ -177,7 +178,8 @@ const {
hideSelectionInfo,
isTransparent,
showControls,
disableModelLink
disableModelLink,
hideSpeckleBranding
} = useEmbed()
const mp = useMixpanel()
@@ -262,8 +264,15 @@ const lastUpdate = computed(() => {
} else return undefined
})
const hideSpeckleBranding = computed(() => {
return project.value ? project.value?.embedOptions?.hideSpeckleBranding : true
const canEditEmbedOptions = computed(() => {
return project.value?.hasAccessToFeature
})
const hideSpeckleLogo = computed(() => {
if (!project.value?.workspace) return true
if (!canEditEmbedOptions.value) return false
if (project.value?.embedOptions?.hideSpeckleBranding) return true
else return hideSpeckleBranding.value
})
useHead({ title })
@@ -48,7 +48,7 @@ defineProps<{
date?: string
name?: string
url?: string
hideSpeckleBranding?: boolean
hideSpeckleBranding?: MaybeNullOrUndefined<boolean>
disableModelLink?: MaybeNullOrUndefined<boolean>
}>()
@@ -1,6 +1,6 @@
<template>
<CommonCard
class="w-full border-outline-2 !p-4"
class="w-full border-outline-2 !py-3 px-4"
:class="{
'cursor-pointer hover:border-outline-3 shadow-sm hover:border-zinc-400 bg-foundation':
clickable
@@ -8,11 +8,13 @@
@click="clickable && onClick"
>
<div class="flex flex-col sm:flex-row justify-between gap-4">
<div class="flex gap-4">
<WorkspaceAvatar :name="name" :logo="logo" size="xl" />
<div class="flex gap-3">
<WorkspaceAvatar :name="name" :logo="logo" size="lg" />
<div class="flex flex-col sm:flex-row gap-4 justify-between flex-1">
<div class="flex flex-col items-start text-body-2xs text-foreground-2">
<h6 class="text-heading-sm text-foreground">{{ name }}</h6>
<h6 class="text-foreground text-body-sm font-medium">
{{ name }}
</h6>
<slot name="text"></slot>
</div>
</div>
@@ -25,12 +25,21 @@
{{ description }}
</p>
<WorkspaceDiscoverableWorkspacesCard
v-for="workspace in discoverableWorkspacesAndJoinRequests"
v-for="workspace in workspacesToShow"
:key="`discoverable-${workspace.id}`"
:workspace="workspace"
:request-status="workspace.requestStatus"
location="workspace_join_page"
/>
<FormButton
v-if="!showAllWorkspaces && discoverableWorkspacesAndJoinRequestsCount > 3"
color="subtle"
size="lg"
full-width
@click="showAllWorkspaces = true"
>
Show all ({{ discoverableWorkspacesAndJoinRequestsCount }})
</FormButton>
<div class="mt-2 w-full flex flex-col gap-2">
<FormButton
v-if="hasDiscoverableJoinRequests && !isWorkspaceNewPlansEnabled"
@@ -77,6 +86,14 @@ const {
hasDiscoverableJoinRequests
} = useDiscoverableWorkspaces()
const showAllWorkspaces = ref(false)
const workspacesToShow = computed(() => {
return showAllWorkspaces.value
? discoverableWorkspacesAndJoinRequests.value
: discoverableWorkspacesAndJoinRequests.value.slice(0, 3)
})
const description = computed(() => {
if (discoverableWorkspacesAndJoinRequestsCount.value === 1) {
return 'We found a workspace that matches your email domain'
@@ -1,11 +1,30 @@
<template>
<WorkspaceCard :logo="workspace.logo ?? ''" :name="workspace.name">
<WorkspaceCard
:logo="workspace.logo ?? ''"
:name="workspace.name"
:class="requestStatus === 'pending' ? '' : 'bg-foundation'"
>
<template #text>
<div class="flex flex-col gap-y-1">
<div class="text-body-2xs line-clamp-3">
<div v-if="workspace.description" class="text-body-2xs line-clamp-3">
{{ workspace.description }}
</div>
<UserAvatarGroup :users="users" :max-count="5" size="sm" />
<div class="flex flex-col gap-2">
<UserAvatarGroup
v-if="members.length > 0 && requestStatus !== 'pending'"
:users="members"
:max-count="5"
size="base"
/>
<div class="flex gap-1 text-body-3xs text-foreground-2">
<span class="font-medium">Admins:</span>
<span v-for="(admin, index) in adminTeam.slice(0, 3)" :key="admin.id">
{{ admin.name
}}{{ index < 2 && index < adminTeam.length - 1 ? ', ' : '' }}
</span>
<span v-if="adminTeam.length > 3">+{{ adminTeam.length - 3 }}</span>
</div>
</div>
</div>
</template>
<template #actions>
@@ -45,7 +64,13 @@ const { requestToJoinWorkspace, dismissDiscoverableWorkspace } =
useDiscoverableWorkspaces()
const mixpanel = useMixpanel()
const users = computed(() => props.workspace.team?.items?.map((u) => u.user) ?? [])
const adminTeam = computed(() => props.workspace.adminTeam?.map((t) => t.user) ?? [])
const adminIds = computed(() => new Set(adminTeam.value.map((admin) => admin.id)))
const members = computed(() =>
(props.workspace.team?.items?.map((u) => u.user) ?? []).filter(
(user) => !adminIds.value.has(user.id)
)
)
const onRequest = () => {
requestToJoinWorkspace(props.workspace.id, props.location || 'discovery_card')
@@ -30,11 +30,20 @@
</div>
</div>
<div v-if="verifiedDomain" class="flex flex-col gap-2 w-full">
<FormCheckbox
v-model="enableDomainDiscoverabilityModel"
name="enableDomainDiscoverability"
:label="`Allow users with the @${verifiedDomain} domain to request to join workspace`"
/>
<CommonCard class="flex flex-col gap-1 !p-3">
<FormCheckbox
v-model="enableDomainDiscoverabilityModel"
name="enableDomainDiscoverability"
:label="`Make workspace discoverable to @${verifiedDomain} users`"
/>
<div class="ml-6 text-body-2xs text-foreground-2 select-none">
<p>
Users signing up with a
<span class="font-medium">@{{ verifiedDomain }}</span>
email will be able to find and request to join this workspace.
</p>
</div>
</CommonCard>
</div>
<div class="flex flex-col gap-3 mt-4 w-full md:max-w-96">
<FormButton size="lg" submit full-width>{{ nextButtonText }}</FormButton>
@@ -61,7 +61,7 @@ type Documents = {
"\n fragment ProjectModelPageDialogDeleteVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogDeleteVersionFragmentDoc,
"\n fragment ProjectModelPageDialogEditMessageVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogEditMessageVersionFragmentDoc,
"\n fragment ProjectModelPageDialogMoveToVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogMoveToVersionFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": typeof types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n referencedObject\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectModelPageVersionsCardVersionFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n": typeof types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageAutomationDeleteDialog_Project on Project {\n id\n name\n workspaceId\n }\n": typeof types.ProjectPageAutomationDeleteDialog_ProjectFragmentDoc,
@@ -131,7 +131,7 @@ type Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": typeof types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": typeof types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": typeof types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n": typeof types.ModelPageProjectFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n": typeof types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": typeof types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": typeof types.ViewerCommentsListItemFragmentDoc,
@@ -363,8 +363,8 @@ type Documents = {
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": typeof types.OnViewerCommentsUpdatedDocument,
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": typeof types.LinkableCommentFragmentDoc,
"\n fragment ActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n }\n": typeof types.ActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n": typeof types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": typeof types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n": typeof types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": typeof types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment WorkspacePlanLimits_Workspace on Workspace {\n id\n slug\n plan {\n name\n }\n }\n": typeof types.WorkspacePlanLimits_WorkspaceFragmentDoc,
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspacesPlan_Workspace on Workspace {\n id\n slug\n plan {\n status\n createdAt\n name\n paymentMethod\n usage {\n projectCount\n modelCount\n }\n }\n seats {\n editors {\n assigned\n available\n }\n viewers {\n assigned\n available\n }\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n currency\n }\n }\n": typeof types.WorkspacesPlan_WorkspaceFragmentDoc,
@@ -482,7 +482,7 @@ const documents: Documents = {
"\n fragment ProjectModelPageDialogDeleteVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogDeleteVersionFragmentDoc,
"\n fragment ProjectModelPageDialogEditMessageVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogEditMessageVersionFragmentDoc,
"\n fragment ProjectModelPageDialogMoveToVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogMoveToVersionFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n referencedObject\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectModelPageVersionsCardVersionFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n": types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageAutomationDeleteDialog_Project on Project {\n id\n name\n workspaceId\n }\n": types.ProjectPageAutomationDeleteDialog_ProjectFragmentDoc,
@@ -552,7 +552,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc,
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
@@ -784,8 +784,8 @@ const documents: Documents = {
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument,
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc,
"\n fragment ActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n }\n": types.ActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n": types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n": types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc,
"\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n": types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc,
"\n fragment WorkspacePlanLimits_Workspace on Workspace {\n id\n slug\n plan {\n name\n }\n }\n": types.WorkspacePlanLimits_WorkspaceFragmentDoc,
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment WorkspacesPlan_Workspace on Workspace {\n id\n slug\n plan {\n status\n createdAt\n name\n paymentMethod\n usage {\n projectCount\n modelCount\n }\n }\n seats {\n editors {\n assigned\n available\n }\n viewers {\n assigned\n available\n }\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n currency\n }\n }\n": types.WorkspacesPlan_WorkspaceFragmentDoc,
@@ -1061,7 +1061,7 @@ export function graphql(source: "\n fragment ProjectModelPageDialogMoveToVersio
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n"): (typeof documents)["\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n"];
export function graphql(source: "\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n workspace {\n id\n slug\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1341,7 +1341,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecuritySsoWrapp
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n"): (typeof documents)["\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n }\n"];
export function graphql(source: "\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n"): (typeof documents)["\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2269,11 +2269,11 @@ export function graphql(source: "\n fragment ActiveWorkspace_Workspace on Works
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n"];
export function graphql(source: "\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n adminTeam {\n user {\n id\n name\n avatar\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 fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n adminTeam {\n user {\n id\n name\n avatar\n }\n }\n team {\n totalCount\n items {\n user {\n id\n name\n avatar\n }\n }\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
@@ -12,6 +12,7 @@ export type EmbedOptions = {
disableModelLink?: boolean
noScroll?: boolean
manualLoad?: boolean
hideSpeckleBranding?: boolean
}
export function isEmbedOptions(obj: unknown): obj is EmbedOptions {
@@ -26,7 +27,8 @@ export function isEmbedOptions(obj: unknown): obj is EmbedOptions {
'hideSelectionInfo',
'disableModelLink',
'noScroll',
'manualLoad'
'manualLoad',
'hideSpeckleBranding'
].includes(key) &&
typeof possibleOptions[key as keyof EmbedOptions] === 'boolean'
)
@@ -101,6 +103,7 @@ export function useEmbed() {
const isEnabled = createComputed('isEnabled')
const isTransparent = createComputed('isTransparent')
const disableModelLink = createComputed('disableModelLink')
const hideSpeckleBranding = createComputed('hideSpeckleBranding')
const hideSelectionInfo = createComputed('hideSelectionInfo')
const noScroll = createComputed('noScroll')
const manualLoad = createComputed('manualLoad')
@@ -125,6 +128,7 @@ export function useEmbed() {
showControls: showControlsNew,
hideSelectionInfo,
disableModelLink,
hideSpeckleBranding,
noScroll,
manualLoad
}
@@ -30,6 +30,13 @@ graphql(`
}
}
}
adminTeam {
user {
id
name
avatar
}
}
}
`)
@@ -42,6 +49,13 @@ graphql(`
name
logo
slug
adminTeam {
user {
id
name
avatar
}
}
team {
totalCount
items {
+1 -1
View File
@@ -5,13 +5,13 @@
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=8192 nuxt build",
"build:postinstall": "nuxt prepare",
"build:sourcemaps": "BUILD_SOURCEMAPS=true yarn build",
"dev:nuxt": "nuxt dev",
"dev:app": "concurrently \"nuxt dev\" \"yarn gqlgen:watch\"",
"dev": "yarn dev:app",
"preview": "nuxt preview",
"analyze": "nuxt analyze",
"postinstall": "yarn ensure:tailwind-deps && nuxt prepare",
"lint:js": "eslint .",
"lint:tsc": "vue-tsc --noEmit",
"lint:prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --check .",
@@ -6,7 +6,7 @@ export default class BatchedPool<T> {
#baseInterval: number
#processingLoop: Promise<void>
#finished = false
#disposed = false
constructor(params: {
concurrencyAndSizes: number[]
@@ -28,7 +28,7 @@ export default class BatchedPool<T> {
}
async #runWorker(batchSize: number): Promise<void> {
while (!this.#finished || this.#queue.length > 0) {
while (!this.#disposed || this.#queue.length > 0) {
if (this.#queue.length > 0) {
const batch = this.getBatch(batchSize)
await this.#processFunction(batch)
@@ -38,7 +38,7 @@ export default class BatchedPool<T> {
}
async disposeAsync(): Promise<void> {
this.#finished = true
this.#disposed = true
await this.#processingLoop
}
@@ -10,7 +10,7 @@ export default class BatchingQueue<T> {
#maxInterval: number
#processingLoop: Promise<void>
#finished = false
#disposed = false
constructor(params: {
batchSize: number
@@ -26,7 +26,7 @@ export default class BatchingQueue<T> {
}
async disposeAsync(): Promise<void> {
this.#finished = true
this.#disposed = true
await this.#processingLoop
}
@@ -42,13 +42,17 @@ export default class BatchingQueue<T> {
return this.#queue.size
}
isDisposed(): boolean {
return this.#disposed
}
#getBatch(batchSize: number): T[] {
return this.#queue.spliceValues(0, Math.min(batchSize, this.#queue.size))
}
async #loop(): Promise<void> {
let interval = this.#baseInterval
while (!this.#finished || this.#queue.size > 0) {
while (!this.#disposed || this.#queue.size > 0) {
const startTime = performance.now()
if (this.#queue.size > 0) {
const batch = this.#getBatch(this.#batchSize)
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'vitest'
import { CachePump } from './cachePump.js'
import { Database } from '../operations/interfaces.js'
import AsyncGeneratorQueue from './asyncGeneratorQueue.js'
import { Item } from '../types/types.js'
import { DefermentManager } from './defermentManager.js'
const makeDatabase = (): Database =>
({
cacheSaveBatch: async (): Promise<void> => {},
getAll: async (): Promise<(Item | undefined)[]> => Promise.resolve([])
} as unknown as Database)
const makeGathered = (): AsyncGeneratorQueue<Item> =>
({
add: () => {},
async *consume() {}
} as unknown as AsyncGeneratorQueue<Item>)
const makeDeferments = (): DefermentManager =>
({
undefer: () => {}
} as unknown as DefermentManager)
describe('CachePump disposal', () => {
test('disposeAsync is idempotent and always resolves', async () => {
const pump = new CachePump(makeDatabase(), makeGathered(), makeDeferments(), {
maxCacheWriteSize: 2,
maxCacheBatchWriteWait: 100,
maxCacheBatchReadWait: 1,
maxWriteQueueSize: 2,
maxCacheReadSize: 2
})
await pump.disposeAsync()
await expect(pump.disposeAsync()).resolves.toBeUndefined()
})
test('should not throw on add after dispose if writeQueue was never created', async () => {
const pump = new CachePump(makeDatabase(), makeGathered(), makeDeferments(), {
maxCacheWriteSize: 2,
maxCacheBatchWriteWait: 100,
maxCacheBatchReadWait: 1,
maxWriteQueueSize: 2,
maxCacheReadSize: 2
})
await pump.disposeAsync()
// Should not throw, but will not add anything
expect(() =>
pump.add({ baseId: 'a', base: { id: 'b', speckle_type: 'type' } })
).not.toThrow()
})
})
@@ -5,6 +5,7 @@ import BufferQueue from './bufferQueue.js'
import AsyncGeneratorQueue from './asyncGeneratorQueue.js'
import { DefermentManager } from './defermentManager.js'
import { MemoryDatabase } from '../operations/databases/memoryDatabase.js'
import { Database } from '../operations/interfaces.js'
describe('CachePump testing', () => {
test('write two items to queue use pumpItems that are NOT found', async () => {
@@ -71,4 +72,32 @@ describe('CachePump testing', () => {
expect(notFoundItems.values()).toMatchSnapshot()
await cachePump.disposeAsync()
})
test('can dispose while waiting and not wait', async () => {
const i1: Item = { baseId: 'id1', base: { id: 'id', speckle_type: 'type' } }
const i2: Item = { baseId: 'id2', base: { id: 'id', speckle_type: 'type' } }
const db: Database = {
getAll: async () => Promise.resolve([])
} as unknown as Database
const gathered = new AsyncGeneratorQueue<Item>()
const deferments = new DefermentManager({ maxSizeInMb: 1, ttlms: 1 })
const cachePump = new CachePump(db, gathered, deferments, {
maxCacheReadSize: 1,
maxCacheWriteSize: 1,
maxCacheBatchWriteWait: 1,
maxCacheBatchReadWait: 1,
maxWriteQueueSize: 1
})
const foundItems = new BufferQueue<Item>()
const notFoundItems = new BufferQueue<string>()
await cachePump.disposeAsync()
await cachePump.pumpItems({
ids: [i1.baseId, i2.baseId],
foundItems,
notFoundItems
})
})
})
@@ -57,6 +57,7 @@ export class CachePump implements Pump {
const maxCacheReadSize = this.#options.maxCacheReadSize
for (let i = 0; i < ids.length; ) {
if (this.#writeQueue?.isDisposed()) break
if ((this.#writeQueue?.count() ?? 0) > this.#options.maxWriteQueueSize) {
this.#logger(
'pausing reads (# in write queue: ' + this.#writeQueue?.count() + ')'
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { DefermentManager } from './defermentManager.js'
import { DefermentManagerOptions } from '../operations/options.js'
import { Item } from '../types/types.js'
describe('DefermentManager disposal', () => {
const options: DefermentManagerOptions = { ttlms: 10, maxSizeInMb: 1 }
const makeItem = (id: string): Item => ({
baseId: id,
base: { id, speckle_type: 'test' }
})
it('should throw on get/defer/undefer after dispose', async () => {
const manager = new DefermentManager(options)
manager.dispose()
expect(() => manager.get('a')).toThrow('DefermentManager is disposed')
expect(() => manager.undefer(makeItem('a'))).toThrow('DefermentManager is disposed')
await expect(manager.defer({ id: 'a' })).rejects.toThrow(
'DefermentManager is disposed'
)
})
it('dispose is idempotent', () => {
const manager = new DefermentManager(options)
manager.dispose()
expect(() => manager.dispose()).not.toThrow()
})
})
@@ -7,6 +7,7 @@ export class DefermentManager {
private timer?: ReturnType<typeof setTimeout>
private logger: CustomLogger
private currentSize = 0
private disposed = false
constructor(private options: DefermentManagerOptions) {
this.resetGlobalTimer()
@@ -22,10 +23,12 @@ export class DefermentManager {
}
get(id: string): DeferredBase | undefined {
if (this.disposed) throw new Error('DefermentManager is disposed')
return this.deferments.get(id)
}
async defer(params: { id: string }): Promise<Base> {
if (this.disposed) throw new Error('DefermentManager is disposed')
const now = this.now()
const deferredBase = this.deferments.get(params.id)
if (deferredBase) {
@@ -42,6 +45,7 @@ export class DefermentManager {
}
undefer(item: Item): void {
if (this.disposed) throw new Error('DefermentManager is disposed')
const now = this.now()
this.currentSize += item.size || 0
//order matters here with found before undefer
@@ -65,6 +69,8 @@ export class DefermentManager {
}
dispose(): void {
if (this.disposed) return
this.disposed = true
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
@@ -48,48 +48,14 @@ describe('downloader', () => {
fetch: fetchMocker
})
downloader.initializePool({ results: pump, total: 2, maxDownloadBatchWait: 200 })
downloader.add('id')
downloader.add('id1')
downloader.add('id2')
await downloader.disposeAsync()
const r = []
for await (const x of pump.gather([i1.baseId, i2.baseId])) {
r.push(x)
}
expect(r).toMatchSnapshot()
})
test('download batch of three', async () => {
const fetchMocker = createFetchMock(vi)
const i1: Item = { baseId: 'id1', base: { id: 'id1', speckle_type: 'type' } }
const i2: Item = { baseId: 'id2', base: { id: 'id2', speckle_type: 'type' } }
const i3: Item = { baseId: 'id3', base: { id: 'id3', speckle_type: 'type' } }
fetchMocker.mockResponseOnce(
'id1\t' +
JSON.stringify(i1.base) +
'\nid2\t' +
JSON.stringify(i2.base) +
'\nid3\t' +
JSON.stringify(i3.base) +
'\n'
)
const pump = new MemoryPump()
const downloader = new ServerDownloader({
serverUrl: 'http://speckle.test',
streamId: 'streamId',
objectId: 'objectId',
token: 'token',
fetch: fetchMocker
})
downloader.initializePool({ results: pump, total: 2, maxDownloadBatchWait: 200 })
downloader.add('id')
await downloader.disposeAsync()
const r = []
for await (const x of pump.gather([i1.baseId, i2.baseId, i3.baseId])) {
r.push(x)
}
expect(r).toMatchSnapshot()
await downloader.disposeAsync()
})
@@ -118,8 +84,10 @@ describe('downloader', () => {
fetch: fetchMocker
})
downloader.initializePool({ results: pump, total: 2, maxDownloadBatchWait: 200 })
downloader.add('id')
downloader.initializePool({ results: pump, total: 3, maxDownloadBatchWait: 200 })
downloader.add('id1')
downloader.add('id2')
downloader.add('id3')
await downloader.disposeAsync()
const r = []
for await (const x of pump.gather([i1.baseId, i2.baseId, i3.baseId])) {
+1 -1
View File
@@ -13,7 +13,7 @@ const ignore = [
/** @type {import("mocha").MochaOptions} */
const config = {
spec: ['modules/**/*.spec.js', 'modules/**/*.spec.ts', 'observability/**/*.spec.ts'],
spec: ['modules/**/*.spec.ts', 'observability/**/*.spec.ts'],
require: ['test/hooks.ts'],
...(ignore.length ? { ignore } : {}),
slow: 0,
@@ -148,9 +148,14 @@ extend type Workspace {
planPrices: WorkspacePaidPlanPrices
}
extend type Project {
hasAccessToFeature(featureName: WorkspaceFeatureName!): Boolean!
}
enum WorkspaceFeatureName {
domainBasedSecurityPolicies
oidcSso
hideSpeckleBranding
workspaceDataRegionSpecificity
}
@@ -2058,6 +2058,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
hasAccessToFeature: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
invitableCollaborators: WorkspaceCollaboratorCollection;
/** Collaborators who have been invited, but not yet accepted. */
@@ -2139,6 +2140,11 @@ export type ProjectCommentThreadsArgs = {
};
export type ProjectHasAccessToFeatureArgs = {
featureName: WorkspaceFeatureName;
};
export type ProjectInvitableCollaboratorsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<InvitableCollaboratorsFilter>;
@@ -4673,6 +4679,7 @@ export type WorkspaceEmbedOptions = {
export const WorkspaceFeatureName = {
DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies',
HideSpeckleBranding: 'hideSpeckleBranding',
OidcSso: 'oidcSso',
WorkspaceDataRegionSpecificity: 'workspaceDataRegionSpecificity'
} as const;
@@ -6689,6 +6696,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
embedOptions?: Resolver<ResolversTypes['ProjectEmbedOptions'], ParentType, ContextType>;
hasAccessToFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<ProjectHasAccessToFeatureArgs, 'featureName'>>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
invitableCollaborators?: Resolver<ResolversTypes['WorkspaceCollaboratorCollection'], ParentType, ContextType, RequireFields<ProjectInvitableCollaboratorsArgs, 'limit'>>;
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingStreamCollaborator']>>, ParentType, ContextType>;
@@ -2038,6 +2038,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
hasAccessToFeature: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
invitableCollaborators: WorkspaceCollaboratorCollection;
/** Collaborators who have been invited, but not yet accepted. */
@@ -2119,6 +2120,11 @@ export type ProjectCommentThreadsArgs = {
};
export type ProjectHasAccessToFeatureArgs = {
featureName: WorkspaceFeatureName;
};
export type ProjectInvitableCollaboratorsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<InvitableCollaboratorsFilter>;
@@ -4653,6 +4659,7 @@ export type WorkspaceEmbedOptions = {
export const WorkspaceFeatureName = {
DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies',
HideSpeckleBranding: 'hideSpeckleBranding',
OidcSso: 'oidcSso',
WorkspaceDataRegionSpecificity: 'workspaceDataRegionSpecificity'
} as const;
@@ -1,7 +1,7 @@
import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { authorizeResolver } from '@/modules/shared'
import { Roles, throwUncoveredError } from '@speckle/shared'
import { Roles, throwUncoveredError, WorkspacePlanFeatures } from '@speckle/shared'
import {
getWorkspaceFactory,
getWorkspaceRoleForUserFactory,
@@ -161,6 +161,28 @@ export = FF_GATEKEEPER_MODULE_ENABLED
return { workspaceId: parent.id }
}
},
Project: {
hasAccessToFeature: async (parent, args) => {
if (!parent.workspaceId) {
return false
}
switch (args.featureName) {
case WorkspacePlanFeatures.HideSpeckleBranding: {
return await canWorkspaceAccessFeatureFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db })
})({
workspaceId: parent.workspaceId,
workspaceFeature: args.featureName
})
}
default: {
// Only publicly validate embed-related features at the project level
return false
}
}
}
},
WorkspacePlan: {
usage: async (parent) => {
return { workspaceId: parent.workspaceId }
@@ -27,6 +27,11 @@ const resolvers: Resolvers = FF_GATEKEEPER_MODULE_ENABLED
},
ServerWorkspacesInfo: {
planPrices: () => null
},
Project: {
hasAccessToFeature: () => {
return false
}
}
}
@@ -2039,6 +2039,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
hasAccessToFeature: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
invitableCollaborators: WorkspaceCollaboratorCollection;
/** Collaborators who have been invited, but not yet accepted. */
@@ -2120,6 +2121,11 @@ export type ProjectCommentThreadsArgs = {
};
export type ProjectHasAccessToFeatureArgs = {
featureName: WorkspaceFeatureName;
};
export type ProjectInvitableCollaboratorsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<InvitableCollaboratorsFilter>;
@@ -4654,6 +4660,7 @@ export type WorkspaceEmbedOptions = {
export const WorkspaceFeatureName = {
DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies',
HideSpeckleBranding: 'hideSpeckleBranding',
OidcSso: 'oidcSso',
WorkspaceDataRegionSpecificity: 'workspaceDataRegionSpecificity'
} as const;