@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,225 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { slugSchema } from '@shipfox/api-common-dto';
|
|
3
|
+
import { createProjectBodySchema } from '@shipfox/api-projects-dto';
|
|
4
|
+
import { ApiError } from '@shipfox/client-api';
|
|
5
|
+
import { useActiveWorkspace } from '@shipfox/client-auth';
|
|
6
|
+
import { displayNameFieldError, QueryLoadError, SlugChangeWarning, SlugField } from '@shipfox/client-ui';
|
|
7
|
+
import { Button } from '@shipfox/react-ui/button';
|
|
8
|
+
import { Callout } from '@shipfox/react-ui/callout';
|
|
9
|
+
import { EmptyState } from '@shipfox/react-ui/empty-state';
|
|
10
|
+
import { FormField, FormFieldInput, fieldError } from '@shipfox/react-ui/form-field';
|
|
11
|
+
import { FullPageLoader } from '@shipfox/react-ui/loader';
|
|
12
|
+
import { toast } from '@shipfox/react-ui/toast';
|
|
13
|
+
import { Header, Text } from '@shipfox/react-ui/typography';
|
|
14
|
+
import { useForm } from '@tanstack/react-form';
|
|
15
|
+
import { useNavigate } from '@tanstack/react-router';
|
|
16
|
+
import { useState } from 'react';
|
|
17
|
+
import { useMaybeActiveProjectQuery } from '#chrome.js';
|
|
18
|
+
import { useProjectSlugAvailability, useUpdateProjectMutation } from '#hooks/api/projects.js';
|
|
19
|
+
import { projectSettingsErrorToFormError } from './project-settings-form-errors.js';
|
|
20
|
+
function isSlugValid(value) {
|
|
21
|
+
return slugSchema.safeParse(value).success;
|
|
22
|
+
}
|
|
23
|
+
export function ProjectSettingsPage() {
|
|
24
|
+
const projectQuery = useMaybeActiveProjectQuery();
|
|
25
|
+
if (projectQuery.isPending) return /*#__PURE__*/ _jsx(FullPageLoader, {});
|
|
26
|
+
if (projectQuery.isError && projectQuery.data === undefined) {
|
|
27
|
+
if (projectQuery.error instanceof ApiError && projectQuery.error.status === 404) {
|
|
28
|
+
return /*#__PURE__*/ _jsx(EmptyState, {
|
|
29
|
+
icon: "errorWarningLine",
|
|
30
|
+
tone: "error",
|
|
31
|
+
title: "Project not found",
|
|
32
|
+
description: "This project doesn't exist, or you don't have access to it."
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return /*#__PURE__*/ _jsx(QueryLoadError, {
|
|
36
|
+
query: projectQuery,
|
|
37
|
+
subject: "project"
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const project = projectQuery.data;
|
|
41
|
+
if (!project) {
|
|
42
|
+
return /*#__PURE__*/ _jsx(EmptyState, {
|
|
43
|
+
icon: "errorWarningLine",
|
|
44
|
+
tone: "error",
|
|
45
|
+
title: "Project not found",
|
|
46
|
+
description: "This project doesn't exist, or you don't have access to it."
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return /*#__PURE__*/ _jsx(ProjectSettingsForm, {
|
|
50
|
+
project: project
|
|
51
|
+
}, `${project.id}:${project.name}:${project.slug}`);
|
|
52
|
+
}
|
|
53
|
+
function ProjectSettingsForm({ project }) {
|
|
54
|
+
const workspace = useActiveWorkspace();
|
|
55
|
+
const navigate = useNavigate();
|
|
56
|
+
const updateProject = useUpdateProjectMutation();
|
|
57
|
+
const [warningOpen, setWarningOpen] = useState(false);
|
|
58
|
+
const [pendingValues, setPendingValues] = useState();
|
|
59
|
+
const [formError, setFormError] = useState();
|
|
60
|
+
const checkProjectSlugAvailability = useProjectSlugAvailability(workspace.id, project.id);
|
|
61
|
+
const form = useForm({
|
|
62
|
+
defaultValues: {
|
|
63
|
+
name: project.name,
|
|
64
|
+
slug: project.slug
|
|
65
|
+
},
|
|
66
|
+
onSubmit: async ({ value })=>{
|
|
67
|
+
if (value.slug !== project.slug) {
|
|
68
|
+
setPendingValues(value);
|
|
69
|
+
setWarningOpen(true);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await save(value);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
async function save(values) {
|
|
76
|
+
setFormError(undefined);
|
|
77
|
+
const nameChanged = values.name !== project.name;
|
|
78
|
+
const slugChanged = values.slug !== project.slug;
|
|
79
|
+
if (!nameChanged && !slugChanged) return;
|
|
80
|
+
const command = {
|
|
81
|
+
projectId: project.id,
|
|
82
|
+
...nameChanged ? {
|
|
83
|
+
name: values.name
|
|
84
|
+
} : {},
|
|
85
|
+
...slugChanged ? {
|
|
86
|
+
slug: values.slug
|
|
87
|
+
} : {}
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
const updated = await updateProject.mutateAsync(command);
|
|
91
|
+
setPendingValues(undefined);
|
|
92
|
+
setWarningOpen(false);
|
|
93
|
+
toast.success('Project settings saved.');
|
|
94
|
+
await navigate({
|
|
95
|
+
to: '/w/$workspaceSlug/p/$projectSlug/settings/general',
|
|
96
|
+
params: {
|
|
97
|
+
workspaceSlug: workspace.slug,
|
|
98
|
+
projectSlug: updated.slug
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
setPendingValues(undefined);
|
|
103
|
+
setWarningOpen(false);
|
|
104
|
+
const mapped = projectSettingsErrorToFormError(error);
|
|
105
|
+
if (mapped.kind === 'field') {
|
|
106
|
+
form.setFieldMeta(mapped.field, (previous)=>({
|
|
107
|
+
...previous,
|
|
108
|
+
errorMap: {
|
|
109
|
+
...previous.errorMap,
|
|
110
|
+
onServer: mapped.message
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
} else {
|
|
114
|
+
setFormError(mapped.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
119
|
+
children: [
|
|
120
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
121
|
+
className: "flex min-w-0 flex-col gap-24",
|
|
122
|
+
children: [
|
|
123
|
+
/*#__PURE__*/ _jsxs("header", {
|
|
124
|
+
className: "flex flex-col gap-6",
|
|
125
|
+
children: [
|
|
126
|
+
/*#__PURE__*/ _jsx(Header, {
|
|
127
|
+
variant: "h1",
|
|
128
|
+
children: "General"
|
|
129
|
+
}),
|
|
130
|
+
/*#__PURE__*/ _jsx(Text, {
|
|
131
|
+
size: "sm",
|
|
132
|
+
className: "text-foreground-neutral-muted",
|
|
133
|
+
children: "Update the project name and the slug used in its URLs."
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
}),
|
|
137
|
+
formError ? /*#__PURE__*/ _jsx(Callout, {
|
|
138
|
+
role: "alert",
|
|
139
|
+
type: "error",
|
|
140
|
+
children: formError
|
|
141
|
+
}) : null,
|
|
142
|
+
/*#__PURE__*/ _jsxs("form", {
|
|
143
|
+
className: "flex max-w-[560px] flex-col gap-16",
|
|
144
|
+
noValidate: true,
|
|
145
|
+
onSubmit: (event)=>{
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
event.stopPropagation();
|
|
148
|
+
void form.handleSubmit();
|
|
149
|
+
},
|
|
150
|
+
children: [
|
|
151
|
+
/*#__PURE__*/ _jsx(form.Field, {
|
|
152
|
+
name: "name",
|
|
153
|
+
validators: {
|
|
154
|
+
onBlur: ({ value })=>displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),
|
|
155
|
+
onSubmit: ({ value })=>displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name)
|
|
156
|
+
},
|
|
157
|
+
children: (field)=>/*#__PURE__*/ _jsx(FormField, {
|
|
158
|
+
label: "Project name",
|
|
159
|
+
id: "project-settings-name",
|
|
160
|
+
error: fieldError(field),
|
|
161
|
+
children: /*#__PURE__*/ _jsx(FormFieldInput, {
|
|
162
|
+
name: "name",
|
|
163
|
+
type: "text",
|
|
164
|
+
value: field.state.value,
|
|
165
|
+
onChange: (event)=>field.handleChange(event.target.value),
|
|
166
|
+
onBlur: field.handleBlur
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
}),
|
|
170
|
+
/*#__PURE__*/ _jsx(form.Field, {
|
|
171
|
+
name: "slug",
|
|
172
|
+
validators: {
|
|
173
|
+
onBlur: createProjectBodySchema.shape.slug,
|
|
174
|
+
onSubmit: createProjectBodySchema.shape.slug
|
|
175
|
+
},
|
|
176
|
+
children: (field)=>/*#__PURE__*/ _jsx(SlugField, {
|
|
177
|
+
id: "project-settings-slug",
|
|
178
|
+
label: "Project slug",
|
|
179
|
+
name: "slug",
|
|
180
|
+
value: field.state.value,
|
|
181
|
+
onChange: (value)=>field.handleChange(value),
|
|
182
|
+
onBlur: field.handleBlur,
|
|
183
|
+
error: fieldError(field),
|
|
184
|
+
description: /*#__PURE__*/ _jsxs("span", {
|
|
185
|
+
className: "break-all font-code",
|
|
186
|
+
children: [
|
|
187
|
+
"/w/",
|
|
188
|
+
workspace.slug,
|
|
189
|
+
"/p/",
|
|
190
|
+
field.state.value
|
|
191
|
+
]
|
|
192
|
+
}),
|
|
193
|
+
placeholder: "platform",
|
|
194
|
+
className: "font-code",
|
|
195
|
+
currentSlug: project.slug,
|
|
196
|
+
checkEnabled: true,
|
|
197
|
+
debounceMs: 0,
|
|
198
|
+
isValid: isSlugValid,
|
|
199
|
+
checkAvailability: checkProjectSlugAvailability
|
|
200
|
+
})
|
|
201
|
+
}),
|
|
202
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
203
|
+
type: "submit",
|
|
204
|
+
isLoading: updateProject.isPending,
|
|
205
|
+
className: "self-start",
|
|
206
|
+
children: "Save changes"
|
|
207
|
+
})
|
|
208
|
+
]
|
|
209
|
+
})
|
|
210
|
+
]
|
|
211
|
+
}),
|
|
212
|
+
/*#__PURE__*/ _jsx(SlugChangeWarning, {
|
|
213
|
+
open: warningOpen,
|
|
214
|
+
onOpenChange: setWarningOpen,
|
|
215
|
+
entityLabel: "project",
|
|
216
|
+
isLoading: updateProject.isPending,
|
|
217
|
+
onConfirm: ()=>{
|
|
218
|
+
if (pendingValues) void save(pendingValues);
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
]
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
//# sourceMappingURL=project-settings-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/pages/project-settings-page.tsx"],"sourcesContent":["import {slugSchema} from '@shipfox/api-common-dto';\nimport {createProjectBodySchema} from '@shipfox/api-projects-dto';\nimport {ApiError} from '@shipfox/client-api';\nimport {useActiveWorkspace} from '@shipfox/client-auth';\nimport {\n displayNameFieldError,\n QueryLoadError,\n SlugChangeWarning,\n SlugField,\n} from '@shipfox/client-ui';\nimport {Button} from '@shipfox/react-ui/button';\nimport {Callout} from '@shipfox/react-ui/callout';\nimport {EmptyState} from '@shipfox/react-ui/empty-state';\nimport {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';\nimport {FullPageLoader} from '@shipfox/react-ui/loader';\nimport {toast} from '@shipfox/react-ui/toast';\nimport {Header, Text} from '@shipfox/react-ui/typography';\nimport {useForm} from '@tanstack/react-form';\nimport {useNavigate} from '@tanstack/react-router';\nimport {useState} from 'react';\nimport {useMaybeActiveProjectQuery} from '#chrome.js';\nimport type {Project} from '#core/project.js';\nimport {useProjectSlugAvailability, useUpdateProjectMutation} from '#hooks/api/projects.js';\nimport {projectSettingsErrorToFormError} from './project-settings-form-errors.js';\n\ninterface ProjectSettingsValues {\n name: string;\n slug: string;\n}\n\nfunction isSlugValid(value: string): boolean {\n return slugSchema.safeParse(value).success;\n}\n\nexport function ProjectSettingsPage() {\n const projectQuery = useMaybeActiveProjectQuery();\n if (projectQuery.isPending) return <FullPageLoader />;\n if (projectQuery.isError && projectQuery.data === undefined) {\n if (projectQuery.error instanceof ApiError && projectQuery.error.status === 404) {\n return (\n <EmptyState\n icon=\"errorWarningLine\"\n tone=\"error\"\n title=\"Project not found\"\n description=\"This project doesn't exist, or you don't have access to it.\"\n />\n );\n }\n return <QueryLoadError query={projectQuery} subject=\"project\" />;\n }\n const project = projectQuery.data;\n if (!project) {\n return (\n <EmptyState\n icon=\"errorWarningLine\"\n tone=\"error\"\n title=\"Project not found\"\n description=\"This project doesn't exist, or you don't have access to it.\"\n />\n );\n }\n return (\n <ProjectSettingsForm key={`${project.id}:${project.name}:${project.slug}`} project={project} />\n );\n}\n\nfunction ProjectSettingsForm({project}: {project: Project}) {\n const workspace = useActiveWorkspace();\n const navigate = useNavigate();\n const updateProject = useUpdateProjectMutation();\n const [warningOpen, setWarningOpen] = useState(false);\n const [pendingValues, setPendingValues] = useState<ProjectSettingsValues>();\n const [formError, setFormError] = useState<string>();\n\n const checkProjectSlugAvailability = useProjectSlugAvailability(workspace.id, project.id);\n\n const form = useForm({\n defaultValues: {name: project.name, slug: project.slug},\n onSubmit: async ({value}) => {\n if (value.slug !== project.slug) {\n setPendingValues(value);\n setWarningOpen(true);\n return;\n }\n await save(value);\n },\n });\n\n async function save(values: ProjectSettingsValues) {\n setFormError(undefined);\n const nameChanged = values.name !== project.name;\n const slugChanged = values.slug !== project.slug;\n if (!nameChanged && !slugChanged) return;\n const command = {\n projectId: project.id,\n ...(nameChanged ? {name: values.name} : {}),\n ...(slugChanged ? {slug: values.slug} : {}),\n };\n\n try {\n const updated = await updateProject.mutateAsync(command);\n setPendingValues(undefined);\n setWarningOpen(false);\n toast.success('Project settings saved.');\n await navigate({\n to: '/w/$workspaceSlug/p/$projectSlug/settings/general',\n params: {workspaceSlug: workspace.slug, projectSlug: updated.slug},\n });\n } catch (error) {\n setPendingValues(undefined);\n setWarningOpen(false);\n const mapped = projectSettingsErrorToFormError(error);\n if (mapped.kind === 'field') {\n form.setFieldMeta(mapped.field, (previous) => ({\n ...previous,\n errorMap: {...previous.errorMap, onServer: mapped.message},\n }));\n } else {\n setFormError(mapped.message);\n }\n }\n }\n\n return (\n <>\n <div className=\"flex min-w-0 flex-col gap-24\">\n <header className=\"flex flex-col gap-6\">\n <Header variant=\"h1\">General</Header>\n <Text size=\"sm\" className=\"text-foreground-neutral-muted\">\n Update the project name and the slug used in its URLs.\n </Text>\n </header>\n\n {formError ? (\n <Callout role=\"alert\" type=\"error\">\n {formError}\n </Callout>\n ) : null}\n\n <form\n className=\"flex max-w-[560px] flex-col gap-16\"\n noValidate\n onSubmit={(event) => {\n event.preventDefault();\n event.stopPropagation();\n void form.handleSubmit();\n }}\n >\n <form.Field\n name=\"name\"\n validators={{\n onBlur: ({value}) =>\n displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),\n onSubmit: ({value}) =>\n displayNameFieldError(value, 'Project name', createProjectBodySchema.shape.name),\n }}\n >\n {(field) => (\n <FormField label=\"Project name\" id=\"project-settings-name\" error={fieldError(field)}>\n <FormFieldInput\n name=\"name\"\n type=\"text\"\n value={field.state.value}\n onChange={(event) => field.handleChange(event.target.value)}\n onBlur={field.handleBlur}\n />\n </FormField>\n )}\n </form.Field>\n\n <form.Field\n name=\"slug\"\n validators={{\n onBlur: createProjectBodySchema.shape.slug,\n onSubmit: createProjectBodySchema.shape.slug,\n }}\n >\n {(field) => (\n <SlugField\n id=\"project-settings-slug\"\n label=\"Project slug\"\n name=\"slug\"\n value={field.state.value}\n onChange={(value) => field.handleChange(value)}\n onBlur={field.handleBlur}\n error={fieldError(field)}\n description={\n <span className=\"break-all font-code\">\n /w/{workspace.slug}/p/{field.state.value}\n </span>\n }\n placeholder=\"platform\"\n className=\"font-code\"\n currentSlug={project.slug}\n checkEnabled\n debounceMs={0}\n isValid={isSlugValid}\n checkAvailability={checkProjectSlugAvailability}\n />\n )}\n </form.Field>\n\n <Button type=\"submit\" isLoading={updateProject.isPending} className=\"self-start\">\n Save changes\n </Button>\n </form>\n </div>\n\n <SlugChangeWarning\n open={warningOpen}\n onOpenChange={setWarningOpen}\n entityLabel=\"project\"\n isLoading={updateProject.isPending}\n onConfirm={() => {\n if (pendingValues) void save(pendingValues);\n }}\n />\n </>\n );\n}\n"],"names":["slugSchema","createProjectBodySchema","ApiError","useActiveWorkspace","displayNameFieldError","QueryLoadError","SlugChangeWarning","SlugField","Button","Callout","EmptyState","FormField","FormFieldInput","fieldError","FullPageLoader","toast","Header","Text","useForm","useNavigate","useState","useMaybeActiveProjectQuery","useProjectSlugAvailability","useUpdateProjectMutation","projectSettingsErrorToFormError","isSlugValid","value","safeParse","success","ProjectSettingsPage","projectQuery","isPending","isError","data","undefined","error","status","icon","tone","title","description","query","subject","project","ProjectSettingsForm","id","name","slug","workspace","navigate","updateProject","warningOpen","setWarningOpen","pendingValues","setPendingValues","formError","setFormError","checkProjectSlugAvailability","form","defaultValues","onSubmit","save","values","nameChanged","slugChanged","command","projectId","updated","mutateAsync","to","params","workspaceSlug","projectSlug","mapped","kind","setFieldMeta","field","previous","errorMap","onServer","message","div","className","header","variant","size","role","type","noValidate","event","preventDefault","stopPropagation","handleSubmit","Field","validators","onBlur","shape","label","state","onChange","handleChange","target","handleBlur","span","placeholder","currentSlug","checkEnabled","debounceMs","isValid","checkAvailability","isLoading","open","onOpenChange","entityLabel","onConfirm"],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { StoryObj } from '@storybook/react';
|
|
2
|
+
type Scenario = 'default' | 'long-values' | 'taken-slug';
|
|
3
|
+
interface ProjectSettingsPageStoryProps {
|
|
4
|
+
scenario: Scenario;
|
|
5
|
+
}
|
|
6
|
+
declare function ProjectSettingsPageStory({ scenario }: ProjectSettingsPageStoryProps): import("react").JSX.Element;
|
|
7
|
+
declare const meta: {
|
|
8
|
+
title: string;
|
|
9
|
+
component: typeof ProjectSettingsPageStory;
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: string;
|
|
12
|
+
};
|
|
13
|
+
args: {
|
|
14
|
+
scenario: "default";
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export default meta;
|
|
18
|
+
type Story = StoryObj<typeof meta>;
|
|
19
|
+
export declare const Playground: Story;
|
|
20
|
+
export declare const LongValues: Story;
|
|
21
|
+
export declare const TakenSlug: Story;
|
|
22
|
+
export declare const SlugChangeWarning: Story;
|
|
23
|
+
//# sourceMappingURL=project-settings-page.stories.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"project-settings-page.stories.d.ts","sourceRoot":"","sources":["../../src/pages/project-settings-page.stories.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAO,QAAQ,EAAC,MAAM,kBAAkB,CAAC;AAmBrD,KAAK,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,CAAC;AAezD,UAAU,6BAA6B;IACrC,QAAQ,EAAE,QAAQ,CAAC;CACpB;AAcD,iBAAS,wBAAwB,CAAC,EAAC,QAAQ,EAAC,EAAE,6BAA6B,+BA6B1E;AAED,QAAA,MAAM,IAAI;;;;;;;;;CAKuC,CAAC;AAElD,eAAe,IAAI,CAAC;AACpB,KAAK,KAAK,GAAG,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;AAEnC,eAAO,MAAM,UAAU,EAAE,KAAU,CAAC;AAEpC,eAAO,MAAM,UAAU,EAAE,KAExB,CAAC;AAEF,eAAO,MAAM,SAAS,EAAE,KAUvB,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,KAgB/B,CAAC"}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { configureApiClient } from '@shipfox/client-api';
|
|
3
|
+
import { authStateAtom } from '@shipfox/client-auth';
|
|
4
|
+
import { Toaster } from '@shipfox/react-ui/toast';
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
6
|
+
import { createMemoryHistory, createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from '@tanstack/react-router';
|
|
7
|
+
import { createStore, Provider as JotaiProvider } from 'jotai';
|
|
8
|
+
import { useMemo } from 'react';
|
|
9
|
+
import { expect, screen, userEvent, within } from 'storybook/test';
|
|
10
|
+
import { ProjectSettingsPage } from './project-settings-page.js';
|
|
11
|
+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
12
|
+
const PROJECT_ID = '99999999-9999-4999-8999-999999999999';
|
|
13
|
+
const OTHER_PROJECT_ID = '88888888-8888-4888-8888-888888888888';
|
|
14
|
+
const authState = {
|
|
15
|
+
status: 'authenticated',
|
|
16
|
+
token: 'token',
|
|
17
|
+
user: {
|
|
18
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
19
|
+
email: 'platform@example.com',
|
|
20
|
+
name: 'Platform Engineer',
|
|
21
|
+
emailVerifiedAt: '2026-01-01T00:00:00.000Z'
|
|
22
|
+
},
|
|
23
|
+
workspaces: [
|
|
24
|
+
{
|
|
25
|
+
id: WORKSPACE_ID,
|
|
26
|
+
name: 'Acme',
|
|
27
|
+
slug: 'acme',
|
|
28
|
+
membershipId: 'membership-1'
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
function ProjectSettingsPageStory({ scenario }) {
|
|
33
|
+
configureApiClient({
|
|
34
|
+
baseUrl: 'https://api.example.test',
|
|
35
|
+
fetchImpl: fetchForScenario(scenario)
|
|
36
|
+
});
|
|
37
|
+
const queryClient = useMemo(()=>new QueryClient({
|
|
38
|
+
defaultOptions: {
|
|
39
|
+
queries: {
|
|
40
|
+
retry: false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}), []);
|
|
44
|
+
const store = useMemo(()=>{
|
|
45
|
+
const nextStore = createStore();
|
|
46
|
+
nextStore.set(authStateAtom, authState);
|
|
47
|
+
return nextStore;
|
|
48
|
+
}, []);
|
|
49
|
+
const router = useMemo(()=>createStoryRouter(scenario), [
|
|
50
|
+
scenario
|
|
51
|
+
]);
|
|
52
|
+
return /*#__PURE__*/ _jsx(QueryClientProvider, {
|
|
53
|
+
client: queryClient,
|
|
54
|
+
children: /*#__PURE__*/ _jsxs(JotaiProvider, {
|
|
55
|
+
store: store,
|
|
56
|
+
children: [
|
|
57
|
+
/*#__PURE__*/ _jsx("div", {
|
|
58
|
+
className: "min-h-screen bg-background-neutral-background px-24 py-32",
|
|
59
|
+
children: /*#__PURE__*/ _jsx("div", {
|
|
60
|
+
className: "mx-auto w-full max-w-[1120px]",
|
|
61
|
+
children: /*#__PURE__*/ _jsx(RouterProvider, {
|
|
62
|
+
router: router
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
}),
|
|
66
|
+
/*#__PURE__*/ _jsx(Toaster, {})
|
|
67
|
+
]
|
|
68
|
+
})
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const meta = {
|
|
72
|
+
title: 'Projects/ProjectSettingsPage',
|
|
73
|
+
component: ProjectSettingsPageStory,
|
|
74
|
+
parameters: {
|
|
75
|
+
layout: 'fullscreen'
|
|
76
|
+
},
|
|
77
|
+
args: {
|
|
78
|
+
scenario: 'default'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
export default meta;
|
|
82
|
+
export const Playground = {};
|
|
83
|
+
export const LongValues = {
|
|
84
|
+
args: {
|
|
85
|
+
scenario: 'long-values'
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
export const TakenSlug = {
|
|
89
|
+
args: {
|
|
90
|
+
scenario: 'taken-slug'
|
|
91
|
+
},
|
|
92
|
+
play: async ({ canvasElement })=>{
|
|
93
|
+
const canvas = within(canvasElement);
|
|
94
|
+
const slugInput = await canvas.findByLabelText('Project slug');
|
|
95
|
+
await userEvent.clear(slugInput);
|
|
96
|
+
await userEvent.type(slugInput, 'taken-platform');
|
|
97
|
+
expect(await canvas.findByText('This slug is already taken.')).toBeInTheDocument();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
export const SlugChangeWarning = {
|
|
101
|
+
play: async ({ canvasElement })=>{
|
|
102
|
+
const canvas = within(canvasElement);
|
|
103
|
+
const slugInput = await canvas.findByLabelText('Project slug');
|
|
104
|
+
await userEvent.clear(slugInput);
|
|
105
|
+
await userEvent.type(slugInput, 'renamed-platform');
|
|
106
|
+
await canvas.findByText('Slug is available.');
|
|
107
|
+
await userEvent.click(canvas.getByRole('button', {
|
|
108
|
+
name: 'Save changes'
|
|
109
|
+
}));
|
|
110
|
+
const dialog = await screen.findByRole('dialog');
|
|
111
|
+
expect(dialog).toHaveTextContent('Change project slug?');
|
|
112
|
+
expect(dialog).toHaveTextContent('Links and bookmarks pointing at the old URL stop working.');
|
|
113
|
+
expect(dialog).toHaveTextContent('Workflows that reference this project slug may stop working.');
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
function createStoryRouter(scenario) {
|
|
117
|
+
const project = projectForScenario(scenario);
|
|
118
|
+
const rootRoute = createRootRoute({
|
|
119
|
+
component: Outlet
|
|
120
|
+
});
|
|
121
|
+
const projectSettingsRoute = createRoute({
|
|
122
|
+
getParentRoute: ()=>rootRoute,
|
|
123
|
+
path: '/w/$workspaceSlug/p/$projectSlug/settings/general',
|
|
124
|
+
component: ProjectSettingsPage
|
|
125
|
+
});
|
|
126
|
+
return createRouter({
|
|
127
|
+
history: createMemoryHistory({
|
|
128
|
+
initialEntries: [
|
|
129
|
+
`/w/acme/p/${project.slug}/settings/general`
|
|
130
|
+
]
|
|
131
|
+
}),
|
|
132
|
+
routeTree: rootRoute.addChildren([
|
|
133
|
+
projectSettingsRoute
|
|
134
|
+
])
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function fetchForScenario(scenario) {
|
|
138
|
+
return async (input, init)=>{
|
|
139
|
+
const url = requestUrl(input);
|
|
140
|
+
const method = input instanceof Request ? input.method : init?.method ?? 'GET';
|
|
141
|
+
if (url.pathname === '/projects' && method === 'GET') {
|
|
142
|
+
const search = url.searchParams.get('search');
|
|
143
|
+
if (scenario === 'taken-slug' && search === 'taken-platform') {
|
|
144
|
+
return jsonResponse({
|
|
145
|
+
projects: [
|
|
146
|
+
projectForScenario(scenario, {
|
|
147
|
+
id: OTHER_PROJECT_ID,
|
|
148
|
+
slug: 'taken-platform'
|
|
149
|
+
})
|
|
150
|
+
],
|
|
151
|
+
next_cursor: null
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return jsonResponse({
|
|
155
|
+
projects: [
|
|
156
|
+
projectForScenario(scenario)
|
|
157
|
+
],
|
|
158
|
+
next_cursor: null
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname === `/projects/${PROJECT_ID}` && method === 'PATCH') {
|
|
162
|
+
const body = await input.clone().json();
|
|
163
|
+
const project = projectForScenario(scenario);
|
|
164
|
+
return jsonResponse({
|
|
165
|
+
...project,
|
|
166
|
+
name: body.name ?? project.name,
|
|
167
|
+
slug: body.slug ?? project.slug
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return jsonResponse({}, {
|
|
171
|
+
status: 404
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function projectForScenario(scenario, overrides = {}) {
|
|
176
|
+
const project = {
|
|
177
|
+
id: PROJECT_ID,
|
|
178
|
+
workspace_id: WORKSPACE_ID,
|
|
179
|
+
name: scenario === 'long-values' ? 'Infrastructure Control Plane and Workflow Automation' : 'Platform',
|
|
180
|
+
slug: scenario === 'long-values' ? 'infrastructure-control-plane' : 'platform',
|
|
181
|
+
source: {
|
|
182
|
+
connection_id: '33333333-3333-4333-8333-333333333333',
|
|
183
|
+
external_repository_id: 'platform'
|
|
184
|
+
},
|
|
185
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
186
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
187
|
+
...overrides
|
|
188
|
+
};
|
|
189
|
+
return project;
|
|
190
|
+
}
|
|
191
|
+
function requestUrl(input) {
|
|
192
|
+
if (input instanceof Request) return new URL(input.url);
|
|
193
|
+
if (input instanceof URL) return input;
|
|
194
|
+
return new URL(input, 'https://api.example.test');
|
|
195
|
+
}
|
|
196
|
+
function jsonResponse(body, init = {}) {
|
|
197
|
+
return new Response(JSON.stringify(body), {
|
|
198
|
+
status: 200,
|
|
199
|
+
headers: {
|
|
200
|
+
'content-type': 'application/json'
|
|
201
|
+
},
|
|
202
|
+
...init
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//# sourceMappingURL=project-settings-page.stories.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/pages/project-settings-page.stories.tsx"],"sourcesContent":["import {configureApiClient} from '@shipfox/client-api';\nimport {type AuthState, authStateAtom} from '@shipfox/client-auth';\nimport {Toaster} from '@shipfox/react-ui/toast';\nimport type {Meta, StoryObj} from '@storybook/react';\nimport {QueryClient, QueryClientProvider} from '@tanstack/react-query';\nimport {\n createMemoryHistory,\n createRootRoute,\n createRoute,\n createRouter,\n Outlet,\n RouterProvider,\n} from '@tanstack/react-router';\nimport {createStore, Provider as JotaiProvider} from 'jotai';\nimport {useMemo} from 'react';\nimport {expect, screen, userEvent, within} from 'storybook/test';\nimport {ProjectSettingsPage} from './project-settings-page.js';\n\nconst WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';\nconst PROJECT_ID = '99999999-9999-4999-8999-999999999999';\nconst OTHER_PROJECT_ID = '88888888-8888-4888-8888-888888888888';\n\ntype Scenario = 'default' | 'long-values' | 'taken-slug';\n\ninterface ProjectStoryResponse {\n id: string;\n workspace_id: string;\n name: string;\n slug: string;\n source: {\n connection_id: string;\n external_repository_id: string;\n };\n created_at: string;\n updated_at: string;\n}\n\ninterface ProjectSettingsPageStoryProps {\n scenario: Scenario;\n}\n\nconst authState: AuthState = {\n status: 'authenticated',\n token: 'token',\n user: {\n id: '22222222-2222-4222-8222-222222222222',\n email: 'platform@example.com',\n name: 'Platform Engineer',\n emailVerifiedAt: '2026-01-01T00:00:00.000Z',\n },\n workspaces: [{id: WORKSPACE_ID, name: 'Acme', slug: 'acme', membershipId: 'membership-1'}],\n};\n\nfunction ProjectSettingsPageStory({scenario}: ProjectSettingsPageStoryProps) {\n configureApiClient({\n baseUrl: 'https://api.example.test',\n fetchImpl: fetchForScenario(scenario),\n });\n\n const queryClient = useMemo(\n () => new QueryClient({defaultOptions: {queries: {retry: false}}}),\n [],\n );\n const store = useMemo(() => {\n const nextStore = createStore();\n nextStore.set(authStateAtom, authState);\n return nextStore;\n }, []);\n const router = useMemo(() => createStoryRouter(scenario), [scenario]);\n\n return (\n <QueryClientProvider client={queryClient}>\n <JotaiProvider store={store}>\n <div className=\"min-h-screen bg-background-neutral-background px-24 py-32\">\n <div className=\"mx-auto w-full max-w-[1120px]\">\n <RouterProvider router={router} />\n </div>\n </div>\n <Toaster />\n </JotaiProvider>\n </QueryClientProvider>\n );\n}\n\nconst meta = {\n title: 'Projects/ProjectSettingsPage',\n component: ProjectSettingsPageStory,\n parameters: {layout: 'fullscreen'},\n args: {scenario: 'default'},\n} satisfies Meta<typeof ProjectSettingsPageStory>;\n\nexport default meta;\ntype Story = StoryObj<typeof meta>;\n\nexport const Playground: Story = {};\n\nexport const LongValues: Story = {\n args: {scenario: 'long-values'},\n};\n\nexport const TakenSlug: Story = {\n args: {scenario: 'taken-slug'},\n play: async ({canvasElement}) => {\n const canvas = within(canvasElement);\n const slugInput = await canvas.findByLabelText('Project slug');\n await userEvent.clear(slugInput);\n await userEvent.type(slugInput, 'taken-platform');\n\n expect(await canvas.findByText('This slug is already taken.')).toBeInTheDocument();\n },\n};\n\nexport const SlugChangeWarning: Story = {\n play: async ({canvasElement}) => {\n const canvas = within(canvasElement);\n const slugInput = await canvas.findByLabelText('Project slug');\n await userEvent.clear(slugInput);\n await userEvent.type(slugInput, 'renamed-platform');\n await canvas.findByText('Slug is available.');\n await userEvent.click(canvas.getByRole('button', {name: 'Save changes'}));\n\n const dialog = await screen.findByRole('dialog');\n expect(dialog).toHaveTextContent('Change project slug?');\n expect(dialog).toHaveTextContent('Links and bookmarks pointing at the old URL stop working.');\n expect(dialog).toHaveTextContent(\n 'Workflows that reference this project slug may stop working.',\n );\n },\n};\n\nfunction createStoryRouter(scenario: Scenario) {\n const project = projectForScenario(scenario);\n const rootRoute = createRootRoute({component: Outlet});\n const projectSettingsRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/w/$workspaceSlug/p/$projectSlug/settings/general',\n component: ProjectSettingsPage,\n });\n\n return createRouter({\n history: createMemoryHistory({\n initialEntries: [`/w/acme/p/${project.slug}/settings/general`],\n }),\n routeTree: rootRoute.addChildren([projectSettingsRoute]),\n });\n}\n\nfunction fetchForScenario(scenario: Scenario): typeof fetch {\n return async (input, init) => {\n const url = requestUrl(input);\n const method = input instanceof Request ? input.method : (init?.method ?? 'GET');\n\n if (url.pathname === '/projects' && method === 'GET') {\n const search = url.searchParams.get('search');\n if (scenario === 'taken-slug' && search === 'taken-platform') {\n return jsonResponse({\n projects: [projectForScenario(scenario, {id: OTHER_PROJECT_ID, slug: 'taken-platform'})],\n next_cursor: null,\n });\n }\n return jsonResponse({projects: [projectForScenario(scenario)], next_cursor: null});\n }\n\n if (url.pathname === `/projects/${PROJECT_ID}` && method === 'PATCH') {\n const body = (await (input as Request).clone().json()) as {\n name?: string;\n slug?: string;\n };\n const project = projectForScenario(scenario);\n return jsonResponse({\n ...project,\n name: body.name ?? project.name,\n slug: body.slug ?? project.slug,\n });\n }\n\n return jsonResponse({}, {status: 404});\n };\n}\n\nfunction projectForScenario(\n scenario: Scenario,\n overrides: Partial<ProjectStoryResponse> = {},\n): ProjectStoryResponse {\n const project = {\n id: PROJECT_ID,\n workspace_id: WORKSPACE_ID,\n name:\n scenario === 'long-values'\n ? 'Infrastructure Control Plane and Workflow Automation'\n : 'Platform',\n slug: scenario === 'long-values' ? 'infrastructure-control-plane' : 'platform',\n source: {\n connection_id: '33333333-3333-4333-8333-333333333333',\n external_repository_id: 'platform',\n },\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n ...overrides,\n };\n return project;\n}\n\nfunction requestUrl(input: RequestInfo | URL): URL {\n if (input instanceof Request) return new URL(input.url);\n if (input instanceof URL) return input;\n return new URL(input, 'https://api.example.test');\n}\n\nfunction jsonResponse(body: unknown, init: ResponseInit = {}) {\n return new Response(JSON.stringify(body), {\n status: 200,\n headers: {'content-type': 'application/json'},\n ...init,\n });\n}\n"],"names":["configureApiClient","authStateAtom","Toaster","QueryClient","QueryClientProvider","createMemoryHistory","createRootRoute","createRoute","createRouter","Outlet","RouterProvider","createStore","Provider","JotaiProvider","useMemo","expect","screen","userEvent","within","ProjectSettingsPage","WORKSPACE_ID","PROJECT_ID","OTHER_PROJECT_ID","authState","status","token","user","id","email","name","emailVerifiedAt","workspaces","slug","membershipId","ProjectSettingsPageStory","scenario","baseUrl","fetchImpl","fetchForScenario","queryClient","defaultOptions","queries","retry","store","nextStore","set","router","createStoryRouter","client","div","className","meta","title","component","parameters","layout","args","Playground","LongValues","TakenSlug","play","canvasElement","canvas","slugInput","findByLabelText","clear","type","findByText","toBeInTheDocument","SlugChangeWarning","click","getByRole","dialog","findByRole","toHaveTextContent","project","projectForScenario","rootRoute","projectSettingsRoute","getParentRoute","path","history","initialEntries","routeTree","addChildren","input","init","url","requestUrl","method","Request","pathname","search","searchParams","get","jsonResponse","projects","next_cursor","body","clone","json","overrides","workspace_id","source","connection_id","external_repository_id","created_at","updated_at","URL","Response","JSON","stringify","headers"],"mappings":"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"}
|
|
@@ -76,9 +76,9 @@ export function ProjectsHubPage({ search = '' }) {
|
|
|
76
76
|
iconLeft: "addLine",
|
|
77
77
|
className: "shrink-0 max-[640px]:w-full",
|
|
78
78
|
children: /*#__PURE__*/ _jsx(Link, {
|
|
79
|
-
to: "/
|
|
79
|
+
to: "/w/$workspaceSlug/projects/new",
|
|
80
80
|
params: {
|
|
81
|
-
|
|
81
|
+
workspaceSlug: workspace.slug
|
|
82
82
|
},
|
|
83
83
|
children: "New project"
|
|
84
84
|
})
|
|
@@ -96,7 +96,7 @@ export function ProjectsHubPage({ search = '' }) {
|
|
|
96
96
|
subject: "projects"
|
|
97
97
|
}) : null,
|
|
98
98
|
!isInitialLoading && !query.isError && projects.length === 0 && !search ? /*#__PURE__*/ _jsx(EmptyProjects, {
|
|
99
|
-
|
|
99
|
+
workspaceSlug: workspace.slug
|
|
100
100
|
}) : null,
|
|
101
101
|
!query.isFetching && !query.isError && projects.length === 0 && search ? /*#__PURE__*/ _jsx(NoSearchResults, {
|
|
102
102
|
search: search,
|
|
@@ -115,7 +115,7 @@ export function ProjectsHubPage({ search = '' }) {
|
|
|
115
115
|
connection: connectionsById.get(project.source.connectionId),
|
|
116
116
|
connectionsResolved: connectionsQuery.isSuccess,
|
|
117
117
|
connectionsSettled: connectionsQuery.isSuccess || connectionsQuery.isError,
|
|
118
|
-
|
|
118
|
+
workspaceSlug: workspace.slug
|
|
119
119
|
}, project.id))
|
|
120
120
|
}),
|
|
121
121
|
query.error && query.data ? /*#__PURE__*/ _jsx(Callout, {
|
|
@@ -171,7 +171,7 @@ function ProjectsSkeleton() {
|
|
|
171
171
|
}, row))
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
|
-
function EmptyProjects({
|
|
174
|
+
function EmptyProjects({ workspaceSlug }) {
|
|
175
175
|
return /*#__PURE__*/ _jsx(EmptyState, {
|
|
176
176
|
icon: "folderLine",
|
|
177
177
|
title: "Create your first project",
|
|
@@ -180,9 +180,9 @@ function EmptyProjects({ workspaceId }) {
|
|
|
180
180
|
asChild: true,
|
|
181
181
|
iconRight: "chevronRight",
|
|
182
182
|
children: /*#__PURE__*/ _jsx(Link, {
|
|
183
|
-
to: "/
|
|
183
|
+
to: "/w/$workspaceSlug/projects/new",
|
|
184
184
|
params: {
|
|
185
|
-
|
|
185
|
+
workspaceSlug
|
|
186
186
|
},
|
|
187
187
|
children: "Create project"
|
|
188
188
|
})
|
|
@@ -202,17 +202,17 @@ function NoSearchResults({ search, onClear }) {
|
|
|
202
202
|
})
|
|
203
203
|
});
|
|
204
204
|
}
|
|
205
|
-
function ProjectCard({ project, connection, connectionsResolved, connectionsSettled,
|
|
205
|
+
function ProjectCard({ project, connection, connectionsResolved, connectionsSettled, workspaceSlug }) {
|
|
206
206
|
// On a resolved fetch, `active` carries no badge while a missing connection
|
|
207
207
|
// reads as an error so a broken source is still flagged. An unresolved or
|
|
208
208
|
// failed fetch shows nothing, so a fetch failure never flags every card.
|
|
209
209
|
const status = connectionsResolved ? connection?.lifecycleStatus ?? 'error' : undefined;
|
|
210
210
|
return /*#__PURE__*/ _jsx("li", {
|
|
211
211
|
children: /*#__PURE__*/ _jsx(Link, {
|
|
212
|
-
to: "/
|
|
212
|
+
to: "/w/$workspaceSlug/p/$projectSlug",
|
|
213
213
|
params: {
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
workspaceSlug,
|
|
215
|
+
projectSlug: project.slug
|
|
216
216
|
},
|
|
217
217
|
className: "block h-full rounded-8 focus-visible:shadow-button-neutral-focus focus-visible:outline-none",
|
|
218
218
|
children: /*#__PURE__*/ _jsx(Card, {
|