@shipfox/client-projects 12.0.2 → 14.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 +64 -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
@@ -0,0 +1,216 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {type AuthState, authStateAtom} from '@shipfox/client-auth';
3
+ import {Toaster} from '@shipfox/react-ui/toast';
4
+ import type {Meta, StoryObj} from '@storybook/react';
5
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
6
+ import {
7
+ createMemoryHistory,
8
+ createRootRoute,
9
+ createRoute,
10
+ createRouter,
11
+ Outlet,
12
+ RouterProvider,
13
+ } from '@tanstack/react-router';
14
+ import {createStore, Provider as JotaiProvider} from 'jotai';
15
+ import {useMemo} from 'react';
16
+ import {expect, screen, userEvent, within} from 'storybook/test';
17
+ import {ProjectSettingsPage} from './project-settings-page.js';
18
+
19
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
20
+ const PROJECT_ID = '99999999-9999-4999-8999-999999999999';
21
+ const OTHER_PROJECT_ID = '88888888-8888-4888-8888-888888888888';
22
+
23
+ type Scenario = 'default' | 'long-values' | 'taken-slug';
24
+
25
+ interface ProjectStoryResponse {
26
+ id: string;
27
+ workspace_id: string;
28
+ name: string;
29
+ slug: string;
30
+ source: {
31
+ connection_id: string;
32
+ external_repository_id: string;
33
+ };
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+
38
+ interface ProjectSettingsPageStoryProps {
39
+ scenario: Scenario;
40
+ }
41
+
42
+ const authState: AuthState = {
43
+ status: 'authenticated',
44
+ token: 'token',
45
+ user: {
46
+ id: '22222222-2222-4222-8222-222222222222',
47
+ email: 'platform@example.com',
48
+ name: 'Platform Engineer',
49
+ emailVerifiedAt: '2026-01-01T00:00:00.000Z',
50
+ },
51
+ workspaces: [{id: WORKSPACE_ID, name: 'Acme', slug: 'acme', membershipId: 'membership-1'}],
52
+ };
53
+
54
+ function ProjectSettingsPageStory({scenario}: ProjectSettingsPageStoryProps) {
55
+ configureApiClient({
56
+ baseUrl: 'https://api.example.test',
57
+ fetchImpl: fetchForScenario(scenario),
58
+ });
59
+
60
+ const queryClient = useMemo(
61
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
62
+ [],
63
+ );
64
+ const store = useMemo(() => {
65
+ const nextStore = createStore();
66
+ nextStore.set(authStateAtom, authState);
67
+ return nextStore;
68
+ }, []);
69
+ const router = useMemo(() => createStoryRouter(scenario), [scenario]);
70
+
71
+ return (
72
+ <QueryClientProvider client={queryClient}>
73
+ <JotaiProvider store={store}>
74
+ <div className="min-h-screen bg-background-neutral-background px-24 py-32">
75
+ <div className="mx-auto w-full max-w-[1120px]">
76
+ <RouterProvider router={router} />
77
+ </div>
78
+ </div>
79
+ <Toaster />
80
+ </JotaiProvider>
81
+ </QueryClientProvider>
82
+ );
83
+ }
84
+
85
+ const meta = {
86
+ title: 'Projects/ProjectSettingsPage',
87
+ component: ProjectSettingsPageStory,
88
+ parameters: {layout: 'fullscreen'},
89
+ args: {scenario: 'default'},
90
+ } satisfies Meta<typeof ProjectSettingsPageStory>;
91
+
92
+ export default meta;
93
+ type Story = StoryObj<typeof meta>;
94
+
95
+ export const Playground: Story = {};
96
+
97
+ export const LongValues: Story = {
98
+ args: {scenario: 'long-values'},
99
+ };
100
+
101
+ export const TakenSlug: Story = {
102
+ args: {scenario: 'taken-slug'},
103
+ play: async ({canvasElement}) => {
104
+ const canvas = within(canvasElement);
105
+ const slugInput = await canvas.findByLabelText('Project slug');
106
+ await userEvent.clear(slugInput);
107
+ await userEvent.type(slugInput, 'taken-platform');
108
+
109
+ expect(await canvas.findByText('This slug is already taken.')).toBeInTheDocument();
110
+ },
111
+ };
112
+
113
+ export const SlugChangeWarning: Story = {
114
+ play: async ({canvasElement}) => {
115
+ const canvas = within(canvasElement);
116
+ const slugInput = await canvas.findByLabelText('Project slug');
117
+ await userEvent.clear(slugInput);
118
+ await userEvent.type(slugInput, 'renamed-platform');
119
+ await canvas.findByText('Slug is available.');
120
+ await userEvent.click(canvas.getByRole('button', {name: 'Save changes'}));
121
+
122
+ const dialog = await screen.findByRole('dialog');
123
+ expect(dialog).toHaveTextContent('Change project slug?');
124
+ expect(dialog).toHaveTextContent('Links and bookmarks pointing at the old URL stop working.');
125
+ expect(dialog).toHaveTextContent(
126
+ 'Workflows that reference this project slug may stop working.',
127
+ );
128
+ },
129
+ };
130
+
131
+ function createStoryRouter(scenario: Scenario) {
132
+ const project = projectForScenario(scenario);
133
+ const rootRoute = createRootRoute({component: Outlet});
134
+ const projectSettingsRoute = createRoute({
135
+ getParentRoute: () => rootRoute,
136
+ path: '/w/$workspaceSlug/p/$projectSlug/settings/general',
137
+ component: ProjectSettingsPage,
138
+ });
139
+
140
+ return createRouter({
141
+ history: createMemoryHistory({
142
+ initialEntries: [`/w/acme/p/${project.slug}/settings/general`],
143
+ }),
144
+ routeTree: rootRoute.addChildren([projectSettingsRoute]),
145
+ });
146
+ }
147
+
148
+ function fetchForScenario(scenario: Scenario): typeof fetch {
149
+ return async (input, init) => {
150
+ const url = requestUrl(input);
151
+ const method = input instanceof Request ? input.method : (init?.method ?? 'GET');
152
+
153
+ if (url.pathname === '/projects' && method === 'GET') {
154
+ const search = url.searchParams.get('search');
155
+ if (scenario === 'taken-slug' && search === 'taken-platform') {
156
+ return jsonResponse({
157
+ projects: [projectForScenario(scenario, {id: OTHER_PROJECT_ID, slug: 'taken-platform'})],
158
+ next_cursor: null,
159
+ });
160
+ }
161
+ return jsonResponse({projects: [projectForScenario(scenario)], next_cursor: null});
162
+ }
163
+
164
+ if (url.pathname === `/projects/${PROJECT_ID}` && method === 'PATCH') {
165
+ const body = (await (input as Request).clone().json()) as {
166
+ name?: string;
167
+ slug?: string;
168
+ };
169
+ const project = projectForScenario(scenario);
170
+ return jsonResponse({
171
+ ...project,
172
+ name: body.name ?? project.name,
173
+ slug: body.slug ?? project.slug,
174
+ });
175
+ }
176
+
177
+ return jsonResponse({}, {status: 404});
178
+ };
179
+ }
180
+
181
+ function projectForScenario(
182
+ scenario: Scenario,
183
+ overrides: Partial<ProjectStoryResponse> = {},
184
+ ): ProjectStoryResponse {
185
+ const project = {
186
+ id: PROJECT_ID,
187
+ workspace_id: WORKSPACE_ID,
188
+ name:
189
+ scenario === 'long-values'
190
+ ? 'Infrastructure Control Plane and Workflow Automation'
191
+ : 'Platform',
192
+ slug: scenario === 'long-values' ? 'infrastructure-control-plane' : 'platform',
193
+ source: {
194
+ connection_id: '33333333-3333-4333-8333-333333333333',
195
+ external_repository_id: 'platform',
196
+ },
197
+ created_at: '2026-01-01T00:00:00.000Z',
198
+ updated_at: '2026-01-01T00:00:00.000Z',
199
+ ...overrides,
200
+ };
201
+ return project;
202
+ }
203
+
204
+ function requestUrl(input: RequestInfo | URL): URL {
205
+ if (input instanceof Request) return new URL(input.url);
206
+ if (input instanceof URL) return input;
207
+ return new URL(input, 'https://api.example.test');
208
+ }
209
+
210
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
211
+ return new Response(JSON.stringify(body), {
212
+ status: 200,
213
+ headers: {'content-type': 'application/json'},
214
+ ...init,
215
+ });
216
+ }
@@ -0,0 +1,74 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {fireEvent, screen, waitFor} from '@testing-library/react';
3
+ import {jsonResponse, renderProjectPage} from '#test/pages.js';
4
+ import {ProjectSettingsPage} from './project-settings-page.js';
5
+
6
+ describe('ProjectSettingsPage', () => {
7
+ test('saves a name change without opening the slug warning', async () => {
8
+ let patchBody: unknown;
9
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
10
+ const request = input as Request;
11
+ if (request.method === 'PATCH') {
12
+ patchBody = await request.clone().json();
13
+ return jsonResponse(projectDto({name: 'Platform API'}));
14
+ }
15
+ return jsonResponse({projects: [projectDto()], next_cursor: null});
16
+ });
17
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
18
+
19
+ renderProjectPage('/w/acme/p/platform/settings/general', <ProjectSettingsPage />);
20
+ fireEvent.change(await screen.findByLabelText('Project name'), {
21
+ target: {value: 'Platform API'},
22
+ });
23
+ fireEvent.click(screen.getByRole('button', {name: 'Save changes'}));
24
+
25
+ await waitFor(() => expect(patchBody).toEqual({name: 'Platform API'}));
26
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
27
+ });
28
+
29
+ test('requires confirmation before saving a slug change', async () => {
30
+ let patchBody: unknown;
31
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
32
+ const request = input as Request;
33
+ if (request.method === 'PATCH') {
34
+ patchBody = await request.clone().json();
35
+ return jsonResponse(projectDto({slug: 'platform-api'}));
36
+ }
37
+ return jsonResponse({projects: [projectDto()], next_cursor: null});
38
+ });
39
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
40
+
41
+ renderProjectPage('/w/acme/p/platform/settings/general', <ProjectSettingsPage />);
42
+ fireEvent.change(await screen.findByLabelText('Project slug'), {
43
+ target: {value: 'platform-api'},
44
+ });
45
+ fireEvent.click(screen.getByRole('button', {name: 'Save changes'}));
46
+
47
+ const dialog = await screen.findByRole('dialog');
48
+ expect(dialog).toHaveTextContent('old URL stop working');
49
+ expect(
50
+ fetchImpl.mock.calls.some(([input]) =>
51
+ (input as Request).url.includes('/projects/slug-availability'),
52
+ ),
53
+ ).toBe(false);
54
+ expect(patchBody).toBeUndefined();
55
+
56
+ fireEvent.click(screen.getByRole('button', {name: 'Change slug'}));
57
+ await waitFor(() => expect(patchBody).toEqual({slug: 'platform-api'}));
58
+ });
59
+ });
60
+
61
+ function projectDto({name = 'Platform', slug = 'platform'}: {name?: string; slug?: string} = {}) {
62
+ return {
63
+ id: '44444444-4444-4444-8444-444444444444',
64
+ workspace_id: '11111111-1111-4111-8111-111111111111',
65
+ name,
66
+ slug,
67
+ source: {
68
+ connection_id: '33333333-3333-4333-8333-333333333333',
69
+ external_repository_id: 'platform',
70
+ },
71
+ created_at: '2026-05-07T01:00:00.000Z',
72
+ updated_at: '2026-05-07T01:00:00.000Z',
73
+ };
74
+ }
@@ -0,0 +1,220 @@
1
+ import {slugSchema} from '@shipfox/api-common-dto';
2
+ import {createProjectBodySchema} from '@shipfox/api-projects-dto';
3
+ import {ApiError} from '@shipfox/client-api';
4
+ import {useActiveWorkspace} from '@shipfox/client-auth';
5
+ import {
6
+ displayNameFieldError,
7
+ QueryLoadError,
8
+ SlugChangeWarning,
9
+ SlugField,
10
+ } from '@shipfox/client-ui';
11
+ import {Button} from '@shipfox/react-ui/button';
12
+ import {Callout} from '@shipfox/react-ui/callout';
13
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
14
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
15
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
16
+ import {toast} from '@shipfox/react-ui/toast';
17
+ import {Header, Text} from '@shipfox/react-ui/typography';
18
+ import {useForm} from '@tanstack/react-form';
19
+ import {useNavigate} from '@tanstack/react-router';
20
+ import {useState} from 'react';
21
+ import {useMaybeActiveProjectQuery} from '#chrome.js';
22
+ import type {Project} from '#core/project.js';
23
+ import {useProjectSlugAvailability, useUpdateProjectMutation} from '#hooks/api/projects.js';
24
+ import {projectSettingsErrorToFormError} from './project-settings-form-errors.js';
25
+
26
+ interface ProjectSettingsValues {
27
+ name: string;
28
+ slug: string;
29
+ }
30
+
31
+ function isSlugValid(value: string): boolean {
32
+ return slugSchema.safeParse(value).success;
33
+ }
34
+
35
+ export function ProjectSettingsPage() {
36
+ const projectQuery = useMaybeActiveProjectQuery();
37
+ if (projectQuery.isPending) return <FullPageLoader />;
38
+ if (projectQuery.isError && projectQuery.data === undefined) {
39
+ if (projectQuery.error instanceof ApiError && projectQuery.error.status === 404) {
40
+ return (
41
+ <EmptyState
42
+ icon="errorWarningLine"
43
+ tone="error"
44
+ title="Project not found"
45
+ description="This project doesn't exist, or you don't have access to it."
46
+ />
47
+ );
48
+ }
49
+ return <QueryLoadError query={projectQuery} subject="project" />;
50
+ }
51
+ const project = projectQuery.data;
52
+ if (!project) {
53
+ return (
54
+ <EmptyState
55
+ icon="errorWarningLine"
56
+ tone="error"
57
+ title="Project not found"
58
+ description="This project doesn't exist, or you don't have access to it."
59
+ />
60
+ );
61
+ }
62
+ return (
63
+ <ProjectSettingsForm key={`${project.id}:${project.name}:${project.slug}`} project={project} />
64
+ );
65
+ }
66
+
67
+ function ProjectSettingsForm({project}: {project: Project}) {
68
+ const workspace = useActiveWorkspace();
69
+ const navigate = useNavigate();
70
+ const updateProject = useUpdateProjectMutation();
71
+ const [warningOpen, setWarningOpen] = useState(false);
72
+ const [pendingValues, setPendingValues] = useState<ProjectSettingsValues>();
73
+ const [formError, setFormError] = useState<string>();
74
+
75
+ const checkProjectSlugAvailability = useProjectSlugAvailability(workspace.id, project.id);
76
+
77
+ const form = useForm({
78
+ defaultValues: {name: project.name, slug: project.slug},
79
+ onSubmit: async ({value}) => {
80
+ if (value.slug !== project.slug) {
81
+ setPendingValues(value);
82
+ setWarningOpen(true);
83
+ return;
84
+ }
85
+ await save(value);
86
+ },
87
+ });
88
+
89
+ async function save(values: ProjectSettingsValues) {
90
+ setFormError(undefined);
91
+ const nameChanged = values.name !== project.name;
92
+ const slugChanged = values.slug !== project.slug;
93
+ if (!nameChanged && !slugChanged) return;
94
+ const command = {
95
+ projectId: project.id,
96
+ ...(nameChanged ? {name: values.name} : {}),
97
+ ...(slugChanged ? {slug: values.slug} : {}),
98
+ };
99
+
100
+ try {
101
+ const updated = await updateProject.mutateAsync(command);
102
+ setPendingValues(undefined);
103
+ setWarningOpen(false);
104
+ toast.success('Project settings saved.');
105
+ await navigate({
106
+ to: '/w/$workspaceSlug/p/$projectSlug/settings/general',
107
+ params: {workspaceSlug: workspace.slug, projectSlug: updated.slug},
108
+ });
109
+ } catch (error) {
110
+ setPendingValues(undefined);
111
+ setWarningOpen(false);
112
+ const mapped = projectSettingsErrorToFormError(error);
113
+ if (mapped.kind === 'field') {
114
+ form.setFieldMeta(mapped.field, (previous) => ({
115
+ ...previous,
116
+ errorMap: {...previous.errorMap, onServer: mapped.message},
117
+ }));
118
+ } else {
119
+ setFormError(mapped.message);
120
+ }
121
+ }
122
+ }
123
+
124
+ return (
125
+ <>
126
+ <div className="flex min-w-0 flex-col gap-24">
127
+ <header className="flex flex-col gap-6">
128
+ <Header variant="h1">General</Header>
129
+ <Text size="sm" className="text-foreground-neutral-muted">
130
+ Update the project name and the slug used in its URLs.
131
+ </Text>
132
+ </header>
133
+
134
+ {formError ? (
135
+ <Callout role="alert" type="error">
136
+ {formError}
137
+ </Callout>
138
+ ) : null}
139
+
140
+ <form
141
+ className="flex max-w-[560px] flex-col gap-16"
142
+ noValidate
143
+ onSubmit={(event) => {
144
+ event.preventDefault();
145
+ event.stopPropagation();
146
+ void form.handleSubmit();
147
+ }}
148
+ >
149
+ <form.Field
150
+ name="name"
151
+ validators={{
152
+ onBlur: ({value}) =>
153
+ displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),
154
+ onSubmit: ({value}) =>
155
+ displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),
156
+ }}
157
+ >
158
+ {(field) => (
159
+ <FormField label="Project name" id="project-settings-name" error={fieldError(field)}>
160
+ <FormFieldInput
161
+ name="name"
162
+ type="text"
163
+ value={field.state.value}
164
+ onChange={(event) => field.handleChange(event.target.value)}
165
+ onBlur={field.handleBlur}
166
+ />
167
+ </FormField>
168
+ )}
169
+ </form.Field>
170
+
171
+ <form.Field
172
+ name="slug"
173
+ validators={{
174
+ onBlur: createProjectBodySchema.shape.slug,
175
+ onSubmit: createProjectBodySchema.shape.slug,
176
+ }}
177
+ >
178
+ {(field) => (
179
+ <SlugField
180
+ id="project-settings-slug"
181
+ label="Project slug"
182
+ name="slug"
183
+ value={field.state.value}
184
+ onChange={(value) => field.handleChange(value)}
185
+ onBlur={field.handleBlur}
186
+ error={fieldError(field)}
187
+ description={
188
+ <span className="break-all font-code">
189
+ /w/{workspace.slug}/p/{field.state.value}
190
+ </span>
191
+ }
192
+ placeholder="platform"
193
+ className="font-code"
194
+ currentSlug={project.slug}
195
+ checkEnabled
196
+ debounceMs={0}
197
+ isValid={isSlugValid}
198
+ checkAvailability={checkProjectSlugAvailability}
199
+ />
200
+ )}
201
+ </form.Field>
202
+
203
+ <Button type="submit" isLoading={updateProject.isPending} className="self-start">
204
+ Save changes
205
+ </Button>
206
+ </form>
207
+ </div>
208
+
209
+ <SlugChangeWarning
210
+ open={warningOpen}
211
+ onOpenChange={setWarningOpen}
212
+ entityLabel="project"
213
+ isLoading={updateProject.isPending}
214
+ onConfirm={() => {
215
+ if (pendingValues) void save(pendingValues);
216
+ }}
217
+ />
218
+ </>
219
+ );
220
+ }
@@ -1,10 +1,15 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
2
  import {fireEvent, screen, waitFor} from '@testing-library/react';
3
- import {jsonResponse, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
3
+ import {
4
+ jsonResponse,
5
+ PROJECT_TEST_WID,
6
+ PROJECT_TEST_WSLUG,
7
+ renderProjectPage,
8
+ } from '#test/pages.js';
4
9
  import {ProjectsHubPage} from './projects-hub-page.js';
5
10
 
6
11
  const NEW_PROJECT_REGEX = /New project/i;
7
- const WORKSPACE_PROJECTS_NEW_HREF = `/workspaces/${PROJECT_TEST_WID}/projects/new`;
12
+ const WORKSPACE_PROJECTS_NEW_HREF = `/w/${PROJECT_TEST_WSLUG}/projects/new`;
8
13
  const CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
9
14
 
10
15
  describe('ProjectsHubPage', () => {
@@ -20,7 +25,7 @@ describe('ProjectsHubPage', () => {
20
25
  }),
21
26
  });
22
27
 
23
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
28
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
24
29
 
25
30
  expect(await screen.findByText('Create your first project')).toBeInTheDocument();
26
31
  // The page-level "Projects" title belongs in content because the top nav owns
@@ -48,12 +53,12 @@ describe('ProjectsHubPage', () => {
48
53
  });
49
54
  configureApiClient({fetchImpl});
50
55
 
51
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
56
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
52
57
 
53
58
  expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
54
59
  expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
55
60
  'href',
56
- `/workspaces/${PROJECT_TEST_WID}/settings/agents`,
61
+ `/w/${PROJECT_TEST_WSLUG}/settings/agents`,
57
62
  );
58
63
  fireEvent.click(screen.getByRole('button', {name: 'Close'}));
59
64
 
@@ -69,7 +74,7 @@ describe('ProjectsHubPage', () => {
69
74
  });
70
75
  configureApiClient({fetchImpl});
71
76
 
72
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
77
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
73
78
 
74
79
  expect(await screen.findByText('Create your first project')).toBeInTheDocument();
75
80
  expect(screen.queryByText('Finish setting up a model provider')).not.toBeInTheDocument();
@@ -104,7 +109,7 @@ describe('ProjectsHubPage', () => {
104
109
  });
105
110
  configureApiClient({fetchImpl});
106
111
 
107
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
112
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
108
113
  expect(await screen.findByText('Platform')).toBeInTheDocument();
109
114
  const projectLink = screen.getByText('Platform').closest('a');
110
115
  // The whole card is the link, carrying the neutral focus ring (matching the
@@ -124,7 +129,7 @@ describe('ProjectsHubPage', () => {
124
129
  }),
125
130
  });
126
131
 
127
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
132
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
128
133
 
129
134
  expect(await screen.findByText("Couldn't load projects")).toBeInTheDocument();
130
135
  expect(screen.getByRole('button', {name: 'Retry loading projects'})).toBeInTheDocument();
@@ -147,7 +152,7 @@ describe('ProjectsHubPage', () => {
147
152
  }),
148
153
  });
149
154
 
150
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
155
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
151
156
 
152
157
  expect(await screen.findByText('Platform')).toBeInTheDocument();
153
158
 
@@ -182,7 +187,7 @@ describe('ProjectsHubPage', () => {
182
187
  }),
183
188
  });
184
189
 
185
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
190
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
186
191
 
187
192
  expect(await screen.findByText('Platform')).toBeInTheDocument();
188
193
  expect(await screen.findByText(label)).toBeInTheDocument();
@@ -201,7 +206,7 @@ describe('ProjectsHubPage', () => {
201
206
  }),
202
207
  });
203
208
 
204
- renderProjectPage(`/workspaces/${PROJECT_TEST_WID}`, <ProjectsHubPage />);
209
+ renderProjectPage(`/w/${PROJECT_TEST_WSLUG}`, <ProjectsHubPage />);
205
210
 
206
211
  expect(await screen.findByText('Platform')).toBeInTheDocument();
207
212
 
@@ -267,6 +272,7 @@ function projectDto({
267
272
  id,
268
273
  workspace_id: PROJECT_TEST_WID,
269
274
  name,
275
+ slug: name.toLowerCase(),
270
276
  source: {
271
277
  connection_id: connectionId,
272
278
  external_repository_id: externalRepositoryId,
@@ -69,7 +69,7 @@ export function ProjectsHubPage({search = ''}: {search?: string}) {
69
69
  }
70
70
  />
71
71
  <Button asChild iconLeft="addLine" className="shrink-0 max-[640px]:w-full">
72
- <Link to="/workspaces/$wid/projects/new" params={{wid: workspace.id}}>
72
+ <Link to="/w/$workspaceSlug/projects/new" params={{workspaceSlug: workspace.slug}}>
73
73
  New project
74
74
  </Link>
75
75
  </Button>
@@ -83,7 +83,7 @@ export function ProjectsHubPage({search = ''}: {search?: string}) {
83
83
  {query.isError && hasNoData ? <QueryLoadError query={query} subject="projects" /> : null}
84
84
 
85
85
  {!isInitialLoading && !query.isError && projects.length === 0 && !search ? (
86
- <EmptyProjects workspaceId={workspace.id} />
86
+ <EmptyProjects workspaceSlug={workspace.slug} />
87
87
  ) : null}
88
88
 
89
89
  {!query.isFetching && !query.isError && projects.length === 0 && search ? (
@@ -103,7 +103,7 @@ export function ProjectsHubPage({search = ''}: {search?: string}) {
103
103
  connectionsResolved={connectionsQuery.isSuccess}
104
104
  connectionsSettled={connectionsQuery.isSuccess || connectionsQuery.isError}
105
105
  key={project.id}
106
- workspaceId={workspace.id}
106
+ workspaceSlug={workspace.slug}
107
107
  />
108
108
  ))}
109
109
  </ul>
@@ -152,7 +152,7 @@ function ProjectsSkeleton() {
152
152
  );
153
153
  }
154
154
 
155
- function EmptyProjects({workspaceId}: {workspaceId: string}) {
155
+ function EmptyProjects({workspaceSlug}: {workspaceSlug: string}) {
156
156
  return (
157
157
  <EmptyState
158
158
  icon="folderLine"
@@ -160,7 +160,7 @@ function EmptyProjects({workspaceId}: {workspaceId: string}) {
160
160
  description="Connect a repository-backed project to start building workflows."
161
161
  action={
162
162
  <Button asChild iconRight="chevronRight">
163
- <Link to="/workspaces/$wid/projects/new" params={{wid: workspaceId}}>
163
+ <Link to="/w/$workspaceSlug/projects/new" params={{workspaceSlug}}>
164
164
  Create project
165
165
  </Link>
166
166
  </Button>
@@ -189,13 +189,13 @@ function ProjectCard({
189
189
  connection,
190
190
  connectionsResolved,
191
191
  connectionsSettled,
192
- workspaceId,
192
+ workspaceSlug,
193
193
  }: {
194
194
  project: Project;
195
195
  connection: IntegrationConnection | undefined;
196
196
  connectionsResolved: boolean;
197
197
  connectionsSettled: boolean;
198
- workspaceId: string;
198
+ workspaceSlug: string;
199
199
  }) {
200
200
  // On a resolved fetch, `active` carries no badge while a missing connection
201
201
  // reads as an error so a broken source is still flagged. An unresolved or
@@ -205,8 +205,8 @@ function ProjectCard({
205
205
  return (
206
206
  <li>
207
207
  <Link
208
- to="/workspaces/$wid/projects/$pid"
209
- params={{wid: workspaceId, pid: project.id}}
208
+ to="/w/$workspaceSlug/p/$projectSlug"
209
+ params={{workspaceSlug, projectSlug: project.slug}}
210
210
  className="block h-full rounded-8 focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
211
211
  >
212
212
  <Card className="h-full p-16 transition-colors hover:bg-background-components-hover">
@@ -19,6 +19,7 @@ describe('projectErrorCopy', () => {
19
19
  ['malformed-provider-response', 'Provider response changed'],
20
20
  ['source-connection-not-found', 'Source connection not found'],
21
21
  ['source-connection-inactive', 'Source connection inactive'],
22
+ ['slug-conflict', 'Project slug already exists'],
22
23
  ])('maps %s', (code, title) => {
23
24
  const result = projectErrorCopy(apiError(code));
24
25