import { UpdateProjectRequest as UpdateProjectRequestCodec, type DispatchMode, type ProjectConfigSummary, type ProjectDetail, type UpdateProjectRequest, } from "@/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Save } from "lucide-react"; import type { FormEvent } from "react"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { LoadingPanel } from "@/client/components"; import { Breadcrumbs, Button, ButtonLink, Card, ErrorBanner, Input, PageHeader } from "@/client/components/ui"; import { formatApiError, getApiClient, inferRepositoryProvider, queryKeys } from "@/client/lib"; import { useToast } from "@/client/toast"; interface SettingsFormState { name: string; repoUrl: string; defaultBranch: string; configPath: string; repoToken: string; dispatchMode: DispatchMode; } interface ProjectSettingsFormProps { project: ProjectConfigSummary; projectId: string; } const buildInitialForm = (project: ProjectConfigSummary): SettingsFormState => ({ name: project.name, repoUrl: project.repoUrl, defaultBranch: project.defaultBranch, configPath: project.configPath, repoToken: "", dispatchMode: project.dispatchMode, }); const ProjectSettingsForm = ({ project, projectId }: ProjectSettingsFormProps) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const [form, setForm] = useState(() => buildInitialForm(project)); const updateProjectMutation = useMutation({ mutationFn: (payload: UpdateProjectRequest) => getApiClient().updateProject(projectId, payload), onSuccess: (response) => { queryClient.setQueryData(queryKeys.projectDetail(projectId), (current) => current ? { ...current, project: response.project } : current, ); void queryClient.invalidateQueries({ queryKey: queryKeys.projectDetail(projectId) }); void queryClient.invalidateQueries({ queryKey: queryKeys.projectsRoot() }); pushToast({ tone: "success", title: "Project updated", message: `${response.project.ownerSlug}/${response.project.projectSlug} settings saved.`, }); navigate(`/app/projects/${projectId}`, { replace: true }); }, onError: (reason) => { pushToast({ tone: "error", title: "Update failed", message: formatApiError(reason) }); }, }); const updateField = (field: Field, value: SettingsFormState[Field]) => { updateProjectMutation.reset(); setForm((current) => ({ ...current, [field]: value })); }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (updateProjectMutation.isPending) return; updateProjectMutation.reset(); const payload: Record = {}; if (form.name !== project.name) payload.name = form.name; if (form.repoUrl !== project.repoUrl) payload.repoUrl = form.repoUrl; if (form.defaultBranch !== project.defaultBranch) payload.defaultBranch = form.defaultBranch; if (form.configPath !== project.configPath) payload.configPath = form.configPath; if (form.repoToken) payload.repoToken = form.repoToken; if (form.dispatchMode !== project.dispatchMode) payload.dispatchMode = form.dispatchMode; if (Object.keys(payload).length === 0) { pushToast({ tone: "success", title: "No changes", message: "Nothing to update." }); return; } updateProjectMutation.mutate(UpdateProjectRequestCodec.assertDecode(payload)); }; const submitError = updateProjectMutation.isError ? formatApiError(updateProjectMutation.error) : null; const submitting = updateProjectMutation.isPending; return (
updateField("name", event.target.value)} placeholder="Docs Forge" helperText="Operator-facing project label shown across the shell." required />
updateField("repoUrl", event.target.value)} placeholder="https://github.com/owner/repo" helperText="HTTPS clone URL only in v1." required />
updateField("defaultBranch", event.target.value)} placeholder="main" helperText="Branch used for manual runs unless overridden." required /> updateField("configPath", event.target.value)} placeholder=".anvil.yml" helperText="Repository-defined pipeline config path." />
updateField("repoToken", event.target.value)} placeholder="Leave empty to keep current token" helperText="Enter a new token to replace, or leave empty to keep the existing one." /> {submitError ? : null}
Cancel

Repository host

{form.repoUrl ? inferRepositoryProvider(form.repoUrl) : "not set"}

Dispatch

{(["queue", "workflows"] as const).map((option) => ( ))}

{form.dispatchMode === "queue" ? "Runs are dispatched via Cloudflare Queues with at-least-once delivery." : "Runs use Cloudflare Workflows for durable execution with automatic retries."}

); }; export const ProjectSettingsPage = () => { const { projectId } = useParams<{ projectId: string }>(); const resolvedProjectId = projectId ?? ""; const detailQuery = useQuery({ queryKey: queryKeys.projectDetail(resolvedProjectId), queryFn: () => getApiClient().getProjectDetail(resolvedProjectId), enabled: resolvedProjectId.length > 0, }); if (!projectId) { return (
); } if (detailQuery.isPending) { return ; } if (detailQuery.isError || !detailQuery.data) { return (
); } return ( ); };