@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.
Files changed (125) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +7 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +207 -0
  8. package/LICENSE +21 -0
  9. package/dist/chrome.d.ts +3 -0
  10. package/dist/chrome.d.ts.map +1 -0
  11. package/dist/chrome.js +42 -0
  12. package/dist/chrome.js.map +1 -0
  13. package/dist/components/model-provider-reminder-banner.d.ts +4 -0
  14. package/dist/components/model-provider-reminder-banner.d.ts.map +1 -0
  15. package/dist/components/model-provider-reminder-banner.js +73 -0
  16. package/dist/components/model-provider-reminder-banner.js.map +1 -0
  17. package/dist/components/project-crumb.d.ts +7 -0
  18. package/dist/components/project-crumb.d.ts.map +1 -0
  19. package/dist/components/project-crumb.js +93 -0
  20. package/dist/components/project-crumb.js.map +1 -0
  21. package/dist/components/project-switcher.d.ts +7 -0
  22. package/dist/components/project-switcher.d.ts.map +1 -0
  23. package/dist/components/project-switcher.js +138 -0
  24. package/dist/components/project-switcher.js.map +1 -0
  25. package/dist/components/source-strip.d.ts +14 -0
  26. package/dist/components/source-strip.d.ts.map +1 -0
  27. package/dist/components/source-strip.js +98 -0
  28. package/dist/components/source-strip.js.map +1 -0
  29. package/dist/components/workspace-setup-guard.d.ts +9 -0
  30. package/dist/components/workspace-setup-guard.d.ts.map +1 -0
  31. package/dist/components/workspace-setup-guard.js +160 -0
  32. package/dist/components/workspace-setup-guard.js.map +1 -0
  33. package/dist/feature.d.ts +35 -0
  34. package/dist/feature.d.ts.map +1 -0
  35. package/dist/feature.js +45 -0
  36. package/dist/feature.js.map +1 -0
  37. package/dist/hooks/api/definitions.d.ts +97 -0
  38. package/dist/hooks/api/definitions.d.ts.map +1 -0
  39. package/dist/hooks/api/definitions.js +55 -0
  40. package/dist/hooks/api/definitions.js.map +1 -0
  41. package/dist/hooks/api/projects.d.ts +93 -0
  42. package/dist/hooks/api/projects.d.ts.map +1 -0
  43. package/dist/hooks/api/projects.js +79 -0
  44. package/dist/hooks/api/projects.js.map +1 -0
  45. package/dist/index.d.ts +13 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +13 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/pages/create-project-page.d.ts +2 -0
  50. package/dist/pages/create-project-page.d.ts.map +1 -0
  51. package/dist/pages/create-project-page.js +406 -0
  52. package/dist/pages/create-project-page.js.map +1 -0
  53. package/dist/pages/create-project-page.stories.d.ts +24 -0
  54. package/dist/pages/create-project-page.stories.d.ts.map +1 -0
  55. package/dist/pages/create-project-page.stories.js +345 -0
  56. package/dist/pages/create-project-page.stories.js.map +1 -0
  57. package/dist/pages/home-router.d.ts +2 -0
  58. package/dist/pages/home-router.d.ts.map +1 -0
  59. package/dist/pages/home-router.js +7 -0
  60. package/dist/pages/home-router.js.map +1 -0
  61. package/dist/pages/project-workflows-page.d.ts +4 -0
  62. package/dist/pages/project-workflows-page.d.ts.map +1 -0
  63. package/dist/pages/project-workflows-page.js +481 -0
  64. package/dist/pages/project-workflows-page.js.map +1 -0
  65. package/dist/pages/projects-hub-page.d.ts +2 -0
  66. package/dist/pages/projects-hub-page.d.ts.map +1 -0
  67. package/dist/pages/projects-hub-page.js +256 -0
  68. package/dist/pages/projects-hub-page.js.map +1 -0
  69. package/dist/project-error.d.ts +7 -0
  70. package/dist/project-error.d.ts.map +1 -0
  71. package/dist/project-error.js +93 -0
  72. package/dist/project-error.js.map +1 -0
  73. package/dist/routes/create-project.d.ts +6 -0
  74. package/dist/routes/create-project.d.ts.map +1 -0
  75. package/dist/routes/create-project.js +7 -0
  76. package/dist/routes/create-project.js.map +1 -0
  77. package/dist/routes/home.d.ts +6 -0
  78. package/dist/routes/home.d.ts.map +1 -0
  79. package/dist/routes/home.js +7 -0
  80. package/dist/routes/home.js.map +1 -0
  81. package/dist/routes/project-index.d.ts +10 -0
  82. package/dist/routes/project-index.d.ts.map +1 -0
  83. package/dist/routes/project-index.js +15 -0
  84. package/dist/routes/project-index.js.map +1 -0
  85. package/dist/routes/workflows.d.ts +5 -0
  86. package/dist/routes/workflows.d.ts.map +1 -0
  87. package/dist/routes/workflows.js +16 -0
  88. package/dist/routes/workflows.js.map +1 -0
  89. package/dist/tsconfig.test.tsbuildinfo +1 -0
  90. package/package.json +120 -0
  91. package/src/chrome.tsx +26 -0
  92. package/src/components/model-provider-reminder-banner.tsx +78 -0
  93. package/src/components/project-crumb.tsx +70 -0
  94. package/src/components/project-switcher.test.tsx +91 -0
  95. package/src/components/project-switcher.tsx +127 -0
  96. package/src/components/source-strip.tsx +91 -0
  97. package/src/components/workspace-setup-guard.test.tsx +534 -0
  98. package/src/components/workspace-setup-guard.tsx +185 -0
  99. package/src/feature.ts +44 -0
  100. package/src/hooks/api/definitions.ts +50 -0
  101. package/src/hooks/api/projects.test.ts +78 -0
  102. package/src/hooks/api/projects.ts +75 -0
  103. package/src/index.ts +15 -0
  104. package/src/pages/create-project-page.stories.tsx +344 -0
  105. package/src/pages/create-project-page.test.tsx +317 -0
  106. package/src/pages/create-project-page.tsx +386 -0
  107. package/src/pages/home-router.test.tsx +27 -0
  108. package/src/pages/home-router.tsx +5 -0
  109. package/src/pages/project-workflows-page.test.tsx +258 -0
  110. package/src/pages/project-workflows-page.tsx +445 -0
  111. package/src/pages/projects-hub-page.test.tsx +313 -0
  112. package/src/pages/projects-hub-page.tsx +246 -0
  113. package/src/project-error.test.ts +59 -0
  114. package/src/project-error.ts +106 -0
  115. package/src/routes/create-project.tsx +4 -0
  116. package/src/routes/home.tsx +4 -0
  117. package/src/routes/project-index.tsx +11 -0
  118. package/src/routes/workflows.tsx +10 -0
  119. package/test/pages.tsx +151 -0
  120. package/test/setup.ts +3 -0
  121. package/tsconfig.build.json +9 -0
  122. package/tsconfig.build.tsbuildinfo +1 -0
  123. package/tsconfig.json +3 -0
  124. package/tsconfig.test.json +8 -0
  125. package/vitest.config.ts +70 -0
