Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
Expand Down Expand Up @@ -73,6 +74,7 @@ export default async function OrganizationLayout({
computerUseEnabled,
workflowTestsEnabled,
changelogEnabled,
projectsEnabled,
] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
Expand All @@ -89,6 +91,7 @@ export default async function OrganizationLayout({
isComputerUseAvailable(),
isWorkflowTestsEnabled(organizationId),
isChangelogEnabled(organizationId),
isProjectsEnabled(),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

Expand All @@ -103,6 +106,7 @@ export default async function OrganizationLayout({
'mothership-computer-use': computerUseEnabled,
'mothership-plan-mode': benchmarkEnabled,
'mothership-memory-spaces': benchmarkEnabled,
projects: projectsEnabled,
}}
>
<OrganizationProvider context={context}>
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@/lib/desktop/executor/availability'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
import { ApplicationLoading } from '@/app/_shell/application-loading'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
Expand Down Expand Up @@ -131,6 +132,7 @@ async function WorkspaceContent({
changelogEnabled,
desktopExecutorRegistered,
computerUseEnabled,
projectsEnabled,
] = await Promise.all([
cookies(),
orgSettings,
Expand All @@ -149,6 +151,7 @@ async function WorkspaceContent({
isChangelogEnabled(hostContext.hostOrganizationId),
hasDesktopBackgroundExecutor(session.user.id),
isComputerUseAvailable(),
isProjectsEnabled(),
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
])
Expand All @@ -164,6 +167,7 @@ async function WorkspaceContent({
'mothership-model-selector': modelSelectorEnabled,
'mothership-computer-use': computerUseEnabled,
'mothership-plan-mode': planModeEnabled,
projects: projectsEnabled,
}}
>
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface WorkspaceFeatureFlags {
'mothership-plan-mode': boolean
'mothership-computer-use'?: boolean
'mothership-memory-spaces'?: boolean
projects?: boolean
}

const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/** @vitest-environment jsdom */

import { act } from 'react'
import { flushMacrotask } from '@sim/testing/helpers'
import { jsonResponse } from '@sim/testing/helpers/http'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { ProjectIssueRestrictions } from '@/ee/access-control/components/project-issue-restrictions'

let root: Root
let container: HTMLDivElement
let client: QueryClient
let requests: string[]

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
requests = []
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
requests.push(url)
return jsonResponse({
projects: [
{
id: 'project-1',
name: 'Payments',
organizationId: 'org-1',
ownerId: 'user-1',
archivedAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
environments: [],
capabilities: { administer: true, issues: true },
},
],
nextCursor: null,
})
})
)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
client.clear()
container.remove()
})

async function render(projects: boolean) {
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<FeatureFlagsProvider
flags={{
dashboards: false,
'workflow-tests': false,
changelog: false,
'mothership-model-selector': false,
'mothership-plan-mode': false,
projects,
}}
>
<ProjectIssueRestrictions organizationId='org-1' value={[]} onChange={() => {}} />
</FeatureFlagsProvider>
</QueryClientProvider>
)
})
await act(() => flushMacrotask())
}

describe('ProjectIssueRestrictions', () => {
it('never reads the Project APIs while the projects flag is off', async () => {
await render(false)
expect(requests).toEqual([])
expect(container.querySelector('#project-issues-project-1')).toBeNull()
})

it('offers each authorized Project once the projects flag is on', async () => {
await render(true)
expect(requests.some((url) => url.startsWith('/api/projects?'))).toBe(true)
expect(container.querySelector('#project-issues-project-1')).not.toBeNull()
})

it('hides Projects loaded before the projects flag turned off', async () => {
await render(true)
act(() => root.unmount())
root = createRoot(container)
requests = []
await render(false)
expect(requests).toEqual([])
expect(container.querySelector('#project-issues-project-1')).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export function ProjectIssueRestrictions({
onChange,
}: ProjectIssueRestrictionsProps) {
const projects = useProjects(organizationId)
if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) {
if (
!projects.isEnabled ||
projects.isPending ||
(isApiClientError(projects.error) && projects.error.status === 503)
) {
return null
}
const choices = projects.data?.pages.flatMap((page) => page.projects) ?? []
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/hooks/queries/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { listProjectsContract } from '@/lib/api/contracts/projects'
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'

const PROJECT_LIST_STALE_TIME = 30_000
const projectKeys = {
Expand All @@ -10,7 +11,9 @@ const projectKeys = {
list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const,
}

/** Lists the caller's Projects; never requests while the `projects` rollout flag is off. */
export function useProjects(organizationId: string) {
const projectsEnabled = useFeatureFlag('projects')
return useInfiniteQuery({
queryKey: projectKeys.list(organizationId),
initialPageParam: null as string | null,
Expand All @@ -27,6 +30,6 @@ export function useProjects(organizationId: string) {
error.status === 408 ||
error.status === 429 ||
(error.status >= 500 && error.status !== 503)),
enabled: Boolean(organizationId),
enabled: projectsEnabled && Boolean(organizationId),
Comment thread
waleedlatif1 marked this conversation as resolved.
})
}
7 changes: 6 additions & 1 deletion apps/sim/lib/projects/rollout.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ class ProjectUnavailableError extends HttpError {
}
}

/** Project rollout is a global switch; it never varies by organization or user. */
export function isProjectsEnabled(): Promise<boolean> {
return isFeatureEnabled('projects')
}

export async function requireProjectApiEnabled(): Promise<void> {
if (!(await isFeatureEnabled('projects'))) throw new ProjectUnavailableError()
if (!(await isProjectsEnabled())) throw new ProjectUnavailableError()
}
Loading