@shipfox/client-projects 12.0.1 → 13.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 +59 -0
- package/dist/chrome.d.ts +5 -1
- package/dist/chrome.d.ts.map +1 -1
- package/dist/chrome.js +18 -26
- package/dist/chrome.js.map +1 -1
- package/dist/components/model-provider-reminder-banner.d.ts.map +1 -1
- package/dist/components/model-provider-reminder-banner.js +4 -2
- package/dist/components/model-provider-reminder-banner.js.map +1 -1
- package/dist/components/project-crumb.d.ts +3 -1
- package/dist/components/project-crumb.d.ts.map +1 -1
- package/dist/components/project-crumb.js +7 -5
- package/dist/components/project-crumb.js.map +1 -1
- package/dist/components/project-switcher.d.ts +2 -1
- package/dist/components/project-switcher.d.ts.map +1 -1
- package/dist/components/project-switcher.js +10 -8
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/core/definition.d.ts +6 -0
- package/dist/core/definition.d.ts.map +1 -1
- package/dist/core/definition.js.map +1 -1
- package/dist/core/project.d.ts +7 -0
- package/dist/core/project.d.ts.map +1 -1
- package/dist/core/project.js.map +1 -1
- package/dist/feature.d.ts +29 -5
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +26 -5
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/mappers.d.ts.map +1 -1
- package/dist/hooks/api/mappers.js +9 -1
- package/dist/hooks/api/mappers.js.map +1 -1
- package/dist/hooks/api/projects.d.ts +21 -2
- package/dist/hooks/api/projects.d.ts.map +1 -1
- package/dist/hooks/api/projects.js +175 -2
- package/dist/hooks/api/projects.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/create-project-page.d.ts.map +1 -1
- package/dist/pages/create-project-page.js +97 -21
- package/dist/pages/create-project-page.js.map +1 -1
- package/dist/pages/create-project-page.stories.js +5 -3
- package/dist/pages/create-project-page.stories.js.map +1 -1
- package/dist/pages/project-settings-form-errors.d.ts +11 -0
- package/dist/pages/project-settings-form-errors.d.ts.map +1 -0
- package/dist/pages/project-settings-form-errors.js +16 -0
- package/dist/pages/project-settings-form-errors.js.map +1 -0
- package/dist/pages/project-settings-page.d.ts +2 -0
- package/dist/pages/project-settings-page.d.ts.map +1 -0
- package/dist/pages/project-settings-page.js +225 -0
- package/dist/pages/project-settings-page.js.map +1 -0
- package/dist/pages/project-settings-page.stories.d.ts +23 -0
- package/dist/pages/project-settings-page.stories.d.ts.map +1 -0
- package/dist/pages/project-settings-page.stories.js +206 -0
- package/dist/pages/project-settings-page.stories.js.map +1 -0
- package/dist/pages/projects-hub-page.js +11 -11
- package/dist/pages/projects-hub-page.js.map +1 -1
- package/dist/project-error.d.ts +1 -0
- package/dist/project-error.d.ts.map +1 -1
- package/dist/project-error.js +7 -0
- package/dist/project-error.js.map +1 -1
- package/dist/routes/inputs.d.ts +2 -2
- package/dist/routes/inputs.d.ts.map +1 -1
- package/dist/routes/inputs.js +5 -5
- package/dist/routes/inputs.js.map +1 -1
- package/dist/routes/project-index.d.ts +2 -2
- package/dist/routes/project-index.d.ts.map +1 -1
- package/dist/routes/project-index.js +3 -3
- package/dist/routes/project-index.js.map +1 -1
- package/dist/routes/project-settings-index.d.ts +10 -0
- package/dist/routes/project-settings-index.d.ts.map +1 -0
- package/dist/routes/project-settings-index.js +12 -0
- package/dist/routes/project-settings-index.js.map +1 -0
- package/dist/routes/project-settings.d.ts +6 -0
- package/dist/routes/project-settings.d.ts.map +1 -0
- package/dist/routes/project-settings.js +7 -0
- package/dist/routes/project-settings.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +11 -10
- package/src/chrome.tsx +27 -19
- package/src/components/model-provider-reminder-banner.tsx +3 -1
- package/src/components/project-crumb.tsx +18 -5
- package/src/components/project-switcher.test.tsx +7 -4
- package/src/components/project-switcher.tsx +16 -5
- package/src/core/definition.ts +7 -0
- package/src/core/project.ts +8 -0
- package/src/feature.ts +26 -4
- package/src/hooks/api/mappers.test.ts +19 -0
- package/src/hooks/api/mappers.ts +6 -0
- package/src/hooks/api/projects.test.ts +247 -1
- package/src/hooks/api/projects.ts +227 -2
- package/src/index.ts +9 -1
- package/src/pages/create-project-page.stories.tsx +5 -4
- package/src/pages/create-project-page.test.tsx +191 -12
- package/src/pages/create-project-page.tsx +102 -15
- package/src/pages/home-router.test.tsx +2 -2
- package/src/pages/project-settings-form-errors.ts +17 -0
- package/src/pages/project-settings-page.stories.tsx +216 -0
- package/src/pages/project-settings-page.test.tsx +74 -0
- package/src/pages/project-settings-page.tsx +220 -0
- package/src/pages/projects-hub-page.test.tsx +17 -11
- package/src/pages/projects-hub-page.tsx +9 -9
- package/src/project-error.test.ts +1 -0
- package/src/project-error.ts +8 -0
- package/src/routes/inputs.test.ts +8 -6
- package/src/routes/inputs.ts +7 -6
- package/src/routes/project-index.tsx +3 -3
- package/src/routes/project-settings-index.tsx +11 -0
- package/src/routes/project-settings.tsx +4 -0
- package/test/pages.tsx +26 -22
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {QueryClient} from '@tanstack/react-query';
|
|
2
3
|
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
|
3
|
-
import {
|
|
4
|
+
import {projectsQueryKeys} from '#hooks/api/projects.js';
|
|
5
|
+
import {
|
|
6
|
+
jsonResponse,
|
|
7
|
+
PROJECT_TEST_WID,
|
|
8
|
+
PROJECT_TEST_WSLUG,
|
|
9
|
+
renderProjectPage,
|
|
10
|
+
} from '#test/pages.js';
|
|
4
11
|
import {CreateProjectPage} from './create-project-page.js';
|
|
5
12
|
|
|
6
13
|
const CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
|
|
7
14
|
const SECOND_CONNECTION_ID = '66666666-6666-4666-8666-666666666666';
|
|
8
15
|
const REPOSITORY_NOT_FOUND_RE = /Repository not found/;
|
|
16
|
+
const PROJECT_REQUEST_FAILED_RE = /Project request failed/;
|
|
9
17
|
const GITEA_RADIO_LABEL_RE = /^Gitea Source$/;
|
|
10
18
|
|
|
11
19
|
describe('CreateProjectPage', () => {
|
|
@@ -31,20 +39,29 @@ describe('CreateProjectPage', () => {
|
|
|
31
39
|
});
|
|
32
40
|
configureApiClient({fetchImpl});
|
|
33
41
|
|
|
34
|
-
renderProjectPage(`/
|
|
42
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
35
43
|
const nameInput = await screen.findByLabelText('Project name');
|
|
44
|
+
const slugInput = await screen.findByLabelText('Project slug');
|
|
36
45
|
await waitFor(() => expect(nameInput).toHaveValue('Platform'));
|
|
46
|
+
expect(slugInput).toHaveValue('platform');
|
|
47
|
+
expect(slugInput).toHaveAttribute('aria-describedby', 'project-slug-description');
|
|
48
|
+
expect(screen.getByText('/w/acme/p/platform')).toBeInTheDocument();
|
|
37
49
|
expect(screen.getByRole('radio', {name: GITEA_RADIO_LABEL_RE})).toBeChecked();
|
|
38
50
|
expect(screen.getAllByText('gitea-owner/platform').length).toBeGreaterThan(0);
|
|
39
51
|
fireEvent.change(nameInput, {
|
|
40
52
|
target: {value: ' Launch Pad '},
|
|
41
53
|
});
|
|
54
|
+
fireEvent.change(slugInput, {
|
|
55
|
+
target: {value: 'launchpad'},
|
|
56
|
+
});
|
|
57
|
+
expect(screen.getByText('/w/acme/p/launchpad')).toBeInTheDocument();
|
|
42
58
|
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
43
59
|
|
|
44
60
|
expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
|
|
45
61
|
expect(createProjectBody).toEqual({
|
|
46
62
|
workspace_id: PROJECT_TEST_WID,
|
|
47
63
|
name: 'Launch Pad',
|
|
64
|
+
slug: 'launchpad',
|
|
48
65
|
source: {
|
|
49
66
|
connection_id: CONNECTION_ID,
|
|
50
67
|
external_repository_id: 'platform',
|
|
@@ -52,6 +69,106 @@ describe('CreateProjectPage', () => {
|
|
|
52
69
|
});
|
|
53
70
|
}, 10_000);
|
|
54
71
|
|
|
72
|
+
test('auto-derives the slug from each keystroke in the name field until the slug is touched', async () => {
|
|
73
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
74
|
+
const request = input as Request;
|
|
75
|
+
if (request.url.includes('/integration-connections?')) {
|
|
76
|
+
return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
|
|
77
|
+
}
|
|
78
|
+
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
79
|
+
return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
|
|
80
|
+
}
|
|
81
|
+
return Promise.resolve(jsonResponse({}));
|
|
82
|
+
});
|
|
83
|
+
configureApiClient({fetchImpl});
|
|
84
|
+
|
|
85
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
86
|
+
const nameInput = await screen.findByLabelText('Project name');
|
|
87
|
+
const slugInput = await screen.findByLabelText('Project slug');
|
|
88
|
+
await waitFor(() => expect(nameInput).toHaveValue('Platform'));
|
|
89
|
+
|
|
90
|
+
fireEvent.change(nameInput, {target: {value: 'L'}});
|
|
91
|
+
fireEvent.change(nameInput, {target: {value: 'La'}});
|
|
92
|
+
fireEvent.change(nameInput, {target: {value: 'Launch Pad'}});
|
|
93
|
+
|
|
94
|
+
expect(slugInput).toHaveValue('launch-pad');
|
|
95
|
+
expect(screen.getByText('/w/acme/p/launch-pad')).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('checks project slug availability authoritatively after a cache miss', async () => {
|
|
99
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
100
|
+
const request = input as Request;
|
|
101
|
+
if (request.url.includes('/integration-connections?')) {
|
|
102
|
+
return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
|
|
103
|
+
}
|
|
104
|
+
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
105
|
+
return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
|
|
106
|
+
}
|
|
107
|
+
if (request.url.includes('/projects?')) {
|
|
108
|
+
return Promise.resolve(jsonResponse({projects: [], next_cursor: null}));
|
|
109
|
+
}
|
|
110
|
+
return Promise.resolve(jsonResponse({}));
|
|
111
|
+
});
|
|
112
|
+
configureApiClient({fetchImpl});
|
|
113
|
+
|
|
114
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
115
|
+
const slugInput = await screen.findByLabelText('Project slug');
|
|
116
|
+
fireEvent.change(slugInput, {target: {value: 'custom-project'}});
|
|
117
|
+
|
|
118
|
+
expect(await screen.findByText('Slug is available.')).toBeInTheDocument();
|
|
119
|
+
expect(
|
|
120
|
+
fetchImpl.mock.calls.some(
|
|
121
|
+
([input]) =>
|
|
122
|
+
(input as Request).url.includes('/projects?') &&
|
|
123
|
+
(input as Request).url.includes('search=custom-project'),
|
|
124
|
+
),
|
|
125
|
+
).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('reports a cached project slug conflict without another availability request', async () => {
|
|
129
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
130
|
+
const request = input as Request;
|
|
131
|
+
if (request.url.includes('/integration-connections?')) {
|
|
132
|
+
return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
|
|
133
|
+
}
|
|
134
|
+
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
135
|
+
return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
|
|
136
|
+
}
|
|
137
|
+
return Promise.resolve(jsonResponse({}));
|
|
138
|
+
});
|
|
139
|
+
configureApiClient({fetchImpl});
|
|
140
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
141
|
+
queryClient.setQueryData(projectsQueryKeys.list(PROJECT_TEST_WID, 'custom-project'), {
|
|
142
|
+
pages: [
|
|
143
|
+
{
|
|
144
|
+
projects: [
|
|
145
|
+
{
|
|
146
|
+
id: '55555555-5555-4555-8555-555555555555',
|
|
147
|
+
workspaceId: PROJECT_TEST_WID,
|
|
148
|
+
name: 'Custom project',
|
|
149
|
+
slug: 'custom-project',
|
|
150
|
+
source: {connectionId: CONNECTION_ID, externalRepositoryId: 'custom-project'},
|
|
151
|
+
createdAt: new Date().toISOString(),
|
|
152
|
+
updatedAt: new Date().toISOString(),
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
nextCursor: null,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
pageParams: [undefined],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />, queryClient);
|
|
162
|
+
fireEvent.change(await screen.findByLabelText('Project slug'), {
|
|
163
|
+
target: {value: 'custom-project'},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(await screen.findByText('This slug is already taken.')).toBeInTheDocument();
|
|
167
|
+
expect(
|
|
168
|
+
fetchImpl.mock.calls.some(([input]) => (input as Request).url.includes('/projects?')),
|
|
169
|
+
).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
55
172
|
test('uses the current repository-derived name when submitted before touching the field', async () => {
|
|
56
173
|
let createProjectBody: unknown;
|
|
57
174
|
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
@@ -70,7 +187,7 @@ describe('CreateProjectPage', () => {
|
|
|
70
187
|
});
|
|
71
188
|
configureApiClient({fetchImpl});
|
|
72
189
|
|
|
73
|
-
renderProjectPage(`/
|
|
190
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
74
191
|
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
75
192
|
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
76
193
|
|
|
@@ -78,6 +195,7 @@ describe('CreateProjectPage', () => {
|
|
|
78
195
|
expect(createProjectBody).toEqual({
|
|
79
196
|
workspace_id: PROJECT_TEST_WID,
|
|
80
197
|
name: 'Platform',
|
|
198
|
+
slug: 'platform',
|
|
81
199
|
source: {
|
|
82
200
|
connection_id: CONNECTION_ID,
|
|
83
201
|
external_repository_id: 'platform',
|
|
@@ -100,7 +218,7 @@ describe('CreateProjectPage', () => {
|
|
|
100
218
|
});
|
|
101
219
|
configureApiClient({fetchImpl});
|
|
102
220
|
|
|
103
|
-
renderProjectPage(`/
|
|
221
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
104
222
|
expect((await screen.findAllByText('Gitea Source')).length).toBeGreaterThan(0);
|
|
105
223
|
expect(await screen.findByText('Other Gitea Source')).toBeInTheDocument();
|
|
106
224
|
expect(screen.queryByText('gitea-owner/platform')).not.toBeInTheDocument();
|
|
@@ -123,7 +241,7 @@ describe('CreateProjectPage', () => {
|
|
|
123
241
|
});
|
|
124
242
|
configureApiClient({fetchImpl});
|
|
125
243
|
|
|
126
|
-
renderProjectPage(`/
|
|
244
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
127
245
|
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
128
246
|
fireEvent.change(await screen.findByLabelText('Project name'), {
|
|
129
247
|
target: {value: 'Bad\u202eName'},
|
|
@@ -154,9 +272,9 @@ describe('CreateProjectPage', () => {
|
|
|
154
272
|
}),
|
|
155
273
|
});
|
|
156
274
|
|
|
157
|
-
renderProjectPage(`/
|
|
275
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
158
276
|
const link = await screen.findByRole('link', {name: 'Add another integration'});
|
|
159
|
-
expect(link).toHaveAttribute('href', `/
|
|
277
|
+
expect(link).toHaveAttribute('href', `/w/${PROJECT_TEST_WSLUG}/integrations`);
|
|
160
278
|
});
|
|
161
279
|
|
|
162
280
|
test('shows the model provider reminder on project creation when no provider is configured', async () => {
|
|
@@ -177,12 +295,12 @@ describe('CreateProjectPage', () => {
|
|
|
177
295
|
});
|
|
178
296
|
configureApiClient({fetchImpl});
|
|
179
297
|
|
|
180
|
-
renderProjectPage(`/
|
|
298
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
181
299
|
|
|
182
300
|
expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
|
|
183
301
|
expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
|
|
184
302
|
'href',
|
|
185
|
-
`/
|
|
303
|
+
`/w/${PROJECT_TEST_WSLUG}/settings/agents`,
|
|
186
304
|
);
|
|
187
305
|
});
|
|
188
306
|
|
|
@@ -223,7 +341,7 @@ describe('CreateProjectPage', () => {
|
|
|
223
341
|
});
|
|
224
342
|
configureApiClient({fetchImpl});
|
|
225
343
|
|
|
226
|
-
renderProjectPage(`/
|
|
344
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
227
345
|
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
228
346
|
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
229
347
|
|
|
@@ -232,6 +350,66 @@ describe('CreateProjectPage', () => {
|
|
|
232
350
|
expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
|
|
233
351
|
});
|
|
234
352
|
|
|
353
|
+
test('surfaces a slug conflict on the slug field without a generic form error', async () => {
|
|
354
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
355
|
+
const request = input as Request;
|
|
356
|
+
if (request.url.includes('/integration-connections?')) {
|
|
357
|
+
return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
|
|
358
|
+
}
|
|
359
|
+
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
360
|
+
return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
|
|
361
|
+
}
|
|
362
|
+
if (request.url.endsWith('/projects') && request.method === 'POST') {
|
|
363
|
+
return Promise.resolve(jsonResponse({code: 'slug-conflict'}, {status: 409}));
|
|
364
|
+
}
|
|
365
|
+
return Promise.resolve(jsonResponse({}));
|
|
366
|
+
});
|
|
367
|
+
configureApiClient({fetchImpl});
|
|
368
|
+
|
|
369
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
370
|
+
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
371
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
372
|
+
|
|
373
|
+
expect(await screen.findByText('This project slug is already in use.')).toBeInTheDocument();
|
|
374
|
+
expect(screen.getByLabelText('Project slug')).toHaveAttribute(
|
|
375
|
+
'aria-describedby',
|
|
376
|
+
'project-slug-error',
|
|
377
|
+
);
|
|
378
|
+
await waitFor(() => expect(screen.getByLabelText('Project slug')).toHaveFocus());
|
|
379
|
+
expect(screen.queryByText(PROJECT_REQUEST_FAILED_RE)).not.toBeInTheDocument();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('clears a slug conflict when the slug is regenerated from the project name', async () => {
|
|
383
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
384
|
+
const request = input as Request;
|
|
385
|
+
if (request.url.includes('/integration-connections?')) {
|
|
386
|
+
return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
|
|
387
|
+
}
|
|
388
|
+
if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
|
|
389
|
+
return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
|
|
390
|
+
}
|
|
391
|
+
if (request.url.endsWith('/projects') && request.method === 'POST') {
|
|
392
|
+
return Promise.resolve(jsonResponse({code: 'slug-conflict'}, {status: 409}));
|
|
393
|
+
}
|
|
394
|
+
return Promise.resolve(jsonResponse({}));
|
|
395
|
+
});
|
|
396
|
+
configureApiClient({fetchImpl});
|
|
397
|
+
|
|
398
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
399
|
+
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
400
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
401
|
+
|
|
402
|
+
expect(await screen.findByText('This project slug is already in use.')).toBeInTheDocument();
|
|
403
|
+
fireEvent.change(await screen.findByLabelText('Project name'), {
|
|
404
|
+
target: {value: 'Launch Pad'},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await waitFor(() => {
|
|
408
|
+
expect(screen.queryByText('This project slug is already in use.')).not.toBeInTheDocument();
|
|
409
|
+
expect(screen.getByLabelText('Project slug')).toHaveValue('launch-pad');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
235
413
|
test('shows provider-specific submit errors', async () => {
|
|
236
414
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
237
415
|
const request = input as Request;
|
|
@@ -245,7 +423,7 @@ describe('CreateProjectPage', () => {
|
|
|
245
423
|
});
|
|
246
424
|
configureApiClient({fetchImpl});
|
|
247
425
|
|
|
248
|
-
renderProjectPage(`/
|
|
426
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
|
|
249
427
|
expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
|
|
250
428
|
fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
|
|
251
429
|
|
|
@@ -297,11 +475,12 @@ function repositoryDto() {
|
|
|
297
475
|
};
|
|
298
476
|
}
|
|
299
477
|
|
|
300
|
-
function projectDto({id}: {id: string}) {
|
|
478
|
+
function projectDto({id, slug = 'project-detail'}: {id: string; slug?: string}) {
|
|
301
479
|
return {
|
|
302
480
|
id,
|
|
303
481
|
workspace_id: '11111111-1111-4111-8111-111111111111',
|
|
304
482
|
name: 'Project Detail',
|
|
483
|
+
slug,
|
|
305
484
|
source: {
|
|
306
485
|
connection_id: CONNECTION_ID,
|
|
307
486
|
external_repository_id: 'platform',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {slugifyName, slugSchema} from '@shipfox/api-common-dto';
|
|
1
2
|
import {createProjectBodySchema} from '@shipfox/api-projects-dto';
|
|
2
3
|
import {useMaybeActiveWorkspace} from '@shipfox/client-auth';
|
|
3
4
|
import {
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
useRepositoriesInfiniteQuery,
|
|
9
10
|
useSourceConnectionsQuery,
|
|
10
11
|
} from '@shipfox/client-integrations';
|
|
11
|
-
import {displayNameFieldError} from '@shipfox/client-ui';
|
|
12
|
+
import {displayNameFieldError, SlugField} from '@shipfox/client-ui';
|
|
12
13
|
import {Button} from '@shipfox/react-ui/button';
|
|
13
14
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
14
15
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
@@ -20,9 +21,17 @@ import {Link, Navigate, useNavigate} from '@tanstack/react-router';
|
|
|
20
21
|
import {useEffect, useRef, useState} from 'react';
|
|
21
22
|
import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
|
|
22
23
|
import {type CreateProjectCommand, projectNameFromRepository} from '#core/project.js';
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
getProject,
|
|
26
|
+
useCreateProjectMutation,
|
|
27
|
+
useProjectSlugAvailability,
|
|
28
|
+
} from '#hooks/api/projects.js';
|
|
24
29
|
import {projectErrorCopy} from '#project-error.js';
|
|
25
30
|
|
|
31
|
+
function isSlugValid(value: string): boolean {
|
|
32
|
+
return slugSchema.safeParse(value).success;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
export function CreateProjectPage() {
|
|
27
36
|
const workspace = useMaybeActiveWorkspace();
|
|
28
37
|
const navigate = useNavigate();
|
|
@@ -69,21 +78,34 @@ export function CreateProjectPage() {
|
|
|
69
78
|
const defaultProjectName = projectNameFromRepository(
|
|
70
79
|
selectedRepository?.name ?? selectedRepositoryId ?? '',
|
|
71
80
|
);
|
|
81
|
+
const defaultProjectSlug = slugifyName(defaultProjectName, {fallback: 'project'});
|
|
72
82
|
|
|
73
83
|
const [formError, setFormError] = useState<string | undefined>();
|
|
84
|
+
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
|
85
|
+
const [slugCheckEnabled, setSlugCheckEnabled] = useState(false);
|
|
86
|
+
const [slugConflict, setSlugConflict] = useState(false);
|
|
87
|
+
const workspaceId = workspace?.id;
|
|
88
|
+
const checkProjectSlugAvailability = useProjectSlugAvailability(workspaceId);
|
|
74
89
|
|
|
75
90
|
const form = useForm({
|
|
76
|
-
defaultValues: {name: defaultProjectName},
|
|
91
|
+
defaultValues: {name: defaultProjectName, slug: defaultProjectSlug},
|
|
77
92
|
onSubmit: async ({value}) => {
|
|
78
|
-
|
|
93
|
+
const projectSlug = slugManuallyEdited
|
|
94
|
+
? value.slug
|
|
95
|
+
: slugifyName(value.name, {fallback: 'project'});
|
|
96
|
+
await createProjectFromForm(nameTouched ? value.name : defaultProjectName, projectSlug);
|
|
79
97
|
},
|
|
80
98
|
});
|
|
81
99
|
|
|
82
100
|
useEffect(() => {
|
|
83
101
|
if (!nameTouched && form.state.values.name !== defaultProjectName) {
|
|
84
102
|
form.setFieldValue('name', defaultProjectName);
|
|
103
|
+
if (!slugManuallyEdited) {
|
|
104
|
+
setSlugConflict(false);
|
|
105
|
+
form.setFieldValue('slug', slugifyName(defaultProjectName, {fallback: 'project'}));
|
|
106
|
+
}
|
|
85
107
|
}
|
|
86
|
-
}, [defaultProjectName, form, nameTouched]);
|
|
108
|
+
}, [defaultProjectName, form, nameTouched, slugManuallyEdited]);
|
|
87
109
|
|
|
88
110
|
function selectConnection(connectionId: string) {
|
|
89
111
|
setSelectedConnectionId(connectionId);
|
|
@@ -99,11 +121,18 @@ export function CreateProjectPage() {
|
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
if (!connectionsQuery.isError && connections.length === 0) {
|
|
102
|
-
return
|
|
124
|
+
return (
|
|
125
|
+
<Navigate
|
|
126
|
+
to="/w/$workspaceSlug/integrations"
|
|
127
|
+
params={{workspaceSlug: workspace.slug}}
|
|
128
|
+
replace
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
103
131
|
}
|
|
104
132
|
|
|
105
|
-
async function createProjectFromForm(projectName: string) {
|
|
133
|
+
async function createProjectFromForm(projectName: string, projectSlug: string) {
|
|
106
134
|
setFormError(undefined);
|
|
135
|
+
setSlugConflict(false);
|
|
107
136
|
if (!workspace) {
|
|
108
137
|
setFormError('Workspace is still loading. Try again in a moment.');
|
|
109
138
|
errorRef.current?.focus();
|
|
@@ -124,6 +153,7 @@ export function CreateProjectPage() {
|
|
|
124
153
|
const command: CreateProjectCommand = {
|
|
125
154
|
workspaceId: workspace.id,
|
|
126
155
|
name: projectName,
|
|
156
|
+
slug: projectSlug,
|
|
127
157
|
source: {
|
|
128
158
|
connectionId: selectedConnection.id,
|
|
129
159
|
externalRepositoryId: selectedRepository.externalRepositoryId,
|
|
@@ -132,17 +162,29 @@ export function CreateProjectPage() {
|
|
|
132
162
|
const project = await createProject.mutateAsync(command);
|
|
133
163
|
toast.success('Project created.');
|
|
134
164
|
await navigate({
|
|
135
|
-
to: '/
|
|
136
|
-
params: {
|
|
165
|
+
to: '/w/$workspaceSlug/p/$projectSlug',
|
|
166
|
+
params: {workspaceSlug: workspace.slug, projectSlug: project.slug},
|
|
137
167
|
});
|
|
138
168
|
} catch (error) {
|
|
139
169
|
const copy = projectErrorCopy(error);
|
|
140
170
|
if (copy.existingProjectId) {
|
|
141
171
|
toast.info('Project already exists.');
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
172
|
+
try {
|
|
173
|
+
const project = await getProject(copy.existingProjectId);
|
|
174
|
+
await navigate({
|
|
175
|
+
to: '/w/$workspaceSlug/p/$projectSlug',
|
|
176
|
+
params: {workspaceSlug: workspace.slug, projectSlug: project.slug},
|
|
177
|
+
});
|
|
178
|
+
} catch (recoveryError) {
|
|
179
|
+
const recoveryCopy = projectErrorCopy(recoveryError);
|
|
180
|
+
setFormError(`${recoveryCopy.title}: ${recoveryCopy.message}`);
|
|
181
|
+
requestAnimationFrame(() => errorRef.current?.focus());
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (copy.slugConflict) {
|
|
186
|
+
setSlugConflict(true);
|
|
187
|
+
requestAnimationFrame(() => document.getElementById('project-slug')?.focus());
|
|
146
188
|
return;
|
|
147
189
|
}
|
|
148
190
|
setFormError(`${copy.title}: ${copy.message}`);
|
|
@@ -209,7 +251,10 @@ export function CreateProjectPage() {
|
|
|
209
251
|
|
|
210
252
|
{connections.length === 1 ? (
|
|
211
253
|
<Button asChild variant="transparent" size="sm" className="shrink-0">
|
|
212
|
-
<Link
|
|
254
|
+
<Link
|
|
255
|
+
to="/w/$workspaceSlug/integrations"
|
|
256
|
+
params={{workspaceSlug: workspace.slug}}
|
|
257
|
+
>
|
|
213
258
|
Add another integration
|
|
214
259
|
</Link>
|
|
215
260
|
</Button>
|
|
@@ -288,8 +333,14 @@ export function CreateProjectPage() {
|
|
|
288
333
|
type="text"
|
|
289
334
|
value={field.state.value}
|
|
290
335
|
onChange={(event) => {
|
|
336
|
+
const nextName = event.target.value;
|
|
291
337
|
setNameTouched(true);
|
|
292
|
-
field.handleChange(
|
|
338
|
+
field.handleChange(nextName);
|
|
339
|
+
if (!slugManuallyEdited) {
|
|
340
|
+
setSlugCheckEnabled(true);
|
|
341
|
+
setSlugConflict(false);
|
|
342
|
+
form.setFieldValue('slug', slugifyName(nextName, {fallback: 'project'}));
|
|
343
|
+
}
|
|
293
344
|
}}
|
|
294
345
|
onBlur={field.handleBlur}
|
|
295
346
|
placeholder="Platform"
|
|
@@ -298,6 +349,42 @@ export function CreateProjectPage() {
|
|
|
298
349
|
)}
|
|
299
350
|
</form.Field>
|
|
300
351
|
|
|
352
|
+
<form.Field
|
|
353
|
+
name="slug"
|
|
354
|
+
validators={{
|
|
355
|
+
onBlur: createProjectBodySchema.shape.slug,
|
|
356
|
+
onSubmit: createProjectBodySchema.shape.slug,
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
{(field) => (
|
|
360
|
+
<SlugField
|
|
361
|
+
id="project-slug"
|
|
362
|
+
label="Project slug"
|
|
363
|
+
name="slug"
|
|
364
|
+
value={field.state.value}
|
|
365
|
+
onChange={(value) => {
|
|
366
|
+
setSlugManuallyEdited(true);
|
|
367
|
+
setSlugCheckEnabled(true);
|
|
368
|
+
setSlugConflict(false);
|
|
369
|
+
field.handleChange(value);
|
|
370
|
+
}}
|
|
371
|
+
onBlur={field.handleBlur}
|
|
372
|
+
error={slugConflict ? 'This project slug is already in use.' : fieldError(field)}
|
|
373
|
+
description={
|
|
374
|
+
<span className="font-code">
|
|
375
|
+
{`/w/${workspace.slug}/p/${field.state.value}`}
|
|
376
|
+
</span>
|
|
377
|
+
}
|
|
378
|
+
placeholder="platform"
|
|
379
|
+
className="font-code"
|
|
380
|
+
checkEnabled={slugCheckEnabled}
|
|
381
|
+
debounceMs={0}
|
|
382
|
+
isValid={isSlugValid}
|
|
383
|
+
checkAvailability={checkProjectSlugAvailability}
|
|
384
|
+
/>
|
|
385
|
+
)}
|
|
386
|
+
</form.Field>
|
|
387
|
+
|
|
301
388
|
<Button
|
|
302
389
|
type="submit"
|
|
303
390
|
iconRight="chevronRight"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import '@testing-library/jest-dom/vitest';
|
|
3
3
|
import {configureApiClient} from '@shipfox/client-api';
|
|
4
4
|
import {screen} from '@testing-library/react';
|
|
5
|
-
import {jsonResponse,
|
|
5
|
+
import {jsonResponse, PROJECT_TEST_WSLUG, renderProjectPage} from '#test/pages.js';
|
|
6
6
|
import {HomeRouter} from './home-router.js';
|
|
7
7
|
|
|
8
8
|
describe('HomeRouter', () => {
|
|
@@ -16,7 +16,7 @@ describe('HomeRouter', () => {
|
|
|
16
16
|
});
|
|
17
17
|
configureApiClient({fetchImpl});
|
|
18
18
|
|
|
19
|
-
renderProjectPage(`/
|
|
19
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <HomeRouter />);
|
|
20
20
|
|
|
21
21
|
expect(await screen.findByRole('heading', {name: 'Projects'})).toBeInTheDocument();
|
|
22
22
|
const calledUrls = fetchImpl.mock.calls.map(([input]) =>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
|
|
3
|
+
export type ProjectSettingsField = 'name' | 'slug';
|
|
4
|
+
|
|
5
|
+
export type ProjectSettingsFormError =
|
|
6
|
+
| {kind: 'field'; field: ProjectSettingsField; message: string}
|
|
7
|
+
| {kind: 'form'; message: string};
|
|
8
|
+
|
|
9
|
+
export function projectSettingsErrorToFormError(error: unknown): ProjectSettingsFormError {
|
|
10
|
+
if (error instanceof ApiError && error.code === 'slug-conflict') {
|
|
11
|
+
return {kind: 'field', field: 'slug', message: 'That project slug is already taken.'};
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
kind: 'form',
|
|
15
|
+
message: error instanceof Error ? error.message : 'Could not update project settings.',
|
|
16
|
+
};
|
|
17
|
+
}
|