feat: enable saved views for all workspace plans (#5343)

* feat: enable saved views for all workspace plans

* more test fixes
This commit is contained in:
Kristaps Fabians Geikins
2025-09-01 10:25:10 +03:00
committed by GitHub
parent fdf3b93e95
commit a074aedd65
11 changed files with 154 additions and 269 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

@@ -97,7 +97,6 @@
/>
</div>
</template>
<ViewerSavedViewsPlanUpsell v-else />
<ViewerSavedViewsPanelGroupsCreateDialog
v-model:open="showCreateGroupDialog"
@success="onAddGroup"
@@ -1,20 +0,0 @@
<template>
<div class="flex flex-col gap-4 p-4">
<img src="~/assets/images/viewer/saved-views/plan_upsell.webp" alt="Saved Views" />
<div>
<div class="text-foreground text-body font-semibold">Save custom views</div>
<div class="text-body-2xs font-medium text-foreground-2">
<p class="pb-3">Upgrade to a business plan to save, organise and present</p>
<ul class="flex flex-col gap-2 list-disc list-inside">
<li>It's cool</li>
<li>It's nice</li>
<li>It's got enough spice</li>
</ul>
</div>
</div>
<div class="flex gap-2">
<FormButton size="sm">Upgrade</FormButton>
<FormButton size="sm" color="outline">Learn more</FormButton>
</div>
</div>
</template>
@@ -74,7 +74,7 @@ import { Roles, WorkspacePlans } from '@speckle/shared'
import {
ProjectNotEnoughPermissionsError,
SavedViewNoAccessError,
WorkspacePlanNoFeatureAccessError
WorkspaceNoAccessError
} from '@speckle/shared/authz'
import * as ViewerRoute from '@speckle/shared/viewer/route'
import { resourceBuilder } from '@speckle/shared/viewer/route'
@@ -121,7 +121,6 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
let otherGuy: BasicTestUser
let myProject: BasicTestStream
let myProjectWorkspace: BasicTestWorkspace
let myLackingProjectWorkspace: BasicTestWorkspace
let myLackingProject: BasicTestStream
let myModel1: BasicTestBranch
let myModel2: BasicTestBranch
@@ -269,13 +268,13 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
addPlan: WorkspacePlans.Pro
})
])
myLackingProjectWorkspace = workspaceCreate[0]
myProjectWorkspace = workspaceCreate[1]
const projectCreate = await Promise.all([
createTestStream(
buildBasicTestProject({
workspaceId: myLackingProjectWorkspace.id
// non-workspaced project
workspaceId: undefined
}),
me
),
@@ -355,61 +354,6 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok
})
it('should fail with ForbiddenError if workspace plan does not include SavedViews', async () => {
const res = await createSavedView(
buildCreateInput({
projectId: myLackingProject.id,
resourceIdString: 'abc'
})
)
expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code })
expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok
})
it('should fail with ForbiddenError to create a saved view group if user lacks access (free plan)', async () => {
const resourceIds = ViewerRoute.resourceBuilder().addModel(
myLackingProject.id
)
const resourceIdString = resourceIds.toString()
const res = await createSavedViewGroup({
input: {
projectId: myLackingProject.id,
resourceIdString,
groupName: 'Should Not Work'
}
})
expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code })
expect(res.data?.projectMutations.savedViewMutations.createGroup).to.not.be.ok
})
it('should fail with ForbiddenError to create a saved view if user lacks access (free plan)', async () => {
const resourceIds = ViewerRoute.resourceBuilder().addModel(
myLackingProject.id
)
const resourceIdString = resourceIds.toString()
const viewerState = fakeViewerState({
projectId: myLackingProject.id,
resources: {
request: {
resourceIdString
}
}
})
const res = await createSavedView(
buildCreateInput({
projectId: myLackingProject.id,
resourceIdString,
viewerState
})
)
expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code })
expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok
})
it('should support dedicated auth policy check', async () => {
const res = await canCreateSavedView({
projectId: myLackingProject.id
@@ -419,7 +363,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
const data = res.data?.project.permissions.canCreateSavedView
expect(data?.authorized).to.be.false
expect(data?.code).to.equal(WorkspacePlanNoFeatureAccessError.code)
expect(data?.code).to.equal(WorkspaceNoAccessError.code)
})
})
@@ -977,7 +977,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthOKResult()
@@ -990,7 +990,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -1008,7 +1008,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -1026,7 +1026,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews,
feature: WorkspacePlanFeatures.HideSpeckleBranding,
allowUnworkspaced: true
})
@@ -1044,7 +1044,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -20,8 +20,7 @@ import {
SavedViewNoAccessError,
SavedViewNotFoundError,
UngroupedSavedViewGroupLockError,
WorkspaceNoAccessError,
WorkspacePlanNoFeatureAccessError
WorkspaceNoAccessError
} from '../domain/authErrors.js'
import { nanoid } from 'nanoid'
@@ -191,11 +190,17 @@ describe('ensureCanAccessSavedViewFragment', () => {
)
it.each(<const>['read', 'write'])(
'fails when workspace plan is too cheap (%s)',
'succeeds to %s even on free plan',
async (access) => {
const sut = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: 'team'
name: 'free'
}),
getSavedView: getSavedViewFake({
id: savedViewId,
projectId,
visibility: SavedViewVisibility.public,
authorId: userId
})
})
@@ -205,9 +210,7 @@ describe('ensureCanAccessSavedViewFragment', () => {
savedViewId,
access
})
expect(result).toBeAuthErrorResult({
code: WorkspacePlanNoFeatureAccessError.code
})
expect(result).toBeAuthOKResult()
}
)
@@ -413,27 +416,6 @@ describe('ensureCanAccessSavedViewGroupFragment', () => {
})
})
it.each(<const>['read', 'write'])(
'fails when workspace plan is too cheap (%s)',
async (access) => {
const sut = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: 'team'
})
})
const result = await sut({
userId,
projectId,
savedViewGroupId,
access
})
expect(result).toBeAuthErrorResult({
code: WorkspacePlanNoFeatureAccessError.code
})
}
)
it.each(<const>['read', 'write'])(
'fails if view doesnt exist (%s)',
async (access) => {
@@ -347,7 +347,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeOKResult()
@@ -362,7 +362,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -380,7 +380,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -395,7 +395,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -413,7 +413,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -14,10 +14,9 @@ import {
ProjectNotEnoughPermissionsError,
ServerNoAccessError,
WorkspaceNoAccessError,
WorkspacePlanNoFeatureAccessError,
WorkspaceReadOnlyError
} from '../../../domain/authErrors.js'
import { PaidWorkspacePlans } from '../../../../workspaces/index.js'
import { WorkspacePlans } from '../../../../workspaces/index.js'
const buildSUT = (overrides?: OverridesOf<typeof canCreateSavedViewPolicy>) =>
canCreateSavedViewPolicy({
@@ -71,7 +70,7 @@ describe('canCreateSavedViewPolicy', () => {
id: 'workspace-id'
}),
getWorkspacePlan: getWorkspacePlanFake({
name: PaidWorkspacePlans.Pro
name: WorkspacePlans.Pro
}),
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -153,10 +152,10 @@ describe('canCreateSavedViewPolicy', () => {
})
})
it('fails if not on pro/business plan', async () => {
it('succeeds even on free plan', async () => {
const canCreate = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: PaidWorkspacePlans.Team
name: WorkspacePlans.Free
})
})
@@ -164,15 +163,13 @@ describe('canCreateSavedViewPolicy', () => {
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspacePlanNoFeatureAccessError.code
})
expect(result).toBeAuthOKResult()
})
it('fails if workspace readonly', async () => {
const canCreate = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: PaidWorkspacePlans.Pro,
name: WorkspacePlans.Pro,
status: 'canceled'
})
})
+122 -120
View File
@@ -134,135 +134,137 @@ export const WorkspacePaidPlanConfigs: (params: {
featureFlags: Partial<FeatureFlags> | undefined
}) => {
[plan in PaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = (params) => ({
[PaidWorkspacePlans.Team]: {
plan: PaidWorkspacePlans.Team,
features: [...baseFeatures],
limits: {
projectCount: 5,
modelCount: 25,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.TeamUnlimited]: {
plan: PaidWorkspacePlans.TeamUnlimited,
features: [...baseFeatures],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.Pro]: {
plan: PaidWorkspacePlans.Pro,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: {
projectCount: 10,
modelCount: 50,
versionsHistory: null,
commentHistory: null
}
},
[PaidWorkspacePlans.ProUnlimited]: {
plan: PaidWorkspacePlans.ProUnlimited,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: null,
commentHistory: null
} = (params) => {
const finalBaseFeatures = [
...baseFeatures,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
]
return {
[PaidWorkspacePlans.Team]: {
plan: PaidWorkspacePlans.Team,
features: [...finalBaseFeatures],
limits: {
projectCount: 5,
modelCount: 25,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.TeamUnlimited]: {
plan: PaidWorkspacePlans.TeamUnlimited,
features: [...finalBaseFeatures],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.Pro]: {
plan: PaidWorkspacePlans.Pro,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: {
projectCount: 10,
modelCount: 50,
versionsHistory: null,
commentHistory: null
}
},
[PaidWorkspacePlans.ProUnlimited]: {
plan: PaidWorkspacePlans.ProUnlimited,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: null,
commentHistory: null
}
}
}
})
}
export const WorkspaceUnpaidPlanConfigs: (params: {
featureFlags: Partial<FeatureFlags> | undefined
}) => {
[plan in UnpaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = (params) => ({
[UnpaidWorkspacePlans.Enterprise]: {
plan: UnpaidWorkspacePlans.Enterprise,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: unlimited
},
[UnpaidWorkspacePlans.Unlimited]: {
plan: UnpaidWorkspacePlans.Unlimited,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: unlimited
},
[UnpaidWorkspacePlans.Academia]: {
plan: UnpaidWorkspacePlans.Academia,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: unlimited
},
[UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).teamUnlimited,
plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced
},
[UnpaidWorkspacePlans.ProUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).proUnlimited,
plan: UnpaidWorkspacePlans.ProUnlimitedInvoiced
},
[UnpaidWorkspacePlans.Free]: {
plan: UnpaidWorkspacePlans.Free,
features: baseFeatures,
limits: {
projectCount: 1,
modelCount: 5,
versionsHistory: { value: 7, unit: 'day' },
commentHistory: { value: 7, unit: 'day' }
} = (params) => {
const finalBaseFeatures = [
...baseFeatures,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
]
return {
[UnpaidWorkspacePlans.Enterprise]: {
plan: UnpaidWorkspacePlans.Enterprise,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership
],
limits: unlimited
},
[UnpaidWorkspacePlans.Unlimited]: {
plan: UnpaidWorkspacePlans.Unlimited,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership
],
limits: unlimited
},
[UnpaidWorkspacePlans.Academia]: {
plan: UnpaidWorkspacePlans.Academia,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: unlimited
},
[UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).teamUnlimited,
plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced
},
[UnpaidWorkspacePlans.ProUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).proUnlimited,
plan: UnpaidWorkspacePlans.ProUnlimitedInvoiced
},
[UnpaidWorkspacePlans.Free]: {
plan: UnpaidWorkspacePlans.Free,
features: finalBaseFeatures,
limits: {
projectCount: 1,
modelCount: 5,
versionsHistory: { value: 7, unit: 'day' },
commentHistory: { value: 7, unit: 'day' }
}
}
}
})
}
export const WorkspacePlanConfigs = (params: {
featureFlags: Partial<FeatureFlags> | undefined
+1 -21
View File
@@ -1,23 +1,3 @@
{
/* load each package separately, rather than as one giant progream */
"files": [],
"references": [
{ "path": "packages/fileimport-service" },
{ "path": "packages/frontend-2" },
{ "path": "packages/monitor-deployment" },
{ "path": "packages/objectloader" },
{ "path": "packages/objectloader2" },
{ "path": "packages/objectsender" },
{ "path": "packages/preview-frontend" },
{ "path": "packages/preview-service" },
{ "path": "packages/server" },
{ "path": "packages/shared" },
{ "path": "packages/tailwind-theme" },
{ "path": "packages/ui-components" },
{ "path": "packages/ui-components-nuxt" },
{ "path": "packages/viewer" },
{ "path": "packages/viewer-sandbox" },
{ "path": "packages/webhook-service" }
/* add all other packages listed in workspace.code-workspace */
]
"files": []
}
+1
View File
@@ -111,6 +111,7 @@
"Prorotation"
],
"typescript.tsserver.maxTsServerMemory": 8192,
"typescript.disableAutomaticTypeAcquisition": true,
"tailwindCSS.experimental.configFile": {
"packages/frontend-2/tailwind.config.cjs": "packages/frontend-2/**"
},