Skip to content

Commit f4b0247

Browse files
committed
Gate Project copy destinations across query and cached UI
1 parent 6c9698e commit f4b0247

2 files changed

Lines changed: 125 additions & 7 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { createRoot, type Root } from 'react-dom/client'
5+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
6+
import { FileCopyModal } from '@/app/workspace/[workspaceId]/files/components/file-copy-modal'
7+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
8+
import { workspaceKeys } from '@/hooks/queries/workspace'
9+
10+
vi.mock(
11+
'next/navigation',
12+
async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
13+
)
14+
15+
let client: QueryClient
16+
let root: Root
17+
let container: HTMLDivElement
18+
const requests: string[] = []
19+
beforeEach(() => {
20+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
21+
vi.useFakeTimers()
22+
requests.length = 0
23+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
24+
client.setQueryData(workspaceKeys.list(), { workspaces: [] })
25+
vi.stubGlobal('fetch', async (input: string) => {
26+
requests.push(input)
27+
return Response.json(
28+
input.startsWith('/api/projects')
29+
? {
30+
projects: [
31+
{
32+
id: 'project',
33+
name: 'Cached Project',
34+
organizationId: null,
35+
ownerId: 'user',
36+
archivedAt: null,
37+
createdAt: '2026-10-08T00:00:00Z',
38+
updatedAt: '2026-10-08T00:00:00Z',
39+
environments: [],
40+
capabilities: { administer: true, issues: true },
41+
},
42+
],
43+
nextCursor: 'next-page',
44+
}
45+
: { workspaces: [] }
46+
)
47+
})
48+
container = document.createElement('div')
49+
document.body.append(container)
50+
root = createRoot(container)
51+
})
52+
afterEach(async () => {
53+
await act(async () => root.unmount())
54+
client.clear()
55+
container.remove()
56+
vi.useRealTimers()
57+
})
58+
async function render(projects: boolean, projectFiles: boolean) {
59+
await act(async () =>
60+
root.render(
61+
<QueryClientProvider client={client}>
62+
<FeatureFlagsProvider
63+
flags={{
64+
projects,
65+
'project-files': projectFiles,
66+
dashboards: false,
67+
'mothership-model-selector': false,
68+
'mothership-plan-mode': false,
69+
}}
70+
>
71+
<FileCopyModal
72+
source={{
73+
owner: { entityType: 'workspace', entityId: 'workspace' },
74+
fileIds: ['file'],
75+
folderIds: [],
76+
}}
77+
onClose={() => {}}
78+
/>
79+
</FeatureFlagsProvider>
80+
</QueryClientProvider>
81+
)
82+
)
83+
}
84+
85+
it.each([
86+
[false, true],
87+
[true, false],
88+
[false, false],
89+
])(
90+
'stops Project destination reads and pagination after flags become %s/%s',
91+
async (projects, projectFiles) => {
92+
await render(true, true)
93+
await act(async () => vi.advanceTimersByTimeAsync(10))
94+
expect(document.body.textContent).toContain('Load more Projects')
95+
const selector = document.querySelector('[aria-label="Copy destination"]')
96+
if (!selector) throw new Error('Copy destination selector is unavailable')
97+
await act(async () =>
98+
selector.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
99+
)
100+
expect(document.body.textContent).toContain('Cached Project')
101+
requests.length = 0
102+
await render(projects, projectFiles)
103+
await act(async () => client.invalidateQueries())
104+
expect(requests.filter((url) => url.startsWith('/api/projects'))).toEqual([])
105+
expect(document.body.textContent).not.toContain('Load more Projects')
106+
expect(document.body.textContent).not.toContain('Cached Project')
107+
}
108+
)

‎apps/sim/app/workspace/[workspaceId]/files/components/file-copy-modal.tsx‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getErrorMessage } from '@sim/utils/errors'
1616
import type { FileCopySource } from '@/lib/api/contracts/file-copy-input'
1717
import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits'
1818
import type { EditableFileOwner } from '@/lib/workspace-files/ownership'
19+
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
1920
import { useCopyFileItems, useFileCopyDestination } from '@/hooks/queries/file-copy'
2021
import { useProjects } from '@/hooks/queries/projects'
2122
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
@@ -62,7 +63,10 @@ export function FileCopyModal({ source, onClose }: FileCopyModalProps) {
6263

6364
function FileCopyDestinationPicker({ source, onClose }: FileCopyModalProps) {
6465
const workspaces = useWorkspacesQuery()
65-
const projects = useProjects()
66+
const projectsEnabled = useFeatureFlag('projects')
67+
const projectFilesEnabled = useFeatureFlag('project-files')
68+
const projectDestinationsEnabled = projectsEnabled && projectFilesEnabled
69+
const projects = useProjects(undefined, { enabled: projectDestinationsEnabled })
6670
const copy = useCopyFileItems()
6771
const [selection, setSelection] = useState('')
6872
const destinations = useMemo(() => {
@@ -72,14 +76,14 @@ function FileCopyDestinationPicker({ source, onClose }: FileCopyModalProps) {
7276
owner: { entityType: 'workspace', entityId: workspace.id },
7377
name: workspace.name,
7478
})
75-
for (const page of projects.data?.pages ?? [])
79+
for (const page of projectDestinationsEnabled ? (projects.data?.pages ?? []) : [])
7680
for (const project of page.projects)
7781
result.set(`project:${project.id}`, {
7882
owner: { entityType: 'project', entityId: project.id },
7983
name: project.name,
8084
})
8185
return result
82-
}, [workspaces.data, projects.data])
86+
}, [workspaces.data, projects.data, projectDestinationsEnabled])
8387
const destination = destinations.get(selection)
8488
const groups = ['project', 'workspace'].map((kind) => ({
8589
section: kind === 'project' ? 'Projects' : 'Environments',
@@ -92,7 +96,11 @@ function FileCopyDestinationPicker({ source, onClose }: FileCopyModalProps) {
9296
type='custom'
9397
title='Destination'
9498
hint='Copies saved content. The original files stay in their current location.'
95-
error={workspaces.isError && projects.isError ? 'Could not load destinations.' : undefined}
99+
error={
100+
workspaces.isError && (!projectDestinationsEnabled || projects.isError)
101+
? 'Could not load destinations.'
102+
: undefined
103+
}
96104
>
97105
<ChipSelect
98106
aria-label='Copy destination'
@@ -103,12 +111,14 @@ function FileCopyDestinationPicker({ source, onClose }: FileCopyModalProps) {
103111
searchable
104112
fullWidth
105113
placeholder={
106-
workspaces.isPending && projects.isPending
114+
workspaces.isPending && (!projectDestinationsEnabled || projects.isPending)
107115
? 'Loading...'
108-
: 'Choose a Project or environment'
116+
: projectDestinationsEnabled
117+
? 'Choose a Project or environment'
118+
: 'Choose an environment'
109119
}
110120
/>
111-
{projects.hasNextPage && (
121+
{projectDestinationsEnabled && projects.hasNextPage && (
112122
<Chip disabled={projects.isFetchingNextPage} onClick={() => void projects.fetchNextPage()}>
113123
{projects.isFetchingNextPage ? 'Loading...' : 'Load more Projects'}
114124
</Chip>

0 commit comments

Comments
 (0)