@shipfox/client-projects 24.0.0 → 26.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +32 -0
- package/dist/hooks/api/projects.d.ts +4 -0
- package/dist/hooks/api/projects.d.ts.map +1 -1
- package/dist/hooks/api/projects.js +39 -0
- package/dist/hooks/api/projects.js.map +1 -1
- package/dist/pages/create-project-page.d.ts.map +1 -1
- package/dist/pages/create-project-page.js +58 -6
- package/dist/pages/create-project-page.js.map +1 -1
- package/dist/pages/projects-hub-page.d.ts.map +1 -1
- package/dist/pages/projects-hub-page.js +0 -4
- package/dist/pages/projects-hub-page.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/src/hooks/api/projects.test.ts +119 -0
- package/src/hooks/api/projects.ts +46 -0
- package/src/pages/create-project-page.test.tsx +125 -30
- package/src/pages/create-project-page.tsx +55 -5
- package/src/pages/projects-hub-page.test.tsx +4 -67
- package/src/pages/projects-hub-page.tsx +0 -2
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/components/model-provider-reminder-banner.d.ts +0 -4
- package/dist/components/model-provider-reminder-banner.d.ts.map +0 -1
- package/dist/components/model-provider-reminder-banner.js +0 -73
- package/dist/components/model-provider-reminder-banner.js.map +0 -1
- package/src/components/model-provider-reminder-banner.tsx +0 -75
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-projects",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "26.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -28,16 +28,16 @@
|
|
|
28
28
|
"@swc/helpers": "^0.5.17",
|
|
29
29
|
"@tanstack/react-form": "^1.32.0",
|
|
30
30
|
"@shipfox/api-common-dto": "15.0.0",
|
|
31
|
-
"@shipfox/api-definitions-dto": "
|
|
32
|
-
"@shipfox/api-integration-core-dto": "
|
|
31
|
+
"@shipfox/api-definitions-dto": "16.1.0",
|
|
32
|
+
"@shipfox/api-integration-core-dto": "16.0.0",
|
|
33
33
|
"@shipfox/api-projects-dto": "15.0.0",
|
|
34
|
-
"@shipfox/client-agent": "
|
|
34
|
+
"@shipfox/client-agent": "26.0.0",
|
|
35
35
|
"@shipfox/client-api": "6.0.1",
|
|
36
|
-
"@shipfox/client-auth": "
|
|
37
|
-
"@shipfox/client-integrations": "
|
|
38
|
-
"@shipfox/client-
|
|
39
|
-
"@shipfox/
|
|
40
|
-
"@shipfox/
|
|
36
|
+
"@shipfox/client-auth": "26.0.0",
|
|
37
|
+
"@shipfox/client-integrations": "26.0.0",
|
|
38
|
+
"@shipfox/client-ui": "26.0.0",
|
|
39
|
+
"@shipfox/react-ui": "2.3.0",
|
|
40
|
+
"@shipfox/client-shell": "26.0.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
createProject,
|
|
5
5
|
isProjectSlugAvailable,
|
|
6
6
|
listProjects,
|
|
7
|
+
projectExistenceQueryOptions,
|
|
7
8
|
projectSlugQueryOptions,
|
|
8
9
|
projectsInfiniteQueryOptions,
|
|
9
10
|
projectsQueryKeys,
|
|
11
|
+
readWorkspaceHasNoProject,
|
|
10
12
|
resolveProjectSlug,
|
|
11
13
|
updateProject,
|
|
12
14
|
} from './projects.js';
|
|
@@ -46,6 +48,123 @@ describe('listProjects', () => {
|
|
|
46
48
|
});
|
|
47
49
|
});
|
|
48
50
|
|
|
51
|
+
describe('readWorkspaceHasNoProject', () => {
|
|
52
|
+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('returns true when the fresh existence read returns an empty list', async () => {
|
|
59
|
+
configureApiClient({
|
|
60
|
+
fetchImpl: vi.fn().mockResolvedValue(jsonResponse({projects: [], next_cursor: null})),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await expect(
|
|
64
|
+
readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID}),
|
|
65
|
+
).resolves.toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('returns false when the fresh existence read returns a project', async () => {
|
|
69
|
+
configureApiClient({
|
|
70
|
+
fetchImpl: vi.fn().mockResolvedValue(
|
|
71
|
+
jsonResponse({
|
|
72
|
+
projects: [
|
|
73
|
+
{
|
|
74
|
+
id: '44444444-4444-4444-8444-444444444444',
|
|
75
|
+
workspace_id: WORKSPACE_ID,
|
|
76
|
+
name: 'Platform',
|
|
77
|
+
slug: 'platform',
|
|
78
|
+
source: {
|
|
79
|
+
connection_id: '33333333-3333-4333-8333-333333333333',
|
|
80
|
+
external_repository_id: 'platform',
|
|
81
|
+
},
|
|
82
|
+
created_at: '2026-05-07T01:00:00.000Z',
|
|
83
|
+
updated_at: '2026-05-07T01:00:00.000Z',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
next_cursor: null,
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await expect(
|
|
92
|
+
readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID}),
|
|
93
|
+
).resolves.toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('falls back to a cached empty list when the fresh read fails', async () => {
|
|
97
|
+
configureApiClient({
|
|
98
|
+
fetchImpl: vi
|
|
99
|
+
.fn()
|
|
100
|
+
.mockResolvedValue(jsonResponse({message: 'upstream unavailable'}, {status: 500})),
|
|
101
|
+
});
|
|
102
|
+
const queryClient = new QueryClient();
|
|
103
|
+
queryClient.setQueryData(projectExistenceQueryOptions(WORKSPACE_ID).queryKey, {
|
|
104
|
+
projects: [],
|
|
105
|
+
nextCursor: null,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await expect(readWorkspaceHasNoProject({queryClient, workspaceId: WORKSPACE_ID})).resolves.toBe(
|
|
109
|
+
true,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('skips the fresh read when a cached non-empty list rules out the first-project path', async () => {
|
|
114
|
+
const fetchImpl = vi.fn().mockRejectedValue(new Error('unexpected fetch'));
|
|
115
|
+
configureApiClient({fetchImpl});
|
|
116
|
+
const queryClient = new QueryClient();
|
|
117
|
+
queryClient.setQueryData(projectExistenceQueryOptions(WORKSPACE_ID).queryKey, {
|
|
118
|
+
projects: [
|
|
119
|
+
{
|
|
120
|
+
id: '55555555-5555-4555-8555-555555555555',
|
|
121
|
+
workspaceId: WORKSPACE_ID,
|
|
122
|
+
name: 'Platform',
|
|
123
|
+
slug: 'platform',
|
|
124
|
+
source: {connectionId: 'c', externalRepositoryId: 'platform'},
|
|
125
|
+
createdAt: new Date().toISOString(),
|
|
126
|
+
updatedAt: new Date().toISOString(),
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
nextCursor: null,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await expect(readWorkspaceHasNoProject({queryClient, workspaceId: WORKSPACE_ID})).resolves.toBe(
|
|
133
|
+
false,
|
|
134
|
+
);
|
|
135
|
+
// The landing is already decided, so no existence request is issued and no
|
|
136
|
+
// failure is reported for an established workspace.
|
|
137
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('returns false when the fresh read fails without any cache', async () => {
|
|
141
|
+
configureApiClient({
|
|
142
|
+
fetchImpl: vi
|
|
143
|
+
.fn()
|
|
144
|
+
.mockResolvedValue(jsonResponse({message: 'upstream unavailable'}, {status: 500})),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await expect(
|
|
148
|
+
readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID}),
|
|
149
|
+
).resolves.toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('reports the failed fresh read to the error-reporting pipeline', async () => {
|
|
153
|
+
const reportErrorSpy = vi.fn();
|
|
154
|
+
vi.stubGlobal('reportError', reportErrorSpy);
|
|
155
|
+
configureApiClient({
|
|
156
|
+
fetchImpl: vi
|
|
157
|
+
.fn()
|
|
158
|
+
.mockResolvedValue(jsonResponse({message: 'upstream unavailable'}, {status: 500})),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await readWorkspaceHasNoProject({queryClient: new QueryClient(), workspaceId: WORKSPACE_ID});
|
|
162
|
+
|
|
163
|
+
expect(reportErrorSpy).toHaveBeenCalledOnce();
|
|
164
|
+
vi.unstubAllGlobals();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
49
168
|
describe('createProject', () => {
|
|
50
169
|
beforeEach(() => {
|
|
51
170
|
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
@@ -305,6 +305,52 @@ export function projectExistenceQueryOptions(
|
|
|
305
305
|
});
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
export async function readWorkspaceHasNoProject({
|
|
309
|
+
queryClient,
|
|
310
|
+
workspaceId,
|
|
311
|
+
}: {
|
|
312
|
+
queryClient: QueryClient;
|
|
313
|
+
workspaceId: string;
|
|
314
|
+
}): Promise<boolean> {
|
|
315
|
+
const options = projectExistenceQueryOptions(workspaceId);
|
|
316
|
+
// A cached non-empty list already rules out the first-project landing, so
|
|
317
|
+
// skip the fresh read entirely: the snapshot only steers navigation for the
|
|
318
|
+
// empty-workspace case, and a failed fresh read here would otherwise report
|
|
319
|
+
// a global error on every create in an established workspace without
|
|
320
|
+
// changing the landing.
|
|
321
|
+
const cached = queryClient.getQueryData<ProjectList>(options.queryKey);
|
|
322
|
+
if (cached !== undefined && cached.projects.length > 0) return false;
|
|
323
|
+
try {
|
|
324
|
+
// The landing decision must not trust fresh-but-stale existence data
|
|
325
|
+
// (e.g. a project created in another tab inside the 30s staleTime window),
|
|
326
|
+
// so force a fresh read for this check. The snapshot also runs without
|
|
327
|
+
// retry so a slow or failing existence endpoint cannot stall the submit
|
|
328
|
+
// handler on the app-default retry backoff; the catch falls back to cache.
|
|
329
|
+
const data = await queryClient.fetchQuery({...options, staleTime: 0, retry: false});
|
|
330
|
+
return data.projects.length === 0;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
reportExistenceReadFailure(error);
|
|
333
|
+
const fallback = queryClient.getQueryData<ProjectList>(options.queryKey);
|
|
334
|
+
if (fallback !== undefined) return fallback.projects.length === 0;
|
|
335
|
+
// Unknown existence keeps the existing project navigation.
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The existence snapshot is a best-effort pre-flight read: a failure must not
|
|
342
|
+
* block project creation, but it still reaches the global error-reporting
|
|
343
|
+
* pipeline so a degrading existence endpoint does not silently misroute the
|
|
344
|
+
* first-project landing.
|
|
345
|
+
*/
|
|
346
|
+
function reportExistenceReadFailure(error: unknown): void {
|
|
347
|
+
globalThis.reportError?.(
|
|
348
|
+
new Error('Failed to read workspace project existence for the first-project landing.', {
|
|
349
|
+
cause: error,
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
308
354
|
export function projectQueryOptions(projectId: string | undefined): ProjectDetailQueryOptions {
|
|
309
355
|
return queryOptions({
|
|
310
356
|
queryKey: projectId
|
|
@@ -2,7 +2,7 @@ import {configureApiClient} from '@shipfox/client-api';
|
|
|
2
2
|
import {QueryClient} from '@tanstack/react-query';
|
|
3
3
|
import {fireEvent, screen, waitFor, within} from '@testing-library/react';
|
|
4
4
|
import userEvent from '@testing-library/user-event';
|
|
5
|
-
import {projectsQueryKeys} from '#hooks/api/projects.js';
|
|
5
|
+
import {projectExistenceQueryOptions, projectsQueryKeys} from '#hooks/api/projects.js';
|
|
6
6
|
import {
|
|
7
7
|
jsonResponse,
|
|
8
8
|
PROJECT_TEST_WID,
|
|
@@ -22,7 +22,7 @@ describe('CreateProjectPage', () => {
|
|
|
22
22
|
window.sessionStorage.clear();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
test('with a single connection: pre-selects, renders repos,
|
|
25
|
+
test('with a single connection: pre-selects, renders repos, and lands the first project on the workspace home', async () => {
|
|
26
26
|
let createProjectBody: unknown;
|
|
27
27
|
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
28
28
|
const request = input as Request;
|
|
@@ -32,6 +32,22 @@ describe('CreateProjectPage', () => {
|
|
|
32
32
|
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
33
33
|
return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
|
|
34
34
|
}
|
|
35
|
+
if (request.url.includes('/projects?')) {
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
// The pre-create existence snapshot (limit=1) sees an empty workspace;
|
|
38
|
+
// the home list (limit=50) sees the created project, so the home does
|
|
39
|
+
// not fall back to the pre-create empty state.
|
|
40
|
+
if (url.searchParams.get('search')) {
|
|
41
|
+
return jsonResponse({projects: [], next_cursor: null});
|
|
42
|
+
}
|
|
43
|
+
if (url.searchParams.get('limit') === '1') {
|
|
44
|
+
return jsonResponse({projects: [], next_cursor: null});
|
|
45
|
+
}
|
|
46
|
+
return jsonResponse({
|
|
47
|
+
projects: [projectDto({id: '44444444-4444-4444-8444-444444444444'})],
|
|
48
|
+
next_cursor: null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
35
51
|
if (request.url.endsWith('/projects') && request.method === 'POST') {
|
|
36
52
|
createProjectBody = await request.json();
|
|
37
53
|
return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
|
|
@@ -76,7 +92,13 @@ describe('CreateProjectPage', () => {
|
|
|
76
92
|
expect(screen.getByText('/w/acme/p/launchpad')).toBeInTheDocument();
|
|
77
93
|
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
78
94
|
|
|
79
|
-
|
|
95
|
+
// The first project lands on the workspace home, where the Get-started
|
|
96
|
+
// panel is the first panel, the just-created project is listed instead of
|
|
97
|
+
// the pre-create empty state, and the project detail is not shown.
|
|
98
|
+
expect(await screen.findByRole('searchbox', {name: 'Search projects'})).toBeInTheDocument();
|
|
99
|
+
expect(await screen.findByText('Project Detail')).toBeInTheDocument();
|
|
100
|
+
expect(screen.queryByText('Create your first project')).not.toBeInTheDocument();
|
|
101
|
+
expect(screen.queryByRole('heading', {name: 'Runs'})).not.toBeInTheDocument();
|
|
80
102
|
expect(createProjectBody).toEqual({
|
|
81
103
|
workspace_id: PROJECT_TEST_WID,
|
|
82
104
|
name: 'Launch Pad',
|
|
@@ -88,6 +110,56 @@ describe('CreateProjectPage', () => {
|
|
|
88
110
|
});
|
|
89
111
|
}, 10_000);
|
|
90
112
|
|
|
113
|
+
test('does not double-submit while the existence snapshot is in flight', async () => {
|
|
114
|
+
let releaseExistenceRead: (() => void) | undefined;
|
|
115
|
+
const existenceReadGate = new Promise<void>((resolve) => {
|
|
116
|
+
releaseExistenceRead = resolve;
|
|
117
|
+
});
|
|
118
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
119
|
+
const request = input as Request;
|
|
120
|
+
if (request.url.includes('/integration-connections?')) {
|
|
121
|
+
return jsonResponse({connections: [connectionDto()]});
|
|
122
|
+
}
|
|
123
|
+
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
124
|
+
return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
|
|
125
|
+
}
|
|
126
|
+
if (request.url.includes('/projects?')) {
|
|
127
|
+
const url = new URL(request.url);
|
|
128
|
+
if (url.searchParams.get('limit') === '1') {
|
|
129
|
+
await existenceReadGate;
|
|
130
|
+
return jsonResponse({projects: [], next_cursor: null});
|
|
131
|
+
}
|
|
132
|
+
return jsonResponse({
|
|
133
|
+
projects: [projectDto({id: '44444444-4444-4444-8444-444444444444'})],
|
|
134
|
+
next_cursor: null,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (request.url.endsWith('/projects') && request.method === 'POST') {
|
|
138
|
+
return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
|
|
139
|
+
}
|
|
140
|
+
return jsonResponse({});
|
|
141
|
+
});
|
|
142
|
+
configureApiClient({fetchImpl});
|
|
143
|
+
|
|
144
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
145
|
+
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
146
|
+
|
|
147
|
+
const createButton = screen.getByRole('button', {name: 'Create project'});
|
|
148
|
+
fireEvent.click(createButton);
|
|
149
|
+
fireEvent.click(createButton);
|
|
150
|
+
|
|
151
|
+
// The button stays disabled while the existence snapshot runs, so a second
|
|
152
|
+
// click cannot start a duplicate create.
|
|
153
|
+
await waitFor(() =>
|
|
154
|
+
expect(screen.getByRole('button', {name: 'Create project'})).toBeDisabled(),
|
|
155
|
+
);
|
|
156
|
+
releaseExistenceRead?.();
|
|
157
|
+
|
|
158
|
+
// Exactly one POST and a deterministic landing on the workspace home.
|
|
159
|
+
expect(await screen.findByRole('searchbox', {name: 'Search projects'})).toBeInTheDocument();
|
|
160
|
+
expect(projectPostCount(fetchImpl)).toBe(1);
|
|
161
|
+
}, 10_000);
|
|
162
|
+
|
|
91
163
|
test('does not render an empty source panel when connections fail to load', async () => {
|
|
92
164
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
93
165
|
const request = input as Request;
|
|
@@ -334,6 +406,56 @@ describe('CreateProjectPage', () => {
|
|
|
334
406
|
expect(projectPostCount(fetchImpl)).toBe(0);
|
|
335
407
|
});
|
|
336
408
|
|
|
409
|
+
test('with an existing project: keeps navigating to the created project', async () => {
|
|
410
|
+
let createProjectBody: unknown;
|
|
411
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
412
|
+
const request = input as Request;
|
|
413
|
+
if (request.url.includes('/integration-connections?')) {
|
|
414
|
+
return jsonResponse({connections: [connectionDto()]});
|
|
415
|
+
}
|
|
416
|
+
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
417
|
+
return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
|
|
418
|
+
}
|
|
419
|
+
if (request.url.endsWith('/projects') && request.method === 'POST') {
|
|
420
|
+
createProjectBody = await request.json();
|
|
421
|
+
return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
|
|
422
|
+
}
|
|
423
|
+
return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
|
|
424
|
+
});
|
|
425
|
+
configureApiClient({fetchImpl});
|
|
426
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
427
|
+
queryClient.setQueryData(projectExistenceQueryOptions(PROJECT_TEST_WID).queryKey, {
|
|
428
|
+
projects: [
|
|
429
|
+
{
|
|
430
|
+
id: '55555555-5555-4555-8555-555555555555',
|
|
431
|
+
workspaceId: PROJECT_TEST_WID,
|
|
432
|
+
name: 'Platform',
|
|
433
|
+
slug: 'platform',
|
|
434
|
+
source: {connectionId: CONNECTION_ID, externalRepositoryId: 'platform'},
|
|
435
|
+
createdAt: new Date().toISOString(),
|
|
436
|
+
updatedAt: new Date().toISOString(),
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
nextCursor: null,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />, queryClient);
|
|
443
|
+
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
444
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
445
|
+
|
|
446
|
+
// A workspace that already has a project keeps landing on the project.
|
|
447
|
+
expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
|
|
448
|
+
expect(createProjectBody).toEqual({
|
|
449
|
+
workspace_id: PROJECT_TEST_WID,
|
|
450
|
+
name: 'Platform',
|
|
451
|
+
slug: 'platform',
|
|
452
|
+
source: {
|
|
453
|
+
connection_id: CONNECTION_ID,
|
|
454
|
+
external_repository_id: 'platform',
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}, 10_000);
|
|
458
|
+
|
|
337
459
|
test('with a single connection: shows workspace-scoped "Add another integration" link', async () => {
|
|
338
460
|
configureApiClient({
|
|
339
461
|
fetchImpl: vi.fn((input: RequestInfo | URL) => {
|
|
@@ -355,33 +477,6 @@ describe('CreateProjectPage', () => {
|
|
|
355
477
|
expect(link).toHaveAttribute('href', `/w/${PROJECT_TEST_WSLUG}/integrations`);
|
|
356
478
|
});
|
|
357
479
|
|
|
358
|
-
test('shows the model provider reminder on project creation when no provider is configured', async () => {
|
|
359
|
-
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
360
|
-
const request = input as Request;
|
|
361
|
-
if (request.url.endsWith('/agent/model-providers')) {
|
|
362
|
-
return Promise.resolve(
|
|
363
|
-
jsonResponse({configs: [], default_provider_id: null, default_harness_id: null}),
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
if (request.url.includes('/integration-connections?')) {
|
|
367
|
-
return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
|
|
368
|
-
}
|
|
369
|
-
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
370
|
-
return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
|
|
371
|
-
}
|
|
372
|
-
return Promise.resolve(jsonResponse({}));
|
|
373
|
-
});
|
|
374
|
-
configureApiClient({fetchImpl});
|
|
375
|
-
|
|
376
|
-
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
377
|
-
|
|
378
|
-
expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
|
|
379
|
-
expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
|
|
380
|
-
'href',
|
|
381
|
-
`/w/${PROJECT_TEST_WSLUG}/settings/agents`,
|
|
382
|
-
);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
480
|
test('navigates to the existing project for duplicate recovery', async () => {
|
|
386
481
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
387
482
|
const request = input as Request;
|
|
@@ -19,12 +19,18 @@ import {Panel, PanelActions, PanelBody, PanelHeader, PanelTitle} from '@shipfox/
|
|
|
19
19
|
import {toast} from '@shipfox/react-ui/toast';
|
|
20
20
|
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
21
21
|
import {useForm} from '@tanstack/react-form';
|
|
22
|
+
import {type InfiniteData, useQueryClient} from '@tanstack/react-query';
|
|
22
23
|
import {Link, Navigate, useNavigate} from '@tanstack/react-router';
|
|
23
24
|
import {useEffect, useRef, useState} from 'react';
|
|
24
|
-
import {
|
|
25
|
-
|
|
25
|
+
import {
|
|
26
|
+
type CreateProjectCommand,
|
|
27
|
+
type ProjectList,
|
|
28
|
+
projectNameFromRepository,
|
|
29
|
+
} from '#core/project.js';
|
|
26
30
|
import {
|
|
27
31
|
getProject,
|
|
32
|
+
projectsInfiniteQueryOptions,
|
|
33
|
+
readWorkspaceHasNoProject,
|
|
28
34
|
useCreateProjectMutation,
|
|
29
35
|
useProjectSlugAvailability,
|
|
30
36
|
} from '#hooks/api/projects.js';
|
|
@@ -37,8 +43,13 @@ function isSlugValid(value: string): boolean {
|
|
|
37
43
|
export function CreateProjectPage() {
|
|
38
44
|
const workspace = useMaybeActiveWorkspace();
|
|
39
45
|
const navigate = useNavigate();
|
|
46
|
+
const queryClient = useQueryClient();
|
|
40
47
|
const createProject = useCreateProjectMutation();
|
|
41
48
|
const errorRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
// The submit flow awaits an existence snapshot before the create mutation, so
|
|
50
|
+
// the pending state must cover the whole handler, not just the mutation.
|
|
51
|
+
const submittingRef = useRef(false);
|
|
52
|
+
const [submitting, setSubmitting] = useState(false);
|
|
42
53
|
|
|
43
54
|
const connectionsQuery = useSourceConnectionsQuery(workspace?.id);
|
|
44
55
|
const connections = connectionsQuery.data ?? [];
|
|
@@ -150,8 +161,17 @@ export function CreateProjectPage() {
|
|
|
150
161
|
errorRef.current?.focus();
|
|
151
162
|
return;
|
|
152
163
|
}
|
|
164
|
+
if (submittingRef.current) return;
|
|
165
|
+
submittingRef.current = true;
|
|
166
|
+
setSubmitting(true);
|
|
153
167
|
|
|
154
168
|
try {
|
|
169
|
+
// Snapshot project existence before the mutation: after a successful
|
|
170
|
+
// create the workspace has one project and "first" is already false.
|
|
171
|
+
const wasFirstProject = await readWorkspaceHasNoProject({
|
|
172
|
+
queryClient,
|
|
173
|
+
workspaceId: workspace.id,
|
|
174
|
+
});
|
|
155
175
|
const command: CreateProjectCommand = {
|
|
156
176
|
workspaceId: workspace.id,
|
|
157
177
|
name: projectName,
|
|
@@ -163,6 +183,35 @@ export function CreateProjectPage() {
|
|
|
163
183
|
};
|
|
164
184
|
const project = await createProject.mutateAsync(command);
|
|
165
185
|
toast.success('Project created.');
|
|
186
|
+
if (wasFirstProject) {
|
|
187
|
+
// The setup checklist panel is the first thing on the home, so the
|
|
188
|
+
// first project lands where the Get-started guide lives. Seed the
|
|
189
|
+
// workspace list so the home does not re-render the pre-create empty
|
|
190
|
+
// state, then fetch the authoritative list: the seeded entry has no
|
|
191
|
+
// queryFn of its own (no observer mounted it), so refetching it would
|
|
192
|
+
// fail and a project created concurrently (another tab, import, or
|
|
193
|
+
// API) would stay hidden for the stale window. Fetching with
|
|
194
|
+
// staleTime: 0 forces a fresh read that replaces the seed; a failed
|
|
195
|
+
// fetch is swallowed so the seeded project remains the fallback and
|
|
196
|
+
// the home never shows a stale empty state.
|
|
197
|
+
const listQueryKey = projectsInfiniteQueryOptions(workspace.id).queryKey;
|
|
198
|
+
queryClient.setQueryData<InfiniteData<ProjectList, string | undefined>>(listQueryKey, {
|
|
199
|
+
pages: [{projects: [project], nextCursor: null}],
|
|
200
|
+
pageParams: [undefined],
|
|
201
|
+
});
|
|
202
|
+
await queryClient
|
|
203
|
+
.fetchInfiniteQuery({
|
|
204
|
+
...projectsInfiniteQueryOptions(workspace.id),
|
|
205
|
+
staleTime: 0,
|
|
206
|
+
retry: false,
|
|
207
|
+
})
|
|
208
|
+
.catch(() => undefined);
|
|
209
|
+
await navigate({
|
|
210
|
+
to: '/w/$workspaceSlug',
|
|
211
|
+
params: {workspaceSlug: workspace.slug},
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
166
215
|
await navigate({
|
|
167
216
|
to: '/w/$workspaceSlug/p/$projectSlug',
|
|
168
217
|
params: {workspaceSlug: workspace.slug, projectSlug: project.slug},
|
|
@@ -191,6 +240,9 @@ export function CreateProjectPage() {
|
|
|
191
240
|
}
|
|
192
241
|
setFormError(`${copy.title}: ${copy.message}`);
|
|
193
242
|
requestAnimationFrame(() => errorRef.current?.focus());
|
|
243
|
+
} finally {
|
|
244
|
+
submittingRef.current = false;
|
|
245
|
+
setSubmitting(false);
|
|
194
246
|
}
|
|
195
247
|
}
|
|
196
248
|
|
|
@@ -205,8 +257,6 @@ export function CreateProjectPage() {
|
|
|
205
257
|
Create project
|
|
206
258
|
</Header>
|
|
207
259
|
|
|
208
|
-
<ModelProviderReminderBanner workspaceId={workspace.id} />
|
|
209
|
-
|
|
210
260
|
{connectionsQuery.isError ? (
|
|
211
261
|
<Callout role="alert" type="error">
|
|
212
262
|
<div className="flex w-full flex-wrap items-center justify-between gap-cluster">
|
|
@@ -399,7 +449,7 @@ export function CreateProjectPage() {
|
|
|
399
449
|
<Button
|
|
400
450
|
type="submit"
|
|
401
451
|
iconRight="chevronRight"
|
|
402
|
-
isLoading={
|
|
452
|
+
isLoading={submitting}
|
|
403
453
|
disabled={!selectedConnection || !selectedRepository}
|
|
404
454
|
className="w-full"
|
|
405
455
|
>
|
|
@@ -25,7 +25,6 @@ describe('ProjectsHubPage', () => {
|
|
|
25
25
|
configureApiClient({
|
|
26
26
|
fetchImpl: createHubFetch({
|
|
27
27
|
projects: jsonResponse({projects: [], next_cursor: null}),
|
|
28
|
-
modelProviders: jsonResponse(modelProviderConfigsDto()),
|
|
29
28
|
}),
|
|
30
29
|
});
|
|
31
30
|
|
|
@@ -46,13 +45,16 @@ describe('ProjectsHubPage', () => {
|
|
|
46
45
|
configureApiClient({
|
|
47
46
|
fetchImpl: createHubFetch({
|
|
48
47
|
projects: jsonResponse({projects: [], next_cursor: null}),
|
|
49
|
-
modelProviders: jsonResponse(modelProviderConfigsDto()),
|
|
50
48
|
}),
|
|
51
49
|
});
|
|
52
50
|
|
|
53
51
|
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
|
|
54
52
|
|
|
55
53
|
expect(await screen.findByText('Create your first project')).toBeInTheDocument();
|
|
54
|
+
// Without the slot, the hub renders no checklist panel above the projects.
|
|
55
|
+
expect(
|
|
56
|
+
screen.queryByRole('region', {name: 'Workspace setup checklist'}),
|
|
57
|
+
).not.toBeInTheDocument();
|
|
56
58
|
const projectsRegion = screen.getByRole('region', {name: 'Projects'});
|
|
57
59
|
expect(projectsRegion.querySelectorAll('[data-slot="panel"]')).toHaveLength(1);
|
|
58
60
|
const panelHeader = projectsRegion.querySelector<HTMLElement>('[data-slot="panel-header"]');
|
|
@@ -74,50 +76,9 @@ describe('ProjectsHubPage', () => {
|
|
|
74
76
|
).toBe(WORKSPACE_PROJECTS_NEW_HREF);
|
|
75
77
|
});
|
|
76
78
|
|
|
77
|
-
test('shows and dismisses the model provider reminder when no provider is configured', async () => {
|
|
78
|
-
const fetchImpl = createHubFetch({
|
|
79
|
-
projects: jsonResponse({projects: [], next_cursor: null}),
|
|
80
|
-
modelProviders: jsonResponse({
|
|
81
|
-
configs: [],
|
|
82
|
-
default_provider_id: null,
|
|
83
|
-
default_harness_id: null,
|
|
84
|
-
}),
|
|
85
|
-
});
|
|
86
|
-
configureApiClient({fetchImpl});
|
|
87
|
-
|
|
88
|
-
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
|
|
89
|
-
|
|
90
|
-
expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
|
|
91
|
-
expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
|
|
92
|
-
'href',
|
|
93
|
-
`/w/${PROJECT_TEST_WSLUG}/settings/agents`,
|
|
94
|
-
);
|
|
95
|
-
fireEvent.click(screen.getByRole('button', {name: 'Close'}));
|
|
96
|
-
|
|
97
|
-
await waitFor(() => {
|
|
98
|
-
expect(screen.queryByText('Finish setting up a model provider')).not.toBeInTheDocument();
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('hides the model provider reminder when a provider is configured', async () => {
|
|
103
|
-
const fetchImpl = createHubFetch({
|
|
104
|
-
projects: jsonResponse({projects: [], next_cursor: null}),
|
|
105
|
-
modelProviders: jsonResponse(modelProviderConfigsDto()),
|
|
106
|
-
});
|
|
107
|
-
configureApiClient({fetchImpl});
|
|
108
|
-
|
|
109
|
-
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
|
|
110
|
-
|
|
111
|
-
expect(await screen.findByText('Create your first project')).toBeInTheDocument();
|
|
112
|
-
expect(screen.queryByText('Finish setting up a model provider')).not.toBeInTheDocument();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
79
|
test('renders projects and loads the next cursor page', async () => {
|
|
116
80
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
117
81
|
const url = new URL(requestInputUrl(input));
|
|
118
|
-
if (url.pathname.endsWith('/agent/model-providers')) {
|
|
119
|
-
return Promise.resolve(jsonResponse(modelProviderConfigsDto()));
|
|
120
|
-
}
|
|
121
82
|
if (url.pathname === '/integration-connections') {
|
|
122
83
|
return Promise.resolve(jsonResponse(connectionsDto()));
|
|
123
84
|
}
|
|
@@ -188,9 +149,6 @@ describe('ProjectsHubPage', () => {
|
|
|
188
149
|
let cursorRequests = 0;
|
|
189
150
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
190
151
|
const url = new URL(requestInputUrl(input));
|
|
191
|
-
if (url.pathname.endsWith('/agent/model-providers')) {
|
|
192
|
-
return Promise.resolve(jsonResponse(modelProviderConfigsDto()));
|
|
193
|
-
}
|
|
194
152
|
if (url.pathname === '/integration-connections') {
|
|
195
153
|
return Promise.resolve(jsonResponse(connectionsDto()));
|
|
196
154
|
}
|
|
@@ -336,17 +294,12 @@ function createHubFetch({
|
|
|
336
294
|
next_cursor: null,
|
|
337
295
|
}),
|
|
338
296
|
connections = jsonResponse(connectionsDto()),
|
|
339
|
-
modelProviders = jsonResponse(modelProviderConfigsDto()),
|
|
340
297
|
}: {
|
|
341
298
|
projects?: Response;
|
|
342
299
|
connections?: Response;
|
|
343
|
-
modelProviders?: Response;
|
|
344
300
|
} = {}) {
|
|
345
301
|
return vi.fn((input: RequestInfo | URL) => {
|
|
346
302
|
const url = new URL(requestInputUrl(input));
|
|
347
|
-
if (url.pathname.endsWith('/agent/model-providers')) {
|
|
348
|
-
return Promise.resolve(modelProviders.clone());
|
|
349
|
-
}
|
|
350
303
|
if (url.pathname === '/integration-connections') {
|
|
351
304
|
return Promise.resolve(connections.clone());
|
|
352
305
|
}
|
|
@@ -387,22 +340,6 @@ function projectDto({
|
|
|
387
340
|
};
|
|
388
341
|
}
|
|
389
342
|
|
|
390
|
-
function modelProviderConfigsDto() {
|
|
391
|
-
return {
|
|
392
|
-
configs: [
|
|
393
|
-
{
|
|
394
|
-
kind: 'builtin',
|
|
395
|
-
provider_id: 'anthropic',
|
|
396
|
-
default_model: null,
|
|
397
|
-
created_at: new Date().toISOString(),
|
|
398
|
-
updated_at: new Date().toISOString(),
|
|
399
|
-
},
|
|
400
|
-
],
|
|
401
|
-
default_provider_id: 'anthropic',
|
|
402
|
-
default_harness_id: null,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
|
|
406
343
|
function connectionsDto({
|
|
407
344
|
lifecycleStatus = 'active',
|
|
408
345
|
id = CONNECTION_ID,
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
import {Skeleton} from '@shipfox/react-ui/skeleton';
|
|
25
25
|
import {Text} from '@shipfox/react-ui/typography';
|
|
26
26
|
import {Link, useNavigate} from '@tanstack/react-router';
|
|
27
|
-
import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
|
|
28
27
|
import type {Project} from '#core/project.js';
|
|
29
28
|
import {useProjectsInfiniteQuery} from '#hooks/api/projects.js';
|
|
30
29
|
|
|
@@ -52,7 +51,6 @@ export function ProjectsHubPage({search = ''}: {search?: string}) {
|
|
|
52
51
|
return (
|
|
53
52
|
<div className="flex w-full flex-col gap-section">
|
|
54
53
|
{WorkspaceSetupChecklist ? <WorkspaceSetupChecklist /> : null}
|
|
55
|
-
<ModelProviderReminderBanner workspaceId={workspace.id} />
|
|
56
54
|
|
|
57
55
|
<section aria-label="Projects">
|
|
58
56
|
<Panel>
|