@shipfox/client-projects 12.0.2 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +49 -0
  3. package/dist/chrome.d.ts +5 -1
  4. package/dist/chrome.d.ts.map +1 -1
  5. package/dist/chrome.js +18 -26
  6. package/dist/chrome.js.map +1 -1
  7. package/dist/components/model-provider-reminder-banner.d.ts.map +1 -1
  8. package/dist/components/model-provider-reminder-banner.js +4 -2
  9. package/dist/components/model-provider-reminder-banner.js.map +1 -1
  10. package/dist/components/project-crumb.d.ts +3 -1
  11. package/dist/components/project-crumb.d.ts.map +1 -1
  12. package/dist/components/project-crumb.js +7 -5
  13. package/dist/components/project-crumb.js.map +1 -1
  14. package/dist/components/project-switcher.d.ts +2 -1
  15. package/dist/components/project-switcher.d.ts.map +1 -1
  16. package/dist/components/project-switcher.js +10 -8
  17. package/dist/components/project-switcher.js.map +1 -1
  18. package/dist/core/definition.d.ts +6 -0
  19. package/dist/core/definition.d.ts.map +1 -1
  20. package/dist/core/definition.js.map +1 -1
  21. package/dist/core/project.d.ts +7 -0
  22. package/dist/core/project.d.ts.map +1 -1
  23. package/dist/core/project.js.map +1 -1
  24. package/dist/feature.d.ts +29 -5
  25. package/dist/feature.d.ts.map +1 -1
  26. package/dist/feature.js +26 -5
  27. package/dist/feature.js.map +1 -1
  28. package/dist/hooks/api/mappers.d.ts.map +1 -1
  29. package/dist/hooks/api/mappers.js +9 -1
  30. package/dist/hooks/api/mappers.js.map +1 -1
  31. package/dist/hooks/api/projects.d.ts +21 -2
  32. package/dist/hooks/api/projects.d.ts.map +1 -1
  33. package/dist/hooks/api/projects.js +175 -2
  34. package/dist/hooks/api/projects.js.map +1 -1
  35. package/dist/index.d.ts +3 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/pages/create-project-page.d.ts.map +1 -1
  40. package/dist/pages/create-project-page.js +97 -21
  41. package/dist/pages/create-project-page.js.map +1 -1
  42. package/dist/pages/create-project-page.stories.js +5 -3
  43. package/dist/pages/create-project-page.stories.js.map +1 -1
  44. package/dist/pages/project-settings-form-errors.d.ts +11 -0
  45. package/dist/pages/project-settings-form-errors.d.ts.map +1 -0
  46. package/dist/pages/project-settings-form-errors.js +16 -0
  47. package/dist/pages/project-settings-form-errors.js.map +1 -0
  48. package/dist/pages/project-settings-page.d.ts +2 -0
  49. package/dist/pages/project-settings-page.d.ts.map +1 -0
  50. package/dist/pages/project-settings-page.js +225 -0
  51. package/dist/pages/project-settings-page.js.map +1 -0
  52. package/dist/pages/project-settings-page.stories.d.ts +23 -0
  53. package/dist/pages/project-settings-page.stories.d.ts.map +1 -0
  54. package/dist/pages/project-settings-page.stories.js +206 -0
  55. package/dist/pages/project-settings-page.stories.js.map +1 -0
  56. package/dist/pages/projects-hub-page.js +11 -11
  57. package/dist/pages/projects-hub-page.js.map +1 -1
  58. package/dist/project-error.d.ts +1 -0
  59. package/dist/project-error.d.ts.map +1 -1
  60. package/dist/project-error.js +7 -0
  61. package/dist/project-error.js.map +1 -1
  62. package/dist/routes/inputs.d.ts +2 -2
  63. package/dist/routes/inputs.d.ts.map +1 -1
  64. package/dist/routes/inputs.js +5 -5
  65. package/dist/routes/inputs.js.map +1 -1
  66. package/dist/routes/project-index.d.ts +2 -2
  67. package/dist/routes/project-index.d.ts.map +1 -1
  68. package/dist/routes/project-index.js +3 -3
  69. package/dist/routes/project-index.js.map +1 -1
  70. package/dist/routes/project-settings-index.d.ts +10 -0
  71. package/dist/routes/project-settings-index.d.ts.map +1 -0
  72. package/dist/routes/project-settings-index.js +12 -0
  73. package/dist/routes/project-settings-index.js.map +1 -0
  74. package/dist/routes/project-settings.d.ts +6 -0
  75. package/dist/routes/project-settings.d.ts.map +1 -0
  76. package/dist/routes/project-settings.js +7 -0
  77. package/dist/routes/project-settings.js.map +1 -0
  78. package/dist/tsconfig.test.tsbuildinfo +1 -1
  79. package/package.json +11 -10
  80. package/src/chrome.tsx +27 -19
  81. package/src/components/model-provider-reminder-banner.tsx +3 -1
  82. package/src/components/project-crumb.tsx +18 -5
  83. package/src/components/project-switcher.test.tsx +7 -4
  84. package/src/components/project-switcher.tsx +16 -5
  85. package/src/core/definition.ts +7 -0
  86. package/src/core/project.ts +8 -0
  87. package/src/feature.ts +26 -4
  88. package/src/hooks/api/mappers.test.ts +19 -0
  89. package/src/hooks/api/mappers.ts +6 -0
  90. package/src/hooks/api/projects.test.ts +247 -1
  91. package/src/hooks/api/projects.ts +227 -2
  92. package/src/index.ts +9 -1
  93. package/src/pages/create-project-page.stories.tsx +5 -4
  94. package/src/pages/create-project-page.test.tsx +191 -12
  95. package/src/pages/create-project-page.tsx +102 -15
  96. package/src/pages/home-router.test.tsx +2 -2
  97. package/src/pages/project-settings-form-errors.ts +17 -0
  98. package/src/pages/project-settings-page.stories.tsx +216 -0
  99. package/src/pages/project-settings-page.test.tsx +74 -0
  100. package/src/pages/project-settings-page.tsx +220 -0
  101. package/src/pages/projects-hub-page.test.tsx +17 -11
  102. package/src/pages/projects-hub-page.tsx +9 -9
  103. package/src/project-error.test.ts +1 -0
  104. package/src/project-error.ts +8 -0
  105. package/src/routes/inputs.test.ts +8 -6
  106. package/src/routes/inputs.ts +7 -6
  107. package/src/routes/project-index.tsx +3 -3
  108. package/src/routes/project-settings-index.tsx +11 -0
  109. package/src/routes/project-settings.tsx +4 -0
  110. package/test/pages.tsx +26 -22
  111. package/tsconfig.build.tsbuildinfo +1 -1
