@shipfox/client-workspace-settings 12.0.2 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/components/general/form-errors.d.ts +11 -0
  4. package/dist/components/general/form-errors.d.ts.map +1 -0
  5. package/dist/components/general/form-errors.js +16 -0
  6. package/dist/components/general/form-errors.js.map +1 -0
  7. package/dist/components/members/workspace-members-section.js +2 -2
  8. package/dist/components/members/workspace-members-section.js.map +1 -1
  9. package/dist/components/workspace-settings-shell.d.ts +3 -3
  10. package/dist/components/workspace-settings-shell.d.ts.map +1 -1
  11. package/dist/components/workspace-settings-shell.js +5 -2
  12. package/dist/components/workspace-settings-shell.js.map +1 -1
  13. package/dist/feature.d.ts +20 -4
  14. package/dist/feature.d.ts.map +1 -1
  15. package/dist/feature.js +15 -3
  16. package/dist/feature.js.map +1 -1
  17. package/dist/pages/general-settings-page.d.ts +2 -0
  18. package/dist/pages/general-settings-page.d.ts.map +1 -0
  19. package/dist/pages/general-settings-page.js +191 -0
  20. package/dist/pages/general-settings-page.js.map +1 -0
  21. package/dist/routes/general.d.ts +6 -0
  22. package/dist/routes/general.d.ts.map +1 -0
  23. package/dist/routes/general.js +7 -0
  24. package/dist/routes/general.js.map +1 -0
  25. package/dist/routes/index.d.ts +1 -1
  26. package/dist/routes/index.d.ts.map +1 -1
  27. package/dist/routes/index.js +2 -2
  28. package/dist/routes/index.js.map +1 -1
  29. package/dist/tsconfig.test.tsbuildinfo +1 -1
  30. package/package.json +6 -6
  31. package/src/components/general/form-errors.ts +17 -0
  32. package/src/components/members/workspace-members-section.tsx +2 -2
  33. package/src/components/workspace-settings-shell.tsx +5 -3
  34. package/src/feature.ts +15 -3
  35. package/src/pages/general-settings-page.test.tsx +91 -0
  36. package/src/pages/general-settings-page.tsx +198 -0
  37. package/src/routes/general.tsx +4 -0
  38. package/src/routes/index.tsx +5 -2
  39. package/test/pages.tsx +11 -5
  40. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workspace-settings",
3
3
  "license": "MIT",
