@shipfox/client-projects 21.0.0 → 22.0.1

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/dist/components/source-strip.d.ts.map +1 -1
  4. package/dist/components/source-strip.js +49 -45
  5. package/dist/components/source-strip.js.map +1 -1
  6. package/dist/pages/create-project-page.d.ts.map +1 -1
  7. package/dist/pages/create-project-page.js +161 -175
  8. package/dist/pages/create-project-page.js.map +1 -1
  9. package/dist/pages/project-settings-page.d.ts.map +1 -1
  10. package/dist/pages/project-settings-page.js +82 -84
  11. package/dist/pages/project-settings-page.js.map +1 -1
  12. package/dist/pages/projects-hub-page.d.ts.map +1 -1
  13. package/dist/pages/projects-hub-page.js +160 -159
  14. package/dist/pages/projects-hub-page.js.map +1 -1
  15. package/dist/pages/projects-hub-page.stories.d.ts +22 -0
  16. package/dist/pages/projects-hub-page.stories.d.ts.map +1 -0
  17. package/dist/pages/projects-hub-page.stories.js +214 -0
  18. package/dist/pages/projects-hub-page.stories.js.map +1 -0
  19. package/dist/tsconfig.test.tsbuildinfo +1 -1
  20. package/package.json +7 -7
  21. package/src/components/source-strip.tsx +29 -26
  22. package/src/pages/create-project-page.test.tsx +85 -1
  23. package/src/pages/create-project-page.tsx +165 -153
  24. package/src/pages/home-router.test.tsx +2 -1
  25. package/src/pages/project-settings-page.test.tsx +10 -1
  26. package/src/pages/project-settings-page.tsx +88 -76
  27. package/src/pages/projects-hub-page.stories.tsx +203 -0
  28. package/src/pages/projects-hub-page.test.tsx +90 -10
  29. package/src/pages/projects-hub-page.tsx +136 -125
  30. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-projects",
3
3
  "license": "MIT",
4
- "version": "21.0.0",
4
+ "version": "22.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -31,13 +31,13 @@
31
31
  "@shipfox/api-definitions-dto": "12.3.0",
32
32
  "@shipfox/api-integration-core-dto": "12.2.0",
33
33
  "@shipfox/api-projects-dto": "12.2.0",
34
- "@shipfox/client-agent": "21.0.0",
34
+ "@shipfox/client-agent": "22.0.1",
35
35
  "@shipfox/client-api": "6.0.1",
36
- "@shipfox/client-auth": "21.0.0",
37
- "@shipfox/client-integrations": "21.0.0",
38
- "@shipfox/client-shell": "21.0.0",
39
- "@shipfox/client-ui": "21.0.0",
40
- "@shipfox/react-ui": "2.0.0"
36
+ "@shipfox/client-auth": "22.0.1",
37
+ "@shipfox/client-integrations": "22.0.1",
38
+ "@shipfox/client-shell": "22.0.1",
39
+ "@shipfox/client-ui": "22.0.0",
40
+ "@shipfox/react-ui": "2.1.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@tanstack/react-query": "^5.101.0",
@@ -2,6 +2,7 @@ import {useActiveWorkspace} from '@shipfox/client-auth';
2
2
  import {useSourceConnectionsQuery} from '@shipfox/client-integrations';
3
3
  import {StatusBadge} from '@shipfox/react-ui/badge';
4
4
  import {Icon, type IconName} from '@shipfox/react-ui/icon';
5
+ import {Panel} from '@shipfox/react-ui/panel';
5
6
  import {Skeleton} from '@shipfox/react-ui/skeleton';
6
7
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
7
8
  import {Code, Text} from '@shipfox/react-ui/typography';
