@shipfox/client-projects 0.2.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/.storybook/main.ts +1 -0
- package/.storybook/preview.css +7 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +207 -0
- package/LICENSE +21 -0
- package/dist/chrome.d.ts +3 -0
- package/dist/chrome.d.ts.map +1 -0
- package/dist/chrome.js +42 -0
- package/dist/chrome.js.map +1 -0
- package/dist/components/model-provider-reminder-banner.d.ts +4 -0
- package/dist/components/model-provider-reminder-banner.d.ts.map +1 -0
- package/dist/components/model-provider-reminder-banner.js +73 -0
- package/dist/components/model-provider-reminder-banner.js.map +1 -0
- package/dist/components/project-crumb.d.ts +7 -0
- package/dist/components/project-crumb.d.ts.map +1 -0
- package/dist/components/project-crumb.js +93 -0
- package/dist/components/project-crumb.js.map +1 -0
- package/dist/components/project-switcher.d.ts +7 -0
- package/dist/components/project-switcher.d.ts.map +1 -0
- package/dist/components/project-switcher.js +138 -0
- package/dist/components/project-switcher.js.map +1 -0
- package/dist/components/source-strip.d.ts +14 -0
- package/dist/components/source-strip.d.ts.map +1 -0
- package/dist/components/source-strip.js +98 -0
- package/dist/components/source-strip.js.map +1 -0
- package/dist/components/workspace-setup-guard.d.ts +9 -0
- package/dist/components/workspace-setup-guard.d.ts.map +1 -0
- package/dist/components/workspace-setup-guard.js +160 -0
- package/dist/components/workspace-setup-guard.js.map +1 -0
- package/dist/feature.d.ts +35 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +45 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/definitions.d.ts +97 -0
- package/dist/hooks/api/definitions.d.ts.map +1 -0
- package/dist/hooks/api/definitions.js +55 -0
- package/dist/hooks/api/definitions.js.map +1 -0
- package/dist/hooks/api/projects.d.ts +93 -0
- package/dist/hooks/api/projects.d.ts.map +1 -0
- package/dist/hooks/api/projects.js +79 -0
- package/dist/hooks/api/projects.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/create-project-page.d.ts +2 -0
- package/dist/pages/create-project-page.d.ts.map +1 -0
- package/dist/pages/create-project-page.js +406 -0
- package/dist/pages/create-project-page.js.map +1 -0
- package/dist/pages/create-project-page.stories.d.ts +24 -0
- package/dist/pages/create-project-page.stories.d.ts.map +1 -0
- package/dist/pages/create-project-page.stories.js +345 -0
- package/dist/pages/create-project-page.stories.js.map +1 -0
- package/dist/pages/home-router.d.ts +2 -0
- package/dist/pages/home-router.d.ts.map +1 -0
- package/dist/pages/home-router.js +7 -0
- package/dist/pages/home-router.js.map +1 -0
- package/dist/pages/project-workflows-page.d.ts +4 -0
- package/dist/pages/project-workflows-page.d.ts.map +1 -0
- package/dist/pages/project-workflows-page.js +481 -0
- package/dist/pages/project-workflows-page.js.map +1 -0
- package/dist/pages/projects-hub-page.d.ts +2 -0
- package/dist/pages/projects-hub-page.d.ts.map +1 -0
- package/dist/pages/projects-hub-page.js +256 -0
- package/dist/pages/projects-hub-page.js.map +1 -0
- package/dist/project-error.d.ts +7 -0
- package/dist/project-error.d.ts.map +1 -0
- package/dist/project-error.js +93 -0
- package/dist/project-error.js.map +1 -0
- package/dist/routes/create-project.d.ts +6 -0
- package/dist/routes/create-project.d.ts.map +1 -0
- package/dist/routes/create-project.js +7 -0
- package/dist/routes/create-project.js.map +1 -0
- package/dist/routes/home.d.ts +6 -0
- package/dist/routes/home.d.ts.map +1 -0
- package/dist/routes/home.js +7 -0
- package/dist/routes/home.js.map +1 -0
- package/dist/routes/project-index.d.ts +10 -0
- package/dist/routes/project-index.d.ts.map +1 -0
- package/dist/routes/project-index.js +15 -0
- package/dist/routes/project-index.js.map +1 -0
- package/dist/routes/workflows.d.ts +5 -0
- package/dist/routes/workflows.d.ts.map +1 -0
- package/dist/routes/workflows.js +16 -0
- package/dist/routes/workflows.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +120 -0
- package/src/chrome.tsx +26 -0
- package/src/components/model-provider-reminder-banner.tsx +78 -0
- package/src/components/project-crumb.tsx +70 -0
- package/src/components/project-switcher.test.tsx +91 -0
- package/src/components/project-switcher.tsx +127 -0
- package/src/components/source-strip.tsx +91 -0
- package/src/components/workspace-setup-guard.test.tsx +534 -0
- package/src/components/workspace-setup-guard.tsx +185 -0
- package/src/feature.ts +44 -0
- package/src/hooks/api/definitions.ts +50 -0
- package/src/hooks/api/projects.test.ts +78 -0
- package/src/hooks/api/projects.ts +75 -0
- package/src/index.ts +15 -0
- package/src/pages/create-project-page.stories.tsx +344 -0
- package/src/pages/create-project-page.test.tsx +317 -0
- package/src/pages/create-project-page.tsx +386 -0
- package/src/pages/home-router.test.tsx +27 -0
- package/src/pages/home-router.tsx +5 -0
- package/src/pages/project-workflows-page.test.tsx +258 -0
- package/src/pages/project-workflows-page.tsx +445 -0
- package/src/pages/projects-hub-page.test.tsx +313 -0
- package/src/pages/projects-hub-page.tsx +246 -0
- package/src/project-error.test.ts +59 -0
- package/src/project-error.ts +106 -0
- package/src/routes/create-project.tsx +4 -0
- package/src/routes/home.tsx +4 -0
- package/src/routes/project-index.tsx +11 -0
- package/src/routes/workflows.tsx +10 -0
- package/test/pages.tsx +151 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +70 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import {
|
|
3
|
+
createProjectBodySchema,
|
|
4
|
+
type ListProjectsResponseDto,
|
|
5
|
+
type ProjectResponseDto,
|
|
6
|
+
} from '@shipfox/api-projects-dto';
|
|
7
|
+
import {useMaybeActiveWorkspace} from '@shipfox/client-auth';
|
|
8
|
+
import {
|
|
9
|
+
ConnectionPicker,
|
|
10
|
+
IntegrationIcon,
|
|
11
|
+
RepositoryPicker,
|
|
12
|
+
useRepositoriesInfiniteQuery,
|
|
13
|
+
useSourceConnectionsQuery,
|
|
14
|
+
} from '@shipfox/client-integrations';
|
|
15
|
+
import {displayNameFieldError} from '@shipfox/client-ui';
|
|
16
|
+
import {Button} from '@shipfox/react-ui/button';
|
|
17
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
18
|
+
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
19
|
+
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
20
|
+
import {toast} from '@shipfox/react-ui/toast';
|
|
21
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
22
|
+
import {useForm} from '@tanstack/react-form';
|
|
23
|
+
import {type QueryClient, useQueryClient} from '@tanstack/react-query';
|
|
24
|
+
import {Link, Navigate, useNavigate} from '@tanstack/react-router';
|
|
25
|
+
import {useEffect, useRef, useState} from 'react';
|
|
26
|
+
import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
|
|
27
|
+
import {projectsQueryKeys, useCreateProjectMutation} from '#hooks/api/projects.js';
|
|
28
|
+
import {projectErrorCopy} from '#project-error.js';
|
|
29
|
+
|
|
30
|
+
const REPOSITORY_NAME_SPLIT_RE = /[/-]/;
|
|
31
|
+
|
|
32
|
+
export function CreateProjectPage() {
|
|
33
|
+
const workspace = useMaybeActiveWorkspace();
|
|
34
|
+
const queryClient = useQueryClient();
|
|
35
|
+
const navigate = useNavigate();
|
|
36
|
+
const createProject = useCreateProjectMutation();
|
|
37
|
+
const errorRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
|
|
39
|
+
const connectionsQuery = useSourceConnectionsQuery(workspace?.id);
|
|
40
|
+
const connections = connectionsQuery.data?.connections ?? [];
|
|
41
|
+
|
|
42
|
+
const [selectedConnectionId, setSelectedConnectionId] = useState<string | undefined>();
|
|
43
|
+
const singleConnectionId = connections.length === 1 ? connections[0]?.id : undefined;
|
|
44
|
+
const effectiveSelectedConnectionId = selectedConnectionId ?? singleConnectionId;
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (singleConnectionId && selectedConnectionId !== singleConnectionId) {
|
|
47
|
+
setSelectedConnectionId(singleConnectionId);
|
|
48
|
+
}
|
|
49
|
+
}, [singleConnectionId, selectedConnectionId]);
|
|
50
|
+
|
|
51
|
+
const selectedConnection: IntegrationConnectionDto | undefined = connections.find(
|
|
52
|
+
(connection) => connection.id === effectiveSelectedConnectionId,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const [repoFilter, setRepoFilter] = useState('');
|
|
56
|
+
const debouncedRepoFilter = useDebouncedValue(repoFilter, 250);
|
|
57
|
+
const trimmedFilter = debouncedRepoFilter.trim();
|
|
58
|
+
|
|
59
|
+
const repositoriesQuery = useRepositoriesInfiniteQuery(
|
|
60
|
+
effectiveSelectedConnectionId,
|
|
61
|
+
trimmedFilter ? {search: trimmedFilter} : undefined,
|
|
62
|
+
);
|
|
63
|
+
const repositories = repositoriesQuery.data?.pages.flatMap((page) => page.repositories) ?? [];
|
|
64
|
+
|
|
65
|
+
const [selectedRepositoryId, setSelectedRepositoryId] = useState<string | undefined>();
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!selectedRepositoryId && repositories[0]) {
|
|
68
|
+
setSelectedRepositoryId(repositories[0].external_repository_id);
|
|
69
|
+
}
|
|
70
|
+
}, [repositories, selectedRepositoryId]);
|
|
71
|
+
const selectedRepository = repositories.find(
|
|
72
|
+
(repository) => repository.external_repository_id === selectedRepositoryId,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const [nameTouched, setNameTouched] = useState(false);
|
|
76
|
+
const defaultProjectName = projectNameFromRepository(
|
|
77
|
+
selectedRepository?.name ?? selectedRepositoryId ?? '',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const [formError, setFormError] = useState<string | undefined>();
|
|
81
|
+
|
|
82
|
+
const form = useForm({
|
|
83
|
+
defaultValues: {name: defaultProjectName},
|
|
84
|
+
onSubmit: async ({value}) => {
|
|
85
|
+
await createProjectFromForm(nameTouched ? value.name : defaultProjectName);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!nameTouched && form.state.values.name !== defaultProjectName) {
|
|
91
|
+
form.setFieldValue('name', defaultProjectName);
|
|
92
|
+
}
|
|
93
|
+
}, [defaultProjectName, form, nameTouched]);
|
|
94
|
+
|
|
95
|
+
function selectConnection(connectionId: string) {
|
|
96
|
+
setSelectedConnectionId(connectionId);
|
|
97
|
+
setSelectedRepositoryId(undefined);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (connectionsQuery.isPending) {
|
|
101
|
+
return <FullPageLoader />;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!workspace) {
|
|
105
|
+
return <FullPageLoader />;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!connectionsQuery.isError && connections.length === 0) {
|
|
109
|
+
return <Navigate to="/workspaces/$wid/integrations" params={{wid: workspace.id}} replace />;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function createProjectFromForm(projectName: string) {
|
|
113
|
+
setFormError(undefined);
|
|
114
|
+
if (!workspace) {
|
|
115
|
+
setFormError('Workspace is still loading. Try again in a moment.');
|
|
116
|
+
errorRef.current?.focus();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!selectedConnection) {
|
|
120
|
+
setFormError('Choose a source integration before creating a project.');
|
|
121
|
+
errorRef.current?.focus();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!selectedRepository) {
|
|
125
|
+
setFormError('Choose a repository before creating a project.');
|
|
126
|
+
errorRef.current?.focus();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const projectBody = createProjectBodySchema.parse({
|
|
132
|
+
workspace_id: workspace.id,
|
|
133
|
+
name: projectName,
|
|
134
|
+
source: {
|
|
135
|
+
connection_id: selectedConnection.id,
|
|
136
|
+
external_repository_id: selectedRepository.external_repository_id,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const project = await createProject.mutateAsync(projectBody);
|
|
140
|
+
setWorkspaceProjectExists(queryClient, workspace.id, project);
|
|
141
|
+
queryClient.setQueryData(projectsQueryKeys.detail(project.id), project);
|
|
142
|
+
await queryClient.invalidateQueries({queryKey: projectsQueryKeys.list(workspace.id)});
|
|
143
|
+
toast.success('Project created.');
|
|
144
|
+
await navigate({
|
|
145
|
+
to: '/workspaces/$wid/projects/$pid',
|
|
146
|
+
params: {wid: workspace.id, pid: project.id},
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const copy = projectErrorCopy(error);
|
|
150
|
+
if (copy.existingProjectId) {
|
|
151
|
+
await queryClient.invalidateQueries({
|
|
152
|
+
queryKey: projectsQueryKeys.exists(workspace.id),
|
|
153
|
+
refetchType: 'active',
|
|
154
|
+
});
|
|
155
|
+
await queryClient.invalidateQueries({queryKey: projectsQueryKeys.list(workspace.id)});
|
|
156
|
+
toast.info('Project already exists.');
|
|
157
|
+
await navigate({
|
|
158
|
+
to: '/workspaces/$wid/projects/$pid',
|
|
159
|
+
params: {wid: workspace.id, pid: copy.existingProjectId},
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
setFormError(`${copy.title}: ${copy.message}`);
|
|
164
|
+
requestAnimationFrame(() => errorRef.current?.focus());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const showRepoPicker = Boolean(selectedConnection);
|
|
169
|
+
const filteredEmptyMessage = trimmedFilter
|
|
170
|
+
? `No repositories matching "${repoFilter.trim()}".`
|
|
171
|
+
: 'No repositories visible to this connection.';
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className="flex w-full flex-col gap-24">
|
|
175
|
+
<header className="flex flex-col gap-6">
|
|
176
|
+
<Header id="create-project-title" variant="h1">
|
|
177
|
+
Create project
|
|
178
|
+
</Header>
|
|
179
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
180
|
+
A Shipfox project starts from a Git repository. Choose the repository Shipfox should
|
|
181
|
+
track, then give the project a name.
|
|
182
|
+
</Text>
|
|
183
|
+
</header>
|
|
184
|
+
|
|
185
|
+
<ModelProviderReminderBanner workspaceId={workspace.id} />
|
|
186
|
+
|
|
187
|
+
{connectionsQuery.isError ? (
|
|
188
|
+
<Callout role="alert" type="error">
|
|
189
|
+
<div className="flex w-full flex-wrap items-center justify-between gap-12">
|
|
190
|
+
<Text size="sm" className="min-w-[240px] flex-1">
|
|
191
|
+
Could not load source integrations. Refresh the integrations list to continue.
|
|
192
|
+
</Text>
|
|
193
|
+
<Button
|
|
194
|
+
size="sm"
|
|
195
|
+
variant="secondary"
|
|
196
|
+
onClick={() => connectionsQuery.refetch()}
|
|
197
|
+
className="shrink-0"
|
|
198
|
+
>
|
|
199
|
+
Refresh integrations
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
</Callout>
|
|
203
|
+
) : null}
|
|
204
|
+
|
|
205
|
+
<form
|
|
206
|
+
onSubmit={(event) => {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
event.stopPropagation();
|
|
209
|
+
void form.handleSubmit();
|
|
210
|
+
}}
|
|
211
|
+
noValidate
|
|
212
|
+
aria-labelledby="create-project-title"
|
|
213
|
+
className="grid items-start gap-32 lg:grid-cols-[minmax(0,1fr)_340px]"
|
|
214
|
+
>
|
|
215
|
+
<div className="flex min-w-0 flex-col gap-32">
|
|
216
|
+
<section className="flex flex-col gap-16" aria-label="Source integration">
|
|
217
|
+
<div className="flex items-start justify-between gap-16">
|
|
218
|
+
<div className="flex flex-col gap-4">
|
|
219
|
+
<Header variant="h3">Source integration</Header>
|
|
220
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
221
|
+
Choose the integration that can access the repository.
|
|
222
|
+
</Text>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{connections.length === 1 ? (
|
|
226
|
+
<Button asChild variant="transparent" size="sm" className="shrink-0">
|
|
227
|
+
<Link to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
|
|
228
|
+
Add another integration
|
|
229
|
+
</Link>
|
|
230
|
+
</Button>
|
|
231
|
+
) : null}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{connections.length > 0 ? (
|
|
235
|
+
<ConnectionPicker
|
|
236
|
+
connections={connections}
|
|
237
|
+
selectedConnectionId={effectiveSelectedConnectionId}
|
|
238
|
+
onSelect={selectConnection}
|
|
239
|
+
/>
|
|
240
|
+
) : null}
|
|
241
|
+
</section>
|
|
242
|
+
|
|
243
|
+
{showRepoPicker ? (
|
|
244
|
+
<section className="flex flex-col gap-16" aria-label="Repository">
|
|
245
|
+
<div className="flex flex-col gap-4">
|
|
246
|
+
<Header variant="h3">Repository</Header>
|
|
247
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
248
|
+
Select the repository this project tracks.
|
|
249
|
+
</Text>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<RepositoryPicker
|
|
253
|
+
repositories={repositories}
|
|
254
|
+
selectedRepositoryId={selectedRepositoryId}
|
|
255
|
+
onSelect={setSelectedRepositoryId}
|
|
256
|
+
isLoading={repositoriesQuery.isPending}
|
|
257
|
+
isFetchingNextPage={repositoriesQuery.isFetchingNextPage}
|
|
258
|
+
hasNextPage={repositoriesQuery.hasNextPage}
|
|
259
|
+
onLoadMore={() => repositoriesQuery.fetchNextPage()}
|
|
260
|
+
emptyMessage={filteredEmptyMessage}
|
|
261
|
+
searchValue={repoFilter}
|
|
262
|
+
onSearchChange={setRepoFilter}
|
|
263
|
+
/>
|
|
264
|
+
</section>
|
|
265
|
+
) : null}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<aside className="lg:sticky lg:top-32">
|
|
269
|
+
<div className="flex flex-col gap-18 rounded-8 border border-border-neutral-base bg-background-neutral-base p-20">
|
|
270
|
+
<div className="flex flex-col gap-4">
|
|
271
|
+
<Header variant="h3">Project details</Header>
|
|
272
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
273
|
+
Confirm the source and create the project.
|
|
274
|
+
</Text>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{formError ? (
|
|
278
|
+
<Callout role="alert" type="error">
|
|
279
|
+
<div ref={errorRef} tabIndex={-1}>
|
|
280
|
+
{formError}
|
|
281
|
+
</div>
|
|
282
|
+
</Callout>
|
|
283
|
+
) : null}
|
|
284
|
+
|
|
285
|
+
<ProjectSummary
|
|
286
|
+
connection={selectedConnection}
|
|
287
|
+
repositoryName={selectedRepository?.full_name}
|
|
288
|
+
/>
|
|
289
|
+
|
|
290
|
+
<form.Field
|
|
291
|
+
name="name"
|
|
292
|
+
validators={{
|
|
293
|
+
onBlur: ({value}) =>
|
|
294
|
+
displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),
|
|
295
|
+
onSubmit: ({value}) =>
|
|
296
|
+
displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
{(field) => (
|
|
300
|
+
<FormField label="Project name" id="project-name" error={fieldError(field)}>
|
|
301
|
+
<FormFieldInput
|
|
302
|
+
name="name"
|
|
303
|
+
type="text"
|
|
304
|
+
value={field.state.value}
|
|
305
|
+
onChange={(event) => {
|
|
306
|
+
setNameTouched(true);
|
|
307
|
+
field.handleChange(event.target.value);
|
|
308
|
+
}}
|
|
309
|
+
onBlur={field.handleBlur}
|
|
310
|
+
placeholder="Platform"
|
|
311
|
+
/>
|
|
312
|
+
</FormField>
|
|
313
|
+
)}
|
|
314
|
+
</form.Field>
|
|
315
|
+
|
|
316
|
+
<Button
|
|
317
|
+
type="submit"
|
|
318
|
+
iconRight="chevronRight"
|
|
319
|
+
isLoading={createProject.isPending}
|
|
320
|
+
disabled={!selectedConnection || !selectedRepository}
|
|
321
|
+
className="w-full"
|
|
322
|
+
>
|
|
323
|
+
Create project
|
|
324
|
+
</Button>
|
|
325
|
+
</div>
|
|
326
|
+
</aside>
|
|
327
|
+
</form>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function ProjectSummary({
|
|
333
|
+
connection,
|
|
334
|
+
repositoryName,
|
|
335
|
+
}: {
|
|
336
|
+
connection: IntegrationConnectionDto | undefined;
|
|
337
|
+
repositoryName: string | undefined;
|
|
338
|
+
}) {
|
|
339
|
+
return (
|
|
340
|
+
<div className="flex min-w-0 items-center gap-10">
|
|
341
|
+
{repositoryName ? (
|
|
342
|
+
<IntegrationIcon
|
|
343
|
+
source={connection?.provider}
|
|
344
|
+
aria-hidden
|
|
345
|
+
className="size-20 shrink-0 text-foreground-neutral-base"
|
|
346
|
+
/>
|
|
347
|
+
) : null}
|
|
348
|
+
<Text
|
|
349
|
+
size="sm"
|
|
350
|
+
bold
|
|
351
|
+
className={repositoryName ? 'min-w-0 truncate' : 'text-foreground-neutral-muted'}
|
|
352
|
+
>
|
|
353
|
+
{repositoryName ?? 'Pick a repository'}
|
|
354
|
+
</Text>
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function projectNameFromRepository(repositoryId: string): string {
|
|
360
|
+
return repositoryId
|
|
361
|
+
.trim()
|
|
362
|
+
.split(REPOSITORY_NAME_SPLIT_RE)
|
|
363
|
+
.filter(Boolean)
|
|
364
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
365
|
+
.join(' ');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
369
|
+
const [debounced, setDebounced] = useState(value);
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
const handle = setTimeout(() => setDebounced(value), delayMs);
|
|
372
|
+
return () => clearTimeout(handle);
|
|
373
|
+
}, [value, delayMs]);
|
|
374
|
+
return debounced;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function setWorkspaceProjectExists(
|
|
378
|
+
queryClient: QueryClient,
|
|
379
|
+
workspaceId: string,
|
|
380
|
+
project: ProjectResponseDto,
|
|
381
|
+
) {
|
|
382
|
+
queryClient.setQueryData<ListProjectsResponseDto>(projectsQueryKeys.exists(workspaceId), {
|
|
383
|
+
projects: [project],
|
|
384
|
+
next_cursor: null,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
4
|
+
import {screen} from '@testing-library/react';
|
|
5
|
+
import {jsonResponse, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
|
|
6
|
+
import {HomeRouter} from './home-router.js';
|
|
7
|
+
|
|
8
|
+
describe('HomeRouter', () => {
|
|
9
|
+
test('renders the completed-workspace project landing', async () => {
|
|
10
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
11
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
12
|
+
if (url.includes('/projects?')) {
|
|
13
|
+
return Promise.resolve(jsonResponse({projects: [], next_cursor: null}));
|
|
14
|
+
}
|
|
15
|
+
return Promise.resolve(jsonResponse({}, {status: 404}));
|
|
16
|
+
});
|
|
17
|
+
configureApiClient({fetchImpl});
|
|
18
|
+
|
|
19
|
+
renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <HomeRouter />);
|
|
20
|
+
|
|
21
|
+
expect(await screen.findByRole('heading', {name: 'Projects'})).toBeInTheDocument();
|
|
22
|
+
const calledUrls = fetchImpl.mock.calls.map(([input]) =>
|
|
23
|
+
input instanceof Request ? input.url : String(input),
|
|
24
|
+
);
|
|
25
|
+
expect(calledUrls.some((url) => url.includes('/integration-connections?'))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
|
3
|
+
import {jsonResponse, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
|
|
4
|
+
import {ProjectWorkflowsPage} from './project-workflows-page.js';
|
|
5
|
+
|
|
6
|
+
const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
|
|
7
|
+
const CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
|
|
8
|
+
|
|
9
|
+
describe('ProjectWorkflowsPage', () => {
|
|
10
|
+
test('renders workflow definitions and the source strip', async () => {
|
|
11
|
+
configureApiClient({fetchImpl: createProjectDetailFetch()});
|
|
12
|
+
|
|
13
|
+
renderProjectPage(
|
|
14
|
+
`/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}`,
|
|
15
|
+
<ProjectWorkflowsPage projectId={PROJECT_ID} />,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(await screen.findByRole('heading', {name: 'Workflows'})).toBeInTheDocument();
|
|
19
|
+
expect(screen.getAllByText('Deploy production')[0]).toBeInTheDocument();
|
|
20
|
+
expect(screen.getAllByText('.shipfox/workflows/deploy.yml')[0]).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByRole('region', {name: 'Project source'})).toBeInTheDocument();
|
|
22
|
+
// Source strip resolves connection display_name from the integrations
|
|
23
|
+
// workspace cache; external_repository_id renders as a Code chip.
|
|
24
|
+
expect(await screen.findByText('Acme GitHub')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getAllByText('platform')[0]).toBeInTheDocument();
|
|
26
|
+
expect(screen.getAllByText('succeeded')[0]).toBeInTheDocument();
|
|
27
|
+
expect(screen.queryByRole('heading', {name: 'Source identity'})).not.toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('shows definitions error while keeping the source strip visible', async () => {
|
|
31
|
+
configureApiClient({
|
|
32
|
+
fetchImpl: createProjectDetailFetch({
|
|
33
|
+
definitions: jsonResponse({code: 'server-error'}, {status: 500}),
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
renderProjectPage(
|
|
38
|
+
`/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}`,
|
|
39
|
+
<ProjectWorkflowsPage projectId={PROJECT_ID} />,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(await screen.findByText("Couldn't load workflows")).toBeInTheDocument();
|
|
43
|
+
// SyncBadge in the strip falls back to Unavailable when sync is undefined
|
|
44
|
+
// (definitions errored before providing one).
|
|
45
|
+
expect(screen.getByText('Unavailable')).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByRole('region', {name: 'Project source'})).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('shows failed sync empty state', async () => {
|
|
50
|
+
configureApiClient({
|
|
51
|
+
fetchImpl: createProjectDetailFetch({
|
|
52
|
+
definitions: jsonResponse(
|
|
53
|
+
definitionsDto({
|
|
54
|
+
definitions: [],
|
|
55
|
+
sync: {
|
|
56
|
+
ref: 'main',
|
|
57
|
+
status: 'failed',
|
|
58
|
+
last_sync_at: '2026-05-07T01:00:00.000Z',
|
|
59
|
+
started_at: '2026-05-07T01:00:00.000Z',
|
|
60
|
+
finished_at: null,
|
|
61
|
+
last_error_code: 'no-workflow-files',
|
|
62
|
+
last_error_message: 'No workflow files found',
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
renderProjectPage(
|
|
70
|
+
`/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}`,
|
|
71
|
+
<ProjectWorkflowsPage projectId={PROJECT_ID} />,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
await screen.findByText('No workflow files found under .shipfox/workflows/.'),
|
|
76
|
+
).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByText('Workflow sync failed')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('opens and closes the definition drawer by clicking the row', async () => {
|
|
81
|
+
configureApiClient({fetchImpl: createProjectDetailFetch()});
|
|
82
|
+
|
|
83
|
+
renderProjectPage(
|
|
84
|
+
`/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}`,
|
|
85
|
+
<ProjectWorkflowsPage projectId={PROJECT_ID} />,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const workflowName = (await screen.findAllByText('Deploy production'))[0];
|
|
89
|
+
if (!workflowName) throw new Error('Workflow row was not rendered');
|
|
90
|
+
fireEvent.click(workflowName);
|
|
91
|
+
|
|
92
|
+
expect(await screen.findByText('Normalized definition')).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText((content) => content.includes('"deploy"'))).toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
fireEvent.keyDown(document, {key: 'Escape'});
|
|
96
|
+
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(screen.queryByText('Normalized definition')).not.toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('queues a run from a workflow definition', async () => {
|
|
103
|
+
configureApiClient({fetchImpl: createProjectDetailFetch()});
|
|
104
|
+
|
|
105
|
+
renderProjectPage(
|
|
106
|
+
`/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}`,
|
|
107
|
+
<ProjectWorkflowsPage projectId={PROJECT_ID} />,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Run button lives in the row's hover-reveal slot; getAllByRole still
|
|
111
|
+
// sees it (opacity-0, not display:none).
|
|
112
|
+
const [runButton] = await screen.findAllByRole('button', {name: 'Run'});
|
|
113
|
+
if (!runButton) throw new Error('Run button was not rendered');
|
|
114
|
+
|
|
115
|
+
fireEvent.click(runButton);
|
|
116
|
+
|
|
117
|
+
expect(await screen.findByText('Run queued')).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('renders not found state', async () => {
|
|
121
|
+
configureApiClient({
|
|
122
|
+
fetchImpl: vi.fn((input) => {
|
|
123
|
+
const url = new URL(requestInputUrl(input));
|
|
124
|
+
if (url.pathname === `/projects/${PROJECT_ID}`) {
|
|
125
|
+
return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
|
|
126
|
+
}
|
|
127
|
+
if (url.pathname === '/integration-connections') {
|
|
128
|
+
return Promise.resolve(jsonResponse(connectionsDto()));
|
|
129
|
+
}
|
|
130
|
+
return Promise.resolve(jsonResponse(definitionsDto()));
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
renderProjectPage(
|
|
135
|
+
`/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}`,
|
|
136
|
+
<ProjectWorkflowsPage projectId={PROJECT_ID} />,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(await screen.findByText('Project not found')).toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function createProjectDetailFetch({
|
|
144
|
+
project = jsonResponse(projectDto()),
|
|
145
|
+
definitions = jsonResponse(definitionsDto()),
|
|
146
|
+
run = jsonResponse(runDto(), {status: 201}),
|
|
147
|
+
connections = jsonResponse(connectionsDto()),
|
|
148
|
+
}: {
|
|
149
|
+
project?: Response;
|
|
150
|
+
definitions?: Response;
|
|
151
|
+
run?: Response;
|
|
152
|
+
connections?: Response;
|
|
153
|
+
} = {}) {
|
|
154
|
+
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
|
155
|
+
const url = new URL(requestInputUrl(input));
|
|
156
|
+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
|
157
|
+
|
|
158
|
+
if (url.pathname === `/projects/${PROJECT_ID}`) {
|
|
159
|
+
return Promise.resolve(project.clone());
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname === '/definitions') {
|
|
162
|
+
return Promise.resolve(definitions.clone());
|
|
163
|
+
}
|
|
164
|
+
if (url.pathname === '/integration-connections') {
|
|
165
|
+
return Promise.resolve(connections.clone());
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
url.pathname.startsWith('/workflow-definitions/') &&
|
|
169
|
+
url.pathname.endsWith('/fire-manual') &&
|
|
170
|
+
method === 'POST'
|
|
171
|
+
) {
|
|
172
|
+
return Promise.resolve(run.clone());
|
|
173
|
+
}
|
|
174
|
+
return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requestInputUrl(input: RequestInfo | URL) {
|
|
179
|
+
if (input instanceof Request) return input.url;
|
|
180
|
+
return String(input);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function projectDto() {
|
|
184
|
+
return {
|
|
185
|
+
id: PROJECT_ID,
|
|
186
|
+
workspace_id: PROJECT_TEST_WID,
|
|
187
|
+
name: 'Platform',
|
|
188
|
+
source: {
|
|
189
|
+
connection_id: CONNECTION_ID,
|
|
190
|
+
external_repository_id: 'platform',
|
|
191
|
+
},
|
|
192
|
+
created_at: new Date().toISOString(),
|
|
193
|
+
updated_at: new Date().toISOString(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function connectionsDto() {
|
|
198
|
+
return {
|
|
199
|
+
connections: [
|
|
200
|
+
{
|
|
201
|
+
id: CONNECTION_ID,
|
|
202
|
+
workspace_id: PROJECT_TEST_WID,
|
|
203
|
+
provider: 'github',
|
|
204
|
+
external_account_id: 'acme',
|
|
205
|
+
slug: 'github_acme',
|
|
206
|
+
display_name: 'Acme GitHub',
|
|
207
|
+
lifecycle_status: 'active',
|
|
208
|
+
capabilities: ['source_control'],
|
|
209
|
+
created_at: '2026-05-07T00:00:00.000Z',
|
|
210
|
+
updated_at: '2026-05-07T00:00:00.000Z',
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function definitionsDto(overrides: Partial<{definitions: unknown[]; sync: unknown}> = {}) {
|
|
217
|
+
return {...baseDefinitionsDto(), ...overrides};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function baseDefinitionsDto() {
|
|
221
|
+
return {
|
|
222
|
+
definitions: [
|
|
223
|
+
{
|
|
224
|
+
id: '55555555-5555-4555-8555-555555555555',
|
|
225
|
+
project_id: PROJECT_ID,
|
|
226
|
+
config_path: '.shipfox/workflows/deploy.yml',
|
|
227
|
+
source: 'vcs',
|
|
228
|
+
sha: 'abc123',
|
|
229
|
+
ref: 'main',
|
|
230
|
+
name: 'Deploy production',
|
|
231
|
+
workflow_document: {
|
|
232
|
+
name: 'Deploy production',
|
|
233
|
+
triggers: {on_demand: {source: 'manual', event: 'fire'}},
|
|
234
|
+
jobs: {deploy: {steps: [{run: './deploy.sh'}]}},
|
|
235
|
+
},
|
|
236
|
+
workflow_model: {kind: 'workflow', name: 'Deploy production'},
|
|
237
|
+
manual_trigger: {name: 'on_demand'},
|
|
238
|
+
fetched_at: '2026-05-07T01:00:00.000Z',
|
|
239
|
+
created_at: '2026-05-07T01:00:00.000Z',
|
|
240
|
+
updated_at: '2026-05-07T01:00:00.000Z',
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
next_cursor: null,
|
|
244
|
+
sync: {
|
|
245
|
+
ref: 'main',
|
|
246
|
+
status: 'succeeded',
|
|
247
|
+
last_sync_at: '2026-05-07T01:00:00.000Z',
|
|
248
|
+
started_at: '2026-05-07T00:59:55.000Z',
|
|
249
|
+
finished_at: '2026-05-07T01:00:00.000Z',
|
|
250
|
+
last_error_code: null,
|
|
251
|
+
last_error_message: null,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function runDto() {
|
|
257
|
+
return {run_id: '66666666-6666-4666-8666-666666666666'};
|
|
258
|
+
}
|