@shipfox/client-projects 12.0.2 → 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.
Files changed (111) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +49 -0
  3. package/dist/chrome.d.ts +5 -1
  4. package/dist/chrome.d.ts.map +1 -1
  5. package/dist/chrome.js +18 -26
  6. package/dist/chrome.js.map +1 -1
  7. package/dist/components/model-provider-reminder-banner.d.ts.map +1 -1
  8. package/dist/components/model-provider-reminder-banner.js +4 -2
  9. package/dist/components/model-provider-reminder-banner.js.map +1 -1
  10. package/dist/components/project-crumb.d.ts +3 -1
  11. package/dist/components/project-crumb.d.ts.map +1 -1
  12. package/dist/components/project-crumb.js +7 -5
  13. package/dist/components/project-crumb.js.map +1 -1
  14. package/dist/components/project-switcher.d.ts +2 -1
  15. package/dist/components/project-switcher.d.ts.map +1 -1
  16. package/dist/components/project-switcher.js +10 -8
  17. package/dist/components/project-switcher.js.map +1 -1
  18. package/dist/core/definition.d.ts +6 -0
  19. package/dist/core/definition.d.ts.map +1 -1
  20. package/dist/core/definition.js.map +1 -1
  21. package/dist/core/project.d.ts +7 -0
  22. package/dist/core/project.d.ts.map +1 -1
  23. package/dist/core/project.js.map +1 -1
  24. package/dist/feature.d.ts +29 -5
  25. package/dist/feature.d.ts.map +1 -1
  26. package/dist/feature.js +26 -5
  27. package/dist/feature.js.map +1 -1
  28. package/dist/hooks/api/mappers.d.ts.map +1 -1
  29. package/dist/hooks/api/mappers.js +9 -1
  30. package/dist/hooks/api/mappers.js.map +1 -1
  31. package/dist/hooks/api/projects.d.ts +21 -2
  32. package/dist/hooks/api/projects.d.ts.map +1 -1
  33. package/dist/hooks/api/projects.js +175 -2
  34. package/dist/hooks/api/projects.js.map +1 -1
  35. package/dist/index.d.ts +3 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/pages/create-project-page.d.ts.map +1 -1
  40. package/dist/pages/create-project-page.js +97 -21
  41. package/dist/pages/create-project-page.js.map +1 -1
  42. package/dist/pages/create-project-page.stories.js +5 -3
  43. package/dist/pages/create-project-page.stories.js.map +1 -1
  44. package/dist/pages/project-settings-form-errors.d.ts +11 -0
  45. package/dist/pages/project-settings-form-errors.d.ts.map +1 -0
  46. package/dist/pages/project-settings-form-errors.js +16 -0
  47. package/dist/pages/project-settings-form-errors.js.map +1 -0
  48. package/dist/pages/project-settings-page.d.ts +2 -0
  49. package/dist/pages/project-settings-page.d.ts.map +1 -0
  50. package/dist/pages/project-settings-page.js +225 -0
  51. package/dist/pages/project-settings-page.js.map +1 -0
  52. package/dist/pages/project-settings-page.stories.d.ts +23 -0
  53. package/dist/pages/project-settings-page.stories.d.ts.map +1 -0
  54. package/dist/pages/project-settings-page.stories.js +206 -0
  55. package/dist/pages/project-settings-page.stories.js.map +1 -0
  56. package/dist/pages/projects-hub-page.js +11 -11
  57. package/dist/pages/projects-hub-page.js.map +1 -1
  58. package/dist/project-error.d.ts +1 -0
  59. package/dist/project-error.d.ts.map +1 -1
  60. package/dist/project-error.js +7 -0
  61. package/dist/project-error.js.map +1 -1
  62. package/dist/routes/inputs.d.ts +2 -2
  63. package/dist/routes/inputs.d.ts.map +1 -1
  64. package/dist/routes/inputs.js +5 -5
  65. package/dist/routes/inputs.js.map +1 -1
  66. package/dist/routes/project-index.d.ts +2 -2
  67. package/dist/routes/project-index.d.ts.map +1 -1
  68. package/dist/routes/project-index.js +3 -3
  69. package/dist/routes/project-index.js.map +1 -1
  70. package/dist/routes/project-settings-index.d.ts +10 -0
  71. package/dist/routes/project-settings-index.d.ts.map +1 -0
  72. package/dist/routes/project-settings-index.js +12 -0
  73. package/dist/routes/project-settings-index.js.map +1 -0
  74. package/dist/routes/project-settings.d.ts +6 -0
  75. package/dist/routes/project-settings.d.ts.map +1 -0
  76. package/dist/routes/project-settings.js +7 -0
  77. package/dist/routes/project-settings.js.map +1 -0
  78. package/dist/tsconfig.test.tsbuildinfo +1 -1
  79. package/package.json +11 -10
  80. package/src/chrome.tsx +27 -19
  81. package/src/components/model-provider-reminder-banner.tsx +3 -1
  82. package/src/components/project-crumb.tsx +18 -5
  83. package/src/components/project-switcher.test.tsx +7 -4
  84. package/src/components/project-switcher.tsx +16 -5
  85. package/src/core/definition.ts +7 -0
  86. package/src/core/project.ts +8 -0
  87. package/src/feature.ts +26 -4
  88. package/src/hooks/api/mappers.test.ts +19 -0
  89. package/src/hooks/api/mappers.ts +6 -0
  90. package/src/hooks/api/projects.test.ts +247 -1
  91. package/src/hooks/api/projects.ts +227 -2
  92. package/src/index.ts +9 -1
  93. package/src/pages/create-project-page.stories.tsx +5 -4
  94. package/src/pages/create-project-page.test.tsx +191 -12
  95. package/src/pages/create-project-page.tsx +102 -15
  96. package/src/pages/home-router.test.tsx +2 -2
  97. package/src/pages/project-settings-form-errors.ts +17 -0
  98. package/src/pages/project-settings-page.stories.tsx +216 -0
  99. package/src/pages/project-settings-page.test.tsx +74 -0
  100. package/src/pages/project-settings-page.tsx +220 -0
  101. package/src/pages/projects-hub-page.test.tsx +17 -11
  102. package/src/pages/projects-hub-page.tsx +9 -9
  103. package/src/project-error.test.ts +1 -0
  104. package/src/project-error.ts +8 -0
  105. package/src/routes/inputs.test.ts +8 -6
  106. package/src/routes/inputs.ts +7 -6
  107. package/src/routes/project-index.tsx +3 -3
  108. package/src/routes/project-settings-index.tsx +11 -0
  109. package/src/routes/project-settings.tsx +4 -0
  110. package/test/pages.tsx +26 -22
  111. 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 {jsonResponse, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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', `/workspaces/${PROJECT_TEST_WID}/integrations`);
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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
- `/workspaces/${PROJECT_TEST_WID}/settings/agents`,
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
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 {useCreateProjectMutation} from '#hooks/api/projects.js';
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
- await createProjectFromForm(nameTouched ? value.name : defaultProjectName);
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 <Navigate to="/workspaces/$wid/integrations" params={{wid: workspace.id}} replace />;
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: '/workspaces/$wid/projects/$pid',
136
- params: {wid: workspace.id, pid: project.id},
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
- await navigate({
143
- to: '/workspaces/$wid/projects/$pid',
144
- params: {wid: workspace.id, pid: copy.existingProjectId},
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 to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
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(event.target.value);
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, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
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(`/workspaces/${PROJECT_TEST_WID}`, <HomeRouter />);
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
+ }