Skip to content
File

Blob: src/client/pages/project-settings-page.tsx

typescript276 lines
1import {
2 UpdateProjectRequest as UpdateProjectRequestCodec,
3 type DispatchMode,
4 type ProjectConfigSummary,
5 type ProjectDetail,
6 type UpdateProjectRequest,
7} from "@/contracts";
8import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9import { Save } from "lucide-react";
10import type { FormEvent } from "react";
11import { useState } from "react";
12import { useNavigate, useParams } from "react-router-dom";
13import { LoadingPanel } from "@/client/components";
14import { Breadcrumbs, Button, ButtonLink, Card, ErrorBanner, Input, PageHeader } from "@/client/components/ui";
15import { formatApiError, getApiClient, inferRepositoryProvider, queryKeys } from "@/client/lib";
16import { useToast } from "@/client/toast";
17 
18interface SettingsFormState {
19 name: string;
20 repoUrl: string;
21 defaultBranch: string;
22 configPath: string;
23 repoToken: string;
24 dispatchMode: DispatchMode;
25}
26 
27interface ProjectSettingsFormProps {
28 project: ProjectConfigSummary;
29 projectId: string;
30}
31 
32const buildInitialForm = (project: ProjectConfigSummary): SettingsFormState => ({
33 name: project.name,
34 repoUrl: project.repoUrl,
35 defaultBranch: project.defaultBranch,
36 configPath: project.configPath,
37 repoToken: "",
38 dispatchMode: project.dispatchMode,
39});
40 
41const ProjectSettingsForm = ({ project, projectId }: ProjectSettingsFormProps) => {
42 const navigate = useNavigate();
43 const queryClient = useQueryClient();
44 const { pushToast } = useToast();
45 const [form, setForm] = useState<SettingsFormState>(() => buildInitialForm(project));
46 
47 const updateProjectMutation = useMutation({
48 mutationFn: (payload: UpdateProjectRequest) => getApiClient().updateProject(projectId, payload),
49 onSuccess: (response) => {
50 queryClient.setQueryData<ProjectDetail>(queryKeys.projectDetail(projectId), (current) =>
51 current ? { ...current, project: response.project } : current,
52 );
53 void queryClient.invalidateQueries({ queryKey: queryKeys.projectDetail(projectId) });
54 void queryClient.invalidateQueries({ queryKey: queryKeys.projectsRoot() });
55 
56 pushToast({
57 tone: "success",
58 title: "Project updated",
59 message: `${response.project.ownerSlug}/${response.project.projectSlug} settings saved.`,
60 });
61 navigate(`/app/projects/${projectId}`, { replace: true });
62 },
63 onError: (reason) => {
64 pushToast({ tone: "error", title: "Update failed", message: formatApiError(reason) });
65 },
66 });
67 
68 const updateField = <Field extends keyof SettingsFormState>(field: Field, value: SettingsFormState[Field]) => {
69 updateProjectMutation.reset();
70 setForm((current) => ({ ...current, [field]: value }));
71 };
72 
73 const handleSubmit = (event: FormEvent) => {
74 event.preventDefault();
75 if (updateProjectMutation.isPending) return;
76 
77 updateProjectMutation.reset();
78 
79 const payload: Record<string, unknown> = {};
80 if (form.name !== project.name) payload.name = form.name;
81 if (form.repoUrl !== project.repoUrl) payload.repoUrl = form.repoUrl;
82 if (form.defaultBranch !== project.defaultBranch) payload.defaultBranch = form.defaultBranch;
83 if (form.configPath !== project.configPath) payload.configPath = form.configPath;
84 if (form.repoToken) payload.repoToken = form.repoToken;
85 if (form.dispatchMode !== project.dispatchMode) payload.dispatchMode = form.dispatchMode;
86 
87 if (Object.keys(payload).length === 0) {
88 pushToast({ tone: "success", title: "No changes", message: "Nothing to update." });
89 return;
90 }
91 
92 updateProjectMutation.mutate(UpdateProjectRequestCodec.assertDecode(payload));
93 };
94 
95 const submitError = updateProjectMutation.isError ? formatApiError(updateProjectMutation.error) : null;
96 const submitting = updateProjectMutation.isPending;
97 
98 return (
99 <div className="animate-slide-up space-y-5">
100 <Breadcrumbs
101 items={[
102 { label: "Projects", href: "/app/projects" },
103 { label: project.name, href: `/app/projects/${projectId}` },
104 { label: "Settings" },
105 ]}
106 />
107
108 <PageHeader
109 label="Project Settings"
110 title="Update project"
111 description="Change the project name, repository URL, default branch, config path, or repository token."
112 />
113
114 <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_260px]">
115 <div>
116 <Card>
117 <form className="space-y-4" onSubmit={handleSubmit}>
118 <div className="grid gap-4 sm:grid-cols-2">
119 <Input
120 label="Project name"
121 value={form.name}
122 onChange={(event) => updateField("name", event.target.value)}
123 placeholder="Docs Forge"
124 helperText="Operator-facing project label shown across the shell."
125 required
126 />
127 <Input
128 label="Project slug"
129 value={`${project.ownerSlug}/${project.projectSlug}`}
130 disabled
131 helperText="Immutable owner-scoped identifier."
132 />
133 </div>
134
135 <Input
136 label="Repository URL"
137 value={form.repoUrl}
138 onChange={(event) => updateField("repoUrl", event.target.value)}
139 placeholder="https://github.com/owner/repo"
140 helperText="HTTPS clone URL only in v1."
141 required
142 />
143
144 <div className="grid gap-4 sm:grid-cols-2">
145 <Input
146 label="Default branch"
147 value={form.defaultBranch}
148 onChange={(event) => updateField("defaultBranch", event.target.value)}
149 placeholder="main"
150 helperText="Branch used for manual runs unless overridden."
151 required
152 />
153 <Input
154 label="Config path"
155 value={form.configPath}
156 onChange={(event) => updateField("configPath", event.target.value)}
157 placeholder=".anvil.yml"
158 helperText="Repository-defined pipeline config path."
159 />
160 </div>
161
162 <Input
163 label="Repository token"
164 type="password"
165 value={form.repoToken}
166 onChange={(event) => updateField("repoToken", event.target.value)}
167 placeholder="Leave empty to keep current token"
168 helperText="Enter a new token to replace, or leave empty to keep the existing one."
169 />
170
171 {submitError ? <ErrorBanner message={submitError} /> : null}
172
173 <div className="flex flex-wrap gap-3 pt-2">
174 <Button
175 variant="primary"
176 type="submit"
177 disabled={submitting}
178 loading={submitting}
179 icon={!submitting ? <Save className="h-4 w-4" /> : undefined}
180 >
181 Save Changes
182 </Button>
183 <ButtonLink to={`/app/projects/${projectId}`} variant="ghost">
184 Cancel
185 </ButtonLink>
186 </div>
187 </form>
188 </Card>
189 </div>
190
191 <div className="space-y-4">
192 <Card className="p-4">
193 <p className="text-xs uppercase tracking-[0.2em] text-zinc-500">Repository host</p>
194 <p className="mt-2 text-lg font-semibold text-zinc-100">
195 {form.repoUrl ? inferRepositoryProvider(form.repoUrl) : "not set"}
196 </p>
197 </Card>
198
199 <Card className="p-4">
200 <p className="text-xs uppercase tracking-[0.2em] text-zinc-500">Dispatch</p>
201 <div
202 className="mt-2 flex rounded-lg border border-zinc-800/70 bg-zinc-900/80 p-0.5"
203 role="radiogroup"
204 aria-label="Dispatch mode"
205 >
206 {(["queue", "workflows"] as const).map((option) => (
207 <button
208 key={option}
209 type="button"
210 role="radio"
211 aria-checked={form.dispatchMode === option}
212 className={[
213 "flex-1 rounded px-2 py-1 text-[11px] font-medium transition-colors",
214 form.dispatchMode === option
215 ? "bg-accent-500/15 text-accent-300"
216 : "text-zinc-500 hover:bg-zinc-800/80 hover:text-zinc-200",
217 ].join(" ")}
218 onClick={() => updateField("dispatchMode", option)}
219 >
220 {option === "queue" ? "Queue" : "Workflows"}
221 </button>
222 ))}
223 </div>
224 <p className="mt-2.5 text-xs leading-relaxed text-zinc-500">
225 {form.dispatchMode === "queue"
226 ? "Runs are dispatched via Cloudflare Queues with at-least-once delivery."
227 : "Runs use Cloudflare Workflows for durable execution with automatic retries."}
228 </p>
229 </Card>
230 </div>
231 </div>
232 </div>
233 );
234};
235 
236export const ProjectSettingsPage = () => {
237 const { projectId } = useParams<{ projectId: string }>();
238 const resolvedProjectId = projectId ?? "";
239 
240 const detailQuery = useQuery({
241 queryKey: queryKeys.projectDetail(resolvedProjectId),
242 queryFn: () => getApiClient().getProjectDetail(resolvedProjectId),
243 enabled: resolvedProjectId.length > 0,
244 });
245 
246 if (!projectId) {
247 return (
248 <div className="space-y-4">
249 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: "Error" }]} />
250 <ErrorBanner message="Missing project id." />
251 </div>
252 );
253 }
254 
255 if (detailQuery.isPending) {
256 return <LoadingPanel label="Loading project settings..." />;
257 }
258 
259 if (detailQuery.isError || !detailQuery.data) {
260 return (
261 <div className="space-y-4">
262 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: "Error" }]} />
263 <ErrorBanner message={detailQuery.isError ? formatApiError(detailQuery.error) : "Failed to load project."} />
264 </div>
265 );
266 }
267 
268 return (
269 <ProjectSettingsForm
270 key={`${projectId}:${detailQuery.data.project.updatedAt}`}
271 project={detailQuery.data.project}
272 projectId={projectId}
273 />
274 );
275};