4
- "version": "12.0.2",
4
+ "version": "13.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,12 +27,12 @@
27
27
  "dependencies": {
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "@tanstack/react-form": "^1.32.0",
30
+ "@shipfox/api-workspaces-dto": "12.0.0",
30
31
  "@shipfox/client-api": "6.0.1",
31
- "@shipfox/client-auth": "12.0.2",
32
- "@shipfox/api-workspaces-dto": "11.0.0",
33
- "@shipfox/client-shell": "12.0.2",
34
- "@shipfox/client-ui": "6.0.2",
35
- "@shipfox/react-ui": "0.3.7"
32
+ "@shipfox/client-auth": "13.0.0",
33
+ "@shipfox/client-shell": "13.0.0",
34
+ "@shipfox/client-ui": "13.0.0",
35
+ "@shipfox/react-ui": "0.4.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@tanstack/react-query": "^5.101.0",
@@ -0,0 +1,17 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export type WorkspaceGeneralField = 'name' | 'slug';
4
+
5
+ export type WorkspaceGeneralFormError =
6
+ | {kind: 'field'; field: WorkspaceGeneralField; message: string}
7
+ | {kind: 'form'; message: string};
8
+
9
+ export function workspaceGeneralErrorToFormError(error: unknown): WorkspaceGeneralFormError {
10
+ if (error instanceof ApiError && error.code === 'slug-conflict') {
11
+ return {kind: 'field', field: 'slug', message: 'That workspace slug is already taken.'};
12
+ }
13
+ return {
14
+ kind: 'form',
15
+ message: error instanceof Error ? error.message : 'Could not update workspace settings.',
16
+ };
17
+ }
@@ -144,7 +144,7 @@ function MemberRow({
144
144
 
145
145
  return (
146
146
  <TableRow className={remove.isPending ? 'opacity-60' : undefined}>
147
- <TableCell className="font-medium">{member.name ?? ''}</TableCell>
147
+ <TableCell className="font-medium">{member.name ?? 'N/A'}</TableCell>
148
148
  <TableCell>
149
149
  <Code variant="paragraph">{member.email}</Code>
150
150
  </TableCell>
@@ -283,7 +283,7 @@ function InvitationRow({
283
283
  <TableCell>
284
284
  <Code variant="paragraph">{invitation.email}</Code>
285
285
  </TableCell>
286
- <TableCell>{invitation.invitedByDisplay ?? ''}</TableCell>
286
+ <TableCell>{invitation.invitedByDisplay ?? 'N/A'}</TableCell>
287
287
  <TableCell>
288
288
  <div className="flex items-center gap-8">
289
289
  <Text size="sm">{formatDate(invitation.expiresAt)}</Text>
@@ -1,12 +1,14 @@
1
- import {useActiveWorkspace} from '@shipfox/client-shell/runtime';
1
+ import {useMaybeActiveWorkspace} from '@shipfox/client-shell/runtime';
2
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
2
3
  import type {ReactNode} from 'react';
3
4
 
4
5
  interface WorkspaceSettingsShellProps {
5
- children: (workspace: ReturnType<typeof useActiveWorkspace>) => ReactNode;
6
+ children: (workspace: NonNullable<ReturnType<typeof useMaybeActiveWorkspace>>) => ReactNode;
6
7
  }
7
8
 
8
9
  export function WorkspaceSettingsShell({children}: WorkspaceSettingsShellProps) {
9
- const workspace = useActiveWorkspace();
10
+ const workspace = useMaybeActiveWorkspace();
11
+ if (!workspace) return <FullPageLoader />;
10
12
 
11
13
  return children(workspace);
12
14
  }
package/src/feature.ts CHANGED
@@ -9,12 +9,19 @@ export const workspaceSettingsNavigation = [
9
9
  id: 'nav.settings',
10
10
  scope: 'workspace',
11
11
  label: 'Settings',
12
- to: '/workspaces/$wid/settings',
12
+ to: '/w/$workspaceSlug/settings',
13
13
  order: 200,
14
14
  },
15
15
  ] as const satisfies readonly NavTabEntry[];
16
16
 
17
17
  export const workspaceSettingsSections = [
18
+ {
19
+ id: 'settings.general',
20
+ pathSegment: 'general',
21
+ label: 'General',
22
+ icon: 'settings3Line',
23
+ order: 50,
24
+ },
18
25
  {
19
26
  id: 'settings.members',
20
27
  pathSegment: 'members',
@@ -28,15 +35,20 @@ export const workspaceSettingsFeature = defineClientFeature({
28
35
  id: 'shipfox.workspace-settings',
29
36
  routes: [
30
37
  {
31
- path: '/workspaces/$wid/settings',
38
+ path: '/w/$workspaceSlug/settings',
32
39
  parent: 'workspaceSettings',
33
40
  impl: '@shipfox/client-workspace-settings/routes/index',
34
41
  },
35
42
  {
36
- path: '/workspaces/$wid/settings/members',
43
+ path: '/w/$workspaceSlug/settings/members',
37
44
  parent: 'workspaceSettings',
38
45
  impl: '@shipfox/client-workspace-settings/routes/members',
39
46
  },
47
+ {
48
+ path: '/w/$workspaceSlug/settings/general',
49
+ parent: 'workspaceSettings',
50
+ impl: '@shipfox/client-workspace-settings/routes/general',
51
+ },
40
52
  ],
41
53
  navigation: workspaceSettingsNavigation,
42
54
  settingsSections: workspaceSettingsSections,
@@ -0,0 +1,91 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {fireEvent, screen, waitFor} from '@testing-library/react';
3
+ import {jsonResponse, renderWorkspaceSettingsPage} from '#test/pages.js';
4
+ import {GeneralSettingsPage} from './general-settings-page.js';
5
+
6
+ describe('GeneralSettingsPage', () => {
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(workspaceDto({name: 'Acme Labs'}));
14
+ }
15
+ return jsonResponse({available: true});
16
+ });
17
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
18
+
19
+ renderWorkspaceSettingsPage('/w/acme/settings/general', <GeneralSettingsPage />);
20
+ fireEvent.change(await screen.findByLabelText('Workspace name'), {
21
+ target: {value: 'Acme Labs'},
22
+ });
23
+ fireEvent.click(screen.getByRole('button', {name: 'Save changes'}));
24
+
25
+ await waitFor(() => expect(patchBody).toEqual({name: 'Acme Labs'}));
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 consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
32
+ try {
33
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
34
+ const request = input as Request;
35
+ if (request.url.includes('/slug-availability')) {
36
+ return jsonResponse({available: true});
37
+ }
38
+ if (request.method === 'PATCH') {
39
+ patchBody = await request.clone().json();
40
+ return jsonResponse(workspaceDto({slug: 'acme-labs'}));
41
+ }
42
+ return jsonResponse({});
43
+ });
44
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
45
+
46
+ renderWorkspaceSettingsPage('/w/acme/settings/general', <GeneralSettingsPage />);
47
+ fireEvent.change(await screen.findByLabelText('Workspace slug'), {
48
+ target: {value: 'acme-labs'},
49
+ });
50
+ await waitFor(() =>
51
+ expect(
52
+ fetchImpl.mock.calls.some(([input]) =>
53
+ (input as Request).url.includes('/workspaces/slug-availability'),
54
+ ),
55
+ ).toBe(true),
56
+ );
57
+ fireEvent.click(screen.getByRole('button', {name: 'Save changes'}));
58
+
59
+ const dialog = await screen.findByRole('dialog');
60
+ expect(dialog).toHaveTextContent('old URL stop working');
61
+ expect(
62
+ fetchImpl.mock.calls.some(([input]) =>
63
+ (input as Request).url.includes('/workspaces/slug-availability'),
64
+ ),
65
+ ).toBe(true);
66
+ expect(patchBody).toBeUndefined();
67
+
68
+ fireEvent.click(screen.getByRole('button', {name: 'Change slug'}));
69
+ await waitFor(() => expect(patchBody).toEqual({slug: 'acme-labs'}));
70
+ expect(
71
+ consoleError.mock.calls.some((args) =>
72
+ args.some((argument) => String(argument).includes('useActiveWorkspace called outside')),
73
+ ),
74
+ ).toBe(false);
75
+ } finally {
76
+ consoleError.mockRestore();
77
+ }
78
+ });
79
+ });
80
+
81
+ function workspaceDto({name = 'Acme', slug = 'acme'}: {name?: string; slug?: string} = {}) {
82
+ return {
83
+ id: '11111111-1111-4111-8111-111111111111',
84
+ name,
85
+ slug,
86
+ status: 'active',
87
+ settings: {},
88
+ created_at: '2026-04-27T00:00:00.000Z',
89
+ updated_at: '2026-04-27T00:00:00.000Z',
90
+ };
91
+ }
@@ -0,0 +1,198 @@
1
+ import {createWorkspaceBodySchema} from '@shipfox/api-workspaces-dto';
2
+ import {
3
+ checkWorkspaceSlugAvailability,
4
+ type useActiveWorkspace,
5
+ useUpdateWorkspaceMutation,
6
+ } from '@shipfox/client-auth';
7
+ import {displayNameFieldError, SlugChangeWarning, SlugField} from '@shipfox/client-ui';
8
+ import {Button} from '@shipfox/react-ui/button';
9
+ import {Callout} from '@shipfox/react-ui/callout';
10
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
11
+ import {toast} from '@shipfox/react-ui/toast';
12
+ import {Header, Text} from '@shipfox/react-ui/typography';
13
+ import {useForm} from '@tanstack/react-form';
14
+ import {useNavigate} from '@tanstack/react-router';
15
+ import {useState} from 'react';
16
+ import {workspaceGeneralErrorToFormError} from '#components/general/form-errors.js';
17
+ import {WorkspaceSettingsShell} from '#components/workspace-settings-shell.js';
18
+
19
+ interface WorkspaceGeneralValues {
20
+ name: string;
21
+ slug: string;
22
+ }
23
+
24
+ function isSlugValid(value: string): boolean {
25
+ return createWorkspaceBodySchema.shape.slug.safeParse(value).success;
26
+ }
27
+
28
+ export function GeneralSettingsPage() {
29
+ return (
30
+ <WorkspaceSettingsShell>
31
+ {(workspace) => (
32
+ <WorkspaceGeneralForm
33
+ key={`${workspace.id}:${workspace.name}:${workspace.slug}`}
34
+ workspace={workspace}
35
+ />
36
+ )}
37
+ </WorkspaceSettingsShell>
38
+ );
39
+ }
40
+
41
+ function WorkspaceGeneralForm({workspace}: {workspace: ReturnType<typeof useActiveWorkspace>}) {
42
+ const updateWorkspace = useUpdateWorkspaceMutation();
43
+ const navigate = useNavigate();
44
+ const [warningOpen, setWarningOpen] = useState(false);
45
+ const [pendingValues, setPendingValues] = useState<WorkspaceGeneralValues>();
46
+ const [formError, setFormError] = useState<string>();
47
+
48
+ const form = useForm({
49
+ defaultValues: {name: workspace.name, slug: workspace.slug},
50
+ onSubmit: async ({value}) => {
51
+ if (value.slug !== workspace.slug) {
52
+ setPendingValues(value);
53
+ setWarningOpen(true);
54
+ return;
55
+ }
56
+ await save(value);
57
+ },
58
+ });
59
+
60
+ async function save(values: WorkspaceGeneralValues) {
61
+ setFormError(undefined);
62
+ const nameChanged = values.name !== workspace.name;
63
+ const slugChanged = values.slug !== workspace.slug;
64
+ if (!nameChanged && !slugChanged) return;
65
+ const command = {
66
+ workspaceId: workspace.id,
67
+ ...(nameChanged ? {name: values.name} : {}),
68
+ ...(slugChanged ? {slug: values.slug} : {}),
69
+ };
70
+
71
+ try {
72
+ const updated = await updateWorkspace.mutateAsync(command);
73
+ setPendingValues(undefined);
74
+ setWarningOpen(false);
75
+ toast.success('Workspace settings saved.');
76
+ await navigate({
77
+ to: '/w/$workspaceSlug/settings/general',
78
+ params: {workspaceSlug: updated.slug},
79
+ });
80
+ } catch (error) {
81
+ setPendingValues(undefined);
82
+ setWarningOpen(false);
83
+ const mapped = workspaceGeneralErrorToFormError(error);
84
+ if (mapped.kind === 'field') {
85
+ form.setFieldMeta(mapped.field, (previous) => ({
86
+ ...previous,
87
+ errorMap: {...previous.errorMap, onServer: mapped.message},
88
+ }));
89
+ } else {
90
+ setFormError(mapped.message);
91
+ }
92
+ }
93
+ }
94
+
95
+ return (
96
+ <>
97
+ <div className="flex min-w-0 flex-col gap-24">
98
+ <header className="flex flex-col gap-6">
99
+ <Header variant="h1">General</Header>
100
+ <Text size="sm" className="text-foreground-neutral-muted">
101
+ Update the workspace name and the slug used in its URLs.
102
+ </Text>
103
+ </header>
104
+
105
+ {formError ? (
106
+ <Callout role="alert" type="error">
107
+ {formError}
108
+ </Callout>
109
+ ) : null}
110
+
111
+ <form
112
+ className="flex max-w-[560px] flex-col gap-16"
113
+ noValidate
114
+ onSubmit={(event) => {
115
+ event.preventDefault();
116
+ event.stopPropagation();
117
+ void form.handleSubmit();
118
+ }}
119
+ >
120
+ <form.Field
121
+ name="name"
122
+ validators={{
123
+ onBlur: ({value}) =>
124
+ displayNameFieldError(
125
+ value,
126
+ 'Workspace name',
127
+ createWorkspaceBodySchema.shape.name,
128
+ ),
129
+ onSubmit: ({value}) =>
130
+ displayNameFieldError(
131
+ value,
132
+ 'Workspace name',
133
+ createWorkspaceBodySchema.shape.name,
134
+ ),
135
+ }}
136
+ >
137
+ {(field) => (
138
+ <FormField
139
+ label="Workspace name"
140
+ id="workspace-settings-name"
141
+ error={fieldError(field)}
142
+ >
143
+ <FormFieldInput
144
+ name="name"
145
+ type="text"
146
+ value={field.state.value}
147
+ onChange={(event) => field.handleChange(event.target.value)}
148
+ onBlur={field.handleBlur}
149
+ />
150
+ </FormField>
151
+ )}
152
+ </form.Field>
153
+
154
+ <form.Field
155
+ name="slug"
156
+ validators={{
157
+ onBlur: createWorkspaceBodySchema.shape.slug,
158
+ onSubmit: createWorkspaceBodySchema.shape.slug,
159
+ }}
160
+ >
161
+ {(field) => (
162
+ <SlugField
163
+ id="workspace-settings-slug"
164
+ label="Workspace slug"
165
+ name="slug"
166
+ value={field.state.value}
167
+ onChange={(value) => field.handleChange(value)}
168
+ onBlur={field.handleBlur}
169
+ error={fieldError(field)}
170
+ description={<span className="break-all font-code">/w/{field.state.value}</span>}
171
+ placeholder="acme"
172
+ className="font-code"
173
+ currentSlug={workspace.slug}
174
+ checkEnabled
175
+ isValid={isSlugValid}
176
+ checkAvailability={checkWorkspaceSlugAvailability}
177
+ />
178
+ )}
179
+ </form.Field>
180
+
181
+ <Button type="submit" isLoading={updateWorkspace.isPending} className="self-start">
182
+ Save changes
183
+ </Button>
184
+ </form>
185
+ </div>
186
+
187
+ <SlugChangeWarning
188
+ open={warningOpen}
189
+ onOpenChange={setWarningOpen}
190
+ entityLabel="workspace"
191
+ isLoading={updateWorkspace.isPending}
192
+ onConfirm={() => {
193
+ if (pendingValues) void save(pendingValues);
194
+ }}
195
+ />
196
+ </>
197
+ );
198
+ }
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {GeneralSettingsPage} from '#pages/general-settings-page.js';
3
+
4
+ export default defineRoute({component: GeneralSettingsPage});
@@ -2,7 +2,10 @@ import {defineRoute} from '@shipfox/client-shell/runtime';
2
2
  import {redirect} from '@tanstack/react-router';
3
3
 
4
4
  export default defineRoute({
5
- beforeLoad: ({params}: {params: {wid: string}}) => {
6
- throw redirect({to: '/workspaces/$wid/settings/members', params: {wid: params.wid}});
5
+ beforeLoad: ({params}: {params: {workspaceSlug: string}}) => {
6
+ throw redirect({
7
+ to: '/w/$workspaceSlug/settings/members',
8
+ params: {workspaceSlug: params.workspaceSlug},
9
+ });
7
10
  },
8
11
  });
package/test/pages.tsx CHANGED
@@ -24,7 +24,7 @@ const authState: AuthState = {
24
24
  email: 'user@example.com',
25
25
  emailVerifiedAt: new Date().toISOString(),
26
26
  },
27
- workspaces: [{id: WORKSPACE_SETTINGS_TEST_WID, name: 'Acme', membershipId: 'm-1'}],
27
+ workspaces: [{id: WORKSPACE_SETTINGS_TEST_WID, name: 'Acme', slug: 'acme', membershipId: 'm-1'}],
28
28
  };
29
29
 
30
30
  export function jsonResponse(body: unknown, init: ResponseInit = {}) {
@@ -39,22 +39,27 @@ function createTestRouter(path: string, element: ReactElement) {
39
39
  const rootRoute = createRootRoute({component: Outlet});
40
40
  const runnersRoute = createRoute({
41
41
  getParentRoute: () => rootRoute,
42
- path: '/workspaces/$wid/settings/runners',
42
+ path: '/w/$workspaceSlug/settings/runners',
43
43
  component: () => element,
44
44
  });
45
45
  const provisionersRoute = createRoute({
46
46
  getParentRoute: () => rootRoute,
47
- path: '/workspaces/$wid/settings/provisioners',
47
+ path: '/w/$workspaceSlug/settings/provisioners',
48
48
  component: () => element,
49
49
  });
50
50
  const modelProvidersRoute = createRoute({
51
51
  getParentRoute: () => rootRoute,
52
- path: '/workspaces/$wid/settings/agents',
52
+ path: '/w/$workspaceSlug/settings/agents',
53
53
  component: () => element,
54
54
  });
55
55
  const integrationsRoute = createRoute({
56
56
  getParentRoute: () => rootRoute,
57
- path: '/workspaces/$wid/settings/integrations',
57
+ path: '/w/$workspaceSlug/settings/integrations',
58
+ component: () => element,
59
+ });
60
+ const generalRoute = createRoute({
61
+ getParentRoute: () => rootRoute,
62
+ path: '/w/$workspaceSlug/settings/general',
58
63
  component: () => element,
59
64
  });
60
65
 
@@ -65,6 +70,7 @@ function createTestRouter(path: string, element: ReactElement) {
65
70
  provisionersRoute,
66
71
  modelProvidersRoute,
67
72
  integrationsRoute,
73
+ generalRoute,
68
74
  ]),
69
75
  });
70
76
  }