@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +31 -0
- package/dist/components/source-strip.d.ts.map +1 -1
- package/dist/components/source-strip.js +49 -45
- package/dist/components/source-strip.js.map +1 -1
- package/dist/pages/create-project-page.d.ts.map +1 -1
- package/dist/pages/create-project-page.js +161 -175
- package/dist/pages/create-project-page.js.map +1 -1
- package/dist/pages/project-settings-page.d.ts.map +1 -1
- package/dist/pages/project-settings-page.js +82 -84
- package/dist/pages/project-settings-page.js.map +1 -1
- package/dist/pages/projects-hub-page.d.ts.map +1 -1
- package/dist/pages/projects-hub-page.js +160 -159
- package/dist/pages/projects-hub-page.js.map +1 -1
- package/dist/pages/projects-hub-page.stories.d.ts +22 -0
- package/dist/pages/projects-hub-page.stories.d.ts.map +1 -0
- package/dist/pages/projects-hub-page.stories.js +214 -0
- package/dist/pages/projects-hub-page.stories.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/components/source-strip.tsx +29 -26
- package/src/pages/create-project-page.test.tsx +85 -1
- package/src/pages/create-project-page.tsx +165 -153
- package/src/pages/home-router.test.tsx +2 -1
- package/src/pages/project-settings-page.test.tsx +10 -1
- package/src/pages/project-settings-page.tsx +88 -76
- package/src/pages/projects-hub-page.stories.tsx +203 -0
- package/src/pages/projects-hub-page.test.tsx +90 -10
- package/src/pages/projects-hub-page.tsx +136 -125
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -13,8 +13,9 @@ import {Callout} from '@shipfox/react-ui/callout';
|
|
|
13
13
|
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
14
14
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
15
15
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
16
|
+
import {Panel, PanelBody} from '@shipfox/react-ui/panel';
|
|
16
17
|
import {toast} from '@shipfox/react-ui/toast';
|
|
17
|
-
import {Header
|
|
18
|
+
import {Header} from '@shipfox/react-ui/typography';
|
|
18
19
|
import {useForm} from '@tanstack/react-form';
|
|
19
20
|
import {useNavigate} from '@tanstack/react-router';
|
|
20
21
|
import {useState} from 'react';
|
|
@@ -124,86 +125,97 @@ function ProjectSettingsForm({project}: {project: Project}) {
|
|
|
124
125
|
return (
|
|
125
126
|
<>
|
|
126
127
|
<div className="flex min-w-0 flex-col gap-section">
|
|
127
|
-
<
|
|
128
|
-
<Header variant="h1">General</Header>
|
|
129
|
-
<Text size="sm" className="text-foreground-neutral-muted">
|
|
130
|
-
Update the project name and the slug used in its URLs.
|
|
131
|
-
</Text>
|
|
132
|
-
</header>
|
|
128
|
+
<Header variant="h1">General</Header>
|
|
133
129
|
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
{formError
|
|
137
|
-
|
|
138
|
-
|
|
130
|
+
<Panel>
|
|
131
|
+
<PanelBody className="gap-group p-panel">
|
|
132
|
+
{formError ? (
|
|
133
|
+
<Callout role="alert" type="error">
|
|
134
|
+
{formError}
|
|
135
|
+
</Callout>
|
|
136
|
+
) : null}
|
|
139
137
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
138
|
+
<form
|
|
139
|
+
className="flex w-full max-w-[560px] flex-col gap-group"
|
|
140
|
+
noValidate
|
|
141
|
+
onSubmit={(event) => {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
void form.handleSubmit();
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<form.Field
|
|
148
|
+
name="name"
|
|
149
|
+
validators={{
|
|
150
|
+
onBlur: ({value}) =>
|
|
151
|
+
displayNameFieldError(
|
|
152
|
+
value,
|
|
153
|
+
'Project name',
|
|
154
|
+
createProjectBodySchema.shape.name,
|
|
155
|
+
),
|
|
156
|
+
onSubmit: ({value}) =>
|
|
157
|
+
displayNameFieldError(
|
|
158
|
+
value,
|
|
159
|
+
'Project name',
|
|
160
|
+
createProjectBodySchema.shape.name,
|
|
161
|
+
),
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
{(field) => (
|
|
165
|
+
<FormField
|
|
166
|
+
label="Project name"
|
|
167
|
+
id="project-settings-name"
|
|
168
|
+
error={fieldError(field)}
|
|
169
|
+
>
|
|
170
|
+
<FormFieldInput
|
|
171
|
+
name="name"
|
|
172
|
+
type="text"
|
|
173
|
+
value={field.state.value}
|
|
174
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
175
|
+
onBlur={field.handleBlur}
|
|
176
|
+
/>
|
|
177
|
+
</FormField>
|
|
178
|
+
)}
|
|
179
|
+
</form.Field>
|
|
170
180
|
|
|
171
|
-
|
|
172
|
-
name="slug"
|
|
173
|
-
validators={{
|
|
174
|
-
onBlur: createProjectBodySchema.shape.slug,
|
|
175
|
-
onSubmit: createProjectBodySchema.shape.slug,
|
|
176
|
-
}}
|
|
177
|
-
>
|
|
178
|
-
{(field) => (
|
|
179
|
-
<SlugField
|
|
180
|
-
id="project-settings-slug"
|
|
181
|
-
label="Project slug"
|
|
181
|
+
<form.Field
|
|
182
182
|
name="slug"
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
183
|
+
validators={{
|
|
184
|
+
onBlur: createProjectBodySchema.shape.slug,
|
|
185
|
+
onSubmit: createProjectBodySchema.shape.slug,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{(field) => (
|
|
189
|
+
<SlugField
|
|
190
|
+
id="project-settings-slug"
|
|
191
|
+
label="Project slug"
|
|
192
|
+
name="slug"
|
|
193
|
+
value={field.state.value}
|
|
194
|
+
onChange={(value) => field.handleChange(value)}
|
|
195
|
+
onBlur={field.handleBlur}
|
|
196
|
+
error={fieldError(field)}
|
|
197
|
+
description={
|
|
198
|
+
<span className="break-all font-code">
|
|
199
|
+
/w/{workspace.slug}/p/{field.state.value}
|
|
200
|
+
</span>
|
|
201
|
+
}
|
|
202
|
+
placeholder="platform"
|
|
203
|
+
className="font-code"
|
|
204
|
+
currentSlug={project.slug}
|
|
205
|
+
checkEnabled
|
|
206
|
+
debounceMs={0}
|
|
207
|
+
isValid={isSlugValid}
|
|
208
|
+
checkAvailability={checkProjectSlugAvailability}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
</form.Field>
|
|
202
212
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
<Button type="submit" isLoading={updateProject.isPending} className="self-start">
|
|
214
|
+
Save changes
|
|
215
|
+
</Button>
|
|
216
|
+
</form>
|
|
217
|
+
</PanelBody>
|
|
218
|
+
</Panel>
|
|
207
219
|
</div>
|
|
208
220
|
|
|
209
221
|
<SlugChangeWarning
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {type AuthState, authStateAtom} from '@shipfox/client-auth';
|
|
3
|
+
import {Toaster} from '@shipfox/react-ui/toast';
|
|
4
|
+
import type {Meta, StoryObj} from '@storybook/react';
|
|
5
|
+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
6
|
+
import {
|
|
7
|
+
createMemoryHistory,
|
|
8
|
+
createRootRoute,
|
|
9
|
+
createRoute,
|
|
10
|
+
createRouter,
|
|
11
|
+
Outlet,
|
|
12
|
+
RouterProvider,
|
|
13
|
+
} from '@tanstack/react-router';
|
|
14
|
+
import {createStore, Provider as JotaiProvider} from 'jotai';
|
|
15
|
+
import {useMemo} from 'react';
|
|
16
|
+
import {ProjectsHubPage} from './projects-hub-page.js';
|
|
17
|
+
|
|
18
|
+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
19
|
+
const CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
|
|
20
|
+
|
|
21
|
+
type Scenario = 'list' | 'empty' | 'loading' | 'error';
|
|
22
|
+
|
|
23
|
+
const authState: AuthState = {
|
|
24
|
+
status: 'authenticated',
|
|
25
|
+
token: 'token',
|
|
26
|
+
user: {
|
|
27
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
28
|
+
email: 'platform@example.com',
|
|
29
|
+
name: 'Platform Engineer',
|
|
30
|
+
emailVerifiedAt: '2026-01-01T00:00:00.000Z',
|
|
31
|
+
},
|
|
32
|
+
workspaces: [{id: WORKSPACE_ID, name: 'Acme', slug: 'acme', membershipId: 'membership-1'}],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function ProjectsHubPageStory({scenario}: {scenario: Scenario}) {
|
|
36
|
+
configureApiClient({
|
|
37
|
+
baseUrl: 'https://api.example.test',
|
|
38
|
+
fetchImpl: fetchForScenario(scenario),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const queryClient = useMemo(
|
|
42
|
+
() => new QueryClient({defaultOptions: {queries: {retry: false}}}),
|
|
43
|
+
[],
|
|
44
|
+
);
|
|
45
|
+
const store = useMemo(() => {
|
|
46
|
+
const nextStore = createStore();
|
|
47
|
+
nextStore.set(authStateAtom, authState);
|
|
48
|
+
return nextStore;
|
|
49
|
+
}, []);
|
|
50
|
+
const router = useMemo(() => createStoryRouter(), []);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<QueryClientProvider client={queryClient}>
|
|
54
|
+
<JotaiProvider store={store}>
|
|
55
|
+
<div className="min-h-screen bg-background-subtle-base px-24 py-32">
|
|
56
|
+
<div className="mx-auto w-full max-w-[1120px]">
|
|
57
|
+
<RouterProvider router={router} />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<Toaster />
|
|
61
|
+
</JotaiProvider>
|
|
62
|
+
</QueryClientProvider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const meta = {
|
|
67
|
+
title: 'Projects/ProjectsHubPage',
|
|
68
|
+
component: ProjectsHubPageStory,
|
|
69
|
+
parameters: {layout: 'fullscreen'},
|
|
70
|
+
args: {scenario: 'list'},
|
|
71
|
+
} satisfies Meta<typeof ProjectsHubPageStory>;
|
|
72
|
+
|
|
73
|
+
export default meta;
|
|
74
|
+
type Story = StoryObj<typeof meta>;
|
|
75
|
+
|
|
76
|
+
export const Playground: Story = {};
|
|
77
|
+
|
|
78
|
+
export const Empty: Story = {
|
|
79
|
+
args: {scenario: 'empty'},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Loading: Story = {
|
|
83
|
+
args: {scenario: 'loading'},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const ErrorState: Story = {
|
|
87
|
+
args: {scenario: 'error'},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function createStoryRouter() {
|
|
91
|
+
const rootRoute = createRootRoute({component: Outlet});
|
|
92
|
+
const workspaceRoute = createRoute({
|
|
93
|
+
getParentRoute: () => rootRoute,
|
|
94
|
+
path: '/w/$workspaceSlug',
|
|
95
|
+
component: ProjectsHubPage,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return createRouter({
|
|
99
|
+
history: createMemoryHistory({initialEntries: ['/w/acme']}),
|
|
100
|
+
routeTree: rootRoute.addChildren([workspaceRoute]),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fetchForScenario(scenario: Scenario): typeof fetch {
|
|
105
|
+
return (input) => {
|
|
106
|
+
const url = requestUrl(input);
|
|
107
|
+
|
|
108
|
+
if (url.pathname.endsWith('/agent/model-providers')) {
|
|
109
|
+
return Promise.resolve(
|
|
110
|
+
jsonResponse({
|
|
111
|
+
configs: [
|
|
112
|
+
{
|
|
113
|
+
kind: 'builtin',
|
|
114
|
+
provider_id: 'anthropic',
|
|
115
|
+
default_model: null,
|
|
116
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
117
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
default_provider_id: 'anthropic',
|
|
121
|
+
default_harness_id: null,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (url.pathname === '/integration-connections') {
|
|
127
|
+
return Promise.resolve(
|
|
128
|
+
jsonResponse({
|
|
129
|
+
connections: [
|
|
130
|
+
{
|
|
131
|
+
id: CONNECTION_ID,
|
|
132
|
+
workspace_id: WORKSPACE_ID,
|
|
133
|
+
provider: 'github',
|
|
134
|
+
external_account_id: 'octo',
|
|
135
|
+
slug: 'github_octo',
|
|
136
|
+
display_name: 'Acme GitHub',
|
|
137
|
+
lifecycle_status: 'active',
|
|
138
|
+
capabilities: ['source_control'],
|
|
139
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
140
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (url.pathname === '/projects') {
|
|
148
|
+
if (scenario === 'loading') {
|
|
149
|
+
return new Promise<Response>(() => {
|
|
150
|
+
// Keep the request pending to show the initial skeleton.
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (scenario === 'error') {
|
|
154
|
+
return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
155
|
+
}
|
|
156
|
+
if (scenario === 'empty') {
|
|
157
|
+
return Promise.resolve(jsonResponse({projects: [], next_cursor: null}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return Promise.resolve(
|
|
161
|
+
jsonResponse({
|
|
162
|
+
projects: [
|
|
163
|
+
projectForStory('Platform', '11111111-1111-4111-8111-111111111112'),
|
|
164
|
+
projectForStory('Agent', '11111111-1111-4111-8111-111111111113'),
|
|
165
|
+
projectForStory('Workflows', '11111111-1111-4111-8111-111111111114'),
|
|
166
|
+
],
|
|
167
|
+
next_cursor: null,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Promise.resolve(jsonResponse({}, {status: 404}));
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function projectForStory(name: string, id: string) {
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
workspace_id: WORKSPACE_ID,
|
|
180
|
+
name,
|
|
181
|
+
slug: name.toLowerCase(),
|
|
182
|
+
source: {
|
|
183
|
+
connection_id: CONNECTION_ID,
|
|
184
|
+
external_repository_id: `github:octo/${name.toLowerCase()}`,
|
|
185
|
+
},
|
|
186
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
187
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function requestUrl(input: RequestInfo | URL): URL {
|
|
192
|
+
if (input instanceof Request) return new URL(input.url);
|
|
193
|
+
if (input instanceof URL) return input;
|
|
194
|
+
return new URL(input, 'https://api.example.test');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
|
198
|
+
return new Response(JSON.stringify(body), {
|
|
199
|
+
status: 200,
|
|
200
|
+
headers: {'content-type': 'application/json'},
|
|
201
|
+
...init,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
-
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
|
2
|
+
import {fireEvent, screen, waitFor, within} from '@testing-library/react';
|
|
3
3
|
import {
|
|
4
4
|
jsonResponse,
|
|
5
5
|
PROJECT_TEST_WID,
|
|
@@ -17,7 +17,7 @@ describe('ProjectsHubPage', () => {
|
|
|
17
17
|
window.sessionStorage.clear();
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
test('renders Projects
|
|
20
|
+
test('renders the Projects panel controls and empty state with create CTA', async () => {
|
|
21
21
|
configureApiClient({
|
|
22
22
|
fetchImpl: createHubFetch({
|
|
23
23
|
projects: jsonResponse({projects: [], next_cursor: null}),
|
|
@@ -28,10 +28,17 @@ describe('ProjectsHubPage', () => {
|
|
|
28
28
|
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
|
|
29
29
|
|
|
30
30
|
expect(await screen.findByText('Create your first project')).toBeInTheDocument();
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const projectsRegion = screen.getByRole('region', {name: 'Projects'});
|
|
32
|
+
expect(projectsRegion.querySelectorAll('[data-slot="panel"]')).toHaveLength(1);
|
|
33
|
+
const panelHeader = projectsRegion.querySelector<HTMLElement>('[data-slot="panel-header"]');
|
|
34
|
+
if (!panelHeader) throw new Error('Projects panel header was not rendered');
|
|
35
|
+
expect(panelHeader).toBeInTheDocument();
|
|
36
|
+
expect(projectsRegion.querySelector('[data-slot="panel-body"]')).toBeInTheDocument();
|
|
37
|
+
expect(screen.queryByRole('heading', {name: 'Projects'})).not.toBeInTheDocument();
|
|
38
|
+
expect(
|
|
39
|
+
within(panelHeader).getByRole('searchbox', {name: 'Search projects'}),
|
|
40
|
+
).toBeInTheDocument();
|
|
41
|
+
expect(within(panelHeader).getByRole('link', {name: NEW_PROJECT_REGEX})).toHaveAttribute(
|
|
35
42
|
'href',
|
|
36
43
|
WORKSPACE_PROJECTS_NEW_HREF,
|
|
37
44
|
);
|
|
@@ -111,17 +118,90 @@ describe('ProjectsHubPage', () => {
|
|
|
111
118
|
|
|
112
119
|
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
|
|
113
120
|
expect(await screen.findByText('Platform')).toBeInTheDocument();
|
|
121
|
+
const projectsList = screen.getByRole('list', {name: 'Projects list'});
|
|
122
|
+
expect(projectsList).toHaveClass('grid-cols-2', 'max-[760px]:grid-cols-1');
|
|
123
|
+
expect(projectsList).not.toHaveClass('gap-px');
|
|
124
|
+
expect(projectsList).not.toHaveClass('bg-border-neutral-base');
|
|
125
|
+
expect(projectsList.querySelectorAll('[data-slot="panel-cell"]')).toHaveLength(1);
|
|
114
126
|
const projectLink = screen.getByText('Platform').closest('a');
|
|
115
|
-
// The whole
|
|
116
|
-
|
|
117
|
-
expect(projectLink).toHaveClass('
|
|
118
|
-
expect(projectLink?.className).not.toContain('shadow-button-secondary');
|
|
127
|
+
// The whole cell is the link, carrying an inset neutral focus ring.
|
|
128
|
+
expect(projectLink).toHaveClass('focus-visible:shadow-focus-inset');
|
|
129
|
+
expect(projectLink).toHaveClass('hover:bg-background-neutral-hover');
|
|
119
130
|
|
|
120
131
|
fireEvent.click(screen.getByRole('button', {name: 'Load more'}));
|
|
121
132
|
|
|
122
133
|
expect(await screen.findByText('API')).toBeInTheDocument();
|
|
123
134
|
});
|
|
124
135
|
|
|
136
|
+
test('renders an odd project count without an empty border-colored cell', async () => {
|
|
137
|
+
configureApiClient({
|
|
138
|
+
fetchImpl: createHubFetch({
|
|
139
|
+
projects: jsonResponse({
|
|
140
|
+
projects: [
|
|
141
|
+
projectDto({id: '11111111-1111-4111-8111-111111111112', name: 'Platform'}),
|
|
142
|
+
projectDto({id: '11111111-1111-4111-8111-111111111113', name: 'Agent'}),
|
|
143
|
+
projectDto({id: '11111111-1111-4111-8111-111111111114', name: 'Workflows'}),
|
|
144
|
+
],
|
|
145
|
+
next_cursor: null,
|
|
146
|
+
}),
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
|
|
151
|
+
|
|
152
|
+
const projectsList = await screen.findByRole('list', {name: 'Projects list'});
|
|
153
|
+
expect(projectsList.querySelectorAll('[data-slot="panel-cell"]')).toHaveLength(3);
|
|
154
|
+
expect(projectsList).not.toHaveClass('gap-px', 'bg-border-neutral-base');
|
|
155
|
+
// The odd count is padded so the last row's divider still spans the panel,
|
|
156
|
+
// and the pad carries the panel fill rather than the border colour.
|
|
157
|
+
const filler = projectsList.querySelector('[data-slot="panel-cell-filler"]');
|
|
158
|
+
expect(filler).not.toBeNull();
|
|
159
|
+
expect(filler).toHaveClass('bg-background-neutral-base');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('keeps existing projects and the load-more retry after a cursor failure', async () => {
|
|
163
|
+
let cursorRequests = 0;
|
|
164
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
165
|
+
const url = new URL(requestInputUrl(input));
|
|
166
|
+
if (url.pathname.endsWith('/agent/model-providers')) {
|
|
167
|
+
return Promise.resolve(jsonResponse(modelProviderConfigsDto()));
|
|
168
|
+
}
|
|
169
|
+
if (url.pathname === '/integration-connections') {
|
|
170
|
+
return Promise.resolve(jsonResponse(connectionsDto()));
|
|
171
|
+
}
|
|
172
|
+
if (url.pathname === '/projects') {
|
|
173
|
+
if (url.searchParams.get('cursor') === 'cursor-1') {
|
|
174
|
+
cursorRequests += 1;
|
|
175
|
+
return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
176
|
+
}
|
|
177
|
+
return Promise.resolve(
|
|
178
|
+
jsonResponse({
|
|
179
|
+
projects: [projectDto({id: '11111111-1111-4111-8111-111111111112', name: 'Platform'})],
|
|
180
|
+
next_cursor: 'cursor-1',
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
|
|
185
|
+
});
|
|
186
|
+
configureApiClient({fetchImpl});
|
|
187
|
+
|
|
188
|
+
renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
|
|
189
|
+
expect(await screen.findByText('Platform')).toBeInTheDocument();
|
|
190
|
+
|
|
191
|
+
fireEvent.click(screen.getByRole('button', {name: 'Load more'}));
|
|
192
|
+
|
|
193
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
194
|
+
'Could not load the next page. Existing projects are still shown.',
|
|
195
|
+
);
|
|
196
|
+
expect(screen.getByText('Platform')).toBeInTheDocument();
|
|
197
|
+
expect(screen.getByRole('button', {name: 'Load more'})).toBeInTheDocument();
|
|
198
|
+
expect(cursorRequests).toBe(1);
|
|
199
|
+
|
|
200
|
+
fireEvent.click(screen.getByRole('button', {name: 'Load more'}));
|
|
201
|
+
|
|
202
|
+
await waitFor(() => expect(cursorRequests).toBe(2));
|
|
203
|
+
});
|
|
204
|
+
|
|
125
205
|
test('renders an error alert with retry', async () => {
|
|
126
206
|
configureApiClient({
|
|
127
207
|
fetchImpl: createHubFetch({
|