@shipfox/client-projects 12.0.1 → 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 +59 -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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-projects",
3
3
  "license": "MIT",
4
- "version": "12.0.1",
4
+ "version": "13.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,16 +27,17 @@
27
27
  "dependencies": {
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "@tanstack/react-form": "^1.32.0",
30
- "@shipfox/api-definitions-dto": "10.0.0",
31
- "@shipfox/api-integration-core-dto": "9.0.2",
32
- "@shipfox/api-projects-dto": "10.1.0",
33
- "@shipfox/client-agent": "12.0.1",
30
+ "@shipfox/api-definitions-dto": "12.0.0",
31
+ "@shipfox/api-integration-core-dto": "12.0.0",
32
+ "@shipfox/api-projects-dto": "12.0.0",
33
+ "@shipfox/client-agent": "13.0.0",
34
34
  "@shipfox/client-api": "6.0.1",
35
- "@shipfox/client-auth": "12.0.1",
36
- "@shipfox/client-integrations": "12.0.1",
37
- "@shipfox/client-shell": "12.0.1",
38
- "@shipfox/client-ui": "6.0.2",
39
- "@shipfox/react-ui": "0.3.7"
35
+ "@shipfox/client-auth": "13.0.0",
36
+ "@shipfox/client-integrations": "13.0.0",
37
+ "@shipfox/client-shell": "13.0.0",
38
+ "@shipfox/client-ui": "13.0.0",
39
+ "@shipfox/react-ui": "0.4.0",
40
+ "@shipfox/api-common-dto": "12.0.0"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@tanstack/react-query": "^5.101.0",
package/src/chrome.tsx CHANGED
@@ -3,29 +3,37 @@ import {
3
3
  useActiveWorkspace,
4
4
  useRouteParams,
5
5
  } from '@shipfox/client-shell/runtime';
6
- import {Outlet, useNavigate} from '@tanstack/react-router';
7
- import {useEffect} from 'react';
8
6
  import {ProjectCrumb} from '#components/project-crumb.js';
9
- import {useProjectQuery} from '#hooks/api/projects.js';
10
- import {projectRouteParams} from './routes/inputs.js';
7
+ import {resolveProjectSlug, useProjectSlugQuery} from '#hooks/api/projects.js';
8
+
9
+ export {resolveProjectSlug};
10
+
11
+ export function useMaybeActiveProjectQuery() {
12
+ const workspace = useActiveWorkspace();
13
+ const {projectSlug} = useRouteParams(parseWorkspaceProjectParams);
14
+ return useProjectSlugQuery(workspace.id, projectSlug);
15
+ }
16
+
17
+ export function useMaybeActiveProject() {
18
+ return useMaybeActiveProjectQuery().data;
19
+ }
20
+
21
+ export function useActiveProject() {
22
+ const project = useMaybeActiveProject();
23
+ if (!project) throw new Error('No active project is available for this route.');
24
+ return project;
25
+ }
11
26
 
12
27
  export function ProjectBreadcrumb() {
13
28
  const workspace = useActiveWorkspace();
14
- const {pid} = useRouteParams(parseWorkspaceProjectParams);
15
- const project = useProjectQuery(pid).data;
29
+ const project = useMaybeActiveProject();
16
30
  return (
17
- <ProjectCrumb workspaceId={workspace.id} projectId={project?.id} projectName={project?.name} />
31
+ <ProjectCrumb
32
+ workspaceId={workspace.id}
33
+ workspaceSlug={workspace.slug}
34
+ projectId={project?.id}
35
+ projectSlug={project?.slug}
36
+ projectName={project?.name}
37
+ />
18
38
  );
19
39
  }
20
-
21
- export function ProjectLayoutGuard() {
22
- const {wid, pid} = useRouteParams(projectRouteParams);
23
- const navigate = useNavigate();
24
- const project = useProjectQuery(pid).data;
25
- useEffect(() => {
26
- if (project && project.workspaceId !== wid)
27
- void navigate({to: '/workspaces/$wid', params: {wid}, replace: true});
28
- }, [navigate, project, wid]);
29
- if (project && project.workspaceId !== wid) return null;
30
- return <Outlet />;
31
- }
@@ -1,4 +1,5 @@
1
1
  import {useModelProviderConfigsQuery} from '@shipfox/client-agent';
2
+ import {useActiveWorkspace} from '@shipfox/client-auth';
2
3
  import {createTypedBrowserStorage, sessionStorageOrUndefined} from '@shipfox/client-ui';
3
4
  import {
4
5
  Alert,
@@ -13,6 +14,7 @@ import {Link} from '@tanstack/react-router';
13
14
  import {useState} from 'react';
14
15
 
15
16
  export function ModelProviderReminderBanner({workspaceId}: {workspaceId: string}) {
17
+ const workspace = useActiveWorkspace();
16
18
  const configsQuery = useModelProviderConfigsQuery(workspaceId);
17
19
  const [dismissed, setDismissed] = useState(() => isReminderDismissed(workspaceId));
18
20
  const configs = configsQuery.data?.configs;
@@ -39,7 +41,7 @@ export function ModelProviderReminderBanner({workspaceId}: {workspaceId: string}
39
41
  </AlertDescription>
40
42
  <AlertActions>
41
43
  <Button asChild size="sm" variant="secondary">
42
- <Link to="/workspaces/$wid/settings/agents" params={{wid: workspaceId}}>
44
+ <Link to="/w/$workspaceSlug/settings/agents" params={{workspaceSlug: workspace.slug}}>
43
45
  Agents
44
46
  </Link>
45
47
  </Button>
@@ -6,19 +6,27 @@ import {ProjectSwitcher} from './project-switcher.js';
6
6
 
7
7
  export interface ProjectCrumbProps {
8
8
  workspaceId: string;
9
+ workspaceSlug: string;
9
10
  projectId?: string | undefined;
11
+ projectSlug?: string | undefined;
10
12
  projectName?: string | undefined;
11
13
  }
12
14
 
13
- export function ProjectCrumb({workspaceId, projectId, projectName}: ProjectCrumbProps) {
15
+ export function ProjectCrumb({
16
+ workspaceId,
17
+ workspaceSlug,
18
+ projectId,
19
+ projectSlug,
20
+ projectName,
21
+ }: ProjectCrumbProps) {
14
22
  const [open, setOpen] = useState(false);
15
23
 
16
- if (projectId && projectName) {
24
+ if (projectSlug && projectName) {
17
25
  return (
18
26
  <div className="flex items-center">
19
27
  <Link
20
- to="/workspaces/$wid/projects/$pid"
21
- params={{wid: workspaceId, pid: projectId}}
28
+ to="/w/$workspaceSlug/p/$projectSlug"
29
+ params={{workspaceSlug, projectSlug}}
22
30
  aria-current="page"
23
31
  className="text-md font-medium text-foreground-neutral-base px-6 py-4 rounded-6 hover:bg-background-components-hover transition-colors max-w-[240px] truncate"
24
32
  >
@@ -39,6 +47,7 @@ export function ProjectCrumb({workspaceId, projectId, projectName}: ProjectCrumb
39
47
  <PopoverContent className="w-[320px] p-0" align="start" sideOffset={8}>
40
48
  <ProjectSwitcher
41
49
  workspaceId={workspaceId}
50
+ workspaceSlug={workspaceSlug}
42
51
  activeProjectId={projectId}
43
52
  onSelect={() => setOpen(false)}
44
53
  />
@@ -63,7 +72,11 @@ export function ProjectCrumb({workspaceId, projectId, projectName}: ProjectCrumb
63
72
  </button>
64
73
  </PopoverTrigger>
65
74
  <PopoverContent className="w-[320px] p-0" align="start" sideOffset={8}>
66
- <ProjectSwitcher workspaceId={workspaceId} onSelect={() => setOpen(false)} />
75
+ <ProjectSwitcher
76
+ workspaceId={workspaceId}
77
+ workspaceSlug={workspaceSlug}
78
+ onSelect={() => setOpen(false)}
79
+ />
67
80
  </PopoverContent>
68
81
  </Popover>
69
82
  );
@@ -57,16 +57,18 @@ function renderProjectSwitcher({
57
57
  const rootRoute = createRootRoute({component: Outlet});
58
58
  const workspaceRoute = createRoute({
59
59
  getParentRoute: () => rootRoute,
60
- path: '/workspaces/$wid',
61
- component: () => <ProjectSwitcher workspaceId={PROJECT_TEST_WID} onSelect={onSelect} />,
60
+ path: '/w/$workspaceSlug',
61
+ component: () => (
62
+ <ProjectSwitcher workspaceId={PROJECT_TEST_WID} workspaceSlug="acme" onSelect={onSelect} />
63
+ ),
62
64
  });
63
65
  const createProjectRoute = createRoute({
64
66
  getParentRoute: () => rootRoute,
65
- path: '/workspaces/$wid/projects/new',
67
+ path: '/w/$workspaceSlug/projects/new',
66
68
  component: () => <div data-testid="create-project-route">Create project route</div>,
67
69
  });
68
70
  const router = createRouter({
69
- history: createMemoryHistory({initialEntries: [`/workspaces/${PROJECT_TEST_WID}`]}),
71
+ history: createMemoryHistory({initialEntries: ['/w/acme']}),
70
72
  routeTree: rootRoute.addChildren([workspaceRoute, createProjectRoute]),
71
73
  });
72
74
 
@@ -82,6 +84,7 @@ function projectDto({id, name}: {id: string; name: string}) {
82
84
  id,
83
85
  workspace_id: PROJECT_TEST_WID,
84
86
  name,
87
+ slug: name.toLowerCase(),
85
88
  source: {
86
89
  connection_id: '33333333-3333-4333-8333-333333333333',
87
90
  external_repository_id: name.toLowerCase(),
@@ -14,11 +14,17 @@ import {useProjectsInfiniteQuery} from '#hooks/api/projects.js';
14
14
 
15
15
  export interface ProjectSwitcherProps {
16
16
  workspaceId: string;
17
+ workspaceSlug: string;
17
18
  activeProjectId?: string | undefined;
18
19
  onSelect?: () => void;
19
20
  }
20
21
 
21
- export function ProjectSwitcher({workspaceId, activeProjectId, onSelect}: ProjectSwitcherProps) {
22
+ export function ProjectSwitcher({
23
+ workspaceId,
24
+ workspaceSlug,
25
+ activeProjectId,
26
+ onSelect,
27
+ }: ProjectSwitcherProps) {
22
28
  const navigate = useNavigate();
23
29
  const [searchValue, setSearchValue] = useState('');
24
30
  const query = useProjectsInfiniteQuery(workspaceId);
@@ -33,20 +39,25 @@ export function ProjectSwitcher({workspaceId, activeProjectId, onSelect}: Projec
33
39
  }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
34
40
 
35
41
  const handleSelect = (projectId: string) => {
42
+ const project = projects.find((candidate) => candidate.id === projectId);
43
+ if (!project) return;
36
44
  navigate({
37
- to: '/workspaces/$wid/projects/$pid',
38
- params: {wid: workspaceId, pid: projectId},
45
+ to: '/w/$workspaceSlug/p/$projectSlug',
46
+ params: {
47
+ workspaceSlug,
48
+ projectSlug: project.slug,
49
+ },
39
50
  });
40
51
  onSelect?.();
41
52
  };
42
53
 
43
54
  const handleSelectAll = () => {
44
- navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
55
+ navigate({to: '/w/$workspaceSlug', params: {workspaceSlug}});
45
56
  onSelect?.();
46
57
  };
47
58
 
48
59
  const handleCreate = () => {
49
- navigate({to: '/workspaces/$wid/projects/new', params: {wid: workspaceId}});
60
+ navigate({to: '/w/$workspaceSlug/projects/new', params: {workspaceSlug}});
50
61
  onSelect?.();
51
62
  };
52
63
 
@@ -22,6 +22,13 @@ export interface DefinitionSyncSummary {
22
22
  finishedAt: string | null;
23
23
  lastErrorCode: string | null;
24
24
  lastErrorMessage: string | null;
25
+ warnings: DefinitionSyncWarning[];
26
+ }
27
+
28
+ export interface DefinitionSyncWarning {
29
+ code: string;
30
+ message: string;
31
+ path?: string | undefined;
25
32
  }
26
33
 
27
34
  export interface DefinitionList {
@@ -7,6 +7,7 @@ export interface Project {
7
7
  id: string;
8
8
  workspaceId: string;
9
9
  name: string;
10
+ slug: string;
10
11
  source: ProjectSource;
11
12
  createdAt: string;
12
13
  updatedAt: string;
@@ -20,9 +21,16 @@ export interface ProjectList {
20
21
  export interface CreateProjectCommand {
21
22
  workspaceId: string;
22
23
  name: string;
24
+ slug: string;
23
25
  source: ProjectSource;
24
26
  }
25
27
 
28
+ export interface UpdateProjectCommand {
29
+ projectId: string;
30
+ name?: string;
31
+ slug?: string;
32
+ }
33
+
26
34
  const REPOSITORY_NAME_SPLIT_RE = /[/-]/;
27
35
 
28
36
  export function projectNameFromRepository(repositoryId: string): string {
package/src/feature.ts CHANGED
@@ -5,30 +5,52 @@ export const projectsNavigation = [
5
5
  id: 'nav.projects',
6
6
  scope: 'workspace',
7
7
  label: 'Projects',
8
- to: '/workspaces/$wid',
8
+ to: '/w/$workspaceSlug',
9
9
  exact: true,
10
10
  order: 100,
11
11
  },
12
12
  ] as const satisfies readonly NavTabEntry[];
13
13
 
14
+ export const projectSettingsSections = [
15
+ {
16
+ id: 'settings.project-general',
17
+ scope: 'project',
18
+ pathSegment: 'general',
19
+ label: 'General',
20
+ icon: 'settings3Line',
21
+ order: 50,
22
+ },
23
+ ] as const;
24
+
14
25
  export const projectsFeature = defineClientFeature({
15
26
  id: 'shipfox.projects',
16
27
  routes: [
17
28
  {
18
- path: '/workspaces/$wid',
29
+ path: '/w/$workspaceSlug',
19
30
  parent: 'workspaceLayout',
20
31
  impl: '@shipfox/client-projects/routes/home',
21
32
  },
22
33
  {
23
- path: '/workspaces/$wid/projects/new',
34
+ path: '/w/$workspaceSlug/projects/new',
24
35
  parent: 'workspaceLayout',
25
36
  impl: '@shipfox/client-projects/routes/create-project',
26
37
  },
27
38
  {
28
- path: '/workspaces/$wid/projects/$pid',
39
+ path: '/w/$workspaceSlug/p/$projectSlug',
29
40
  parent: 'projectLayout',
30
41
  impl: '@shipfox/client-projects/routes/project-index',
31
42
  },
43
+ {
44
+ path: '/w/$workspaceSlug/p/$projectSlug/settings',
45
+ parent: 'projectSettings',
46
+ impl: '@shipfox/client-projects/routes/project-settings-index',
47
+ },
48
+ {
49
+ path: '/w/$workspaceSlug/p/$projectSlug/settings/general',
50
+ parent: 'projectSettings',
51
+ impl: '@shipfox/client-projects/routes/project-settings',
52
+ },
32
53
  ],
33
54
  navigation: projectsNavigation,
55
+ settingsSections: projectSettingsSections,
34
56
  });
@@ -0,0 +1,19 @@
1
+ import {toDefinitionSyncSummary} from './mappers.js';
2
+
3
+ describe('toDefinitionSyncSummary', () => {
4
+ it('preserves warnings without manufacturing a missing path', () => {
5
+ const summary = toDefinitionSyncSummary({
6
+ ref: 'main',
7
+ status: 'succeeded',
8
+ last_sync_at: '2026-05-07T01:00:00.000Z',
9
+ started_at: '2026-05-07T00:59:55.000Z',
10
+ finished_at: '2026-05-07T01:00:00.000Z',
11
+ last_error_code: null,
12
+ last_error_message: null,
13
+ warnings: [{code: 'warning-code', message: 'Warning without a path'}],
14
+ });
15
+
16
+ expect(summary.warnings).toEqual([{code: 'warning-code', message: 'Warning without a path'}]);
17
+ expect(summary.warnings[0]).not.toHaveProperty('path');
18
+ });
19
+ });
@@ -8,6 +8,7 @@ export function toProject(dto: ProjectResponseDto): Project {
8
8
  id: dto.id,
9
9
  workspaceId: dto.workspace_id,
10
10
  name: dto.name,
11
+ slug: dto.slug,
11
12
  source: {
12
13
  connectionId: dto.source.connection_id,
13
14
  externalRepositoryId: dto.source.external_repository_id,
@@ -51,6 +52,11 @@ export function toDefinitionSyncSummary(dto: DefinitionSyncSummaryDto): Definiti
51
52
  finishedAt: dto.finished_at,
52
53
  lastErrorCode: dto.last_error_code,
53
54
  lastErrorMessage: dto.last_error_message,
55
+ warnings: dto.warnings.map((warning) => ({
56
+ code: warning.code,
57
+ message: warning.message,
58
+ ...(warning.path === undefined ? {} : {path: warning.path}),
59
+ })),
54
60
  };
55
61
  }
56
62
 
@@ -1,5 +1,15 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
- import {createProject, listProjects} from './projects.js';
2
+ import {QueryClient} from '@tanstack/react-query';
3
+ import {
4
+ createProject,
5
+ isProjectSlugAvailable,
6
+ listProjects,
7
+ projectSlugQueryOptions,
8
+ projectsInfiniteQueryOptions,
9
+ projectsQueryKeys,
10
+ resolveProjectSlug,
11
+ updateProject,
12
+ } from './projects.js';
3
13
 
4
14
  function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
5
15
  return new Response(JSON.stringify(body), {
@@ -49,6 +59,7 @@ describe('createProject', () => {
49
59
  id: '44444444-4444-4444-8444-444444444444',
50
60
  workspace_id: '11111111-1111-4111-8111-111111111111',
51
61
  name: 'Platform',
62
+ slug: 'platform',
52
63
  source: {
53
64
  connection_id: '33333333-3333-4333-8333-333333333333',
54
65
  external_repository_id: 'platform',
@@ -61,6 +72,7 @@ describe('createProject', () => {
61
72
  const body = {
62
73
  workspaceId: '11111111-1111-4111-8111-111111111111',
63
74
  name: 'Platform',
75
+ slug: 'platform',
64
76
  source: {
65
77
  connectionId: '33333333-3333-4333-8333-333333333333',
66
78
  externalRepositoryId: 'platform',
@@ -71,11 +83,13 @@ describe('createProject', () => {
71
83
 
72
84
  const request = fetchImpl.mock.calls[0]?.[0] as Request;
73
85
  expect(result.name).toBe('Platform');
86
+ expect(result.slug).toBe('platform');
74
87
  expect(request.url).toBe('https://api.example.test/projects');
75
88
  expect(request.method).toBe('POST');
76
89
  expect(requestBody).toEqual({
77
90
  workspace_id: body.workspaceId,
78
91
  name: body.name,
92
+ slug: body.slug,
79
93
  source: {
80
94
  connection_id: body.source.connectionId,
81
95
  external_repository_id: body.source.externalRepositoryId,
@@ -83,3 +97,235 @@ describe('createProject', () => {
83
97
  });
84
98
  });
85
99
  });
100
+
101
+ describe('updateProject', () => {
102
+ beforeEach(() => {
103
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
104
+ });
105
+
106
+ test('patches the project body', async () => {
107
+ let requestBody: unknown;
108
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
109
+ requestBody = await (input as Request).clone().json();
110
+ return jsonResponse(projectResponse('renamed-project'));
111
+ });
112
+ configureApiClient({fetchImpl});
113
+
114
+ const result = await updateProject({
115
+ projectId: '33333333-3333-4333-8333-333333333333',
116
+ name: 'Renamed project',
117
+ slug: 'renamed-project',
118
+ });
119
+
120
+ const request = fetchImpl.mock.calls[0]?.[0] as Request;
121
+ expect(result.slug).toBe('renamed-project');
122
+ expect(request.url).toBe(
123
+ 'https://api.example.test/projects/33333333-3333-4333-8333-333333333333',
124
+ );
125
+ expect(request.method).toBe('PATCH');
126
+ expect(requestBody).toEqual({name: 'Renamed project', slug: 'renamed-project'});
127
+ });
128
+ });
129
+
130
+ describe('isProjectSlugAvailable', () => {
131
+ test('uses cached project lists without making a request', () => {
132
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
133
+ const queryClient = new QueryClient();
134
+ queryClient.setQueryData(projectsQueryKeys.list(workspaceId, 'taken'), {
135
+ pages: [
136
+ {
137
+ projects: [projectResponse('taken-project')],
138
+ nextCursor: null,
139
+ },
140
+ ],
141
+ pageParams: [undefined],
142
+ });
143
+
144
+ expect(isProjectSlugAvailable({queryClient, workspaceId, projectSlug: 'taken-project'})).toBe(
145
+ false,
146
+ );
147
+ expect(
148
+ isProjectSlugAvailable({
149
+ queryClient,
150
+ workspaceId,
151
+ projectSlug: 'taken-project',
152
+ currentProjectId: '33333333-3333-4333-8333-333333333333',
153
+ }),
154
+ ).toBe(true);
155
+ expect(isProjectSlugAvailable({queryClient, workspaceId, projectSlug: 'new-project'})).toBe(
156
+ true,
157
+ );
158
+ });
159
+ });
160
+
161
+ describe('resolveProjectSlug', () => {
162
+ beforeEach(() => {
163
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
164
+ });
165
+
166
+ test('refreshes the cached list once after an exhausted slug search', async () => {
167
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
168
+ const projectId = '44444444-4444-4444-8444-444444444444';
169
+ const fetchImpl = vi
170
+ .fn()
171
+ .mockResolvedValueOnce(
172
+ jsonResponse({projects: [projectResponse('old-project')], next_cursor: null}),
173
+ )
174
+ .mockResolvedValueOnce(
175
+ jsonResponse({projects: [projectResponse('new-project', projectId)], next_cursor: null}),
176
+ );
177
+ configureApiClient({fetchImpl});
178
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
179
+
180
+ await queryClient.fetchInfiniteQuery(projectsInfiniteQueryOptions(workspaceId));
181
+
182
+ await expect(
183
+ resolveProjectSlug({queryClient, workspaceId, projectSlug: 'new-project'}),
184
+ ).resolves.toBe(projectId);
185
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
186
+ });
187
+
188
+ test('revalidates a cached slug match before returning it', async () => {
189
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
190
+ const projectId = '44444444-4444-4444-8444-444444444444';
191
+ const fetchImpl = vi
192
+ .fn()
193
+ .mockResolvedValueOnce(
194
+ jsonResponse({projects: [projectResponse('checkout-api', projectId)], next_cursor: null}),
195
+ )
196
+ .mockResolvedValueOnce(
197
+ jsonResponse({projects: [projectResponse('renamed-api', projectId)], next_cursor: null}),
198
+ );
199
+ configureApiClient({fetchImpl});
200
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
201
+
202
+ await queryClient.fetchInfiniteQuery(projectsInfiniteQueryOptions(workspaceId));
203
+ queryClient.setQueryData(
204
+ projectsQueryKeys.list(workspaceId),
205
+ queryClient.getQueryData(projectsQueryKeys.list(workspaceId)),
206
+ {updatedAt: 0},
207
+ );
208
+
209
+ await expect(
210
+ resolveProjectSlug({queryClient, workspaceId, projectSlug: 'checkout-api'}),
211
+ ).resolves.toBeUndefined();
212
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
213
+ });
214
+
215
+ test('uses a fresh cached slug and hydrates the detail query', async () => {
216
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
217
+ const projectId = '44444444-4444-4444-8444-444444444444';
218
+ const fetchImpl = vi
219
+ .fn()
220
+ .mockResolvedValueOnce(
221
+ jsonResponse({projects: [projectResponse('checkout-api', projectId)], next_cursor: null}),
222
+ );
223
+ configureApiClient({fetchImpl});
224
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
225
+
226
+ await queryClient.fetchInfiniteQuery(projectsInfiniteQueryOptions(workspaceId));
227
+
228
+ await expect(
229
+ resolveProjectSlug({queryClient, workspaceId, projectSlug: 'checkout-api'}),
230
+ ).resolves.toBe(projectId);
231
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
232
+ expect(queryClient.getQueryData(projectsQueryKeys.detail(projectId))).toMatchObject({
233
+ id: projectId,
234
+ slug: 'checkout-api',
235
+ });
236
+ });
237
+
238
+ test('paginates from the refreshed first page after a cached miss', async () => {
239
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
240
+ const projectId = '44444444-4444-4444-8444-444444444444';
241
+ const fetchImpl = vi
242
+ .fn()
243
+ .mockResolvedValueOnce(
244
+ jsonResponse({
245
+ projects: [projectResponse('new-project', '55555555-5555-4555-8555-555555555555')],
246
+ next_cursor: 'cursor-1',
247
+ }),
248
+ )
249
+ .mockResolvedValueOnce(
250
+ jsonResponse({projects: [projectResponse('first-project')], next_cursor: 'cursor-1'}),
251
+ )
252
+ .mockResolvedValueOnce(
253
+ jsonResponse({projects: [projectResponse('new-project', projectId)], next_cursor: null}),
254
+ );
255
+ configureApiClient({fetchImpl});
256
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
257
+
258
+ await queryClient.fetchInfiniteQuery(projectsInfiniteQueryOptions(workspaceId));
259
+ queryClient.setQueryData(
260
+ projectsQueryKeys.list(workspaceId),
261
+ queryClient.getQueryData(projectsQueryKeys.list(workspaceId)),
262
+ {updatedAt: 0},
263
+ );
264
+
265
+ await expect(
266
+ resolveProjectSlug({queryClient, workspaceId, projectSlug: 'new-project'}),
267
+ ).resolves.toBe(projectId);
268
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
269
+ });
270
+
271
+ test('paginates slug search results until it finds the exact slug', async () => {
272
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
273
+ const projectId = '44444444-4444-4444-8444-444444444444';
274
+ const fetchImpl = vi
275
+ .fn()
276
+ .mockResolvedValueOnce(
277
+ jsonResponse({projects: [projectResponse('old-project')], next_cursor: null}),
278
+ )
279
+ .mockResolvedValueOnce(
280
+ jsonResponse({projects: [projectResponse('still-old')], next_cursor: 'cursor-1'}),
281
+ )
282
+ .mockResolvedValueOnce(
283
+ jsonResponse({projects: [projectResponse('matching-name')], next_cursor: 'cursor-1'}),
284
+ )
285
+ .mockResolvedValueOnce(
286
+ jsonResponse({projects: [projectResponse('target-project', projectId)], next_cursor: null}),
287
+ );
288
+ configureApiClient({fetchImpl});
289
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
290
+
291
+ await queryClient.fetchInfiniteQuery(projectsInfiniteQueryOptions(workspaceId));
292
+
293
+ await expect(
294
+ resolveProjectSlug({queryClient, workspaceId, projectSlug: 'target-project'}),
295
+ ).resolves.toBe(projectId);
296
+ expect(fetchImpl).toHaveBeenCalledTimes(4);
297
+ });
298
+ });
299
+
300
+ describe('projectSlugQueryOptions', () => {
301
+ beforeEach(() => {
302
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
303
+ });
304
+
305
+ test('represents an unknown slug with null query data', async () => {
306
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({projects: [], next_cursor: null}));
307
+ configureApiClient({fetchImpl});
308
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
309
+
310
+ await expect(
311
+ queryClient.fetchQuery(
312
+ projectSlugQueryOptions('11111111-1111-4111-8111-111111111111', 'missing-project'),
313
+ ),
314
+ ).resolves.toBeNull();
315
+ });
316
+ });
317
+
318
+ function projectResponse(slug: string, id = '33333333-3333-4333-8333-333333333333') {
319
+ return {
320
+ id,
321
+ workspace_id: '11111111-1111-4111-8111-111111111111',
322
+ name: slug,
323
+ slug,
324
+ source: {
325
+ connection_id: '22222222-2222-4222-8222-222222222222',
326
+ external_repository_id: slug,
327
+ },
328
+ created_at: '2026-01-01T00:00:00.000Z',
329
+ updated_at: '2026-01-01T00:00:00.000Z',
330
+ };
331
+ }