From 2e85a1ec48d92fbe568371c37613b377f7525e3c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 24 May 2026 12:04:50 -0400 Subject: [PATCH] Add New Automation form and refresh Secrets form layout - Add /automations/new with Basics/Source/Goal/Triggers panels and a kebab-case Slug auto-derived from Name until the user edits it - Wire the "Create Automation" button on /automations to the new page - Move individual automation URL from /automations/ to /automation/; /automations and /automations/new are unchanged - Refresh /settings/secrets/new to use the Panel + Row layout pattern: breadcrumb header, one field per row, plain footer, no inside-Panel stacked form Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/router.tsx | 4 +- .../app/routes/automation-detail.tsx | 2 +- apps/fabro-web/app/routes/automations-new.tsx | 373 ++++++++++++++++++ apps/fabro-web/app/routes/automations.tsx | 8 +- .../app/routes/settings-secrets-new.tsx | 219 ++++++---- 5 files changed, 524 insertions(+), 82 deletions(-) create mode 100644 apps/fabro-web/app/routes/automations-new.tsx diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 53c20b2eb..727f6257c 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -11,6 +11,7 @@ import * as ChatsNew from "./routes/chats-new"; import * as ChatsDetail from "./routes/chats-detail"; import * as AskFabro from "./routes/ask-fabro"; import * as Automations from "./routes/automations"; +import * as AutomationsNew from "./routes/automations-new"; import * as AutomationDetail from "./routes/automation-detail"; import * as AutomationDefinition from "./routes/automation-definition"; import * as AutomationDiagram from "./routes/automation-diagram"; @@ -107,7 +108,8 @@ export const routes: RouteObject[] = [ }), route("ask-fabro", AskFabro), route("automations", Automations), - route("automations/:name", AutomationDetail, { + route("automations/new", AutomationsNew), + route("automation/:name", AutomationDetail, { children: [ indexRoute(AutomationDefinition), route("diagram", AutomationDiagram), diff --git a/apps/fabro-web/app/routes/automation-detail.tsx b/apps/fabro-web/app/routes/automation-detail.tsx index c87f8e182..fe6ebe3a0 100644 --- a/apps/fabro-web/app/routes/automation-detail.tsx +++ b/apps/fabro-web/app/routes/automation-detail.tsx @@ -326,7 +326,7 @@ export default function AutomationDetail() { const workflowQuery = useWorkflow(name); const { pathname } = useLocation(); const workflow = resolveWorkflow(name, workflowQuery.data); - const basePath = `/automations/${name}`; + const basePath = `/automation/${name}`; return (
diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx new file mode 100644 index 000000000..e3caae215 --- /dev/null +++ b/apps/fabro-web/app/routes/automations-new.tsx @@ -0,0 +1,373 @@ +import { useState, type ReactNode } from "react"; +import { Link, useNavigate } from "react-router"; +import { Switch } from "@headlessui/react"; +import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; + +import { Panel, Row } from "../components/settings-panel"; +import { + INPUT_CLASS, + PRIMARY_BUTTON_CLASS, + SECONDARY_BUTTON_CLASS, +} from "../components/ui"; + +export function meta() { + return [{ title: "New automation — Fabro" }]; +} + +export const handle = { hideHeader: true }; + +const SAMPLE_REPOSITORIES = [ + "qltysh/fabro", + "qltysh/fabro-cloud", + "acme/orders-api", + "acme/billing", + "acme/web", +]; + +const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ + { label: "Every hour", value: "0 * * * *" }, + { label: "Daily 9:00 UTC", value: "0 9 * * *" }, + { label: "Weekdays 9:00", value: "0 9 * * 1-5" }, + { label: "Mondays 8:00", value: "0 8 * * 1" }, +]; + +export default function AutomationsNew() { + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [slugTouched, setSlugTouched] = useState(false); + const [description, setDescription] = useState(""); + const [repository, setRepository] = useState(SAMPLE_REPOSITORIES[0]); + const [branch, setBranch] = useState("main"); + const [workflowSlug, setWorkflowSlug] = useState(""); + const [goal, setGoal] = useState(""); + const [manualEnabled, setManualEnabled] = useState(true); + const [scheduleEnabled, setScheduleEnabled] = useState(false); + const [cron, setCron] = useState("0 9 * * 1-5"); + + function onNameChange(next: string) { + setName(next); + if (!slugTouched) setSlug(kebabify(next)); + } + + function onSlugChange(next: string) { + setSlug(kebabify(next)); + setSlugTouched(true); + } + + const canSubmit = + name.trim() !== "" && + slug.trim() !== "" && + workflowSlug.trim() !== "" && + (manualEnabled || scheduleEnabled); + + function onSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!canSubmit) return; + // Non-functional prototype — just return to the list. + navigate("/automations"); + } + + return ( +
+ + + + Name} help="Shown wherever this automation is listed."> + onNameChange(e.target.value)} + placeholder="Fix Build" + autoFocus + autoComplete="off" + className={INPUT_CLASS} + /> + + Slug} + help={ + <> + Identifier used in the URL: /automation/{slug || ""} + + } + > + onSlugChange(e.target.value)} + placeholder="fix-build" + autoComplete="off" + spellCheck={false} + className={`${INPUT_CLASS} font-mono`} + /> + + Description} help="A short summary teammates will see when browsing automations."> +