@@ -0,0 +1,313 @@
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 {ProjectsHubPage} from './projects-hub-page.js';
5
+
6
+ const NEW_PROJECT_REGEX = /New project/i;
7
+ const WORKSPACE_PROJECTS_NEW_HREF = `/workspaces/${PROJECT_TEST_WID}/projects/new`;
8
+ const CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
9
+
10
+ describe('ProjectsHubPage', () => {
11
+ beforeEach(() => {
12
+ window.sessionStorage.clear();
13
+ });
14
+
15
+ test('renders Projects H2 + New project button + empty state with create CTA', async () => {
16
+ configureApiClient({
17
+ fetchImpl: createHubFetch({
18
+ projects: jsonResponse({projects: [], next_cursor: null}),
19
+ modelProviders: jsonResponse(modelProviderConfigsDto()),
20
+ }),
21
+ });
22
+
23
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
24
+
25
+ expect(await screen.findByText('Create your first project')).toBeInTheDocument();
26
+ // The page-level "Projects" title belongs in content because the top nav owns
27
+ // workspace identity.
28
+ expect(screen.getByRole('heading', {name: 'Projects'})).toBeInTheDocument();
29
+ expect(screen.getByRole('link', {name: NEW_PROJECT_REGEX})).toHaveAttribute(
30
+ 'href',
31
+ WORKSPACE_PROJECTS_NEW_HREF,
32
+ );
33
+ expect(
34
+ (screen.getAllByRole('link', {name: 'Create project'})[0] as HTMLAnchorElement).getAttribute(
35
+ 'href',
36
+ ),
37
+ ).toBe(WORKSPACE_PROJECTS_NEW_HREF);
38
+ });
39
+
40
+ test('shows and dismisses the model provider reminder when no provider is configured', async () => {
41
+ const fetchImpl = createHubFetch({
42
+ projects: jsonResponse({projects: [], next_cursor: null}),
43
+ modelProviders: jsonResponse({configs: [], default_provider_id: null}),
44
+ });
45
+ configureApiClient({fetchImpl});
46
+
47
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
48
+
49
+ expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
50
+ expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
51
+ 'href',
52
+ `/workspaces/${PROJECT_TEST_WID}/settings/agents`,
53
+ );
54
+ fireEvent.click(screen.getByRole('button', {name: 'Close'}));
55
+
56
+ await waitFor(() => {
57
+ expect(screen.queryByText('Finish setting up a model provider')).not.toBeInTheDocument();
58
+ });
59
+ });
60
+
61
+ test('hides the model provider reminder when a provider is configured', async () => {
62
+ const fetchImpl = createHubFetch({
63
+ projects: jsonResponse({projects: [], next_cursor: null}),
64
+ modelProviders: jsonResponse(modelProviderConfigsDto()),
65
+ });
66
+ configureApiClient({fetchImpl});
67
+
68
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
69
+
70
+ expect(await screen.findByText('Create your first project')).toBeInTheDocument();
71
+ expect(screen.queryByText('Finish setting up a model provider')).not.toBeInTheDocument();
72
+ });
73
+
74
+ test('renders projects and loads the next cursor page', async () => {
75
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
76
+ const url = new URL(requestInputUrl(input));
77
+ if (url.pathname.endsWith('/agent/model-providers')) {
78
+ return Promise.resolve(jsonResponse(modelProviderConfigsDto()));
79
+ }
80
+ if (url.pathname === '/integration-connections') {
81
+ return Promise.resolve(jsonResponse(connectionsDto()));
82
+ }
83
+ if (url.pathname === '/projects') {
84
+ if (url.searchParams.get('cursor') === 'cursor-1') {
85
+ return Promise.resolve(
86
+ jsonResponse({
87
+ projects: [projectDto({id: 'project-2', name: 'API'})],
88
+ next_cursor: null,
89
+ }),
90
+ );
91
+ }
92
+ return Promise.resolve(
93
+ jsonResponse({
94
+ projects: [projectDto({id: 'project-1', name: 'Platform'})],
95
+ next_cursor: 'cursor-1',
96
+ }),
97
+ );
98
+ }
99
+ return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
100
+ });
101
+ configureApiClient({fetchImpl});
102
+
103
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
104
+ expect(await screen.findByText('Platform')).toBeInTheDocument();
105
+ const projectLink = screen.getByText('Platform').closest('a');
106
+ // The whole card is the link, carrying the neutral focus ring (matching the
107
+ // integration gallery cards).
108
+ expect(projectLink).toHaveClass('focus-visible:shadow-button-neutral-focus');
109
+ expect(projectLink?.className).not.toContain('shadow-button-secondary');
110
+
111
+ fireEvent.click(screen.getByRole('button', {name: 'Load more'}));
112
+
113
+ expect(await screen.findByText('API')).toBeInTheDocument();
114
+ });
115
+
116
+ test('renders an error alert with retry', async () => {
117
+ configureApiClient({
118
+ fetchImpl: createHubFetch({
119
+ projects: jsonResponse({code: 'server-error'}, {status: 500}),
120
+ }),
121
+ });
122
+
123
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
124
+
125
+ expect(await screen.findByText("Couldn't load projects")).toBeInTheDocument();
126
+ expect(screen.getByRole('button', {name: 'Retry loading projects'})).toBeInTheDocument();
127
+ });
128
+
129
+ test('shows no status pill or repository id for a connected source', async () => {
130
+ configureApiClient({
131
+ fetchImpl: createHubFetch({
132
+ projects: jsonResponse({
133
+ projects: [
134
+ projectDto({
135
+ id: 'project-1',
136
+ name: 'Platform',
137
+ externalRepositoryId: 'github:octo/platform',
138
+ }),
139
+ ],
140
+ next_cursor: null,
141
+ }),
142
+ connections: jsonResponse(connectionsDto()),
143
+ }),
144
+ });
145
+
146
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
147
+
148
+ expect(await screen.findByText('Platform')).toBeInTheDocument();
149
+
150
+ // The provider logo resolves to a real icon, not the neutral fallback
151
+ // (componentLine is the only icon with a 25x24 viewBox).
152
+ await waitFor(() => {
153
+ const card = screen.getByText('Platform').closest('li');
154
+ expect(card?.querySelector('[data-slot="skeleton"]')).toBeNull();
155
+ expect(card?.querySelector('svg')).toBeInTheDocument();
156
+ expect(card?.querySelector('svg[viewBox="0 0 25 24"]')).toBeNull();
157
+ });
158
+
159
+ // "active" is the expected state, so it stays unbadged; the raw repository
160
+ // id is dropped because it is meaningless to end users.
161
+ expect(screen.queryByText('Connected')).not.toBeInTheDocument();
162
+ expect(screen.queryByText('Disabled')).not.toBeInTheDocument();
163
+ expect(screen.queryByText('Error')).not.toBeInTheDocument();
164
+ expect(screen.queryByText('github:octo/platform')).not.toBeInTheDocument();
165
+ });
166
+
167
+ test.each([
168
+ ['error', 'Error'],
169
+ ['disabled', 'Disabled'],
170
+ ] as const)('flags a %s source with the matching status pill', async (lifecycleStatus, label) => {
171
+ configureApiClient({
172
+ fetchImpl: createHubFetch({
173
+ projects: jsonResponse({
174
+ projects: [projectDto({id: 'project-1', name: 'Platform'})],
175
+ next_cursor: null,
176
+ }),
177
+ connections: jsonResponse(connectionsDto({lifecycleStatus})),
178
+ }),
179
+ });
180
+
181
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
182
+
183
+ expect(await screen.findByText('Platform')).toBeInTheDocument();
184
+ expect(await screen.findByText(label)).toBeInTheDocument();
185
+ // The aligned card carries no CTA.
186
+ expect(screen.queryByRole('link', {name: 'Reconnect'})).not.toBeInTheDocument();
187
+ });
188
+
189
+ test('keeps cards usable and unflagged when the connections request fails', async () => {
190
+ configureApiClient({
191
+ fetchImpl: createHubFetch({
192
+ projects: jsonResponse({
193
+ projects: [projectDto({id: 'project-1', name: 'Platform'})],
194
+ next_cursor: null,
195
+ }),
196
+ connections: jsonResponse({code: 'server-error'}, {status: 500}),
197
+ }),
198
+ });
199
+
200
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
201
+
202
+ expect(await screen.findByText('Platform')).toBeInTheDocument();
203
+
204
+ // The icon settles to the neutral fallback rather than spinning on the
205
+ // loading skeleton forever when the connections fetch errors.
206
+ await waitFor(() => {
207
+ const card = screen.getByText('Platform').closest('li');
208
+ expect(card?.querySelector('[data-slot="skeleton"]')).toBeNull();
209
+ expect(card?.querySelector('svg')).toBeInTheDocument();
210
+ });
211
+
212
+ // A failed connections fetch is not mistaken for a disconnected source.
213
+ expect(screen.queryByText('Disabled')).not.toBeInTheDocument();
214
+ expect(screen.queryByText('Error')).not.toBeInTheDocument();
215
+ expect(screen.queryByText('Connected')).not.toBeInTheDocument();
216
+ });
217
+ });
218
+
219
+ function createHubFetch({
220
+ projects = jsonResponse({
221
+ projects: [projectDto({id: 'project-1', name: 'Platform'})],
222
+ next_cursor: null,
223
+ }),
224
+ connections = jsonResponse(connectionsDto()),
225
+ modelProviders = jsonResponse(modelProviderConfigsDto()),
226
+ }: {
227
+ projects?: Response;
228
+ connections?: Response;
229
+ modelProviders?: Response;
230
+ } = {}) {
231
+ return vi.fn((input: RequestInfo | URL) => {
232
+ const url = new URL(requestInputUrl(input));
233
+ if (url.pathname.endsWith('/agent/model-providers')) {
234
+ return Promise.resolve(modelProviders.clone());
235
+ }
236
+ if (url.pathname === '/integration-connections') {
237
+ return Promise.resolve(connections.clone());
238
+ }
239
+ if (url.pathname === '/projects') {
240
+ return Promise.resolve(projects.clone());
241
+ }
242
+ return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
243
+ });
244
+ }
245
+
246
+ function requestInputUrl(input: RequestInfo | URL) {
247
+ if (input instanceof Request) return input.url;
248
+ return String(input);
249
+ }
250
+
251
+ function projectDto({
252
+ id,
253
+ name,
254
+ connectionId = CONNECTION_ID,
255
+ externalRepositoryId = 'github:octo/platform',
256
+ }: {
257
+ id: string;
258
+ name: string;
259
+ connectionId?: string;
260
+ externalRepositoryId?: string;
261
+ }) {
262
+ return {
263
+ id,
264
+ workspace_id: PROJECT_TEST_WID,
265
+ name,
266
+ source: {
267
+ connection_id: connectionId,
268
+ external_repository_id: externalRepositoryId,
269
+ },
270
+ created_at: new Date().toISOString(),
271
+ updated_at: new Date().toISOString(),
272
+ };
273
+ }
274
+
275
+ function modelProviderConfigsDto() {
276
+ return {
277
+ configs: [
278
+ {
279
+ provider_id: 'anthropic',
280
+ default_model: null,
281
+ key_fingerprints: {'credential:api_key': '...abcd'},
282
+ created_at: new Date().toISOString(),
283
+ updated_at: new Date().toISOString(),
284
+ },
285
+ ],
286
+ default_provider_id: 'anthropic',
287
+ };
288
+ }
289
+
290
+ function connectionsDto({
291
+ lifecycleStatus = 'active',
292
+ id = CONNECTION_ID,
293
+ }: {
294
+ lifecycleStatus?: 'active' | 'disabled' | 'error';
295
+ id?: string;
296
+ } = {}) {
297
+ return {
298
+ connections: [
299
+ {
300
+ id,
301
+ workspace_id: PROJECT_TEST_WID,
302
+ provider: 'github',
303
+ external_account_id: 'octo',
304
+ slug: 'github_octo',
305
+ display_name: 'Acme GitHub',
306
+ lifecycle_status: lifecycleStatus,
307
+ capabilities: ['source_control'],
308
+ created_at: '2026-05-07T00:00:00.000Z',
309
+ updated_at: '2026-05-07T00:00:00.000Z',
310
+ },
311
+ ],
312
+ };
313
+ }
@@ -0,0 +1,246 @@
1
+ import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
+ import type {ProjectResponseDto} from '@shipfox/api-projects-dto';
3
+ import {useActiveWorkspace} from '@shipfox/client-auth';
4
+ import {
5
+ ConnectionStatusBadge,
6
+ IntegrationIcon,
7
+ useIntegrationConnectionsQuery,
8
+ } from '@shipfox/client-integrations';
9
+ import {QueryLoadError} from '@shipfox/client-ui';
10
+ import {Button} from '@shipfox/react-ui/button';
11
+ import {Callout} from '@shipfox/react-ui/callout';
12
+ import {Card} from '@shipfox/react-ui/card';
13
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
14
+ import {Icon} from '@shipfox/react-ui/icon';
15
+ import {Input} from '@shipfox/react-ui/input';
16
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
17
+ import {Header, Text} from '@shipfox/react-ui/typography';
18
+ import {Link} from '@tanstack/react-router';
19
+ import {useEffect, useState} from 'react';
20
+ import {ModelProviderReminderBanner} from '#components/model-provider-reminder-banner.js';
21
+ import {useProjectsInfiniteQuery} from '#hooks/api/projects.js';
22
+
23
+ export function ProjectsHubPage() {
24
+ const workspace = useActiveWorkspace();
25
+ const [searchInput, setSearchInput] = useState('');
26
+ const trimmedInput = searchInput.trim();
27
+ const debouncedSearch = useDebouncedValue(trimmedInput, 250);
28
+ const query = useProjectsInfiniteQuery(workspace.id, debouncedSearch || undefined);
29
+ const projects = query.data?.pages.flatMap((page) => page.projects) ?? [];
30
+
31
+ // The provider logo and connection health live on the integration connection,
32
+ // not the project, so resolve them once for the whole list and index by id.
33
+ // Skip the fetch when there are no cards to annotate.
34
+ const connectionsQuery = useIntegrationConnectionsQuery(
35
+ projects.length > 0 ? workspace.id : undefined,
36
+ );
37
+ const connectionsById = new Map(
38
+ (connectionsQuery.data?.connections ?? []).map((connection) => [connection.id, connection]),
39
+ );
40
+
41
+ const isInitialLoading = query.isPending;
42
+ const isDebouncePending = trimmedInput !== debouncedSearch;
43
+ const isSearching =
44
+ Boolean(trimmedInput) && (isDebouncePending || (query.isFetching && !query.isFetchingNextPage));
45
+ const hasNoData = !query.data;
46
+
47
+ return (
48
+ <div className="flex w-full flex-col gap-24">
49
+ <header className="flex flex-col gap-16">
50
+ <div className="flex items-start justify-between gap-24 max-[640px]:flex-col">
51
+ <Header variant="h2">Projects</Header>
52
+ </div>
53
+ <div className="flex items-center gap-12 max-[640px]:flex-col max-[640px]:items-stretch">
54
+ <Input
55
+ type="search"
56
+ value={searchInput}
57
+ onChange={(event) => setSearchInput(event.target.value)}
58
+ placeholder="Search projects…"
59
+ aria-label="Search projects"
60
+ className="flex-1"
61
+ iconLeft={<Icon name="searchLine" className="size-16" />}
62
+ iconRight={
63
+ isSearching ? (
64
+ <Icon
65
+ name="spinner"
66
+ size={16}
67
+ className="text-foreground-neutral-muted"
68
+ aria-hidden="true"
69
+ />
70
+ ) : undefined
71
+ }
72
+ />
73
+ <Button asChild iconLeft="addLine" className="shrink-0 max-[640px]:w-full">
74
+ <Link to="/workspaces/$wid/projects/new" params={{wid: workspace.id}}>
75
+ New project
76
+ </Link>
77
+ </Button>
78
+ </div>
79
+ </header>
80
+
81
+ <ModelProviderReminderBanner workspaceId={workspace.id} />
82
+
83
+ {isInitialLoading || (debouncedSearch && hasNoData && query.isFetching) ? (
84
+ <ProjectsSkeleton />
85
+ ) : null}
86
+
87
+ {query.isError && hasNoData ? <QueryLoadError query={query} subject="projects" /> : null}
88
+
89
+ {!isInitialLoading && !query.isError && projects.length === 0 && !debouncedSearch ? (
90
+ <EmptyProjects workspaceId={workspace.id} />
91
+ ) : null}
92
+
93
+ {!query.isFetching && !query.isError && projects.length === 0 && debouncedSearch ? (
94
+ <NoSearchResults search={debouncedSearch} onClear={() => setSearchInput('')} />
95
+ ) : null}
96
+
97
+ {projects.length > 0 ? (
98
+ <section aria-label="Projects list">
99
+ <ul className="grid grid-cols-2 gap-12 max-[760px]:grid-cols-1">
100
+ {projects.map((project) => (
101
+ <ProjectCard
102
+ project={project}
103
+ connection={connectionsById.get(project.source.connection_id)}
104
+ connectionsResolved={connectionsQuery.isSuccess}
105
+ connectionsSettled={connectionsQuery.isSuccess || connectionsQuery.isError}
106
+ key={project.id}
107
+ workspaceId={workspace.id}
108
+ />
109
+ ))}
110
+ </ul>
111
+ {query.error && query.data ? (
112
+ <Callout role="alert" type="error" className="mt-16">
113
+ <Text size="sm">
114
+ Could not load the next page. Existing projects are still shown.
115
+ </Text>
116
+ </Callout>
117
+ ) : null}
118
+ {query.hasNextPage ? (
119
+ <div className="mt-16 flex justify-center">
120
+ <Button
121
+ variant="secondary"
122
+ isLoading={query.isFetchingNextPage}
123
+ onClick={() => query.fetchNextPage()}
124
+ >
125
+ Load more
126
+ </Button>
127
+ </div>
128
+ ) : null}
129
+ </section>
130
+ ) : null}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function useDebouncedValue<T>(value: T, delayMs: number): T {
136
+ const [debounced, setDebounced] = useState(value);
137
+ useEffect(() => {
138
+ const handle = setTimeout(() => setDebounced(value), delayMs);
139
+ return () => clearTimeout(handle);
140
+ }, [value, delayMs]);
141
+ return debounced;
142
+ }
143
+
144
+ function ProjectsSkeleton() {
145
+ return (
146
+ <ul
147
+ role="status"
148
+ aria-label="Loading projects"
149
+ className="grid grid-cols-2 gap-12 max-[760px]:grid-cols-1"
150
+ >
151
+ {[0, 1, 2, 3, 4, 5].map((row) => (
152
+ <li key={row}>
153
+ <Card className="h-full p-16">
154
+ <div className="flex items-center gap-12">
155
+ <Skeleton className="size-24 shrink-0" />
156
+ <Skeleton className="h-16 w-1/2" />
157
+ </div>
158
+ </Card>
159
+ </li>
160
+ ))}
161
+ </ul>
162
+ );
163
+ }
164
+
165
+ function EmptyProjects({workspaceId}: {workspaceId: string}) {
166
+ return (
167
+ <EmptyState
168
+ icon="folderLine"
169
+ title="Create your first project"
170
+ description="Connect a repository-backed project to start building workflows."
171
+ action={
172
+ <Button asChild iconRight="chevronRight">
173
+ <Link to="/workspaces/$wid/projects/new" params={{wid: workspaceId}}>
174
+ Create project
175
+ </Link>
176
+ </Button>
177
+ }
178
+ />
179
+ );
180
+ }
181
+
182
+ function NoSearchResults({search, onClear}: {search: string; onClear: () => void}) {
183
+ return (
184
+ <EmptyState
185
+ icon="searchLine"
186
+ title={`No projects match “${search}”`}
187
+ description="Try a different search, or clear it to see all projects."
188
+ action={
189
+ <Button size="sm" variant="secondary" onClick={onClear}>
190
+ Clear search
191
+ </Button>
192
+ }
193
+ />
194
+ );
195
+ }
196
+
197
+ function ProjectCard({
198
+ project,
199
+ connection,
200
+ connectionsResolved,
201
+ connectionsSettled,
202
+ workspaceId,
203
+ }: {
204
+ project: ProjectResponseDto;
205
+ connection: IntegrationConnectionDto | undefined;
206
+ connectionsResolved: boolean;
207
+ connectionsSettled: boolean;
208
+ workspaceId: string;
209
+ }) {
210
+ // On a resolved fetch, `active` carries no badge while a missing connection
211
+ // reads as an error so a broken source is still flagged. An unresolved or
212
+ // failed fetch shows nothing, so a fetch failure never flags every card.
213
+ const status = connectionsResolved ? (connection?.lifecycle_status ?? 'error') : undefined;
214
+
215
+ return (
216
+ <li>
217
+ <Link
218
+ to="/workspaces/$wid/projects/$pid"
219
+ params={{wid: workspaceId, pid: project.id}}
220
+ className="block h-full rounded-8 focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
221
+ >
222
+ <Card className="h-full p-16 transition-colors hover:bg-background-components-hover">
223
+ <div className="flex min-w-0 items-center gap-12">
224
+ {/* Settle on success or error: a failed fetch falls back to the
225
+ neutral provider icon rather than spinning forever. */}
226
+ {connectionsSettled ? (
227
+ <IntegrationIcon
228
+ source={connection?.provider}
229
+ aria-hidden
230
+ className="size-24 shrink-0 text-foreground-neutral-base"
231
+ />
232
+ ) : (
233
+ <Skeleton className="size-24 shrink-0" />
234
+ )}
235
+ <div className="flex min-w-0 flex-1 items-center gap-8">
236
+ <Text size="md" bold className="truncate">
237
+ {project.name}
238
+ </Text>
239
+ {status ? <ConnectionStatusBadge status={status} className="shrink-0" /> : null}
240
+ </div>
241
+ </div>
242
+ </Card>
243
+ </Link>
244
+ </li>
245
+ );
246
+ }
@@ -0,0 +1,59 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {projectErrorCopy} from './project-error.js';
3
+
4
+ function apiError(code: string, details?: unknown) {
5
+ return new ApiError({
6
+ code,
7
+ status: 400,
8
+ message: code,
9
+ details: details === undefined ? {code} : {code, details},
10
+ });
11
+ }
12
+
13
+ describe('projectErrorCopy', () => {
14
+ test.each([
15
+ ['repository-not-found', 'Repository not found'],
16
+ ['access-denied', 'Repository access denied'],
17
+ ['timeout', 'Provider timed out'],
18
+ ['provider-unavailable', 'Provider unavailable'],
19
+ ['malformed-provider-response', 'Provider response changed'],
20
+ ['source-connection-not-found', 'Source connection not found'],
21
+ ['source-connection-inactive', 'Source connection inactive'],
22
+ ])('maps %s', (code, title) => {
23
+ const result = projectErrorCopy(apiError(code));
24
+
25
+ expect(result.title).toBe(title);
26
+ expect(result.message).not.toBe('');
27
+ });
28
+
29
+ test('includes retry-after copy for rate limits', () => {
30
+ const result = projectErrorCopy(apiError('rate-limited', {retry_after_seconds: 60}));
31
+
32
+ expect(result.title).toBe('Provider rate limited');
33
+ expect(result.message).toContain('60 seconds');
34
+ });
35
+
36
+ test('surfaces duplicate project recovery id', () => {
37
+ const result = projectErrorCopy(
38
+ apiError('project-already-exists', {existing_project_id: 'project-1'}),
39
+ );
40
+
41
+ expect(result.title).toBe('Project already exists');
42
+ expect(result.existingProjectId).toBe('project-1');
43
+ });
44
+
45
+ test('ignores malformed details payloads', () => {
46
+ const result = projectErrorCopy(apiError('project-already-exists', 'bad-details'));
47
+
48
+ expect(result.title).toBe('Project already exists');
49
+ expect(result.existingProjectId).toBeUndefined();
50
+ });
51
+
52
+ test('maps network and unknown errors', () => {
53
+ const network = projectErrorCopy(apiError('network-error'));
54
+ const unknown = projectErrorCopy(new Error('boom'));
55
+
56
+ expect(network.title).toBe('Network problem');
57
+ expect(unknown.title).toBe('Something went wrong');
58
+ });
59
+ });