@@ -2,11 +2,13 @@ import {
2
2
  createProjectBodySchema,
3
3
  listProjectsResponseSchema,
4
4
  projectResponseSchema,
5
+ updateProjectBodySchema,
5
6
  } from '@shipfox/api-projects-dto';
6
7
  import {checkedApiRequest} from '@shipfox/client-api';
7
8
  import {
8
9
  type InfiniteData,
9
10
  keepPreviousData,
11
+ type QueryClient,
10
12
  queryOptions,
11
13
  type UseInfiniteQueryOptions,
12
14
  type UseQueryOptions,
@@ -15,13 +17,23 @@ import {
15
17
  useQuery,
16
18
  useQueryClient,
17
19
  } from '@tanstack/react-query';
18
- import type {CreateProjectCommand, Project, ProjectList} from '#core/project.js';
20
+ import {useCallback} from 'react';
21
+ import type {
22
+ CreateProjectCommand,
23
+ Project,
24
+ ProjectList,
25
+ UpdateProjectCommand,
26
+ } from '#core/project.js';
19
27
  import {toProject, toProjectList} from './mappers.js';
20
28
 
29
+ const PROJECT_LIST_STALE_TIME = 30_000;
30
+
21
31
  export const projectsQueryKeys = {
22
32
  all: ['projects'] as const,
23
33
  list: (workspaceId: string, search = '') =>
24
34
  [...projectsQueryKeys.all, 'list', workspaceId, search] as const,
35
+ slug: (workspaceId: string, projectSlug: string) =>
36
+ [...projectsQueryKeys.all, 'slug', workspaceId, projectSlug] as const,
25
37
  exists: (workspaceId: string) => [...projectsQueryKeys.all, 'exists', workspaceId] as const,
26
38
  detail: (projectId: string) => [...projectsQueryKeys.all, 'detail', projectId] as const,
27
39
  };
@@ -32,6 +44,9 @@ type ProjectListQueryKey =
32
44
  type ProjectExistenceQueryKey =
33
45
  | ReturnType<typeof projectsQueryKeys.exists>
34
46
  | readonly ['projects', 'exists'];
47
+ type ProjectSlugQueryKey =
48
+ | ReturnType<typeof projectsQueryKeys.slug>
49
+ | readonly ['projects', 'slug'];
35
50
  type ProjectDetailQueryKey =
36
51
  | ReturnType<typeof projectsQueryKeys.detail>
37
52
  | readonly ['projects', 'detail'];
@@ -49,6 +64,12 @@ type ProjectExistenceQueryOptions = UseQueryOptions<
49
64
  ProjectList,
50
65
  ProjectExistenceQueryKey
51
66
  >;
67
+ type ProjectSlugQueryOptions = UseQueryOptions<
68
+ Project | null,
69
+ Error,
70
+ Project | null,
71
+ ProjectSlugQueryKey
72
+ >;
52
73
  type ProjectDetailQueryOptions = UseQueryOptions<Project, Error, Project, ProjectDetailQueryKey>;
53
74
 
54
75
  export async function listProjects({
@@ -72,6 +93,112 @@ export async function listProjects({
72
93
  );
73
94
  }
74
95
 
96
+ export async function resolveProjectSlug({
97
+ queryClient,
98
+ workspaceId,
99
+ projectSlug,
100
+ }: {
101
+ queryClient: QueryClient;
102
+ workspaceId: string;
103
+ projectSlug: string;
104
+ }): Promise<string | undefined> {
105
+ const queryKey = projectsQueryKeys.list(workspaceId);
106
+ let data = queryClient.getQueryData<InfiniteData<ProjectList, string | undefined>>(queryKey);
107
+
108
+ if (!data) {
109
+ await queryClient.fetchInfiniteQuery(projectsInfiniteQueryOptions(workspaceId));
110
+ data = queryClient.getQueryData<InfiniteData<ProjectList, string | undefined>>(queryKey);
111
+ }
112
+
113
+ if (data) {
114
+ const project = data.pages
115
+ .flatMap((page) => page.projects)
116
+ .find((candidate) => candidate.slug === projectSlug);
117
+ const dataUpdatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0;
118
+ if (project && Date.now() - dataUpdatedAt < PROJECT_LIST_STALE_TIME) {
119
+ cacheResolvedProject(queryClient, workspaceId, projectSlug, project);
120
+ return project.id;
121
+ }
122
+ const cursor = data.pages.at(-1)?.nextCursor;
123
+ if (project || !cursor) {
124
+ return await refetchAndResolveProjectSlug(queryClient, queryKey, workspaceId, projectSlug);
125
+ }
126
+ return await resolveProjectSlugBySearch(queryClient, workspaceId, projectSlug);
127
+ }
128
+
129
+ return undefined;
130
+ }
131
+
132
+ async function refetchAndResolveProjectSlug(
133
+ queryClient: QueryClient,
134
+ queryKey: ReturnType<typeof projectsQueryKeys.list>,
135
+ workspaceId: string,
136
+ projectSlug: string,
137
+ ): Promise<string | undefined> {
138
+ await queryClient.refetchQueries({queryKey, type: 'all'});
139
+ const data = queryClient.getQueryData<InfiniteData<ProjectList, string | undefined>>(queryKey);
140
+
141
+ if (!data) return undefined;
142
+ const project = data.pages
143
+ .flatMap((page) => page.projects)
144
+ .find((candidate) => candidate.slug === projectSlug);
145
+ if (project) {
146
+ cacheResolvedProject(queryClient, workspaceId, projectSlug, project);
147
+ return project.id;
148
+ }
149
+
150
+ if (!data.pages.at(-1)?.nextCursor) return undefined;
151
+ return await resolveProjectSlugBySearch(queryClient, workspaceId, projectSlug);
152
+ }
153
+
154
+ function cacheResolvedProject(
155
+ queryClient: QueryClient,
156
+ workspaceId: string,
157
+ projectSlug: string,
158
+ project: Project,
159
+ ): void {
160
+ queryClient.setQueryData(projectsQueryKeys.slug(workspaceId, projectSlug), project);
161
+ queryClient.setQueryData(projectsQueryKeys.detail(project.id), project);
162
+ }
163
+
164
+ async function resolveProjectSlugBySearch(
165
+ queryClient: QueryClient,
166
+ workspaceId: string,
167
+ projectSlug: string,
168
+ signal?: AbortSignal,
169
+ ): Promise<string | undefined> {
170
+ const project = await findProjectBySlug({workspaceId, projectSlug, signal});
171
+ if (project) {
172
+ cacheResolvedProject(queryClient, workspaceId, projectSlug, project);
173
+ return project.id;
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ async function findProjectBySlug({
179
+ workspaceId,
180
+ projectSlug,
181
+ signal,
182
+ }: {
183
+ workspaceId: string;
184
+ projectSlug: string;
185
+ signal?: AbortSignal | undefined;
186
+ }): Promise<Project | undefined> {
187
+ let result = await listProjects({workspaceId, search: projectSlug, limit: 100, signal});
188
+ while (true) {
189
+ const project = result.projects.find((candidate) => candidate.slug === projectSlug);
190
+ if (project) return project;
191
+ if (!result.nextCursor) return undefined;
192
+ result = await listProjects({
193
+ workspaceId,
194
+ search: projectSlug,
195
+ limit: 100,
196
+ cursor: result.nextCursor,
197
+ signal,
198
+ });
199
+ }
200
+ }
201
+
75
202
  export async function getProject(projectId: string): Promise<Project> {
76
203
  return toProject(await checkedApiRequest(projectResponseSchema, `/projects/${projectId}`));
77
204
  }
@@ -80,6 +207,7 @@ export async function createProject(command: CreateProjectCommand): Promise<Proj
80
207
  const body = createProjectBodySchema.parse({
81
208
  workspace_id: command.workspaceId,
82
209
  name: command.name,
210
+ slug: command.slug,
83
211
  source: {
84
212
  connection_id: command.source.connectionId,
85
213
  external_repository_id: command.source.externalRepositoryId,
@@ -90,6 +218,54 @@ export async function createProject(command: CreateProjectCommand): Promise<Proj
90
218
  );
91
219
  }
92
220
 
221
+ export async function updateProject(command: UpdateProjectCommand): Promise<Project> {
222
+ const body = updateProjectBodySchema.parse({name: command.name, slug: command.slug});
223
+ return toProject(
224
+ await checkedApiRequest(projectResponseSchema, `/projects/${command.projectId}`, {
225
+ method: 'PATCH',
226
+ body,
227
+ }),
228
+ );
229
+ }
230
+
231
+ export function isProjectSlugAvailable({
232
+ queryClient,
233
+ workspaceId,
234
+ projectSlug,
235
+ currentProjectId,
236
+ }: {
237
+ queryClient: QueryClient;
238
+ workspaceId: string;
239
+ projectSlug: string;
240
+ currentProjectId?: string | undefined;
241
+ }): boolean {
242
+ const cachedLists = queryClient.getQueriesData<InfiniteData<ProjectList, string | undefined>>({
243
+ queryKey: [...projectsQueryKeys.all, 'list', workspaceId],
244
+ });
245
+ const conflict = cachedLists
246
+ .flatMap(([, data]) => data?.pages.flatMap((page) => page.projects) ?? [])
247
+ .some((project) => project.slug === projectSlug && project.id !== currentProjectId);
248
+ return !conflict;
249
+ }
250
+
251
+ export function useProjectSlugAvailability(
252
+ workspaceId: string | undefined,
253
+ currentProjectId?: string,
254
+ ): (projectSlug: string) => Promise<boolean> {
255
+ const queryClient = useQueryClient();
256
+ return useCallback(
257
+ async (projectSlug: string) => {
258
+ if (!workspaceId) return false;
259
+ if (!isProjectSlugAvailable({queryClient, workspaceId, projectSlug, currentProjectId})) {
260
+ return false;
261
+ }
262
+ const project = await findProjectBySlug({workspaceId, projectSlug});
263
+ return project === undefined || project.id === currentProjectId;
264
+ },
265
+ [currentProjectId, queryClient, workspaceId],
266
+ );
267
+ }
268
+
93
269
  export function projectsInfiniteQueryOptions(
94
270
  workspaceId: string | undefined,
95
271
  search?: string,
@@ -112,6 +288,7 @@ export function projectsInfiniteQueryOptions(
112
288
  }),
113
289
  getNextPageParam: (lastPage: ProjectList) => lastPage.nextCursor ?? undefined,
114
290
  placeholderData: keepPreviousData,
291
+ staleTime: PROJECT_LIST_STALE_TIME,
115
292
  };
116
293
  }
117
294
 
@@ -138,6 +315,33 @@ export function projectQueryOptions(projectId: string | undefined): ProjectDetai
138
315
  });
139
316
  }
140
317
 
318
+ export function projectSlugQueryOptions(
319
+ workspaceId: string | undefined,
320
+ projectSlug: string | undefined,
321
+ ): ProjectSlugQueryOptions {
322
+ return queryOptions({
323
+ queryKey:
324
+ workspaceId && projectSlug
325
+ ? projectsQueryKeys.slug(workspaceId, projectSlug)
326
+ : ([...projectsQueryKeys.all, 'slug'] as const),
327
+ enabled: Boolean(workspaceId && projectSlug),
328
+ queryFn: async ({signal, client}) => {
329
+ const resolvedWorkspaceId = workspaceId ?? '';
330
+ const resolvedProjectSlug = projectSlug ?? '';
331
+ const project = await findProjectBySlug({
332
+ workspaceId: resolvedWorkspaceId,
333
+ projectSlug: resolvedProjectSlug,
334
+ signal,
335
+ });
336
+ if (project) {
337
+ cacheResolvedProject(client, resolvedWorkspaceId, resolvedProjectSlug, project);
338
+ }
339
+ return project ?? null;
340
+ },
341
+ staleTime: 30_000,
342
+ });
343
+ }
344
+
141
345
  export function useProjectsInfiniteQuery(
142
346
  workspaceId: string | undefined,
143
347
  search?: string,
@@ -148,7 +352,12 @@ export function useProjectsInfiniteQuery(
148
352
  export function useProjectQuery(projectId: string | undefined) {
149
353
  return useQuery(projectQueryOptions(projectId));
150
354
  }
151
-
355
+ export function useProjectSlugQuery(
356
+ workspaceId: string | undefined,
357
+ projectSlug: string | undefined,
358
+ ) {
359
+ return useQuery(projectSlugQueryOptions(workspaceId, projectSlug));
360
+ }
152
361
  export function useCreateProjectMutation() {
153
362
  const queryClient = useQueryClient();
154
363
  return useMutation({
@@ -170,3 +379,19 @@ export function useCreateProjectMutation() {
170
379
  },
171
380
  });
172
381
  }
382
+
383
+ export function useUpdateProjectMutation() {
384
+ const queryClient = useQueryClient();
385
+
386
+ return useMutation({
387
+ mutationFn: updateProject,
388
+ onSuccess: async (project) => {
389
+ queryClient.setQueryData(projectsQueryKeys.detail(project.id), project);
390
+ queryClient.setQueryData(projectsQueryKeys.slug(project.workspaceId, project.slug), project);
391
+ await queryClient.invalidateQueries({
392
+ queryKey: [...projectsQueryKeys.all, 'slug', project.workspaceId],
393
+ });
394
+ await queryClient.invalidateQueries({queryKey: projectsQueryKeys.list(project.workspaceId)});
395
+ },
396
+ });
397
+ }
package/src/index.ts CHANGED
@@ -6,8 +6,15 @@ export {
6
6
  type ProjectSource,
7
7
  projectNameFromRepository,
8
8
  selectProjectSource,
9
+ type UpdateProjectCommand,
9
10
  } from '#core/project.js';
10
- export {ProjectBreadcrumb, ProjectLayoutGuard} from './chrome.js';
11
+ export {
12
+ ProjectBreadcrumb,
13
+ resolveProjectSlug,
14
+ useActiveProject,
15
+ useMaybeActiveProject,
16
+ useMaybeActiveProjectQuery,
17
+ } from './chrome.js';
11
18
  export {ProjectCrumb, type ProjectCrumbProps} from './components/project-crumb.js';
12
19
  export {ProjectSwitcher, type ProjectSwitcherProps} from './components/project-switcher.js';
13
20
  export {SourceStrip} from './components/source-strip.js';
@@ -15,5 +22,6 @@ export * from './hooks/api/definitions.js';
15
22
  export * from './hooks/api/projects.js';
16
23
  export * from './pages/create-project-page.js';
17
24
  export * from './pages/home-router.js';
25
+ export {ProjectSettingsPage} from './pages/project-settings-page.js';
18
26
  export * from './pages/projects-hub-page.js';
19
27
  export * from './project-error.js';
@@ -17,7 +17,7 @@ import {useMemo} from 'react';
17
17
  import {CreateProjectPage} from './create-project-page.js';
18
18
 
19
19
  const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
20
- const WORKSPACE_PATH = `/workspaces/${WORKSPACE_ID}/projects/new`;
20
+ const WORKSPACE_PATH = '/w/acme/projects/new';
21
21
  const GITHUB_CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
22
22
  const GITEA_CONNECTION_ID = '44444444-4444-4444-8444-444444444444';
23
23
 
@@ -41,7 +41,7 @@ const authState: AuthState = {
41
41
  name: 'Platform Engineer',
42
42
  emailVerifiedAt: '2026-01-01T00:00:00.000Z',
43
43
  },
44
- workspaces: [{id: WORKSPACE_ID, name: 'Acme', membershipId: 'membership-1'}],
44
+ workspaces: [{id: WORKSPACE_ID, name: 'Acme', slug: 'acme', membershipId: 'membership-1'}],
45
45
  };
46
46
 
47
47
  function CreateProjectPageStory({scenario}: CreateProjectPageStoryProps) {
@@ -107,7 +107,7 @@ function createStoryRouter() {
107
107
  const rootRoute = createRootRoute({component: Outlet});
108
108
  const workspaceRoute = createRoute({
109
109
  getParentRoute: () => rootRoute,
110
- path: '/workspaces/$wid',
110
+ path: '/w/$workspaceSlug',
111
111
  component: Outlet,
112
112
  });
113
113
  const createProjectRoute = createRoute({
@@ -127,7 +127,7 @@ function createStoryRouter() {
127
127
  });
128
128
  const projectRoute = createRoute({
129
129
  getParentRoute: () => workspaceRoute,
130
- path: 'projects/$pid',
130
+ path: 'p/$projectSlug',
131
131
  component: () => <div />,
132
132
  });
133
133
 
@@ -176,6 +176,7 @@ function fetchForScenario(scenario: Scenario): typeof fetch {
176
176
  id: '99999999-9999-4999-8999-999999999999',
177
177
  workspace_id: WORKSPACE_ID,
178
178
  name: 'Platform',
179
+ slug: 'platform',
179
180
  source: {
180
181
  connection_id: GITHUB_CONNECTION_ID,
181
182
  external_repository_id: 'platform',