@@ -29,34 +30,36 @@ export function SourceStrip({
29
30
  const connection = connectionsQuery.data?.find((c) => c.id === connectionId);
30
31
 
31
32
  return (
32
- <section
33
- className="flex flex-col gap-inline rounded-8 border border-border-neutral-base bg-background-neutral-base px-row py-row sm:flex-row sm:items-center sm:justify-between"
34
- aria-label="Project source"
33
+ <Panel
34
+ asChild
35
+ className="gap-inline px-row py-row sm:flex-row sm:items-center sm:justify-between"
35
36
  >
36
- <div className="flex min-w-0 items-center gap-inline">
37
- <Icon name={providerIconName(connection?.provider)} className="size-20 shrink-0" />
38
- <div className="flex min-w-0 flex-col gap-tight sm:flex-row sm:items-center sm:gap-inline">
39
- {connectionsQuery.isPending ? (
40
- <Skeleton className="h-16 w-160" />
41
- ) : (
42
- <Text size="sm" bold className="truncate">
43
- {connection?.displayName ?? 'Connected source'}
44
- </Text>
45
- )}
46
- <Tooltip>
47
- <TooltipTrigger asChild>
48
- <Code variant="label" className="truncate text-foreground-neutral-muted">
49
- {externalRepositoryId}
50
- </Code>
51
- </TooltipTrigger>
52
- <TooltipContent>{externalRepositoryId}</TooltipContent>
53
- </Tooltip>
37
+ <section aria-label="Project source">
38
+ <div className="flex min-w-0 items-center gap-inline">
39
+ <Icon name={providerIconName(connection?.provider)} className="size-20 shrink-0" />
40
+ <div className="flex min-w-0 flex-col gap-tight sm:flex-row sm:items-center sm:gap-inline">
41
+ {connectionsQuery.isPending ? (
42
+ <Skeleton className="h-16 w-160" />
43
+ ) : (
44
+ <Text size="sm" bold className="truncate">
45
+ {connection?.displayName ?? 'Connected source'}
46
+ </Text>
47
+ )}
48
+ <Tooltip>
49
+ <TooltipTrigger asChild>
50
+ <Code variant="label" className="truncate text-foreground-neutral-muted">
51
+ {externalRepositoryId}
52
+ </Code>
53
+ </TooltipTrigger>
54
+ <TooltipContent>{externalRepositoryId}</TooltipContent>
55
+ </Tooltip>
56
+ </div>
54
57
  </div>
55
- </div>
56
- <div className="shrink-0">
57
- <SyncBadge sync={sync} isPending={isPending} />
58
- </div>
59
- </section>
58
+ <div className="shrink-0">
59
+ <SyncBadge sync={sync} isPending={isPending} />
60
+ </div>
61
+ </section>
62
+ </Panel>
60
63
  );
61
64
  }
62
65
 
@@ -1,6 +1,7 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
2
  import {QueryClient} from '@tanstack/react-query';
3
- import {fireEvent, screen, waitFor} from '@testing-library/react';
3
+ import {fireEvent, screen, waitFor, within} from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
4
5
  import {projectsQueryKeys} from '#hooks/api/projects.js';
5
6
  import {
6
7
  jsonResponse,
@@ -43,6 +44,24 @@ describe('CreateProjectPage', () => {
43
44
  const nameInput = await screen.findByLabelText('Project name');
44
45
  const slugInput = await screen.findByLabelText('Project slug');
45
46
  await waitFor(() => expect(nameInput).toHaveValue('Platform'));
47
+ expect(
48
+ screen.queryByText(
49
+ 'A Shipfox project starts from a Git repository. Choose the repository Shipfox should track, then give the project a name.',
50
+ ),
51
+ ).not.toBeInTheDocument();
52
+ expect(screen.getByRole('searchbox', {name: 'Search repositories'})).toBeInTheDocument();
53
+ expect(document.querySelectorAll('[data-slot="panel"]')).toHaveLength(3);
54
+ expect(within(panelForHeading('Project details')).getByLabelText('Project name')).toBe(
55
+ nameInput,
56
+ );
57
+ expect(within(panelForHeading('Project details')).getByLabelText('Project slug')).toBe(
58
+ slugInput,
59
+ );
60
+ expect(
61
+ within(panelForHeading('Repository')).getByRole('searchbox', {
62
+ name: 'Search repositories',
63
+ }),
64
+ ).toBeInTheDocument();
46
65
  expect(slugInput).toHaveValue('platform');
47
66
  expect(slugInput).toHaveAttribute('aria-describedby', 'project-slug-description');
48
67
  expect(screen.getByText('/w/acme/p/platform')).toBeInTheDocument();
@@ -69,6 +88,65 @@ describe('CreateProjectPage', () => {
69
88
  });
70
89
  }, 10_000);
71
90
 
91
+ test('does not render an empty source panel when connections fail to load', async () => {
92
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
93
+ const request = input as Request;
94
+ if (request.url.includes('/integration-connections?')) {
95
+ return Promise.resolve(jsonResponse({message: 'upstream unavailable'}, {status: 500}));
96
+ }
97
+ return Promise.resolve(jsonResponse({}));
98
+ });
99
+ configureApiClient({fetchImpl});
100
+
101
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
102
+
103
+ expect(
104
+ await screen.findByText(
105
+ 'Could not load source integrations. Refresh the integrations list to continue.',
106
+ ),
107
+ ).toBeInTheDocument();
108
+ expect(screen.queryByRole('heading', {name: 'Source integration'})).not.toBeInTheDocument();
109
+ expect(document.querySelectorAll('[data-slot="panel"]')).toHaveLength(1);
110
+ });
111
+
112
+ test('passes the repository search filter to the query and renders the filtered empty state', async () => {
113
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
114
+ const request = input as Request;
115
+ if (request.url.includes('/integration-connections?')) {
116
+ return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
117
+ }
118
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
119
+ const search = new URL(request.url).searchParams.get('search');
120
+ return Promise.resolve(
121
+ jsonResponse(
122
+ search === 'xyz'
123
+ ? {repositories: [], next_cursor: null}
124
+ : {repositories: [repositoryDto()], next_cursor: null},
125
+ ),
126
+ );
127
+ }
128
+ return Promise.resolve(jsonResponse({}));
129
+ });
130
+ configureApiClient({fetchImpl});
131
+
132
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/projects/new`, <CreateProjectPage />);
133
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
134
+
135
+ const user = userEvent.setup();
136
+ await user.type(screen.getByRole('searchbox', {name: 'Search repositories'}), 'xyz');
137
+
138
+ expect(await screen.findByText('No repositories matching "xyz".')).toBeInTheDocument();
139
+ expect(
140
+ fetchImpl.mock.calls.some(([input]) => {
141
+ const request = input as Request;
142
+ return (
143
+ request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`) &&
144
+ new URL(request.url).searchParams.get('search') === 'xyz'
145
+ );
146
+ }),
147
+ ).toBe(true);
148
+ });
149
+
72
150
  test('auto-derives the slug from each keystroke in the name field until the slug is touched', async () => {
73
151
  const fetchImpl = vi.fn((input: RequestInfo | URL) => {
74
152
  const request = input as Request;
@@ -496,3 +574,9 @@ function projectPostCount(fetchImpl: ReturnType<typeof vi.fn>): number {
496
574
  return request.url.endsWith('/projects') && request.method === 'POST';
497
575
  }).length;
498
576
  }
577
+
578
+ function panelForHeading(name: string): HTMLElement {
579
+ const panel = screen.getByRole('heading', {name}).closest('[data-slot="panel"]');
580
+ if (!(panel instanceof HTMLElement)) throw new Error(`Panel not found for heading: ${name}`);
581
+ return panel;
582
+ }
@@ -13,7 +13,9 @@ import {displayNameFieldError, SlugField} from '@shipfox/client-ui';
13
13
  import {Button} from '@shipfox/react-ui/button';
14
14
  import {Callout} from '@shipfox/react-ui/callout';
15
15
  import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
16
+ import {Input} from '@shipfox/react-ui/input';
16
17
  import {FullPageLoader} from '@shipfox/react-ui/loader';
18
+ import {Panel, PanelActions, PanelBody, PanelHeader, PanelTitle} from '@shipfox/react-ui/panel';
17
19
  import {toast} from '@shipfox/react-ui/toast';
18
20
  import {Header, Text} from '@shipfox/react-ui/typography';
19
21
  import {useForm} from '@tanstack/react-form';
@@ -199,15 +201,9 @@ export function CreateProjectPage() {
199
201
 
200
202
  return (
201
203
  <div className="flex w-full flex-col gap-section">
202
- <header className="flex flex-col gap-inline">
203
- <Header id="create-project-title" variant="h1">
204
- Create project
205
- </Header>
206
- <Text size="sm" className="text-foreground-neutral-muted">
207
- A Shipfox project starts from a Git repository. Choose the repository Shipfox should
208
- track, then give the project a name.
209
- </Text>
210
- </header>
204
+ <Header id="create-project-title" variant="h1">
205
+ Create project
206
+ </Header>
211
207
 
212
208
  <ModelProviderReminderBanner workspaceId={workspace.id} />
213
209
 
@@ -240,161 +236,177 @@ export function CreateProjectPage() {
240
236
  className="grid items-start gap-region lg:grid-cols-[minmax(0,1fr)_340px]"
241
237
  >
242
238
  <div className="flex min-w-0 flex-col gap-region">
243
- <section className="flex flex-col gap-group" aria-label="Source integration">
244
- <div className="flex items-start justify-between gap-group">
245
- <div className="flex flex-col gap-tight">
246
- <Header variant="h3">Source integration</Header>
247
- <Text size="sm" className="text-foreground-neutral-muted">
248
- Choose the integration that can access the repository.
249
- </Text>
250
- </div>
251
-
252
- {connections.length === 1 ? (
253
- <Button asChild variant="transparent" size="sm" className="shrink-0">
254
- <Link
255
- to="/w/$workspaceSlug/integrations"
256
- params={{workspaceSlug: workspace.slug}}
257
- >
258
- Add another integration
259
- </Link>
260
- </Button>
261
- ) : null}
262
- </div>
263
-
264
- {connections.length > 0 ? (
265
- <ConnectionPicker
266
- connections={connections}
267
- selectedConnectionId={effectiveSelectedConnectionId}
268
- onSelect={selectConnection}
269
- />
270
- ) : null}
271
- </section>
239
+ {connections.length > 0 ? (
240
+ <section aria-label="Source integration">
241
+ <Panel>
242
+ <PanelHeader>
243
+ <PanelTitle>Source integration</PanelTitle>
244
+ {connections.length === 1 ? (
245
+ <PanelActions>
246
+ <Button asChild variant="transparent" size="sm" className="shrink-0">
247
+ <Link
248
+ to="/w/$workspaceSlug/integrations"
249
+ params={{workspaceSlug: workspace.slug}}
250
+ >
251
+ Add another integration
252
+ </Link>
253
+ </Button>
254
+ </PanelActions>
255
+ ) : null}
256
+ </PanelHeader>
257
+
258
+ <PanelBody>
259
+ <ConnectionPicker
260
+ connections={connections}
261
+ selectedConnectionId={effectiveSelectedConnectionId}
262
+ onSelect={selectConnection}
263
+ />
264
+ </PanelBody>
265
+ </Panel>
266
+ </section>
267
+ ) : null}
272
268
 
273
269
  {showRepoPicker ? (
274
- <section className="flex flex-col gap-group" aria-label="Repository">
275
- <div className="flex flex-col gap-tight">
276
- <Header variant="h3">Repository</Header>
277
- <Text size="sm" className="text-foreground-neutral-muted">
278
- Select the repository this project tracks.
279
- </Text>
280
- </div>
281
-
282
- <RepositoryPicker
283
- repositories={repositories}
284
- selectedRepositoryId={selectedRepositoryId}
285
- onSelect={setSelectedRepositoryId}
286
- isLoading={repositoriesQuery.isPending}
287
- isFetchingNextPage={repositoriesQuery.isFetchingNextPage}
288
- hasNextPage={repositoriesQuery.hasNextPage}
289
- onLoadMore={() => repositoriesQuery.fetchNextPage()}
290
- emptyMessage={filteredEmptyMessage}
291
- searchValue={repoFilter}
292
- onSearchChange={setRepoFilter}
293
- />
270
+ <section aria-label="Repository">
271
+ <Panel>
272
+ <PanelHeader className="flex-wrap">
273
+ <PanelTitle>Repository</PanelTitle>
274
+ <PanelActions className="min-w-0 max-[640px]:ml-0 max-[640px]:basis-full">
275
+ <Input
276
+ type="search"
277
+ placeholder="Search repositories…"
278
+ aria-label="Search repositories"
279
+ value={repoFilter}
280
+ onChange={(event) => setRepoFilter(event.target.value)}
281
+ className="w-full max-w-[320px]"
282
+ />
283
+ </PanelActions>
284
+ </PanelHeader>
285
+ <PanelBody>
286
+ <RepositoryPicker
287
+ repositories={repositories}
288
+ selectedRepositoryId={selectedRepositoryId}
289
+ onSelect={setSelectedRepositoryId}
290
+ isLoading={repositoriesQuery.isPending}
291
+ isFetchingNextPage={repositoriesQuery.isFetchingNextPage}
292
+ hasNextPage={repositoriesQuery.hasNextPage}
293
+ onLoadMore={() => repositoriesQuery.fetchNextPage()}
294
+ emptyMessage={filteredEmptyMessage}
295
+ />
296
+ </PanelBody>
297
+ </Panel>
294
298
  </section>
295
299
  ) : null}
296
300
  </div>
297
301
 
298
302
  <aside className="lg:sticky lg:top-32">
299
- <div className="flex flex-col gap-group rounded-8 border border-border-neutral-base bg-background-neutral-base p-panel">
300
- <div className="flex flex-col gap-tight">
301
- <Header variant="h3">Project details</Header>
302
- <Text size="sm" className="text-foreground-neutral-muted">
303
- Confirm the source and create the project.
304
- </Text>
305
- </div>
306
-
307
- {formError ? (
308
- <Callout role="alert" type="error">
309
- <div ref={errorRef} tabIndex={-1}>
310
- {formError}
311
- </div>
312
- </Callout>
313
- ) : null}
314
-
315
- <ProjectSummary
316
- connection={selectedConnection}
317
- repositoryName={selectedRepository?.fullName}
318
- />
319
-
320
- <form.Field
321
- name="name"
322
- validators={{
323
- onBlur: ({value}) =>
324
- displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),
325
- onSubmit: ({value}) =>
326
- displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),
327
- }}
328
- >
329
- {(field) => (
330
- <FormField label="Project name" id="project-name" error={fieldError(field)}>
331
- <FormFieldInput
332
- name="name"
333
- type="text"
303
+ <Panel>
304
+ <PanelHeader>
305
+ <PanelTitle>Project details</PanelTitle>
306
+ </PanelHeader>
307
+ <PanelBody className="gap-group p-panel">
308
+ {formError ? (
309
+ <Callout role="alert" type="error">
310
+ <div ref={errorRef} tabIndex={-1}>
311
+ {formError}
312
+ </div>
313
+ </Callout>
314
+ ) : null}
315
+
316
+ <ProjectSummary
317
+ connection={selectedConnection}
318
+ repositoryName={selectedRepository?.fullName}
319
+ />
320
+
321
+ <form.Field
322
+ name="name"
323
+ validators={{
324
+ onBlur: ({value}) =>
325
+ displayNameFieldError(
326
+ value,
327
+ 'Project name',
328
+ createProjectBodySchema.shape.name,
329
+ ),
330
+ onSubmit: ({value}) =>
331
+ displayNameFieldError(
332
+ value,
333
+ 'Project name',
334
+ createProjectBodySchema.shape.name,
335
+ ),
336
+ }}
337
+ >
338
+ {(field) => (
339
+ <FormField label="Project name" id="project-name" error={fieldError(field)}>
340
+ <FormFieldInput
341
+ name="name"
342
+ type="text"
343
+ value={field.state.value}
344
+ onChange={(event) => {
345
+ const nextName = event.target.value;
346
+ setNameTouched(true);
347
+ field.handleChange(nextName);
348
+ if (!slugManuallyEdited) {
349
+ setSlugCheckEnabled(true);
350
+ setSlugConflict(false);
351
+ form.setFieldValue('slug', slugifyName(nextName, {fallback: 'project'}));
352
+ }
353
+ }}
354
+ onBlur={field.handleBlur}
355
+ placeholder="Platform"
356
+ />
357
+ </FormField>
358
+ )}
359
+ </form.Field>
360
+
361
+ <form.Field
362
+ name="slug"
363
+ validators={{
364
+ onBlur: createProjectBodySchema.shape.slug,
365
+ onSubmit: createProjectBodySchema.shape.slug,
366
+ }}
367
+ >
368
+ {(field) => (
369
+ <SlugField
370
+ id="project-slug"
371
+ label="Project slug"
372
+ name="slug"
334
373
  value={field.state.value}
335
- onChange={(event) => {
336
- const nextName = event.target.value;
337
- setNameTouched(true);
338
- field.handleChange(nextName);
339
- if (!slugManuallyEdited) {
340
- setSlugCheckEnabled(true);
341
- setSlugConflict(false);
342
- form.setFieldValue('slug', slugifyName(nextName, {fallback: 'project'}));
343
- }
374
+ onChange={(value) => {
375
+ setSlugManuallyEdited(true);
376
+ setSlugCheckEnabled(true);
377
+ setSlugConflict(false);
378
+ field.handleChange(value);
344
379
  }}
345
380
  onBlur={field.handleBlur}
346
- placeholder="Platform"
381
+ error={
382
+ slugConflict ? 'This project slug is already in use.' : fieldError(field)
383
+ }
384
+ description={
385
+ <span className="font-code">
386
+ {`/w/${workspace.slug}/p/${field.state.value}`}
387
+ </span>
388
+ }
389
+ placeholder="platform"
390
+ className="font-code"
391
+ checkEnabled={slugCheckEnabled}
392
+ debounceMs={0}
393
+ isValid={isSlugValid}
394
+ checkAvailability={checkProjectSlugAvailability}
347
395
  />
348
- </FormField>
349
- )}
350
- </form.Field>
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
-
388
- <Button
389
- type="submit"
390
- iconRight="chevronRight"
391
- isLoading={createProject.isPending}
392
- disabled={!selectedConnection || !selectedRepository}
393
- className="w-full"
394
- >
395
- Create project
396
- </Button>
397
- </div>
396
+ )}
397
+ </form.Field>
398
+
399
+ <Button
400
+ type="submit"
401
+ iconRight="chevronRight"
402
+ isLoading={createProject.isPending}
403
+ disabled={!selectedConnection || !selectedRepository}
404
+ className="w-full"
405
+ >
406
+ Create project
407
+ </Button>
408
+ </PanelBody>
409
+ </Panel>
398
410
  </aside>
399
411
  </form>
400
412
  </div>
@@ -18,7 +18,8 @@ describe('HomeRouter', () => {
18
18
 
19
19
  renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <HomeRouter />);
20
20
 
21
- expect(await screen.findByRole('heading', {name: 'Projects'})).toBeInTheDocument();
21
+ expect(await screen.findByRole('region', {name: 'Projects'})).toBeInTheDocument();
22
+ expect(screen.getByRole('searchbox', {name: 'Search projects'})).toBeInTheDocument();
22
23
  const calledUrls = fetchImpl.mock.calls.map(([input]) =>
23
24
  input instanceof Request ? input.url : String(input),
24
25
  );
@@ -17,7 +17,16 @@ describe('ProjectSettingsPage', () => {
17
17
  configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
18
18
 
19
19
  renderProjectPage('/w/acme/p/platform/settings/general', <ProjectSettingsPage />);
20
- fireEvent.change(await screen.findByLabelText('Project name'), {
20
+ const nameInput = await screen.findByLabelText('Project name');
21
+ expect(screen.getByRole('heading', {name: 'General'})).toBeInTheDocument();
22
+ const settingsForm = nameInput.closest('form');
23
+ expect(settingsForm).not.toBeNull();
24
+ expect(settingsForm).toHaveClass('max-w-[560px]');
25
+ expect(
26
+ screen.queryByText('Update the project name and the slug used in its URLs.'),
27
+ ).not.toBeInTheDocument();
28
+ expect(document.querySelectorAll('[data-slot="panel"]')).toHaveLength(1);
29
+ fireEvent.change(nameInput, {
21
30
  target: {value: 'Platform API'},
22
31
  });
23
32
  fireEvent.click(screen.getByRole('button', {name: 'Save changes'}));