File
Blob: src/client/pages/project-settings-page.tsx
| 1 | import { |
| 2 | UpdateProjectRequest as UpdateProjectRequestCodec, |
| 3 | type DispatchMode, |
| 4 | type ProjectConfigSummary, |
| 5 | type ProjectDetail, |
| 6 | type UpdateProjectRequest, |
| 7 | } from "@/contracts"; |
| 8 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 9 | import { Save } from "lucide-react"; |
| 10 | import type { FormEvent } from "react"; |
| 11 | import { useState } from "react"; |
| 12 | import { useNavigate, useParams } from "react-router-dom"; |
| 13 | import { LoadingPanel } from "@/client/components"; |
| 14 | import { Breadcrumbs, Button, ButtonLink, Card, ErrorBanner, Input, PageHeader } from "@/client/components/ui"; |
| 15 | import { formatApiError, getApiClient, inferRepositoryProvider, queryKeys } from "@/client/lib"; |
| 16 | import { useToast } from "@/client/toast"; |
| 17 | |
| 18 | interface SettingsFormState { |
| 19 | name: string; |
| 20 | repoUrl: string; |
| 21 | defaultBranch: string; |
| 22 | configPath: string; |
| 23 | repoToken: string; |
| 24 | dispatchMode: DispatchMode; |
| 25 | } |
| 26 | |
| 27 | interface ProjectSettingsFormProps { |
| 28 | project: ProjectConfigSummary; |
| 29 | projectId: string; |
| 30 | } |
| 31 | |
| 32 | const 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 | |
| 41 | const 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 | |
| 236 | export 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 | }; |