|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import type { ReactNode } from "react";
|
| import { useEffect, useRef, useState } from "react";
|
|
|
|
|
|
|
|
|
| import { brandForConnector, brandForKind, hasBrandKind } from "../connectors/brandMarks";
|
|
|
| import type {
|
| Action,
|
| ActionCatalogRow,
|
| Automation,
|
| Cond,
|
| FlowVocab,
|
| GraphNode,
|
| OAuthStatus,
|
| TriggerOption,
|
| UserTable,
|
| } from "./automationApi";
|
|
|
|
|
|
|
| import { AutomationError, createTable, runBlock } from "./automationApi";
|
| import CondBuilder, { condComplete } from "./CondBuilder";
|
| import { groupActions, groupBranches, groupByPanel, numberActions, reorderList }
|
| from "./steps";
|
|
|
|
|
|
|
|
|
| import PresetPlan from "./PresetPlan";
|
| import TriggerPicker, { TriggerMark } from "./TriggerPicker";
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const ACTION_DRAG_TYPE = "application/x-loopable-action";
|
|
|
| interface Props {
|
| automation: Automation;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| name: string;
|
| onNameText: (v: string) => void;
|
| onNameCommit: () => void;
|
|
|
| triggers?: TriggerOption[];
|
| catalog: ActionCatalogRow[];
|
| vocab?: FlowVocab;
|
| tables: UserTable[];
|
| oauth: OAuthStatus | null;
|
|
|
| busy: boolean;
|
|
|
| onPatch: (body: Record<string, unknown>) => void;
|
| onToggleNode: (nodeId: string) => void;
|
| |
| |
| |
| |
| |
| |
|
|
| onPickTrigger: (key: string) => void;
|
| onRunNow: () => void;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| renderNodeBody: (panel: string) => ReactNode;
|
| |
| |
| |
| |
| |
|
|
| scheduleFace: ReactNode;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| showProperties: boolean;
|
| |
|
|
| panelTabs: ReactNode;
|
| |
|
|
| onStepPicked: () => void;
|
| |
| |
| |
| |
|
|
| schedules: boolean;
|
| |
| |
| |
| |
| |
|
|
| walkTable: string;
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| onTablesChanged: () => Promise<void> | void;
|
| |
| |
| |
| |
| |
|
|
| dirty: boolean;
|
| }
|
|
|
| |
| |
| |
| |
|
|
| type Sel = { kind: "trigger" | "action"; id: string };
|
|
|
|
|
| const SUGGESTED = 6;
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const ENRICH_KINDS = ["enrich_instagram", "enrich_tiktok"];
|
| const isEnrich = (kind: string): boolean => ENRICH_KINDS.includes(kind);
|
|
|
| |
| |
| |
|
|
| function ActionMark({ kind }: { kind: string }) {
|
| |
| |
| |
|
|
| const brand = brandForKind(kind);
|
| if (brand) return brand;
|
| const common = {
|
| width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
|
| strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
|
| "aria-hidden": true,
|
| };
|
| if (kind === "group")
|
| return (
|
| <svg {...common}>
|
| <path d="M2.6 8h3l2.6-3.6h5M8.2 11.6H5.6m2.6 0h5" />
|
| <path d="M11.6 2.6 13.4 4.4 11.6 6.2M11.6 9.8l1.8 1.8-1.8 1.8" />
|
| </svg>
|
| );
|
| if (kind === "create_record")
|
| return (
|
| <svg {...common}>
|
| <rect x="2.6" y="2.6" width="10.8" height="10.8" rx="2" />
|
| <path d="M8 5.6v4.8M5.6 8h4.8" />
|
| </svg>
|
| );
|
| if (kind === "find_records")
|
| return (
|
| <svg {...common}>
|
| <circle cx="7.2" cy="7.2" r="4" />
|
| <path d="m10.2 10.2 3.2 3.2" />
|
| </svg>
|
| );
|
| |
| |
| |
| |
| |
| |
| |
|
|
| if (kind === "source")
|
| return (
|
| <svg {...common}>
|
| <circle cx="8" cy="8" r="5.6" />
|
| <path d="M2.6 8h10.8M8 2.4c1.5 1.7 2.2 3.6 2.2 5.6S9.5 12.3 8 13.6C6.5 12.3 5.8 10 5.8 8s.7-3.9 2.2-5.6z" />
|
| </svg>
|
| );
|
| if (kind === "write")
|
| return (
|
| <svg {...common}>
|
| <ellipse cx="8" cy="4.2" rx="4.8" ry="1.9" />
|
| <path d="M3.2 4.2v7.6c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9V4.2M3.2 8c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9" />
|
| </svg>
|
| );
|
| if (kind === "capture")
|
| return (
|
| <svg {...common}>
|
| <path d="M8 2.6v7.2M5.2 7l2.8 2.8L10.8 7M3 12.4h10" />
|
| </svg>
|
| );
|
| if (kind === "branch")
|
| return (
|
| <svg {...common}>
|
| <path d="M2.6 8h3.2l2.6-3.6h5M8.4 11.6H5.8m2.6 0h5" />
|
| <path d="M11.6 2.6 13.4 4.4 11.6 6.2M11.6 9.8l1.8 1.8-1.8 1.8" />
|
| </svg>
|
| );
|
| if (kind === "update_record")
|
| return (
|
| <svg {...common}>
|
| <path d="M11.4 2.8 13.2 4.6 6 11.8l-2.6.8.8-2.6z" />
|
| <path d="M3 13.6h10" />
|
| </svg>
|
| );
|
| if (kind === "send_email")
|
| return (
|
| <svg {...common}>
|
| <rect x="2.2" y="3.6" width="11.6" height="8.8" rx="1.6" />
|
| <path d="m2.6 4.6 5.4 3.8 5.4-3.8" />
|
| </svg>
|
| );
|
| if (kind === "slack")
|
| return (
|
| <svg {...common}>
|
| <path d="M13.4 9.4a1.6 1.6 0 0 1-1.6 1.6H5.4L2.6 13.4V4.2a1.6 1.6 0 0 1 1.6-1.6h7.6a1.6 1.6 0 0 1 1.6 1.6z" />
|
| </svg>
|
| );
|
| if (kind === "run_script")
|
| return (
|
| <svg {...common}>
|
| <path d="M5.6 5.4 3 8l2.6 2.6M10.4 5.4 13 8l-2.6 2.6M9 3.4 7 12.6" />
|
| </svg>
|
| );
|
| if (kind === "generate_ai")
|
| return (
|
| <svg {...common}>
|
| <path d="M8 2.4l1.5 3.4 3.4 1.5-3.4 1.5L8 12.2 6.5 8.8 3.1 7.3l3.4-1.5z" />
|
| </svg>
|
| );
|
| if (kind === "repeating_group")
|
| return (
|
| <svg {...common}>
|
| <path d="M3 8a5 5 0 0 1 5-5c2 0 3.4 1 4.3 2.4M13 8a5 5 0 0 1-5 5c-2 0-3.4-1-4.3-2.4" />
|
| <path d="M12.4 2.6v2.9h-2.9M3.6 13.4v-2.9h2.9" />
|
| </svg>
|
| );
|
| |
| |
| |
| |
| |
| |
|
|
| return (
|
| <svg {...common}>
|
| <rect x="3.2" y="3.2" width="9.6" height="9.6" rx="2.2" />
|
| <circle cx="8" cy="8" r="1.4" />
|
| </svg>
|
| );
|
| }
|
|
|
|
|
| function tableLabel(tables: UserTable[], key: string): string {
|
| if (!key) return "";
|
| return tables.find((t) => t.key === key)?.label || key;
|
| }
|
|
|
| export default function AutomationBuilder({
|
| automation,
|
| name,
|
| onNameText,
|
| onNameCommit,
|
| triggers,
|
| catalog,
|
| vocab,
|
| tables,
|
| oauth,
|
| busy,
|
| onPatch,
|
| onToggleNode,
|
| onPickTrigger,
|
| onRunNow,
|
| renderNodeBody,
|
| scheduleFace,
|
| showProperties,
|
| panelTabs,
|
| onStepPicked,
|
| schedules,
|
| walkTable,
|
| onTablesChanged,
|
| dirty,
|
| }: Props) {
|
| const [sel, setSel] = useState<Sel>({ kind: "trigger", id: "" });
|
| const [picking, setPicking] = useState(false);
|
| const [adding, setAdding] = useState(false);
|
|
|
| const [confirmKey, setConfirmKey] = useState("");
|
|
|
| const [dragOver, setDragOver] = useState("");
|
| |
| |
| |
| |
| |
| |
|
|
| const draggedRef = useRef(false);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const addWrap = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
| useEffect(() => {
|
| if (!picking) return;
|
| const onKey = (e: KeyboardEvent) => {
|
| if (e.key === "Escape") setPicking(false);
|
| };
|
| const onDown = (e: MouseEvent) => {
|
| if (!addWrap.current?.contains(e.target as Node)) setPicking(false);
|
| };
|
| window.addEventListener("keydown", onKey);
|
| window.addEventListener("mousedown", onDown);
|
| return () => {
|
| window.removeEventListener("keydown", onKey);
|
| window.removeEventListener("mousedown", onDown);
|
| };
|
| }, [picking]);
|
|
|
| const trigger = automation.trigger || null;
|
| const triggerKey = trigger?.key || (automation.schedule?.enabled ? "schedule" : "manual");
|
| const options = triggers || [];
|
| const picked = options.find((t) => t.key === triggerKey) || null;
|
| const actions = automation.flow?.actions || [];
|
| const nodes = (automation.graph?.nodes || []).filter((n) => n.kind !== "trigger");
|
| const ops = vocab?.condOps || [];
|
| const nullaryOps = vocab?.nullaryCondOps || [];
|
| const table = tables.find((t) => t.key === (trigger?.table || "")) || null;
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const chosen = !!trigger || !!automation.schedule?.enabled;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const configured = !trigger || trigger.configured !== false;
|
|
|
|
|
| const patchTrigger = (patch: Record<string, unknown>) =>
|
| onPatch({ trigger: { key: triggerKey, ...patch } });
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const patchConfig = (patch: Record<string, unknown>) =>
|
| onPatch({ config: { ...((automation.config as Record<string, unknown>) || {}), ...patch } });
|
|
|
| const patchFlow = (next: Action[]) => onPatch({ flow: { actions: next } });
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const mapTree = (list: Action[], f: (a: Action) => Action | null): Action[] =>
|
| (list || [])
|
| .map((a) => {
|
| const next = f(a);
|
| if (next === null) return null;
|
| if (next.kind !== "group") return next;
|
| const brs = groupBranches(next);
|
| if (!brs.length) return next;
|
| return {
|
| ...next,
|
| config: {
|
| ...next.config,
|
| branches: brs.map((br) => ({ ...br, actions: mapTree(br.actions || [], f) })),
|
| },
|
| };
|
| })
|
| .filter((a): a is Action => a !== null);
|
|
|
|
|
| const editAction = (id: string, change: (a: Action) => Action) =>
|
| patchFlow(mapTree(actions, (a) => (a.id === id ? change(a) : a)));
|
|
|
| const dropAction = (id: string) => {
|
|
|
|
|
|
|
|
|
| patchFlow(mapTree(actions, (a) => (a.id === id ? null : a)));
|
| if (sel.kind === "action" && sel.id === id) setSel({ kind: "trigger", id: "" });
|
| };
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const moveAction = (dragId: string, dropId: string) => {
|
| const next = reorderList(actions, dragId, dropId);
|
|
|
|
|
| if (next) patchFlow(next);
|
| };
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const walkFields = (tables.find((t) => t.key === walkTable) || null)?.fields || [];
|
| const firstField = walkFields[0]?.key || "";
|
| const firstTable = tables[0] || null;
|
| const firstTableField = firstTable?.fields?.[0]?.key || "";
|
|
|
| const seedFor = (kind: string): Record<string, unknown> | null => {
|
| if (kind === "update_record")
|
| return firstField ? { values: { [firstField]: "" } } : null;
|
| if (kind === "create_record")
|
| return firstTable && firstTableField
|
| ? { table: firstTable.key, values: { [firstTableField]: "" } }
|
| : null;
|
| if (kind === "find_records")
|
| return firstTable ? { table: firstTable.key, cond: null, limit: 25 } : null;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| if (kind === "web_read")
|
| return { url: "", selector: "", field: firstField || "",
|
| attr: "text", all: false, timeoutMs: 20000 };
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| if (kind === "web_goto")
|
| return { url: "", timeoutMs: 20000 };
|
| if (kind === "web_click")
|
| return { url: "", selector: "", timeoutMs: 20000 };
|
| if (kind === "web_fill")
|
| return { url: "", selector: "", value: "", secret: false, timeoutMs: 20000 };
|
| if (kind === "web_repair")
|
| return { url: "", selector: "", hint: "", timeoutMs: 20000 };
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| if (isEnrich(kind))
|
| return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| postMetrics: false, commentMetrics: false,
|
| dryRun: false, maxPosts: 10,
|
| fromView: "", sortField: "first_found", sortDir: "desc",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| limit: 25, skipRecent: true, skipRecentDays: 30,
|
| };
|
| if (kind === "group")
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| return firstField
|
| ? {
|
| branches: [
|
| {
|
| id: "b1",
|
| label: "",
|
| cond: null,
|
| actions: [
|
| { id: "", kind: "update_record", enabled: true, when: null,
|
| config: { values: { [firstField]: "" } } },
|
| ],
|
| },
|
| ],
|
| }
|
| : null;
|
| return null;
|
| };
|
|
|
|
|
| const seedBlock = (kind: string): string => {
|
| if (kind === "update_record" || kind === "group")
|
| return "Name the trigger's database first β this needs a column to write.";
|
| if (kind === "create_record" || kind === "find_records")
|
| return "There is no blank database to point at yet.";
|
| |
| |
| |
| |
| |
| |
|
|
| return "This action cannot be added yet β it has no starting configuration.";
|
| };
|
|
|
| |
| |
| |
| |
|
|
| const addAction = (kind: string, into?: { actionId: string; branchId: string }) => {
|
| const seed = seedFor(kind);
|
| if (!seed) return;
|
| const fresh: Action = { id: "", kind, enabled: true, when: null, config: seed };
|
| if (!into) {
|
| patchFlow([...actions, fresh]);
|
| } else {
|
| patchFlow(
|
| mapTree(actions, (a) => {
|
| if (a.id !== into.actionId) return a;
|
| return {
|
| ...a,
|
| config: {
|
| ...a.config,
|
| branches: groupBranches(a).map((br) =>
|
| br.id === into.branchId
|
| ? { ...br, actions: [...(br.actions || []), fresh] }
|
| : br
|
| ),
|
| },
|
| };
|
| })
|
| );
|
| }
|
| setAdding(false);
|
| };
|
|
|
|
|
| const addBranch = (actionId: string) => {
|
| const seedChild = seedFor("update_record");
|
| if (!seedChild) return;
|
| patchFlow(
|
| mapTree(actions, (a) => {
|
| if (a.id !== actionId) return a;
|
| const brs = groupBranches(a);
|
| return {
|
| ...a,
|
| config: {
|
| ...a.config,
|
|
|
|
|
|
|
|
|
| branches: [
|
| ...brs,
|
| { id: "", label: "", cond: null,
|
| actions: [{ id: "", kind: "update_record", enabled: true, when: null,
|
| config: seedChild }] },
|
| ],
|
| },
|
| };
|
| })
|
| );
|
| };
|
|
|
| const dropBranch = (actionId: string, branchId: string) =>
|
| patchFlow(
|
| mapTree(actions, (a) => {
|
| if (a.id !== actionId) return a;
|
| const kept = groupBranches(a).filter((br) => br.id !== branchId);
|
|
|
|
|
| if (!kept.length) return a;
|
| return { ...a, config: { ...a.config, branches: kept } };
|
| })
|
| );
|
|
|
| |
| |
| |
| |
| |
|
|
| const stepNos = numberActions(actions);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| const menuGroups = groupActions(catalog);
|
|
|
|
|
| const actionRow = (c: ActionCatalogRow) => (
|
| <button
|
| key={c.kind}
|
| type="button"
|
| className={"autox-menu-row" + (c.ready && seedFor(c.kind) ? "" : " is-planned")}
|
| disabled={!c.ready || busy || !seedFor(c.kind)}
|
| title={c.ready && !seedFor(c.kind) ? seedBlock(c.kind) : c.detail}
|
| onClick={() => addAction(c.kind)}
|
| >
|
| <span className={"autox-card-mark" + (hasBrandKind(c.kind) ? " is-brand" : "")}>
|
| <ActionMark kind={c.kind} />
|
| </span>
|
| <span className="autox-menu-text">
|
| <span className="autox-menu-label">
|
| {c.label}
|
| {!c.ready ? (
|
| <span className="autox-soon">Coming soon</span>
|
| ) : !seedFor(c.kind) ? (
|
| /* NOT the same state as "coming soon", so not the same word: this one is built and
|
| waiting on THIS automation, and the title says exactly what is missing. */
|
| <span className="autox-soon">Needs a database</span>
|
| ) : null}
|
| </span>
|
| <span className="autox-menu-detail">{c.detail}</span>
|
| </span>
|
| </button>
|
| );
|
|
|
|
|
| const chip = () => {
|
| if (!chosen) return null;
|
| if (!configured)
|
| return (
|
| <span className="autox-chip is-warn" title="This trigger cannot fire until it is finished">
|
| Finish configuration
|
| </span>
|
| );
|
| if (trigger?.paused)
|
| return <span className="autox-chip is-warn">Paused</span>;
|
| if (picked && picked.ready === false)
|
| return <span className="autox-chip is-warn">Not set up</span>;
|
| const last = automation.runs?.[0];
|
| if (last)
|
| return (
|
| <span className="autox-chip is-ok" title={last.summary}>
|
| {last.ok ? "Last run succeeded" : "Last run failed"}
|
| </span>
|
| );
|
| return null;
|
| };
|
|
|
| const actionCard = (a: Action, depth: number, hasNext = false): ReactNode => {
|
| const row = catalog.find((c) => c.kind === a.kind);
|
| const isGroup = a.kind === "group";
|
| const branches = isGroup ? groupBranches(a) : [];
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const pinned =
|
| automation.kind === "discover_instagram" && depth === 0 && a.id === actions[0]?.id;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const needs = (automation.unconfigured || []).find((u) => u.id === a.id)?.needs || [];
|
|
|
| const canDrag = depth === 0 && !busy && !pinned;
|
| return (
|
| <div className="autox-actionwrap" key={a.id}>
|
| <div
|
| className={
|
| "autox-card" +
|
| (sel.kind === "action" && sel.id === a.id ? " is-selected" : "") +
|
| (a.enabled ? "" : " is-off") +
|
| (isGroup ? " is-group" : "") +
|
| /* β THE GUARD IS NOT DECORATION. `dragOver` idles at `""` and a fresh action carries
|
| `id: ""` until the server names it, so a bare `dragOver === a.id` would be
|
| `"" === ""` β TRUE AT REST β and every new card would paint as a drop target from
|
| its first frame. That is wave 20's `is-folddrag` defect exactly, which shipped and
|
| was seen by the owner before any gate noticed. */
|
| (dragOver && dragOver === a.id ? " is-dropinto" : "")
|
| }
|
| draggable={canDrag}
|
| onDragStart={
|
| canDrag
|
| ? (e) => {
|
| draggedRef.current = true;
|
| e.dataTransfer.effectAllowed = "move";
|
| e.dataTransfer.setData(ACTION_DRAG_TYPE, a.id);
|
| }
|
| : undefined
|
| }
|
| onDragOver={
|
| canDrag
|
| ? (e) => {
|
| // Somebody else's drag β a file, a nav row, selected text β is not ours to
|
| // accept. Without this test the card would light up for anything dragged over
|
| // it and then swallow the drop.
|
| if (!e.dataTransfer.types.includes(ACTION_DRAG_TYPE)) return;
|
| e.preventDefault();
|
| e.dataTransfer.dropEffect = "move";
|
| setDragOver(a.id);
|
| }
|
| : undefined
|
| }
|
| onDragLeave={
|
| canDrag
|
| ? (e) => {
|
| // Moving onto a CHILD of this card is not leaving it; without this the
|
| // highlight flickers off every time the pointer crosses the title.
|
| if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
| setDragOver((cur) => (cur === a.id ? "" : cur));
|
| }
|
| : undefined
|
| }
|
| onDrop={
|
| canDrag
|
| ? (e) => {
|
| const dragId = e.dataTransfer.getData(ACTION_DRAG_TYPE);
|
| setDragOver("");
|
| if (!dragId) return;
|
| e.preventDefault();
|
| moveAction(dragId, a.id);
|
| }
|
| : undefined
|
| }
|
| onDragEnd={() => {
|
| setDragOver("");
|
| // Cleared a tick later: the post-drag click arrives BEFORE this would run.
|
| window.setTimeout(() => {
|
| draggedRef.current = false;
|
| }, 0);
|
| }}
|
| >
|
| <button
|
| type="button"
|
| className="autox-card-hit"
|
| aria-pressed={sel.kind === "action" && sel.id === a.id}
|
| onClick={() => {
|
| // The one click a finished drag is allowed to cost is this one.
|
| if (draggedRef.current) return;
|
| setSel({ kind: "action", id: a.id });
|
| // β WAVE 27 Β· C11 β clicking a step SHOWS its Properties. The panel is
|
| // one of two that share the column now, and a click that configured the
|
| // invisible one would read as a click that did nothing.
|
| onStepPicked();
|
| }}
|
| >
|
| {/* β R8's STEP NUMBER, computed once for the whole flow (`numberActions`). The
|
| TRIGGER carries no number at all β that is the ruling, and it reverses wave 21's
|
| "Step 1 is always the Trigger" β so the numbers here start at 1 on the first
|
| ACTION. A fork occupies one number and its branches consume none. */}
|
| <span className="autox-card-n">{stepNos.get(a.id) || ""}</span>
|
| <span className={"autox-card-mark" + (hasBrandKind(a.kind) ? " is-brand" : "")}>
|
| <ActionMark kind={a.kind} />
|
| </span>
|
| <span className="autox-card-text">
|
| <span className="autox-card-title">
|
| {/* β NOT "If conditions are met" ANY MORE, and the literal was the problem
|
| rather than the wording. C-ACT relabels the `group` kind to "If / then" on
|
| the SERVER, so a hard-coded title here was a client paraphrase of a server
|
| vocabulary β it would have gone on saying the old words after the catalog
|
| changed, which is the one failure this file's header names twice. */}
|
| {row?.label || a.kind}
|
| </span>
|
| {isGroup ? (
|
| <span className="autox-card-sub">
|
| {branches.length === 1
|
| ? "1 branch"
|
| : `${branches.length} branches`}
|
| </span>
|
| ) : (
|
| <span className="autox-card-sub">{actionSub(a, tables)}</span>
|
| )}
|
| {/* β THE LABEL IS ON EVERY CARD, IN BOTH STATES. Item 10 asks for
|
| "Configured / Unconfigured" β showing only the bad one would make a configured
|
| action indistinguishable from an action this build has no opinion about, which
|
| is the question the label exists to answer. The Unconfigured one carries WHAT is
|
| missing, because "Unconfigured" alone on a five-field step is a hunt. */}
|
| <span
|
| className={"autox-card-state" + (needs.length ? " is-off" : " is-on")}
|
| title={needs.length ? `Still needs ${needs.join(", ")}` : undefined}
|
| >
|
| {needs.length ? `Unconfigured β needs ${needs.join(", ")}` : "Configured"}
|
| </span>
|
| </span>
|
| </button>
|
| {pinned ? (
|
| <span className="autox-card-pin" title="Every search saves what it finds">
|
| Always first
|
| </span>
|
| ) : (
|
| <button
|
| type="button"
|
| className="autox-card-drop"
|
| disabled={busy}
|
| aria-label={`Remove ${row?.label || a.kind}`}
|
| title={`Remove ${row?.label || a.kind}`}
|
| onClick={() => dropAction(a.id)}
|
| >
|
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| strokeLinecap="round" />
|
| </svg>
|
| </button>
|
| )}
|
| </div>
|
|
|
| {/*
|
| β THE FORK (owner item 12a, ruling R8, contract C-FORK). A group used to hold ONE
|
| nested column; it now holds a LETTERED LANE PER BRANCH, because R8 rules that a fork
|
| occupies one step number and its legs are alternatives rather than later steps.
|
|
|
| THE LETTER AND THE "Otherwise" ARE THE SERVER'S WORDS β `br.label`, printed, never
|
| composed. `clean_actions` letters by index and names a null-cond last leg "Otherwise",
|
| so the lanes re-letter themselves correctly when one is deleted.
|
| The nesting ceiling is still the SERVER's `maxGroupDepth`, so the add-here affordance
|
| disappears at exactly the depth `clean_actions` refuses.
|
| */}
|
| {isGroup ? (
|
| <div className="autox-branches">
|
| {branches.map((br) => (
|
| <div className="autox-branch" key={br.id || br.label}>
|
| <div className="autox-branch-head">
|
| <span className="autox-branch-letter">{br.label}</span>
|
| <span className="autox-branch-cond">
|
| {/* β NOT A RENDERED CONDITION β the tree is edited in Properties, and a
|
| second editable copy here would be two controls for one fact. This says
|
| only WHICH leg you are looking at. A null cond is the catch-all, and the
|
| server has already labelled it, so this says nothing twice. */}
|
| {br.cond ? "when its conditions match" : "everything else"}
|
| </span>
|
| {branches.length > 1 ? (
|
| <button
|
| type="button"
|
| className="autox-branch-x"
|
| disabled={busy}
|
| aria-label={`Remove branch ${br.label}`}
|
| title={`Remove branch ${br.label}`}
|
| onClick={() => dropBranch(a.id, br.id)}
|
| >
|
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| strokeLinecap="round" />
|
| </svg>
|
| </button>
|
| ) : null}
|
| </div>
|
| <div className="autox-nest">
|
| {(br.actions || []).map((k) => actionCard(k, depth + 1))}
|
| {depth + 1 < (vocab?.maxGroupDepth ?? 2) || !(br.actions || []).length ? (
|
| <button
|
| type="button"
|
| className="autox-add is-nested"
|
| disabled={busy || !seedFor("update_record")}
|
| title={seedFor("update_record") ? undefined : seedBlock("update_record")}
|
| onClick={() => addAction("update_record", { actionId: a.id, branchId: br.id })}
|
| >
|
| + Add an action in {br.label}
|
| </button>
|
| ) : null}
|
| </div>
|
| </div>
|
| ))}
|
| <button
|
| type="button"
|
| className="autox-add is-nested"
|
| disabled={busy || !seedFor("update_record")}
|
| title={seedFor("update_record") ? undefined : seedBlock("update_record")}
|
| onClick={() => addBranch(a.id)}
|
| >
|
| + Add a branch
|
| </button>
|
| </div>
|
| ) : null}
|
|
|
| {/*
|
| THE ARROW BACK INTO THE FLOW (item 12a). The lanes are alternatives and exactly one of
|
| them runs β the engine takes the first matching branch and breaks β so they REJOIN, and
|
| without a mark saying so a fork at the end of a column reads as several parallel endings.
|
| Drawn only when there IS a next step to rejoin: an arrow into nothing is a promise the
|
| flow does not keep.
|
| */}
|
| {isGroup && hasNext ? (
|
| <div className="autox-merge" aria-hidden="true">
|
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
| strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
| <path d="M8 2.6v10.8M5.2 10.6 8 13.4l2.8-2.8" />
|
| </svg>
|
| </div>
|
| ) : null}
|
| </div>
|
| );
|
| };
|
|
|
| return (
|
| <>
|
| <div className="autox-flow">
|
| {/* ββ TRIGGER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| <div className="autox-step">
|
| <div className="autox-side">
|
| <span className="autox-tag">Trigger</span>
|
| {chip()}
|
| </div>
|
| <div className="autox-main">
|
| {chosen && options.length ? (
|
| <div
|
| className={
|
| "autox-card is-trigger" + (sel.kind === "trigger" ? " is-selected" : "")
|
| }
|
| >
|
| <button
|
| type="button"
|
| className="autox-card-hit"
|
| aria-pressed={sel.kind === "trigger"}
|
| onClick={() => {
|
| setSel({ kind: "trigger", id: "" });
|
| onStepPicked(); // C11, same rule as an action card
|
| }}
|
| >
|
| <span
|
| className={"autox-card-mark" + (hasBrandKind(triggerKey) ? " is-brand" : "")}
|
| >
|
| <TriggerMark kind={triggerKey} />
|
| </span>
|
| <span className="autox-card-text">
|
| <span className="autox-card-title">
|
| {picked?.label || triggerKey}
|
| </span>
|
| {trigger?.table ? (
|
| <span className="autox-card-sub">
|
| In {tableLabel(tables, trigger.table)}
|
| </span>
|
| ) : null}
|
| </span>
|
| </button>
|
| </div>
|
| ) : (
|
| /*
|
| THE EMPTY STATE (image 1): a dashed add-box and the server's own suggested
|
| triggers. One line of chrome, no tour (R13) β the list IS the explanation, and
|
| it is the server's list so it cannot describe a trigger we removed.
|
| */
|
| <>
|
| {/* THE BOX AND ITS MENU ARE ONE UNIT (see `addWrap`). The menu USED to render at
|
| the bottom of this component, outside `.autox-flow` entirely, and became a
|
| third column of the flex row that holds the canvas and Properties β the
|
| owner's ERROR 4. */}
|
| <div className="autox-trigadd" ref={addWrap}>
|
| <button
|
| type="button"
|
| className="autox-add is-empty"
|
| disabled={busy || !options.length}
|
| aria-haspopup="listbox"
|
| aria-expanded={picking}
|
| onClick={() => setPicking((v) => !v)}
|
| >
|
| + Add trigger
|
| </button>
|
| {/* β WAVE 25 item 5b (C2) β `TriggerPicker`, the SAME component Properties β
|
| Trigger details renders in its compact form. Category first (Time /
|
| Database / Connector) with connector rows nested under their product. */}
|
| {picking ? (
|
| <TriggerPicker
|
| variant="menu"
|
| options={options}
|
| value={triggerKey}
|
| disabled={busy}
|
| onPick={onPickTrigger}
|
| onDismiss={() => setPicking(false)}
|
| />
|
| ) : null}
|
| </div>
|
| {options.length ? (
|
| <div className="autox-suggest">
|
| <p className="autox-suggest-head">Suggested triggers</p>
|
| {options
|
| // β 2026-08-07 (owner ruling) β MANUAL IS OFFERED AGAIN. It was excluded
|
| // (`t.key !== "manual"`) because picking it stored NOTHING and bounced
|
| // straight back to this empty state β "a control that undoes itself is
|
| // worse than no control", correct while that was true. `clean_trigger` now
|
| // STORES `{key:'manual'}`, so the pick sticks, `chosen` goes true and
|
| // Configuration opens. Hiding it now would leave the one trigger a person
|
| // most expects to find as the only one they cannot choose.
|
| .filter((t) => t.ready !== false && !t.planned)
|
| .slice(0, SUGGESTED)
|
| .map((t) => (
|
| <button
|
| key={t.key}
|
| type="button"
|
| className="autox-suggest-row"
|
| disabled={busy}
|
| onClick={() => onPickTrigger(t.key)}
|
| >
|
| <span
|
| className={
|
| "autox-card-mark" + (hasBrandKind(t.key) ? " is-brand" : "")
|
| }
|
| >
|
| <TriggerMark kind={t.key} />
|
| </span>
|
| {t.label}
|
| </button>
|
| ))}
|
| </div>
|
| ) : (
|
| <p className="auto-note">This server did not offer a trigger list.</p>
|
| )}
|
| {/* ONE LINE (R13), and it is what is TRUE of this state rather than a caption
|
| for the box above it: with nothing chosen, Run now is the only thing that
|
| starts this automation. Airtable's empty state means "it cannot run"; ours
|
| does not, and saying so is the difference between a screen that is honest
|
| and one that merely looks the same. */}
|
| <p className="auto-hint">Until then, only Run now starts it.</p>
|
| </>
|
| )}
|
| </div>
|
| </div>
|
|
|
| {/* ββ ACTIONS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| <div className="autox-step">
|
| <div className="autox-side">
|
| <span className="autox-tag">Actions</span>
|
| </div>
|
| <div className="autox-main">
|
| {actions.map((a, i) => actionCard(a, 0, i < actions.length - 1))}
|
|
|
| {/*
|
| β THE MACHINE STEPS ARE NOT CARDS ANY MORE (owner item 5, contract C-CFG). They
|
| were engine-derived read-only cards sitting under the owner's own actions β "fetch
|
| the page", "Bright Data", "Anonymous", "Write" β so the centre column mixed two
|
| different things: what the AUTOMATION is, which nobody chose and nobody can reorder,
|
| and what the OWNER added, which is the whole subject of this builder. Their
|
| configuration and their switches moved to Properties β Configuration β
|
| "How this fetches", one section per panel.
|
|
|
| β THE ENGINE STILL RUNS THEM. `graph()` emits the same nodes; this surface simply
|
| stops DRAWING them, which is why the switches had to move rather than go β R7 names
|
| Bright Data (money) and Write's dry run (reads and reports without writing a row) as
|
| controls that must survive the cards that carried them.
|
| */}
|
|
|
| <button
|
| type="button"
|
| className="autox-add"
|
| disabled={busy || !catalog.length}
|
| onClick={() => setAdding((v) => !v)}
|
| >
|
| + Add advanced logic or action
|
| </button>
|
|
|
| {/* THE ACTION MENU (images 7/8): the server's catalog, grouped by its own `group`
|
| key, with `ready:false` rows faded and carrying the server's reason. A shorter
|
| menu would imply those actions do not exist β the owner asked to see all of
|
| them, and `clean_actions` refuses the unready ones at the door, so faded is a
|
| wall rather than a decoration. */}
|
| {adding ? (
|
| <div className="autox-menu" role="menu">
|
| {menuGroups.map((g) => (
|
| <div className="autox-menu-group" key={g.key}>
|
| <p className="autox-menu-head">{g.key}</p>
|
| {/* β WAVE 27 Β· ITEM 33 / C4 β the group's OWN rows first, then one nest per
|
| connector. `<details>` rather than a hand-built disclosure: it IS the
|
| chevron-and-submenu the reference shows, it opens on Enter and Space
|
| without a keydown handler, and it needs no open/closed state of its own
|
| to get wrong. Closed by default β the reference shows collapsed rows,
|
| and a connector nobody is using should cost one line, not six. */}
|
| {[...g.rows, ...g.sub].map((entry) => ("rows" in entry ? (
|
| <details className="autox-menu-nest" key={`sub:${entry.key}`}>
|
| <summary className="autox-menu-row autox-menu-sum">
|
| {/* β WAVE 30 Β· ITEM 7 / R4 β THE COLLAPSED ROW GETS AN IDENTITY.
|
| This is the row the owner clicks before choosing Instagram or
|
| TikTok, and until now it was the only row in the menu with
|
| nothing on its left: the chevron, then a bare word. The mark is
|
| resolved from the connector KEY, so a connector the server adds
|
| tomorrow gets a slot for free and simply renders no logo. */}
|
| {brandForConnector(entry.key) ? (
|
| <span className="autox-card-mark is-brand">
|
| {brandForConnector(entry.key)}
|
| </span>
|
| ) : null}
|
| <span className="autox-menu-text">
|
| <span className="autox-menu-label">{entry.label}</span>
|
| <span className="autox-menu-detail">
|
| {entry.rows.length} action{entry.rows.length === 1 ? "" : "s"}
|
| </span>
|
| </span>
|
| </summary>
|
| {entry.rows.map((c) => actionRow(c))}
|
| </details>
|
| ) : actionRow(entry)))}
|
| </div>
|
| ))}
|
| </div>
|
| ) : null}
|
| </div>
|
| </div>
|
| </div>
|
|
|
| {/* ββ PROPERTIES ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| <aside
|
| className={"auto-panel autox-props" + (showProperties ? " is-appearing" : "")}
|
| aria-label="Properties"
|
| /* β WAVE 27 Β· C11 β HIDDEN, not unmounted, and that is deliberate. This panel holds
|
| live editing state (the name field's text, a half-typed cron); unmounting it on every
|
| toggle would discard whatever was in flight. `hidden` also takes it out of the
|
| accessibility tree, so a screen reader is not offered two panels when one is drawn. */
|
| hidden={!showProperties}
|
| >
|
| <div className="auto-panel-head">
|
| {panelTabs}
|
| {/*
|
| THREE STATES, because this panel now holds two save disciplines. The discrete choices
|
| (a trigger, an action, a switch, a branch) persist as they are made; the machine
|
| steps' fields, which moved in here with item 5, commit with the header's Save. A
|
| two-state stamp had to be wrong about one of them, and "All changes saved" printed
|
| over an unsaved column map is the direction that loses work.
|
| */}
|
| <span
|
| className={"autox-saved" + (busy ? " is-busy" : dirty ? " is-dirty" : "")}
|
| >
|
| {busy ? "Savingβ¦" : dirty ? "Unsaved changes β press Save" : "All changes saved"}
|
| </span>
|
| </div>
|
|
|
| {/*
|
| β THE NAME (item 15b). It is ABOVE the selection-dependent body rather than inside the
|
| trigger's face, because it is a property of the AUTOMATION and not of whichever step
|
| happens to be selected β clicking an action card must not make the name disappear.
|
| It commits on blur; the caller writes it and prints any refusal.
|
| */}
|
| <div className="auto-field">
|
| <label htmlFor="autox-name">Name</label>
|
| <input
|
| id="autox-name"
|
| className="auto-input"
|
| value={name}
|
| disabled={busy}
|
| placeholder="Name this automation"
|
| onChange={(e) => onNameText(e.target.value)}
|
| onBlur={() => onNameCommit()}
|
| />
|
| </div>
|
|
|
| {sel.kind === "trigger" ? (
|
| <TriggerProps
|
| automation={automation}
|
| triggerKey={triggerKey}
|
| options={options}
|
| picked={picked}
|
| table={table}
|
| tables={tables}
|
| ops={ops}
|
| nullaryOps={nullaryOps}
|
| vocab={vocab}
|
| oauth={oauth}
|
| busy={busy}
|
| nodes={nodes}
|
| onToggleNode={onToggleNode}
|
| renderNodeBody={renderNodeBody}
|
| onAskChange={setConfirmKey}
|
| onPatchTrigger={patchTrigger}
|
| onPatchConfig={patchConfig}
|
| onPickTrigger={onPickTrigger}
|
| onRunNow={onRunNow}
|
| scheduleFace={scheduleFace}
|
| schedules={schedules}
|
| /* β R14 β THE SAME `chosen` THE FLOW COLUMN USES, passed rather than recomputed.
|
| Both halves of one screen answer "has a trigger been picked": the centre column
|
| draws the empty state, Properties hides Configuration. Two derivations of that
|
| would eventually disagree, and the screen would then show an empty-state add-box
|
| beside a filled-in Configuration section β which is not a state the product has. */
|
| chosen={chosen}
|
| />
|
| ) : (
|
| <ActionProps
|
| action={findAction(actions, sel.id)}
|
| /* β THE SAME RULE `actionCard` DERIVES AND THE SERVER ENFORCES (`ig_action_pinned`),
|
| passed down rather than re-derived a third time. The panel needs it because the
|
| pinned step is the one action in the product whose value map the ENGINE NEVER
|
| READS β see `ActionProps`. */
|
| pinned={
|
| automation.kind === "discover_instagram" && sel.id === actions[0]?.id
|
| }
|
| catalog={catalog}
|
| tables={tables}
|
| onTablesChanged={onTablesChanged}
|
| walkFields={walkFields}
|
| /* β WAVE 26 Β· ITEM 5 / R10 β REQUIRED, and it is NOT the same question as
|
| `walkFields`. An empty field list has two causes that need opposite answers: this
|
| flow has no record walking it at all (say so, point at the trigger), or it has one
|
| whose database has no columns yet (an empty picker is then correct). Passing only
|
| the list forced the panel to guess, which is how the empty picker that produced
|
| "the condition names no field" got shipped in the first place. */
|
| walkTable={walkTable}
|
| ops={ops}
|
| nullaryOps={nullaryOps}
|
| vocab={vocab}
|
| busy={busy}
|
| onEdit={(change) => editAction(sel.id, change)}
|
| />
|
| )}
|
| </aside>
|
|
|
| {/* THE CONFIRM (image 6). A trigger change can invalidate the configuration under it, so
|
| it ASKS β and it says what it will cost rather than "are you sure". */}
|
| {confirmKey ? (
|
| <div className="autox-confirm" role="dialog" aria-label="Change the trigger">
|
| <p className="autox-confirm-title">Change the trigger?</p>
|
| <p className="autox-confirm-body">
|
| Anything configured for {picked?.label || triggerKey} is dropped.
|
| </p>
|
| <div className="autox-confirm-acts">
|
| <button type="button" className="auto-btn" onClick={() => setConfirmKey("")}>
|
| Cancel
|
| </button>
|
| <button
|
| type="button"
|
| className="auto-btn is-danger"
|
| onClick={() => {
|
| onPickTrigger(confirmKey);
|
| setConfirmKey("");
|
| }}
|
| >
|
| Change trigger
|
| </button>
|
| </div>
|
| </div>
|
| ) : null}
|
|
|
| {/* β THE PICKER USED TO RENDER HERE, and here is outside both columns. `.auto-work` is a
|
| flex row of `.autox-flow` + `.autox-props`, so a third child was laid out as a third
|
| COLUMN β the menu appeared past the right edge of the Properties panel and squeezed the
|
| canvas until the suggested triggers wrapped one word per line (owner's ERROR 4). It is
|
| rendered beside its own "+ Add trigger" box now; see `addWrap`. */}
|
| </>
|
| );
|
| }
|
|
|
|
|
| function actionSub(a: Action, tables: UserTable[]): string {
|
| const cfg = a.config || {};
|
| if (a.kind === "update_record") {
|
| const keys = Object.keys((cfg as { values?: Record<string, unknown> }).values || {})
|
| .filter(Boolean);
|
| return keys.length ? `Sets ${keys.join(", ")}` : "No values yet";
|
| }
|
| if (a.kind === "create_record") {
|
| const t = String((cfg as { table?: string }).table || "");
|
| return t ? `Into ${tableLabel(tables, t)}` : "No database yet";
|
| }
|
| if (a.kind === "find_records") {
|
| const t = String((cfg as { table?: string }).table || "");
|
| return t ? `In ${tableLabel(tables, t)}` : "No database yet";
|
| }
|
| return "";
|
| }
|
|
|
| function findAction(list: Action[], id: string): Action | null {
|
| for (const a of list) {
|
| if (a.id === id) return a;
|
|
|
|
|
|
|
| if (a.kind === "group")
|
| for (const br of groupBranches(a)) {
|
| const hit = findAction(br.actions || [], id);
|
| if (hit) return hit;
|
| }
|
| }
|
| return null;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
|
|
|
|
|
|
|
|
| function TriggerProps({
|
| automation,
|
| triggerKey,
|
| options,
|
| picked,
|
| table,
|
| tables,
|
| ops,
|
| nullaryOps,
|
| vocab,
|
| oauth,
|
| busy,
|
| nodes,
|
| onToggleNode,
|
| renderNodeBody,
|
| onAskChange,
|
| onPatchTrigger,
|
| onPatchConfig,
|
| onPickTrigger,
|
| onRunNow,
|
| scheduleFace,
|
| schedules,
|
| chosen,
|
| }: {
|
| automation: Automation;
|
| triggerKey: string;
|
| options: TriggerOption[];
|
| picked: TriggerOption | null;
|
| table: UserTable | null;
|
| tables: UserTable[];
|
| ops: string[];
|
| nullaryOps: string[];
|
| vocab?: FlowVocab;
|
| oauth: OAuthStatus | null;
|
| busy: boolean;
|
| /** The engine's machine steps (item 5) β their switches and bodies live under Configuration. */
|
| nodes: GraphNode[];
|
| onToggleNode: (nodeId: string) => void;
|
| renderNodeBody: (panel: string) => ReactNode;
|
| /** Ask before a trigger change that can invalidate the config under it (image 6). */
|
| onAskChange: (key: string) => void;
|
| onPatchTrigger: (patch: Record<string, unknown>) => void;
|
| /** Writes the automation's OWN config β the database a table-less trigger has nowhere
|
| * else to name (owner report 2026-08-07). Spreads on the way in; see `patchConfig`. */
|
| onPatchConfig: (patch: Record<string, unknown>) => void;
|
| onPickTrigger: (key: string) => void;
|
| onRunNow: () => void;
|
| scheduleFace: ReactNode;
|
| schedules: boolean;
|
| /**
|
| * β WAVE 25 item 5b (ruling R14) β HAS A TRIGGER BEEN PICKED AT ALL?
|
| *
|
| * REQUIRED and passed IN, never re-derived: it is the same `chosen` the centre column gates its
|
| * empty state on, and the honest test is what the DEFINITION holds rather than what the picker
|
| * displays (`triggerKey` derives "manual" whenever nothing is stored, so anything computed from
|
| * it is true for every automation ever β a mistake this file already made once and only a
|
| * screenshot caught).
|
| */
|
| chosen: boolean;
|
| }) {
|
| const trigger = automation.trigger || null;
|
| const provider = picked?.connect?.provider || "";
|
| const connected = !!(provider && oauth?.[provider]?.connected);
|
| const startUrl = picked?.connect?.startUrl || "";
|
| const needsTable = !!trigger && "table" in trigger;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const needsOwnTable = automation.kind === "plain" && !needsTable;
|
| const last = automation.runs?.[0];
|
|
|
| const runState = runBlock(automation);
|
|
|
| return (
|
| <>
|
| <h3>Trigger details</h3>
|
| <div className="auto-field">
|
| {/*
|
| β WAVE 25 item 5b (C2) β THE NATIVE `<select>` IS GONE, AND THIS IS THE OWNER'S ITEM.
|
| `reference/ERROR 3.png`: eleven triggers listed flat, in `<optgroup>`s captioned
|
| "Standard" and "Sources", with NO ICONS β four inches from a "+ Add trigger" menu that
|
| draws every one of them with a mark. It is the same `TriggerPicker` in both places now,
|
| category first, connector rows nested under Gmail / Webhooks / Scraper / TikTok.
|
|
|
| β `<label>` WITHOUT `htmlFor`, DELIBERATELY. The control is a `<button>` opening a
|
| listbox, not a form field with an id to point at; an `htmlFor` naming an element that
|
| is not a labelable control is a relationship a screen reader is told about and cannot
|
| use. The button carries its own accessible name from its content and `aria-haspopup`.
|
|
|
| β THE CONFIRM RULE STAYS HERE, where the automation is. A trigger change can invalidate
|
| the configuration under it, so it ASKS when there IS something configured (image 6) β
|
| and that is a fact about this automation, not about a picker. `TriggerPicker` reports a
|
| choice; what a choice COSTS is the builder's to know.
|
| */}
|
| {/* β NO CLASS. `.auto-field > label` already styles every label in this panel
|
| (3xs / 600 / muted), and `.auto-field-label` adds a 12px top margin for the
|
| headings that are NOT inside an `.auto-field`. Using it here would put this one
|
| label 12px lower than the six beside it β DESIGN.md 2's "a panel matches its
|
| SIBLING's rendered values", which is a rule about pixels, not about tokens. */}
|
| <label>Trigger type</label>
|
| <TriggerPicker
|
| variant="field"
|
| options={options}
|
| value={triggerKey}
|
| disabled={busy}
|
| onPick={(next) => {
|
| if (next === triggerKey) return;
|
| if (trigger && trigger.configured !== false) onAskChange(next);
|
| else onPickTrigger(next);
|
| }}
|
| />
|
| </div>
|
| {/*
|
| β THE DESCRIPTION IS THE SERVER'S, AND IT RIDES NOW (wave 24, C-TYPES). This block used
|
| to be a comment explaining why there was NO paragraph here: Airtable prints two sentences
|
| under this select (image 3), ours would have been a client paraphrase of a server
|
| vocabulary, and the note said "when `detail` rides, it renders here and cannot disagree
|
| with the engine". `TriggerOption.detail` landed this wave, so the paragraph is the
|
| engine's own sentence, printed verbatim and never composed with anything.
|
| */}
|
| {picked?.detail ? <p className="auto-hint">{picked.detail}</p> : null}
|
| {picked && picked.ready === false && !picked.planned ? (
|
| <div className="autob-needs">
|
| <span className="autob-needs-text">
|
| {connected
|
| ? "Connected β this trigger is still being switched on for this deployment."
|
| : "This trigger is not set up yet."}
|
| </span>
|
| {!connected && startUrl ? (
|
| <a className="auto-btn is-primary autob-connect" href={startUrl}>
|
| Connect
|
| </a>
|
| ) : null}
|
| </div>
|
| ) : null}
|
|
|
| {/*
|
| β WAVE 25 item 5b (ruling R14) β CONFIGURATION DOES NOT RENDER UNTIL A TRIGGER IS PICKED.
|
| Owner, verbatim: *"let's not show Configuration at the first creation (this is
|
| overwhelming for first time users)"*. A brand-new automation opens on the trigger picker
|
| with nothing chosen, and every control below is a question ABOUT a trigger β a Database
|
| select, watched columns, a view, a Gmail query, a hook URL. Asking them before there is a
|
| trigger to ask them about is a form for a decision nobody has made.
|
|
|
| β ONCE PICKED IT RENDERS EXPANDED, and that is the other half of the ruling rather than an
|
| omission: at that point the section is REQUIRED (`configured:false` rides the wire and the
|
| card says "finish configuration"), so a collapsed heading would hide the thing the surface
|
| is simultaneously telling the reader to go and do.
|
|
|
| β THE WHOLE SECTION, HEADING INCLUDED. Rendering the `<h3>` over nothing is the state R13
|
| forbids β a heading over an empty region says "there should be something here", which is a
|
| different and false statement. This is `null`, not an empty fragment.
|
| */}
|
| {/* β 2026-08-07 β `|| needsOwnTable`: R14's rule was "do not render Configuration before a
|
| trigger is picked", and it is intact for every trigger that stores one. What it must not
|
| also mean is "a manual automation has nothing to configure" β it has exactly one thing,
|
| the database its steps walk, and no other surface can name it. See `needsOwnTable`. */}
|
| {!chosen && !needsOwnTable ? null : (
|
| <>
|
| <h3>Configuration</h3>
|
| {needsTable ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-ttable">
|
| <span className="autox-req">*</span> Database
|
| </label>
|
| <select
|
| id="autox-ttable"
|
| className="auto-input"
|
| value={trigger?.table || ""}
|
| disabled={busy}
|
| onChange={(e) => onPatchTrigger({ table: e.target.value })}
|
| data-role="trigger-table"
|
| >
|
| <option value="">Select a databaseβ¦</option>
|
| {trigger?.table && !tables.some((t) => t.key === trigger.table) ? (
|
| <option value={trigger.table}>{trigger.table} (not visible to you)</option>
|
| ) : null}
|
| {tables.map((t) => (
|
| <option key={t.key} value={t.key}>
|
| {t.label}
|
| </option>
|
| ))}
|
| </select>
|
| </div>
|
| ) : null}
|
|
|
| {/*
|
| ββ 2026-08-07 (owner report) β THE DATABASE A TABLE-LESS TRIGGER HAS NOWHERE ELSE TO NAME.
|
| Owner: *"how come, enrich instagram when standalone 'manual' trigger, doesn't have a
|
| database that it should point to? dont make this mistake again."*
|
|
|
| MEASURED across the trigger vocabulary: `manual`, `schedule` and `email` carry NO `table`,
|
| so `needsTable` is false and the picker above never rendered β while `AutomationDetail`'s
|
| Properties picker is gated to `scrape_db` / `field_instagram`, BOTH RETIRED KINDS. So
|
| **nothing in the client wrote `config.targetTable` for a `plain` automation**, and
|
| `run_plain` answered *"no database is bound yet β pick one on the trigger, or in
|
| Properties"*: a refusal naming two doors, neither of which existed. Every scheduled or
|
| manual flow β the owner's *"on a schedule, enrich these sets of influencer names"* β was
|
| unbuildable.
|
|
|
| β `plain` ONLY, and that is what keeps it from being a second control for one fact. Every
|
| other kind names its database somewhere of its own: an Instagram search through the pinned
|
| Create record (W25/R2 made that action authoritative and `targetTable` follows it), and the
|
| two retired kinds through their own Properties panels. Widening this would put two pickers
|
| on one key and let them disagree, which is the exact defect R2 was written to end.
|
| */}
|
| {needsOwnTable ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-cfgtable">
|
| <span className="autox-req">*</span> Database
|
| </label>
|
| <select
|
| id="autox-cfgtable"
|
| className="auto-input"
|
| data-role="config-table"
|
| value={String(
|
| (automation.config as { targetTable?: string } | undefined)?.targetTable || ""
|
| )}
|
| disabled={busy}
|
| onChange={(e) => onPatchConfig({ targetTable: e.target.value })}
|
| >
|
| <option value="">Select a databaseβ¦</option>
|
| {tables.map((t) => (
|
| <option key={t.key} value={t.key}>
|
| {t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
|
| </option>
|
| ))}
|
| </select>
|
| <p className="auto-hint">
|
| The records this automation’s steps walk. A trigger like Manual or a schedule does
|
| not name one on its own.
|
| </p>
|
| </div>
|
| ) : null}
|
|
|
| {triggerKey === "event_field" ? (
|
| <>
|
| {/*
|
| β "WATCHED COLUMN" IS GONE (owner item 7, C-TRIG law 4). "When a record matches
|
| conditions" is a FILTER, and a watched column was a second, different question
|
| answered in the same box β the trigger fired on a column changing AND on the
|
| conditions holding, which is two triggers wearing one name.
|
| β THE SERVER WENT FIRST, and that ordering was the point: `clean_trigger` stopped
|
| reading `field` (C posted law 4 done) BEFORE this control came out. Deleting the
|
| control while the validator still read the key would have left a stored watched
|
| column that nothing displays and nothing can clear β and deleting the key while the
|
| control still showed it would have made every Save silently drop what the user
|
| picked. The CONDITION is what completes this trigger now: with none, it rides
|
| `configured:false` and says so.
|
| */}
|
| <p className="auto-field-label">
|
| <span className="autox-req">*</span> Conditions
|
| </p>
|
| <CondBuilder
|
| cond={trigger?.when || null}
|
| onChange={(next) => onPatchTrigger({ when: next })}
|
| fields={table?.fields || []}
|
| ops={ops}
|
| nullaryOps={nullaryOps}
|
| maxDepth={vocab?.maxCondDepth ?? 3}
|
| maxChildren={vocab?.maxCondChildren ?? 12}
|
| disabled={busy}
|
| />
|
| </>
|
| ) : null}
|
|
|
| {triggerKey === "record_updated" ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-twatch">Watched columns</label>
|
| <select
|
| id="autox-twatch"
|
| className="auto-input"
|
| multiple
|
| size={5}
|
| value={trigger?.fields || []}
|
| disabled={busy}
|
| onChange={(e) =>
|
| onPatchTrigger({
|
| fields: Array.from(e.target.selectedOptions).map((o) => o.value),
|
| })
|
| }
|
| >
|
| {(table?.fields || []).map((f) => (
|
| <option key={f.key} value={f.key}>
|
| {f.label}
|
| </option>
|
| ))}
|
| </select>
|
| <p className="auto-hint">Select none to fire on any column.</p>
|
| </div>
|
| ) : null}
|
|
|
| {triggerKey === "enters_view" ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-tview">
|
| <span className="autox-req">*</span> View
|
| </label>
|
| {/*
|
| β FOUR STATES, AND THREE OF THEM ARE SENTENCES (owner item 8, C-TYPES).
|
| `views` now rides `GET /automations/tables`, so this control is live β but "drop the
|
| note" would have collapsed it to two states and rebuilt the exact defect the contract
|
| forbids. Absent and empty are DIFFERENT FACTS:
|
| Β· no database chosen -> choose one first
|
| Β· `views` ABSENT -> the server offered nothing (a server older than this
|
| client). Saying "no views" here would be a claim
|
| nobody measured.
|
| Β· `views` present and [] -> this database genuinely has none. That IS measured,
|
| and it is the one that tells the reader to go and
|
| make a view rather than to go and find an admin.
|
| Β· non-empty -> the picker.
|
| */}
|
| {!table ? (
|
| <p className="auto-note">Choose a database first.</p>
|
| ) : !table.views ? (
|
| <p className="auto-note">This server did not offer a view list for that database.</p>
|
| ) : !table.views.length ? (
|
| <p className="auto-note">
|
| That database has no saved views yet β make one on its grid and it appears here.
|
| </p>
|
| ) : (
|
| <select
|
| id="autox-tview"
|
| className="auto-input"
|
| value={trigger?.viewId || ""}
|
| disabled={busy}
|
| onChange={(e) => onPatchTrigger({ viewId: e.target.value })}
|
| >
|
| <option value="">Select a viewβ¦</option>
|
| {/*
|
| β THE STORED VALUE IS ALWAYS AN OPTION, and this control did not have that guard.
|
| A <select> whose `value` matches no <option> renders the FIRST one β here
|
| "Select a viewβ¦", whose value is `""` β so an `enters_view` trigger pointed at a
|
| view that has since been deleted, or at one the server deliberately withholds
|
| (another user's personal view, a share that does not name this viewer), would have
|
| LOOKED unconfigured and the next patch would have written the blank over it. This
|
| repo has paid for that twice; the two "(not in this database)" options elsewhere
|
| in this file exist for the same reason.
|
| */}
|
| {trigger?.viewId && !table.views.some((v) => v.id === trigger.viewId) ? (
|
| <option value={trigger.viewId}>{trigger.viewId} (not offered here)</option>
|
| ) : null}
|
| {table.views.map((v) => (
|
| <option key={v.id} value={v.id}>
|
| {v.label}
|
| </option>
|
| ))}
|
| </select>
|
| )}
|
| </div>
|
| ) : null}
|
|
|
| {triggerKey === "email" ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-tquery">Gmail search</label>
|
| <input
|
| id="autox-tquery"
|
| className="auto-input"
|
| defaultValue={trigger?.query || ""}
|
| disabled={busy}
|
| placeholder="from:orders@example.com"
|
| // FREE TEXT COMMITS ON BLUR β per-keystroke would PATCH a half-typed query.
|
| onBlur={(e) => onPatchTrigger({ query: e.target.value })}
|
| />
|
| </div>
|
| ) : null}
|
|
|
| {triggerKey === "webhook" && trigger?.token ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-thook">Hook URL</label>
|
| <input
|
| id="autox-thook"
|
| className="auto-input is-mono"
|
| readOnly
|
| value={`/api/v1/automations/hook/${trigger.token}`}
|
| />
|
| <p className="auto-hint">Minted once. It survives every other change to this trigger.</p>
|
| </div>
|
| ) : null}
|
|
|
| {/* THE SCHEDULE, rendered by the one component that owns the cron round-trip.
|
| β NOT `triggerKey === "schedule"` ANY MORE (item 7, C-TRIG). `ig_profile_match` watches
|
| nothing β it MAKES rows, on the cron β so its Configuration carries these controls
|
| "always", and the caller says which keys those are. */}
|
| {schedules ? scheduleFace : null}
|
| {triggerKey === "manual" ? (
|
| <p className="auto-hint">
|
| It runs when you press Run once now, and nothing else starts it.
|
| </p>
|
| ) : null}
|
| </>
|
| )}
|
|
|
| {/*
|
| β HOW THIS FETCHES (owner item 5, contract C-CFG) β the machine steps' switches and
|
| config bodies, below the trigger's own fields, under ONE sub-heading.
|
|
|
| β ONE SECTION PER PANEL, NOT PER NODE (`groupByPanel`, and its note is the reason). Four
|
| of `field_instagram`'s nodes share `panel: "capture"` and both of `discover_instagram`'s
|
| share `panel: "find"`, so a section per node would print one body four times and mount the
|
| discovery filter twice against a single `preds` array.
|
|
|
| β AND THE SWITCHES ARE THE POINT, not a leftover. R7: Bright Data buys exact counts with
|
| MONEY and Write's switch is the difference between a run that writes rows and one that
|
| reads and reports. They were on the cards this wave deleted, so deleting the cards without
|
| moving them would have retired two shipped controls silently. Each still posts to the
|
| node-toggle door β the server decides what a switch means, this only says which node.
|
|
|
| `plain` automations have no machine nodes at all, so the whole section is absent for them
|
| rather than an empty heading (R6 makes that the common case from now on).
|
|
|
| β WAVE 26 Β· ITEM 17 / R15 β THE PROSE IS GONE AND THE SWITCHES STAYED, which is the whole
|
| ruling. The owner quoted this section back verbatim β *"How this fetches / Find profiles /
|
| Bio contains skincare⦠/ Collect results / Takes about 20 minutes / Up to 10 profiles ·
|
| about $0.025"* β and asked for it gone. Every line of that quote is server-composed
|
| (`automation_engine.graph`), and it maps EXACTLY onto three renderers, which is why the
|
| deletion could be surgical rather than a section removal:
|
| Β· `n.subtitle` -> "Bio contains skincareβ¦" and "Takes about 20 minutes" (`:5030`, `:5036`)
|
| Β· `g.nodes[0].detail` -> "Up to 10 profiles Β· about $0.025" (`:5032`) β the ESTIMATE LINE
|
| Β· `n.title`/`<h3>` -> "How this fetches", "Find profiles", "Collect results" β KEPT
|
| β THE HEADING WAS A SYMPTOM, NOT THE SUBJECT. Deleting the section would have retired the
|
| Bright Data MONEY switch and the write node's DRY RUN with it β the two controls that were
|
| moved here precisely so deleting the cards would not retire them silently. They are the
|
| `n.toggle` block below and they are untouched.
|
| β `AutomationFind`'s "What it costs" estimate is NOT this line and is NOT in scope: it is
|
| the ANSWER to a button a person pressed, and it is the only surface carrying D-23's
|
| SPEC-basis caveat. The read-only paragraph went; the control did not.
|
| */}
|
| {nodes.length ? (
|
| <>
|
| <h3>How this fetches</h3>
|
| {groupByPanel(nodes).map((g) => (
|
| <div className="autox-machine" key={g.panel}>
|
| {g.nodes.map((n) => (
|
| <div className="autox-machine-row" key={n.id}>
|
| <span
|
| className={"autox-card-mark" + (hasBrandKind(n.kind) ? " is-brand" : "")}
|
| >
|
| <ActionMark kind={n.kind} />
|
| </span>
|
| <span className="autox-machine-text">
|
| <span className="autox-machine-title">{n.title}</span>
|
| </span>
|
| {/* β ITEM 18 / R14 β the per-node status dot stood here and is deleted. The
|
| node already carries its own `ActionMark` above; a coloured dot beside it was
|
| the second thing on one row claiming to say what this step is. */}
|
| {n.toggle ? (
|
| <button
|
| type="button"
|
| className={"auto-step-switch" + (n.enabled ? " is-on" : "")}
|
| disabled={busy}
|
| aria-pressed={n.enabled}
|
| aria-label={`${n.enabled ? "Turn off" : "Turn on"} ${n.title}`}
|
| title={`${n.enabled ? "Turn off" : "Turn on"} ${n.title}`}
|
| onClick={() => onToggleNode(n.id)}
|
| >
|
| <span className="auto-step-switch-knob" />
|
| </button>
|
| ) : null}
|
| </div>
|
| ))}
|
| {/* β ITEM 17 / R15 β THE ESTIMATE LINE STOOD HERE. `g.nodes[0].detail` is where
|
| "Up to 10 profiles Β· about $0.025" reached the screen (composed at
|
| `automation_engine.py:5032`), and the owner named it. It was the engine's own
|
| sentence rather than a client paraphrase, which is why it was defensible and
|
| why it still had to go: an accurate paragraph nobody asked for is still the
|
| architecture explaining itself (DESIGN.md Β§4).
|
| β The COST question keeps a home β "What it costs" in the Find panel below,
|
| behind a press, carrying its own SPEC caveat. This line stated a price nobody
|
| had asked for beside a control that was not about price. */}
|
| {renderNodeBody(g.panel)}
|
| </div>
|
| ))}
|
| </>
|
| ) : null}
|
|
|
| <h3>Test step</h3>
|
| {/*
|
| β HONEST TO OUR SEMANTICS, not to Airtable's. Airtable's "Test step" replays one step
|
| against a chosen record. Ours has no step-replay: the engine runs a whole automation. So
|
| the control says what it actually does β RUN IT β and the results below are the last
|
| real run's, from the run log, rather than a rehearsal nobody performed.
|
| */}
|
| {/* β ITEM 22 / D-70 / R12 β THE SECOND DOOR ONTO THE SAME MONEY, guarded by the SAME
|
| function. It was `!!automation.running`, which is process state and therefore false for
|
| the entire 20-30 minute vendor wait; `runBlock` is the one definition both buttons read,
|
| so a fix cannot land on one and miss the other. */}
|
| <button type="button" className="auto-btn" disabled={busy || runState.blocked}
|
| title={runState.why || undefined}
|
| onClick={onRunNow}>
|
| {runState.blocked ? runState.label : "Run once now"}
|
| </button>
|
| {runState.blocked && !automation.running ? (
|
| <p className="auto-hint">{runState.why}</p>
|
| ) : null}
|
| {last ? (
|
| <>
|
| <p className={"autox-result is-" + (last.ok ? "ok" : "bad")}>
|
| {last.ok ? "Last run succeeded" : "Last run failed"}
|
| </p>
|
| <p className="auto-hint">
|
| {last.ts.replace("T", " ").slice(0, 16)} β {last.summary}
|
| </p>
|
| </>
|
| ) : (
|
| <p className="auto-hint">It has not run yet.</p>
|
| )}
|
| </>
|
| );
|
| }
|
|
|
| /**
|
| * β WAVE 26 Β· ITEM 9 / R11 β "+ New database", INSIDE the action's own database picker.
|
| *
|
| * Owner ruling R11, verbatim: *"name it and go. No dialog stack; columns follow what the action
|
| * writes (how the IG presets already work)."* So this is a name box and a button, in place, and
|
| * it selects what it creates β never a modal over a modal, and never a trip to another surface
|
| * that loses the action being configured.
|
| *
|
| * β IT DOES NOT INVENT THE ROW IT JUST MADE. `POST /tables` answers `{key}` alone, so the
|
| * component asks the caller to RE-LIST and only then selects the key. Splicing a client-built
|
| * `UserTable` in would put a guessed `rowCount` and an empty `fields` on screen beside real ones,
|
| * and the picker renders `rowCount` β a fabricated measurement, which is the one thing this
|
| * codebase refuses everywhere else.
|
| *
|
| * β THE REFUSAL IS THE SERVER'S, PRINTED VERBATIM. `POST /tables` has real refusals a person can
|
| * hit β an empty name, the `MAX_TABLES` ceiling, an unavailable store β and each answers with its
|
| * own sentence. Re-deriving "you have too many databases" here would be a second copy of a rule
|
| * that lives on the other side of the wall ([[schema-role-is-not-a-value-wall]]: find the line
|
| * that ENFORCES it, and let that line do the talking).
|
| */
|
| function NewDatabase({
|
| disabled,
|
| onCreated,
|
| }: {
|
| disabled: boolean;
|
| /**
|
| * Called with the new key AFTER the caller's table list has been re-read.
|
| * β IT MAY RETURN A PROMISE AND THE CALLER MUST AWAIT IT. Selecting the key before the list
|
| * contains it makes the picker's own "stored value is always an option" guard paint
|
| * "(not visible to you)" about the database the user just created β the guard doing exactly its
|
| * job, on a race, and saying something alarming and false.
|
| */
|
| onCreated: (key: string) => Promise<void> | void;
|
| }) {
|
| const [open, setOpen] = useState(false);
|
| const [name, setName] = useState("");
|
| const [busy, setBusy] = useState(false);
|
| const [err, setErr] = useState("");
|
|
|
| const create = () => {
|
| const label = name.trim();
|
| if (!label || busy) return;
|
| setBusy(true);
|
| setErr("");
|
| createTable(label)
|
| .then(async (r) => {
|
| setOpen(false);
|
| setName("");
|
| // AWAITED: the caller re-reads the table list, and only then may the key be selected.
|
| await onCreated(r.key);
|
| })
|
| .catch((e) => {
|
| // The server's own words. `AutomationError` carries the composed sentence; anything else
|
| // is a transport failure and says so rather than blaming the name.
|
| setErr(
|
| e instanceof AutomationError && e.message
|
| ? e.message
|
| : "could not create it β the workspace did not answer"
|
| );
|
| })
|
| .finally(() => setBusy(false));
|
| };
|
|
|
| if (!open) {
|
| return (
|
| <button
|
| type="button"
|
| className="autoc-add"
|
| disabled={disabled}
|
| onClick={() => setOpen(true)}
|
| >
|
| + New database
|
| </button>
|
| );
|
| }
|
| return (
|
| <div className="autox-newdb">
|
| <input
|
| className="auto-input is-small"
|
| aria-label="Name for the new database"
|
| placeholder="Name it"
|
| autoFocus
|
| value={name}
|
| disabled={busy}
|
| onChange={(e) => setName(e.target.value)}
|
| // Enter creates, Escape backs out β the two keys a one-field form owes its user.
|
| onKeyDown={(e) => {
|
| if (e.key === "Enter") {
|
| e.preventDefault();
|
| create();
|
| } else if (e.key === "Escape") {
|
| setOpen(false);
|
| setErr("");
|
| }
|
| }}
|
| />
|
| <button
|
| type="button"
|
| className="auto-btn is-small"
|
| disabled={busy || !name.trim()}
|
| onClick={create}
|
| >
|
| {busy ? "Creatingβ¦" : "Create"}
|
| </button>
|
| <button
|
| type="button"
|
| className="auto-btn is-small"
|
| disabled={busy}
|
| onClick={() => {
|
| setOpen(false);
|
| setErr("");
|
| }}
|
| >
|
| Cancel
|
| </button>
|
| {err ? <p className="autoc-bad">{err}</p> : null}
|
| </div>
|
| );
|
| }
|
|
|
| /**
|
| * β WAVE 28 β EXPORTED so the include-row render suite can mount the real panel.
|
| *
|
| * β THE EXPORT IS THE POINT, not a convenience. R6's three include rows are markup whose defect
|
| * modes are invisible to a source grep: a row that renders with no key attached, a Profile
|
| * checkbox that paints unchecked, two rows bound to the same key. `ReviewProps` below is already
|
| * exported for the same reason, so this is the file's existing shape rather than a new one.
|
| * `_test/` never ships (`deploy_web.py` excludes `_test/` and `_`-prefixed files both).
|
| */
|
| export function ActionProps({
|
| action,
|
| pinned,
|
| catalog,
|
| tables,
|
| onTablesChanged,
|
| walkFields,
|
| walkTable,
|
| ops,
|
| nullaryOps,
|
| vocab,
|
| busy,
|
| onEdit,
|
| }: {
|
| action: Action | null;
|
| /**
|
| * β THE PERMANENT STEP 1 OF AN INSTAGRAM SEARCH (owner report, 2026-08-06). It is the ONE
|
| * action whose `values` and `uniqueOn` the engine never reads: `apply_actions` drops
|
| * `actions[0]` for `discover_instagram` and the runner does the writing itself, upserting the
|
| * profile columns on `(handle, created_by)`.
|
| *
|
| * The card stays β "this search puts what it finds in a database" is true and worth showing β
|
| * but a `* Values` editor above `{{column_key}} reads that column off the record walking the
|
| * flow` is a required-looking control over a step nothing walks, and the owner spent a session
|
| * trying to make sense of it. Worse, it was ANSWERABLE: remapping it to `name: {{handle}}`
|
| * saved cleanly, changed nothing, and left the panel describing a write the engine does not
|
| * perform. A picture of the engine that disagrees with the engine is the defect this file
|
| * refuses in five other places.
|
| */
|
| pinned: boolean;
|
| catalog: ActionCatalogRow[];
|
| tables: UserTable[];
|
| /** ITEM 9 / R11 β re-read the list after this panel's picker creates one. REQUIRED; see Props. */
|
| onTablesChanged: () => Promise<void> | void;
|
| /**
|
| * β THE WALKING RECORD'S COLUMNS β the trigger's database, resolved by the builder.
|
| *
|
| * These three surfaces were built with `fields={[]}` and it made R3's headline feature
|
| * unauthorable: a conditional group's condition, an action's `when`, and `update_record`'s
|
| * column picker all offered "Choose a fieldβ¦" and nothing else. An empty list is not a
|
| * neutral default here β it is a picker with no options, which reads as "this database has
|
| * no columns".
|
| */
|
| walkFields: { key: string; label: string; type: string }[];
|
| /**
|
| * β WAVE 26 Β· ITEM 5 / R10 β DOES A RECORD WALK THIS FLOW AT ALL? The key of the database it
|
| * walks, or `""`.
|
| *
|
| * β REQUIRED, and it answers a question `walkFields` structurally cannot. Both a flow with no
|
| * walking record and a flow walking an empty database hand this panel `[]`, and R10 wants
|
| * OPPOSITE renderings for them: a sentence pointing at the trigger for the first, an ordinary
|
| * (empty) picker for the second.
|
| * β VERIFIED AGAINST THE RUNNER, not inferred from the trigger list β which C4 forbids by name
|
| * ([[loopable-wave24]]: `CRON_DRIVEN_TRIGGERS` was exactly that mistake and became D-55).
|
| * `run_flow` returns immediately on `if not table_key` and otherwise walks that table's rows
|
| * evaluating `lane_match(act["when"], row)`, so a non-empty table key is EXACTLY the condition
|
| * under which `when` is ever consulted. The builder derives it from `_flow_table`'s own
|
| * precedence (`AutomationDetail.tsx`'s `walkTable`, whose comment carries that rule).
|
| */
|
| walkTable: string;
|
| ops: string[];
|
| nullaryOps: string[];
|
| vocab?: FlowVocab;
|
| busy: boolean;
|
| onEdit: (change: (a: Action) => Action) => void;
|
| }) {
|
| if (!action) return <p className="auto-note">That action is no longer part of this flow.</p>;
|
| const row = catalog.find((c) => c.kind === action.kind);
|
| const cfg = action.config || {};
|
| const setCfg = (patch: Record<string, unknown>) =>
|
| onEdit((a) => ({ ...a, config: { ...a.config, ...patch } }));
|
| const values = (cfg as { values?: Record<string, string | number> }).values || {};
|
| const target = tables.find((t) => t.key === String((cfg as { table?: string }).table || ""));
|
| const valueFields = action.kind === "create_record" ? target?.fields || [] : walkFields;
|
| /**
|
| * The saved views of the database the flow's records WALK β the enrich step's optional filter.
|
| *
|
| * β Read off `walkTable`, never off `target`: `target` is the Create-record action's own
|
| * destination and answers a different question, and on an Instagram discovery flow the two are
|
| * routinely different tables. Same trap `walkFields` carries its own note about.
|
| * β `undefined` is a THIRD STATE and is preserved as one (`UserTable.views` is absent until the
|
| * server sends it) β the panel says "no view list was offered" rather than drawing an empty
|
| * picker that reads as "this database has no views".
|
| */
|
| const walkViews = (tables.find((t) => t.key === walkTable) || null)?.views;
|
|
|
| return (
|
| <>
|
| <h3>{row?.label || action.kind}</h3>
|
| {row?.detail ? <p className="auto-hint">{row.detail}</p> : null}
|
|
|
| <h3>Configuration</h3>
|
|
|
| {action.kind === "group" ? (
|
| <>
|
| {/*
|
| β ONE CONDITION EDITOR PER BRANCH (C-FORK). The group's own `config.cond` is GONE β
|
| a fork has a condition per leg, not one for the whole thing β so an editor bound to
|
| `cfg.cond` would now write a key `clean_actions` drops on the floor: the tree would
|
| look saved, survive a reload from local state, and be absent the next time anyone
|
| opened the automation.
|
| THE HEADING IS THE SERVER'S LABEL. The last leg may carry no condition, and the
|
| server has already named it "Otherwise" β giving it one here turns it into a lettered
|
| branch, which is a real thing to want and needs no separate control.
|
| */}
|
| {groupBranches(action).map((br, i) => (
|
| <div key={br.id || i}>
|
| <p className="auto-field-label">
|
| {br.label}
|
| {br.cond ? " β run these actions ifβ¦" : " β everything that reaches here"}
|
| </p>
|
| <CondBuilder
|
| cond={br.cond}
|
| onChange={(next) =>
|
| onEdit((a) => ({
|
| ...a,
|
| config: {
|
| ...a.config,
|
| branches: groupBranches(a).map((x) =>
|
| x.id === br.id ? { ...x, cond: next } : x
|
| ),
|
| },
|
| }))
|
| }
|
| fields={walkFields}
|
| ops={ops}
|
| nullaryOps={nullaryOps}
|
| maxDepth={vocab?.maxCondDepth ?? 3}
|
| maxChildren={vocab?.maxCondChildren ?? 12}
|
| lead=""
|
| disabled={busy}
|
| />
|
| </div>
|
| ))}
|
| <p className="auto-hint">
|
| The first branch whose conditions match is the one that runs β the record does not go
|
| down two of them.
|
| </p>
|
| </>
|
| ) : null}
|
|
|
| {action.kind === "create_record" ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-atable">
|
| <span className="autox-req">*</span> Database
|
| </label>
|
| <select
|
| id="autox-atable"
|
| className="auto-input"
|
| value={String((cfg as { table?: string }).table || "")}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ table: e.target.value })}
|
| >
|
| <option value="">Select a databaseβ¦</option>
|
| {/* β THE STORED VALUE IS ALWAYS AN OPTION β the scar this file carries in three other
|
| places. A `<select>` whose `value` matches no `<option>` renders the FIRST one, so
|
| an action pointed at a database the reader cannot see would LOOK unset and the
|
| next edit would write the blank over it. */}
|
| {(cfg as { table?: string }).table
|
| && !tables.some((t) => t.key === (cfg as { table?: string }).table) ? (
|
| <option value={String((cfg as { table?: string }).table)}>
|
| {String((cfg as { table?: string }).table)} (not visible to you)
|
| </option>
|
| ) : null}
|
| {tables.map((t) => (
|
| <option key={t.key} value={t.key}>
|
| {t.label}
|
| </option>
|
| ))}
|
| </select>
|
| {/* ITEM 9 / R11 β mint one without leaving the action being configured. */}
|
| <NewDatabase
|
| disabled={busy}
|
| onCreated={async (key) => {
|
| await onTablesChanged();
|
| setCfg({ table: key });
|
| }}
|
| />
|
| {/*
|
| β WAVE 25 item 4 (ruling R2b, contract C1, wiring W25-4) β WHAT POINTING IT HERE DOES.
|
| The two lists are the SERVER's (`GET /automations/presets`), diffed against whatever
|
| database this action names β R2 makes `config.table` authoritative, so a hand-made one
|
| answers exactly like a spawned one.
|
| */}
|
| <PresetPlan table={String((cfg as { table?: string }).table || "")} />
|
| </div>
|
| ) : null}
|
|
|
| {/* WHAT THE SEARCH ACTUALLY WRITES, in place of the two controls below that do not apply to
|
| it. β THE LIST IS `_candidate_row`'s, field for field: a shorter "the profile columns"
|
| would be safe and useless, and a list that drifts from what the engine writes would be
|
| the same lie the value map was. */}
|
| {pinned ? (
|
| <p className="auto-hint">
|
| The search writes one row per profile it finds β handle, profile link, name, followers,
|
| following, average engagement, bio, link in bio, verified and category β into that
|
| database. It matches on the handle, so a profile found again updates its row instead of
|
| adding another. There is nothing to map: the columns come from the search.
|
| </p>
|
| ) : null}
|
|
|
| {/*
|
| β WAVE 25 item 2 (ruling R1a, contract C5, wiring W25-5) β KEEP RECORDS UNIQUE.
|
| Today's Create record APPENDS FOREVER: `_commit_action_writes` mints `max(id)+1`, so any
|
| scheduled flow using it duplicates a row per run β every night, silently, until somebody
|
| looks at the table. `uniqueOn` names the column to upsert on instead.
|
|
|
| β `""` IS A REAL CHOICE AND IT IS THE DEFAULT, so no stored automation changes meaning the
|
| day this ships. That is why the empty option is worded as a behaviour ("Add a new record
|
| every time") rather than as an absence ("None"): it describes what will happen, which is
|
| the thing the reader is choosing between.
|
|
|
| β THE COLUMN LIST IS THE TARGET DATABASE'S, not the walking record's β `valueFields`
|
| already resolves that for `create_record`. And the server REFUSES a `uniqueOn` this action
|
| does not write ("it writes: β¦"), so the honest client move is to offer the columns it
|
| writes FIRST while still allowing the rest: pre-empting the refusal here would be a second
|
| copy of a server rule, and this file's law is that the server owns legality.
|
| */}
|
| {action.kind === "create_record" && !pinned ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-aunique">Keep records unique on</label>
|
| <select
|
| id="autox-aunique"
|
| className="auto-input"
|
| value={String((cfg as { uniqueOn?: string }).uniqueOn || "")}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ uniqueOn: e.target.value })}
|
| >
|
| <option value="">Add a new record every time</option>
|
| {(cfg as { uniqueOn?: string }).uniqueOn
|
| && !valueFields.some((f) => f.key === (cfg as { uniqueOn?: string }).uniqueOn) ? (
|
| <option value={String((cfg as { uniqueOn?: string }).uniqueOn)}>
|
| {String((cfg as { uniqueOn?: string }).uniqueOn)} (not in this database)
|
| </option>
|
| ) : null}
|
| {valueFields.map((f) => (
|
| <option key={f.key} value={f.key}>
|
| {f.label}
|
| </option>
|
| ))}
|
| </select>
|
| <p className="auto-hint">
|
| {String((cfg as { uniqueOn?: string }).uniqueOn || "")
|
| ? "A run that finds a matching record updates it instead of adding another."
|
| : "Every run adds a row, even if the same one is already there."}
|
| </p>
|
| </div>
|
| ) : null}
|
|
|
| {/*
|
| β WAVE 25 item 1 (rulings R3/R4, contract C4) β THE ENRICH ACTION'S CONFIGURATION.
|
| It replaces the whole `field_instagram` KIND: enriching a profile is something you do TO a
|
| record, not a species of automation. The switches are that kind's, unchanged in meaning,
|
| because they are the ones that decide what a run COSTS.
|
|
|
| β NO `profileField` PICKER HERE, DELIBERATELY, and it is not an omission. C3's profile
|
| FLAG on the column is what binds this action (R7 β one flagged text field per table), and
|
| the server resolves it at run time; an empty `profileField` is the A3 stored-inert state,
|
| not an error. A second picker would let a reader choose a column the flag does not name,
|
| which is two sources of truth for one binding β and the flag is the one the engine reads.
|
| */}
|
| {isEnrich(action.kind) ? (
|
| <>
|
| {/*
|
| ββ 2026-08-07 (owner ruling) β WHICH RECORDS THIS STEP SPENDS ON.
|
| Owner: *"how many records with what sort, based on a Filtered view or maybe top N
|
| enrichment sorted by date"*. Every enrichment is a vendor call, so an enrich step
|
| without this panel bills for EVERY record in the database on EVERY run β which is why
|
| these controls shipped in the same change as the fix that made the action clickable at
|
| all, rather than after it.
|
|
|
| β THE LIMIT IS A QUOTA OF WORK DONE, NOT A WINDOW OF ROWS READ β the owner's own
|
| clarification: *"if a user choose to enrich 30 and from that sorted list of 30, 20 is
|
| enriched last 30 days, then it goes to next list"*. The server walks PAST every skipped
|
| record until the quota is filled, so the number in this box is a cost ceiling that
|
| means the same thing every run. `enrich_selection` owns the rule; this panel never
|
| re-implements it.
|
| */}
|
| <p className="auto-field-label">Records to enrich</p>
|
| <div className="auto-field">
|
| <label htmlFor="autox-aview">From view</label>
|
| <select
|
| id="autox-aview"
|
| className="auto-input"
|
| value={String((cfg as { fromView?: string }).fromView || "")}
|
| disabled={busy || !walkViews}
|
| onChange={(e) => setCfg({ fromView: e.target.value })}
|
| >
|
| <option value="">Every record in the database</option>
|
| {(walkViews || []).map((v) => (
|
| <option key={v.id} value={v.id}>{v.label}</option>
|
| ))}
|
| </select>
|
| {/* β ABSENT vs EMPTY, the `UserTable.views` third state. "This server did not offer a
|
| view list" is a different fact from "this database has no views", and rendering an
|
| empty picker for the first one is the silent-empty defect wave 23 gated. */}
|
| {!walkViews ? (
|
| <p className="auto-hint">No view list was offered for that database.</p>
|
| ) : null}
|
| </div>
|
| <div className="auto-field">
|
| <label htmlFor="autox-asort">Sort by</label>
|
| <select
|
| id="autox-asort"
|
| className="auto-input"
|
| value={String((cfg as { sortField?: string }).sortField || "first_found")}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ sortField: e.target.value })}
|
| >
|
| {walkFields.length === 0 ? (
|
| <option value="first_found">First found</option>
|
| ) : null}
|
| {walkFields.map((f) => (
|
| <option key={f.key} value={f.key}>{f.label}</option>
|
| ))}
|
| </select>
|
| </div>
|
| <div className="auto-field">
|
| <label htmlFor="autox-asortdir">Order</label>
|
| <select
|
| id="autox-asortdir"
|
| className="auto-input"
|
| value={String((cfg as { sortDir?: string }).sortDir || "desc")}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ sortDir: e.target.value })}
|
| >
|
| <option value="desc">Newest / highest first</option>
|
| <option value="asc">Oldest / lowest first</option>
|
| </select>
|
| </div>
|
| <div className="auto-field">
|
| <label htmlFor="autox-alimit">Limit</label>
|
| <input
|
| id="autox-alimit"
|
| className="auto-input"
|
| type="number"
|
| min={1}
|
| max={100}
|
| defaultValue={Number((cfg as { limit?: number }).limit || 25)}
|
| disabled={busy}
|
| // Commits on blur, like `maxPosts` above and for the same reason: "3" on the way to
|
| // "30" is a legal number the server would happily store.
|
| onBlur={(e) => setCfg({ limit: Number(e.target.value) || 25 })}
|
| />
|
| <p className="auto-hint">
|
| Records enriched per run, at most 100. Skipped records do not use up the limit —
|
| the run keeps going down the list until it has enriched this many.
|
| </p>
|
| </div>
|
| <label className="auto-check">
|
| <input
|
| type="checkbox"
|
| checked={!!(cfg as { skipRecent?: boolean }).skipRecent}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ skipRecent: e.target.checked })}
|
| />
|
| Skip records enriched recently
|
| </label>
|
| {/* β THE DAYS BOX ONLY EXISTS WHILE THE RULE IS ON. A number that configures nothing is
|
| worse than a missing one ([[wrong-parent-not-broken-control]]) β and this one would
|
| read as a cost guard that is running while it is switched off. */}
|
| {(cfg as { skipRecent?: boolean }).skipRecent ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-acool">Enriched within</label>
|
| <input
|
| id="autox-acool"
|
| className="auto-input"
|
| type="number"
|
| min={1}
|
| defaultValue={Number((cfg as { skipRecentDays?: number }).skipRecentDays || 30)}
|
| disabled={busy}
|
| onBlur={(e) => setCfg({ skipRecentDays: Number(e.target.value) || 30 })}
|
| />
|
| <p className="auto-hint">
|
| Days. A profile enriched inside this window is passed over, and the run moves on to
|
| the next one — so you never pay twice for the same profile, and the history
|
| still gains a point once the window closes.
|
| </p>
|
| </div>
|
| ) : null}
|
|
|
| {/*
|
| ββ WAVE 28 Β· R5 / R6 / R7 β THE SOURCE QUESTION IS GONE, AND THREE INCLUDE AXES
|
| REPLACE IT. What stood here was a `Source` select writing `config.tier`
|
| (Anonymous / Paid provider) plus a sibling `noFallback` checkbox β ONE control in two
|
| pieces, asking the user to choose a VENDOR STRATEGY.
|
|
|
| β R5 RETIRED THE QUESTION, not just the control. Enrichment routes per capability to
|
| the paid providers and reports blocked on a refusal; there is no thin anonymous row to
|
| fall back to, so "which source" and "stop if it fails" no longer have answers a person
|
| could give. `tier` and `noFallback` are accepted-and-ignored in stored configs (C2,
|
| and D-65's law: never 400 a definition that was legal when it was written) β which is
|
| why this panel simply stops writing them rather than migrating anything.
|
|
|
| β THE ROWS BELOW ARE A TRANSFORM, NOT AN ADDITION, and that distinction is the whole
|
| defect risk in this change. `postMetrics` and `commentMetrics` ALREADY had checkboxes
|
| 34 lines below this point ("Also capture per-post engagement" / "β¦per-commentβ¦").
|
| Building three NEW rows and leaving those would have put TWO controls on each key β
|
| which compiles, renders, and satisfies any check asking whether a Post-data switch
|
| exists. The old pair is deleted; these carry their keys.
|
|
|
| β KEYS UNCHANGED ON PURPOSE (C2). Every stored enrich action round-trips untouched;
|
| only the labels move. R7: no cost sentence anywhere in this panel β the run log keeps
|
| honest spend reporting, and a warning printed permanently is chrome the eye stops
|
| reading (DESIGN.md Β§4).
|
| */}
|
| {/* β A GROUP HEADING, NOT A `<label htmlFor>`. The first cut pointed it at the Post-data
|
| input, which is wrong twice over: it claims one row is "the" control for a group of
|
| three, and clicking the heading would toggle Posts. `auto-field-label` is this
|
| panel's own idiom for naming a group ("Records to enrich" above uses it). */}
|
| <p className="auto-field-label">Include</p>
|
| {/* β DISPLAY-ONLY, AND IT IS NOT DECORATION. The profile IS the unit of enrichment β
|
| there is no run that skips it β so a switch here would be a control that cannot be
|
| off ([[wrong-parent-not-broken-control]]). It carries NO config key: rendering it
|
| as state would invent a flag no cleaner reads.
|
| β `readOnly` beside `disabled`: a `checked` input with no `onChange` is a React
|
| warning, and `readOnly` says the honest thing about why. */}
|
| <label className="auto-check">
|
| <input id="autox-inc-profile" type="checkbox" checked readOnly disabled />
|
| Profile
|
| </label>
|
| <label className="auto-check">
|
| <input
|
| id="autox-inc-posts"
|
| type="checkbox"
|
| checked={!!(cfg as { postMetrics?: boolean }).postMetrics}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ postMetrics: e.target.checked })}
|
| />
|
| Post data
|
| </label>
|
| <label className="auto-check">
|
| <input
|
| id="autox-inc-comments"
|
| type="checkbox"
|
| checked={!!(cfg as { commentMetrics?: boolean }).commentMetrics}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ commentMetrics: e.target.checked })}
|
| />
|
| Comment data
|
| </label>
|
| {/*
|
| β WAVE 26 Β· ITEM 7 / R2 + C5 β TWO CONTROLS, AND THEY ARE INDEPENDENT.
|
| R2 splits them deliberately: how many posts to KEEP is FREE and rides the profile
|
| pull, while per-post engagement is a SEPARATE, PAID scrape (one extra vendor record
|
| per post per run β roughly 13x). They were nested here, so the free control was only
|
| reachable by first switching the paid one on: a person who wanted 5 posts instead of
|
| 24 had to agree to buy engagement metrics to say so.
|
| */}
|
| <div className="auto-field">
|
| <label htmlFor="autox-aposts">Posts per profile</label>
|
| <input
|
| id="autox-aposts"
|
| className="auto-input"
|
| type="number"
|
| min={1}
|
| /* β 12, AND IT IS THE VENDOR'S β MEASURED, not a policy we chose (C5, and
|
| `automation_engine.py:2165`: a Profiles row carries the TOP 12, "a cap, not a
|
| count"). It read 200. `clean_max_posts` REFUSES a value the panel SENT above the
|
| cap, so the old box could produce a 400 by typing a number it invited. */
|
| max={12}
|
| /* β 10, NOT 24 β the same number twice over. `DEFAULT_POSTS_PER_PULL` is 10, and 24
|
| is now ABOVE the ceiling, so an empty box used to fall back to a value the save
|
| door refuses. [[default-must-pass-its-own-guard]]: a law added later turns
|
| yesterday's safe default into a value the product rejects. */
|
| defaultValue={Number((cfg as { maxPosts?: number }).maxPosts || 10)}
|
| disabled={busy}
|
| // FREE TEXT COMMITS ON BLUR β per-keystroke would PATCH a half-typed number, and
|
| // "2" on the way to "24" is a legal value the server would happily store.
|
| onBlur={(e) => setCfg({ maxPosts: Number(e.target.value) || 10 })}
|
| />
|
| <p className="auto-hint">
|
| At most 12 β the provider returns a profile’s top 12 posts and no more.
|
| </p>
|
| </div>
|
| {/*
|
| ββ W29-T09 (owner item 11: *"the last 12 reels"*) β THE ONLY DOOR TO `config.postGroups`.
|
| The server side has been whole since wave 28 β `clean_post_groups` validates it, the
|
| enrich runner applies it β and `postGroups` had **zero occurrences** anywhere in
|
| `web/src`, so NO USER COULD ASK FOR REELS-ONLY AT ALL. That is why this is a blocker for
|
| the reels routing work and not a nicety: those tickets' negative controls need a config
|
| a person can actually produce.
|
|
|
| β THE KEYS ARE THE SERVER'S AND SO ARE THE WORDS. `vocab.postTypes` carries both; a
|
| client that translated `video` into "Reels" locally would be a second copy of a
|
| vocabulary `clean_post_groups` refuses deviations from. Absent vocabulary renders
|
| NOTHING rather than three guessed names.
|
|
|
| β ABSENT IS OFF, AND OFF IS THE DEFAULT FOREVER. An enrich action stored before this
|
| control carries no `postGroups`, and the runner returns the post list unchanged when the
|
| key is missing β so unchecking every box must DELETE the key, never store `[]`-with-
|
| meaning or a group of zero. Deleting it is what keeps an old automation capturing
|
| exactly what it always captured.
|
|
|
| β ONE WRITER FOR ONE KEY [W-12]. This is the only control in `web/src` that writes
|
| `postGroups`; the `maxPosts` box above writes `maxPosts` and nothing else. The two are
|
| related only in that a group cannot keep more posts than the pull captures β which is
|
| the server's rule, printed here rather than re-implemented.
|
| */}
|
| {(vocab?.postTypes || []).length ? (
|
| <div className="auto-field">
|
| <p className="auto-field-label">Keep only certain posts</p>
|
| {(vocab?.postTypes || []).map((pt) => {
|
| const groups =
|
| ((cfg as { postGroups?: { type: string; limit: number }[] }).postGroups) || [];
|
| const mine = groups.find((g) => g && g.type === pt.key);
|
| const cap = Math.max(
|
| 1,
|
| Number((cfg as { maxPosts?: number }).maxPosts || 10) || 10
|
| );
|
| /* β WRITE THROUGH ONE FUNCTION, so the "no groups left β remove the key" rule
|
| exists once. Two call sites each deciding it is how `[]` starts meaning
|
| "capture nothing". */
|
| const write = (next: { type: string; limit: number }[]) =>
|
| setCfg({ postGroups: next.length ? next : undefined });
|
| return (
|
| <label className="auto-check" key={pt.key}>
|
| <input
|
| type="checkbox"
|
| checked={!!mine}
|
| disabled={busy}
|
| onChange={(e) =>
|
| write(
|
| e.target.checked
|
| ? [...groups.filter((g) => g.type !== pt.key),
|
| { type: pt.key, limit: Math.min(cap, 12) }]
|
| : groups.filter((g) => g.type !== pt.key)
|
| )
|
| }
|
| />
|
| {pt.label}
|
| {mine ? (
|
| <input
|
| className="auto-input auto-input-inline"
|
| type="number"
|
| min={1}
|
| max={cap}
|
| aria-label={`How many ${pt.label} to keep`}
|
| /* Commits on BLUR, exactly like `maxPosts` above and for the identical
|
| reason: "1" on the way to "12" is a value the server would store. */
|
| defaultValue={mine.limit}
|
| disabled={busy}
|
| onBlur={(e) =>
|
| write([
|
| ...groups.filter((g) => g.type !== pt.key),
|
| {
|
| type: pt.key,
|
| limit: Math.max(1, Number(e.target.value) || 1),
|
| },
|
| ])
|
| }
|
| />
|
| ) : null}
|
| </label>
|
| );
|
| })}
|
| <p className="auto-hint">
|
| Leave these unticked to keep every post. Ticked, only the kinds you name are
|
| kept β and a group cannot keep more than the {" "}
|
| {Number((cfg as { maxPosts?: number }).maxPosts || 10) || 10} posts this
|
| enrichment captures.
|
| </p>
|
| </div>
|
| ) : null}
|
| {/*
|
| β WAVE 28 Β· R6/R7 β THE TWO CHECKBOXES THAT STOOD HERE MOVED UP INTO THE INCLUDE
|
| GROUP, KEYS AND ALL (`postMetrics`, `commentMetrics`). They are not deleted features:
|
| they are the SAME two switches, relabelled "Post data" and "Comment data" and grouped
|
| with the always-on Profile row so the three capture axes read as one decision.
|
| β THEIR CONDITIONAL COST HINTS WENT WITH THEM AND DID NOT COME BACK (R7): "Billed per
|
| post, not per profile" and "Billed by the separate Comments dataset". R7 puts spend
|
| reporting in the RUN LOG, where it is a measured fact about work already done, rather
|
| than in the panel, where it was a permanent caption on a switch.
|
| β Leaving them here as well as above is the duplicate-writer trap this wave's item 1
|
| was one literal reading away from shipping β two controls on one key, both correct,
|
| neither authoritative.
|
| */}
|
| <label className="auto-check">
|
| <input
|
| type="checkbox"
|
| checked={!!(cfg as { dryRun?: boolean }).dryRun}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ dryRun: e.target.checked })}
|
| />
|
| Dry run β read and report, write nothing
|
| </label>
|
| </>
|
| ) : null}
|
|
|
| {(action.kind === "update_record" || action.kind === "create_record") && !pinned ? (
|
| <>
|
| <p className="auto-field-label">
|
| <span className="autox-req">*</span> Values
|
| </p>
|
| {Object.entries(values).map(([key, val], i) => (
|
| <div className="autoc-row" key={i}>
|
| <button
|
| type="button"
|
| className="autoc-drop"
|
| disabled={busy}
|
| aria-label="Remove this value"
|
| onClick={() => {
|
| const next = { ...values };
|
| delete next[key];
|
| setCfg({ values: next });
|
| }}
|
| >
|
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| strokeLinecap="round" />
|
| </svg>
|
| </button>
|
| <select
|
| className="auto-input is-tiny"
|
| value={key}
|
| disabled={busy}
|
| aria-label="Column"
|
| onChange={(e) => {
|
| const next: Record<string, string | number> = {};
|
| for (const [k, v] of Object.entries(values))
|
| next[k === key ? e.target.value : k] = v;
|
| setCfg({ values: next });
|
| }}
|
| >
|
| <option value="">Choose a columnβ¦</option>
|
| {/* β WHICH RECORD'S COLUMNS depends on the action: `create_record` writes into
|
| the database it NAMES, `update_record` writes onto the record walking the
|
| flow β the trigger's. Offering the target's columns for both would list the
|
| wrong database's columns for every update action. */}
|
| {key && !valueFields.some((f) => f.key === key) ? (
|
| <option value={key}>{key}</option>
|
| ) : null}
|
| {valueFields.map((f) => (
|
| <option key={f.key} value={f.key}>
|
| {f.label}
|
| </option>
|
| ))}
|
| </select>
|
| <input
|
| className="auto-input is-tiny autoc-value"
|
| defaultValue={String(val ?? "")}
|
| disabled={busy}
|
| aria-label="Value"
|
| placeholder="Value or {{column}}"
|
| onBlur={(e) => setCfg({ values: { ...values, [key]: e.target.value } })}
|
| />
|
| </div>
|
| ))}
|
| <button
|
| type="button"
|
| className="autoc-add"
|
| disabled={busy}
|
| onClick={() => setCfg({ values: { ...values, "": "" } })}
|
| >
|
| + Add a value
|
| </button>
|
| {/* β THE SENTENCE THE OWNER COULD NOT PARSE, 2026-08-06. `{{column_key}}` is a
|
| PLACEHOLDER standing for a placeholder, and "the record walking the flow" is this
|
| module's internal noun for the row a step is currently running on β two layers of
|
| indirection in eleven words, above a box the reader is already unsure how to fill.
|
| It says what to TYPE, using a column this database actually has, and names the two
|
| choices as choices. `walkFields` is the walking record's columns for both action
|
| kinds, which is what a placeholder resolves against. */}
|
| <p className="auto-hint">
|
| Type a fixed value, or{" "}
|
| <code>{`{{${walkFields[0]?.key || "column"}}}`}</code> to copy{" "}
|
| {walkFields[0]?.label ? <>β{walkFields[0].label}β</> : "a column"} from the record this
|
| step is running on.
|
| </p>
|
| </>
|
| ) : null}
|
|
|
| {action.kind === "find_records" ? (
|
| <>
|
| <div className="auto-field">
|
| <label htmlFor="autox-ftable">
|
| <span className="autox-req">*</span> Database
|
| </label>
|
| <select
|
| id="autox-ftable"
|
| className="auto-input"
|
| value={String((cfg as { table?: string }).table || "")}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ table: e.target.value })}
|
| >
|
| <option value="">Select a databaseβ¦</option>
|
| {/* β THE STORED VALUE IS ALWAYS AN OPTION β the same scar its `create_record`
|
| sibling carries three lines up, and this picker did NOT have the guard. A
|
| `<select>` whose `value` matches no `<option>` renders the FIRST one, so a
|
| find_records pointed at a database this reader cannot see looked unset, and the
|
| next edit wrote the blank over it. Sibling surfaces written from one template
|
| diverge exactly this way and nothing greps for "the other one" (D-10). */}
|
| {(cfg as { table?: string }).table
|
| && !tables.some((t) => t.key === (cfg as { table?: string }).table) ? (
|
| <option value={String((cfg as { table?: string }).table)}>
|
| {String((cfg as { table?: string }).table)} (not visible to you)
|
| </option>
|
| ) : null}
|
| {tables.map((t) => (
|
| <option key={t.key} value={t.key}>
|
| {t.label}
|
| </option>
|
| ))}
|
| </select>
|
| {/* ITEM 9 / R11 β "every action's database picker", so this one too. */}
|
| <NewDatabase
|
| disabled={busy}
|
| onCreated={async (key) => {
|
| await onTablesChanged();
|
| setCfg({ table: key });
|
| }}
|
| />
|
| </div>
|
| <p className="auto-field-label">Conditions</p>
|
| <CondBuilder
|
| cond={(cfg as { cond?: Cond | null }).cond || null}
|
| onChange={(next) => setCfg({ cond: next })}
|
| fields={target?.fields || []}
|
| ops={ops}
|
| nullaryOps={nullaryOps}
|
| maxDepth={vocab?.maxCondDepth ?? 3}
|
| maxChildren={vocab?.maxCondChildren ?? 12}
|
| disabled={busy}
|
| />
|
| <div className="auto-field">
|
| <label htmlFor="autox-flimit">How many at most</label>
|
| <input
|
| id="autox-flimit"
|
| className="auto-input"
|
| type="number"
|
| min={1}
|
| defaultValue={Number((cfg as { limit?: number }).limit || 25)}
|
| disabled={busy}
|
| onBlur={(e) => setCfg({ limit: Number(e.target.value) || 25 })}
|
| />
|
| </div>
|
| <p className="auto-hint">
|
| What it found opens from the run log. Piping the rows into a later step is not built
|
| yet.
|
| </p>
|
| </>
|
| ) : null}
|
|
|
| {/*
|
| β WAVE 26 Β· ITEM 5 β OWNER RULINGS R9 AND R10, and they are two different fixes to one
|
| symptom (*"the condition on action act_1 names no field"*).
|
|
|
| R9 β A `create_record` HAS NO CONDITIONS AT ALL. The owner corrected their own first
|
| answer mid-grill: *"if it is Create Record, I don't think you can even add Conditions at
|
| all. That's not how the Create Record works."* Airtable agrees and so does the shape:
|
| every other action operates ON the record the flow is walking, so "run this only when
|
| <that record> β¦" is a question about something that exists. Create record MAKES one.
|
| There is nothing to test yet β which is why the picker on this panel was always empty and
|
| why every condition saved against it named no field.
|
| β THE SERVER DROPS A STORED `when` RATHER THAN REFUSING IT (C4), and this removal is what
|
| makes that silent drop defensible: `clean_actions` has no disclosure channel, so the drop
|
| is only safe while there is no control whose value could appear to be ignored. Deleting
|
| the editor and dropping the value are ONE change in two files β if this editor ever comes
|
| back for `create_record`, the drop becomes silent data loss (booked by A).
|
|
|
| R10 β NO WALKING RECORD β A SENTENCE, NEVER AN EMPTY PICKER. A picker with no options
|
| reads as "this database has no columns", and anything saved through it names no field,
|
| which is the refusal the owner kept meeting. The sentence points at the surface that CAN
|
| answer: the trigger.
|
| β The test is `walkTable`, not `walkFields.length` β see the prop's own note. A flow
|
| walking a database that genuinely has no columns yet keeps its (empty) picker, because
|
| for that flow the picker is the right control and adding a column is the fix.
|
| */}
|
| {action.kind === "create_record" ? null : !walkTable ? (
|
| <>
|
| <h3>Run this only when</h3>
|
| <p className="auto-note">
|
| This flow has no record to test β set conditions on the trigger instead.
|
| </p>
|
| </>
|
| ) : (
|
| <>
|
| <h3>Run this only when</h3>
|
| <CondBuilder
|
| cond={action.when || null}
|
| onChange={(next) => onEdit((a) => ({ ...a, when: next }))}
|
| fields={walkFields}
|
| ops={ops}
|
| nullaryOps={nullaryOps}
|
| maxDepth={vocab?.maxCondDepth ?? 3}
|
| maxChildren={vocab?.maxCondChildren ?? 12}
|
| disabled={busy}
|
| />
|
| {!condComplete(action.when || null, nullaryOps) ? null : (
|
| <p className="auto-hint">Leave empty to run it every time.</p>
|
| )}
|
| </>
|
| )}
|
| </>
|
| );
|
| }
|
|
|
| export function ReviewProps({
|
| cfg,
|
| vocab,
|
| busy,
|
| setCfg,
|
| }: {
|
| cfg: Record<string, unknown>;
|
| vocab?: FlowVocab;
|
| busy: boolean;
|
| setCfg: (patch: Record<string, unknown>) => void;
|
| }) {
|
| const by = String(cfg.decidedBy || "user");
|
| const next = Array.isArray(cfg.next) ? (cfg.next as string[]) : [];
|
| const aiReady = vocab?.aiReady !== false;
|
| return (
|
| <>
|
| <div className="auto-field">
|
| <label htmlFor="autox-rby">Decided by</label>
|
| <select
|
| id="autox-rby"
|
| className="auto-input"
|
| value={by}
|
| disabled={busy}
|
| onChange={(e) => setCfg({ decidedBy: e.target.value })}
|
| >
|
| {(vocab?.reviewDeciders || ["user"]).map((d) => (
|
| <option key={d} value={d}>
|
| {d === "ai" ? "AI" : "A person"}
|
| </option>
|
| ))}
|
| </select>
|
| </div>
|
| {/*
|
| β `aiReady` IS A MEASUREMENT OF THIS DEPLOYMENT, and it is stated rather than styled
|
| around. With no LLM key the engine holds every card for a human β so an AI review that
|
| looked configured would promise a decision nothing will make (C6's fail-closed path).
|
| */}
|
| {by === "ai" && !aiReady ? (
|
| <p className="auto-note">
|
| No AI provider is configured here, so these cards wait for a person.
|
| </p>
|
| ) : null}
|
| {by === "ai" ? (
|
| <div className="auto-field">
|
| <label htmlFor="autox-rprompt">What should it decide?</label>
|
| <textarea
|
| id="autox-rprompt"
|
| className="auto-input"
|
| rows={3}
|
| defaultValue={String(cfg.prompt || "")}
|
| disabled={busy}
|
| placeholder="Approve suppliers with a UK address and more than 20 reviews."
|
| onBlur={(e) => setCfg({ prompt: e.target.value })}
|
| />
|
| </div>
|
| ) : null}
|
| <div className="auto-field">
|
| <label htmlFor="autox-rlabel">Stage name</label>
|
| <input
|
| id="autox-rlabel"
|
| className="auto-input"
|
| defaultValue={String(cfg.label || "Review")}
|
| disabled={busy}
|
| onBlur={(e) => setCfg({ label: e.target.value })}
|
| />
|
| </div>
|
| <p className="auto-field-label">Where it can go next</p>
|
| {next.map((n, i) => (
|
| <div className="autoc-row" key={i}>
|
| <button
|
| type="button"
|
| className="autoc-drop"
|
| disabled={busy}
|
| aria-label={`Remove ${n}`}
|
| onClick={() => setCfg({ next: next.filter((_x, j) => j !== i) })}
|
| >
|
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| strokeLinecap="round" />
|
| </svg>
|
| </button>
|
| <input
|
| className="auto-input is-tiny"
|
| defaultValue={n}
|
| disabled={busy}
|
| aria-label="Stage this review can send a record to"
|
| onBlur={(e) =>
|
| setCfg({ next: next.map((x, j) => (j === i ? e.target.value : x)) })
|
| }
|
| />
|
| </div>
|
| ))}
|
| <button
|
| type="button"
|
| className="autoc-add"
|
| disabled={busy}
|
| onClick={() => setCfg({ next: [...next, ""] })}
|
| >
|
| + Add an exit
|
| </button>
|
| <p className="auto-hint">
|
| Each exit is a lane on the board. A card waits here until it is moved to one of them.
|
| </p>
|
| </>
|
| );
|
| }
|
|
|