@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +59 -0
- package/dist/chrome.d.ts +5 -1
- package/dist/chrome.d.ts.map +1 -1
- package/dist/chrome.js +18 -26
- package/dist/chrome.js.map +1 -1
- package/dist/components/model-provider-reminder-banner.d.ts.map +1 -1
- package/dist/components/model-provider-reminder-banner.js +4 -2
- package/dist/components/model-provider-reminder-banner.js.map +1 -1
- package/dist/components/project-crumb.d.ts +3 -1
- package/dist/components/project-crumb.d.ts.map +1 -1
- package/dist/components/project-crumb.js +7 -5
- package/dist/components/project-crumb.js.map +1 -1
- package/dist/components/project-switcher.d.ts +2 -1
- package/dist/components/project-switcher.d.ts.map +1 -1
- package/dist/components/project-switcher.js +10 -8
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/core/definition.d.ts +6 -0
- package/dist/core/definition.d.ts.map +1 -1
- package/dist/core/definition.js.map +1 -1
- package/dist/core/project.d.ts +7 -0
- package/dist/core/project.d.ts.map +1 -1
- package/dist/core/project.js.map +1 -1
- package/dist/feature.d.ts +29 -5
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +26 -5
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/mappers.d.ts.map +1 -1
- package/dist/hooks/api/mappers.js +9 -1
- package/dist/hooks/api/mappers.js.map +1 -1
- package/dist/hooks/api/projects.d.ts +21 -2
- package/dist/hooks/api/projects.d.ts.map +1 -1
- package/dist/hooks/api/projects.js +175 -2
- package/dist/hooks/api/projects.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/create-project-page.d.ts.map +1 -1
- package/dist/pages/create-project-page.js +97 -21
- package/dist/pages/create-project-page.js.map +1 -1
- package/dist/pages/create-project-page.stories.js +5 -3
- package/dist/pages/create-project-page.stories.js.map +1 -1
- package/dist/pages/project-settings-form-errors.d.ts +11 -0
- package/dist/pages/project-settings-form-errors.d.ts.map +1 -0
- package/dist/pages/project-settings-form-errors.js +16 -0
- package/dist/pages/project-settings-form-errors.js.map +1 -0
- package/dist/pages/project-settings-page.d.ts +2 -0
- package/dist/pages/project-settings-page.d.ts.map +1 -0
- package/dist/pages/project-settings-page.js +225 -0
- package/dist/pages/project-settings-page.js.map +1 -0
- package/dist/pages/project-settings-page.stories.d.ts +23 -0
- package/dist/pages/project-settings-page.stories.d.ts.map +1 -0
- package/dist/pages/project-settings-page.stories.js +206 -0
- package/dist/pages/project-settings-page.stories.js.map +1 -0
- package/dist/pages/projects-hub-page.js +11 -11
- package/dist/pages/projects-hub-page.js.map +1 -1
- package/dist/project-error.d.ts +1 -0
- package/dist/project-error.d.ts.map +1 -1
- package/dist/project-error.js +7 -0
- package/dist/project-error.js.map +1 -1
- package/dist/routes/inputs.d.ts +2 -2
- package/dist/routes/inputs.d.ts.map +1 -1
- package/dist/routes/inputs.js +5 -5
- package/dist/routes/inputs.js.map +1 -1
- package/dist/routes/project-index.d.ts +2 -2
- package/dist/routes/project-index.d.ts.map +1 -1
- package/dist/routes/project-index.js +3 -3
- package/dist/routes/project-index.js.map +1 -1
- package/dist/routes/project-settings-index.d.ts +10 -0
- package/dist/routes/project-settings-index.d.ts.map +1 -0
- package/dist/routes/project-settings-index.js +12 -0
- package/dist/routes/project-settings-index.js.map +1 -0
- package/dist/routes/project-settings.d.ts +6 -0
- package/dist/routes/project-settings.d.ts.map +1 -0
- package/dist/routes/project-settings.js +7 -0
- package/dist/routes/project-settings.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +11 -10
- package/src/chrome.tsx +27 -19
- package/src/components/model-provider-reminder-banner.tsx +3 -1
- package/src/components/project-crumb.tsx +18 -5
- package/src/components/project-switcher.test.tsx +7 -4
- package/src/components/project-switcher.tsx +16 -5
- package/src/core/definition.ts +7 -0
- package/src/core/project.ts +8 -0
- package/src/feature.ts +26 -4
- package/src/hooks/api/mappers.test.ts +19 -0
- package/src/hooks/api/mappers.ts +6 -0
- package/src/hooks/api/projects.test.ts +247 -1
- package/src/hooks/api/projects.ts +227 -2
- package/src/index.ts +9 -1
- package/src/pages/create-project-page.stories.tsx +5 -4
- package/src/pages/create-project-page.test.tsx +191 -12
- package/src/pages/create-project-page.tsx +102 -15
- package/src/pages/home-router.test.tsx +2 -2
- package/src/pages/project-settings-form-errors.ts +17 -0
- package/src/pages/project-settings-page.stories.tsx +216 -0
- package/src/pages/project-settings-page.test.tsx +74 -0
- package/src/pages/project-settings-page.tsx +220 -0
- package/src/pages/projects-hub-page.test.tsx +17 -11
- package/src/pages/projects-hub-page.tsx +9 -9
- package/src/project-error.test.ts +1 -0
- package/src/project-error.ts +8 -0
- package/src/routes/inputs.test.ts +8 -6
- package/src/routes/inputs.ts +7 -6
- package/src/routes/project-index.tsx +3 -3
- package/src/routes/project-settings-index.tsx +11 -0
- package/src/routes/project-settings.tsx +4 -0
- package/test/pages.tsx +26 -22
- 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 {
|
|
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 = `/
|
|
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(`/
|
|
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(`/
|
|
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
|
-
`/
|
|
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(`/
|
|
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(`/
|
|
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(`/
|
|
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(`/
|
|
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(`/
|
|
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(`/
|
|
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="/
|
|
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
|
|
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
|
-
|
|
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({
|
|
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="/
|
|
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
|
-
|
|
192
|
+
workspaceSlug,
|
|
193
193
|
}: {
|
|
194
194
|
project: Project;
|
|
195
195
|
connection: IntegrationConnection | undefined;
|
|
196
196
|
connectionsResolved: boolean;
|
|
197
197
|
connectionsSettled: boolean;
|
|
198
|
-
|
|
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="/
|
|
209
|
-
params={{
|
|
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
|
|