mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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/<slug> to /automation/<slug>; /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) <noreply@anthropic.com>
This commit is contained in:
parent
c5f57dbc50
commit
2e85a1ec48
5 changed files with 524 additions and 82 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
|
|
|
|||
373
apps/fabro-web/app/routes/automations-new.tsx
Normal file
373
apps/fabro-web/app/routes/automations-new.tsx
Normal file
|
|
@ -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 (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<PageHeader />
|
||||
|
||||
<Panel title="Basics">
|
||||
<Row title={<Label required>Name</Label>} help="Shown wherever this automation is listed.">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="Fix Build"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Slug</Label>}
|
||||
help={
|
||||
<>
|
||||
Identifier used in the URL: <span className="font-mono text-fg-2">/automation/{slug || "<slug>"}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="slug"
|
||||
value={slug}
|
||||
onChange={(e) => onSlugChange(e.target.value)}
|
||||
placeholder="fix-build"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label optional>Description</Label>} help="A short summary teammates will see when browsing automations.">
|
||||
<textarea
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Diagnose and fix CI build failures by analyzing logs and applying targeted patches."
|
||||
className={`${INPUT_CLASS} resize-y`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Source">
|
||||
<Row title={<Label required>Repository</Label>} help="The repository this automation will check out and operate on.">
|
||||
<SelectInput
|
||||
name="repository"
|
||||
value={repository}
|
||||
onChange={setRepository}
|
||||
options={SAMPLE_REPOSITORIES}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label required>Branch</Label>} help="Default branch to run against.">
|
||||
<input
|
||||
type="text"
|
||||
name="branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Workflow slug</Label>}
|
||||
help="Snake-case identifier used in the workflow file name (e.g. fix_build.fabro)."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="workflow_slug"
|
||||
value={workflowSlug}
|
||||
onChange={(e) => setWorkflowSlug(snakeify(e.target.value))}
|
||||
placeholder="fix_build"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Goal">
|
||||
<Row title={<Label optional>Goal</Label>} help="Plain-English objective the agent uses to decide when the workflow has succeeded.">
|
||||
<textarea
|
||||
name="goal"
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Diagnose and fix the failing build so CI is green on the target branch."
|
||||
className={`${INPUT_CLASS} resize-y`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Triggers">
|
||||
<Row title="Manual / API" help="Start a run by clicking Run in the UI or calling the API.">
|
||||
<ToggleSwitch
|
||||
checked={manualEnabled}
|
||||
onChange={setManualEnabled}
|
||||
label="Enable manual and API triggers"
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Schedule" help="Start runs automatically on a recurring cron schedule.">
|
||||
<ToggleSwitch
|
||||
checked={scheduleEnabled}
|
||||
onChange={setScheduleEnabled}
|
||||
label="Enable scheduled triggers"
|
||||
/>
|
||||
</Row>
|
||||
{scheduleEnabled ? (
|
||||
<Row
|
||||
title="Cron expression"
|
||||
help={
|
||||
<>
|
||||
Five-field POSIX cron in UTC. Next run: <span className="text-fg-2">{describeCron(cron)}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
name="cron"
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
placeholder="0 9 * * 1-5"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CRON_PRESETS.map((preset) => {
|
||||
const active = preset.value === cron;
|
||||
return (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => setCron(preset.value)}
|
||||
aria-pressed={active}
|
||||
className={`rounded-full px-2.5 py-1 text-xs transition-colors ${
|
||||
active
|
||||
? "bg-teal-500/15 text-teal-300 outline-1 -outline-offset-1 outline-teal-500/40"
|
||||
: "bg-overlay text-fg-3 hover:bg-overlay-strong hover:text-fg-2"
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<FormFooter canSubmit={canSubmit} onCancel={() => navigate("/automations")} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<div>
|
||||
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/automations" className="text-fg-3 hover:text-fg">
|
||||
Automations
|
||||
</Link>
|
||||
<ChevronRightIcon className="size-3" aria-hidden="true" />
|
||||
<span>New automation</span>
|
||||
</nav>
|
||||
<h2 className="text-xl font-semibold text-fg">New automation</h2>
|
||||
<p className="mt-2 max-w-prose text-sm leading-relaxed text-fg-3">
|
||||
Define a workflow that Fabro can run on demand, on a schedule, or via the API.
|
||||
You can refine the graph and per-stage prompts after it's created.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({
|
||||
children,
|
||||
required,
|
||||
optional,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
optional?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
<span>{children}</span>
|
||||
{required ? (
|
||||
<span aria-label="required" className="text-coral">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectInput({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
options: ReadonlyArray<string>;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`${INPUT_CLASS} appearance-none pr-9 font-mono`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
aria-label={label}
|
||||
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
|
||||
>
|
||||
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFooter({
|
||||
canSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
canSubmit: boolean;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onCancel} className={SECONDARY_BUTTON_CLASS}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
|
||||
Create automation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function kebabify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function snakeify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "");
|
||||
}
|
||||
|
||||
function describeCron(expression: string): string {
|
||||
const trimmed = expression.trim();
|
||||
const preset = CRON_PRESETS.find((p) => p.value === trimmed);
|
||||
if (preset) return preset.label;
|
||||
if (!/^[\d*/,\-\s]+$/.test(trimmed) || trimmed.split(/\s+/).length !== 5) {
|
||||
return "Waiting for a valid expression…";
|
||||
}
|
||||
return "Computed when saved";
|
||||
}
|
||||
|
|
@ -24,13 +24,13 @@ export function meta({}: any) {
|
|||
export const handle = {
|
||||
headerExtra: (
|
||||
<div className="relative inline-flex rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
to="/automations/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-l-md border border-r-0 border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
Create Automation
|
||||
</button>
|
||||
</Link>
|
||||
<Menu as="div" className="relative -ml-px flex">
|
||||
<MenuButton className="inline-flex items-center rounded-r-md border border-mint/20 px-1.5 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg">
|
||||
<ChevronDownIcon className="size-3.5" aria-hidden="true" />
|
||||
|
|
@ -145,7 +145,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
|
|||
const Icon = workflow.icon;
|
||||
return (
|
||||
<div className="group flex items-center gap-4 rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
|
||||
<Link to={`/automations/${workflow.slug}`} className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Link to={`/automation/${workflow.slug}`} className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-panel-alt/60"
|
||||
style={{ borderColor: `color-mix(in srgb, ${workflow.color} 20%, transparent)`, color: workflow.color }}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/16/solid";
|
||||
import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import { SecretType } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { ApiError, apiData, secretsApi } from "../lib/api-client";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { Panel, SettingsPageIntro } from "../components/settings-panel";
|
||||
import { Panel, Row } from "../components/settings-panel";
|
||||
import {
|
||||
ErrorMessage,
|
||||
FormField,
|
||||
INPUT_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
|
|
@ -31,19 +30,24 @@ function isFormType(
|
|||
export default function SettingsSecretsNew() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
to="/settings/secrets"
|
||||
className="inline-flex items-center gap-1 text-sm text-fg-3 transition-colors hover:text-fg"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" aria-hidden="true" />
|
||||
Back to secrets
|
||||
</Link>
|
||||
<SettingsPageIntro description="Store a new token or file secret on this Fabro server. The value is write-only — it can be replaced or deleted later, but never read back through the UI." />
|
||||
<PageHeader />
|
||||
<CreateSecretForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/settings/secrets" className="text-fg-3 hover:text-fg">
|
||||
Secrets
|
||||
</Link>
|
||||
<ChevronRightIcon className="size-3" aria-hidden="true" />
|
||||
<span>New secret</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateSecretForm() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -95,83 +99,146 @@ function CreateSecretForm() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Panel title="New secret">
|
||||
<form onSubmit={onSubmit} className="space-y-4 px-4 py-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField label="Type" htmlFor="secret-type">
|
||||
<select
|
||||
id="secret-type"
|
||||
value={type}
|
||||
onChange={(event) => {
|
||||
if (isFormType(event.target.value)) setType(event.target.value);
|
||||
}}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value={SecretType.TOKEN}>Token (environment variable)</option>
|
||||
<option value={SecretType.FILE}>File</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label={nameLabel} htmlFor="secret-name" help={nameHelp}>
|
||||
<input
|
||||
id="secret-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="Value"
|
||||
htmlFor="secret-value"
|
||||
help={
|
||||
isFile
|
||||
? "File contents. Stored as-is and never shown again."
|
||||
: "The secret value. Stored as-is and never shown again."
|
||||
}
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<Panel title="Secret">
|
||||
<Row title={<Label required>Type</Label>} help="Tokens are exposed as environment variables; files are written to a path inside the sandbox.">
|
||||
<SelectInput
|
||||
name="type"
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
if (isFormType(next)) setType(next);
|
||||
}}
|
||||
options={[
|
||||
{ value: SecretType.TOKEN, label: "Token (environment variable)" },
|
||||
{ value: SecretType.FILE, label: "File" },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label required>{nameLabel}</Label>} help={nameHelp}>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Value</Label>}
|
||||
help="Stored as-is and never shown again. Replace or delete it later if it needs to change."
|
||||
>
|
||||
<textarea
|
||||
id="secret-value"
|
||||
name="value"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
rows={isFile ? 4 : 2}
|
||||
rows={isFile ? 6 : 2}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} resize-y font-mono`}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Description"
|
||||
htmlFor="secret-description"
|
||||
help="Optional. Helps operators recognize what this secret is for."
|
||||
>
|
||||
</Row>
|
||||
<Row title={<Label optional>Description</Label>} help="Helps operators recognize what this secret is for.">
|
||||
<input
|
||||
id="secret-description"
|
||||
type="text"
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Optional"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</FormField>
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/settings/secrets")}
|
||||
disabled={submitting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
|
||||
{submitting ? "Saving…" : "Save secret"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
||||
<FormFooter
|
||||
submitting={submitting}
|
||||
canSubmit={canSubmit}
|
||||
onCancel={() => navigate("/settings/secrets")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({
|
||||
children,
|
||||
required,
|
||||
optional,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
optional?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
<span>{children}</span>
|
||||
{required ? (
|
||||
<span aria-label="required" className="text-coral">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectInput({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`${INPUT_CLASS} appearance-none pr-9`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFooter({
|
||||
submitting,
|
||||
canSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
|
||||
{submitting ? "Saving…" : "Save secret"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue