diff --git a/app/islands/AutomationForm.css.ts b/app/islands/AutomationForm.css.ts index 858ec98..9d3ece9 100644 --- a/app/islands/AutomationForm.css.ts +++ b/app/islands/AutomationForm.css.ts @@ -808,3 +808,130 @@ export const placeholderDesc = style({ fontFamily: "inherit", color: vars.color.textMuted, }); + +// --------------------------------------------------------------------------- +// Steps tree (U11): recursive step lists rendered as indented card columns. +// --------------------------------------------------------------------------- + +export const stepList = style({ + display: "flex", + flexDirection: "column", + gap: space[3], +}); + +/** Child container (then/else branch, loop body): one indent level per depth, + * with a guide line so nesting reads at a glance. */ +export const stepChildList = style([ + stepList, + { + marginInlineStart: space[3], + paddingInlineStart: space[3], + borderInlineStart: `2px solid ${vars.color.borderSubtle}`, + }, +]); + +/** Labeled wrapper around a branch/body child list. */ +export const branchBlock = style({ + display: "flex", + flexDirection: "column", + gap: space[2], +}); + +export const branchLabel = style({ + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + textTransform: "uppercase", + letterSpacing: "0.07em", + color: vars.color.textMuted, +}); + +/** Small icon-button used for indent/outdent in a step's header. */ +export const stepToolBtn = style({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "26px", + height: "26px", + padding: 0, + color: vars.color.textMuted, + backgroundColor: "transparent", + border: `1px solid ${vars.color.border}`, + borderRadius: radii.sm, + cursor: "pointer", + flexShrink: 0, + selectors: { + "&:hover:not(:disabled)": { + backgroundColor: vars.color.surfaceHover, + color: vars.color.text, + }, + "&:disabled": { + opacity: 0.4, + cursor: "not-allowed", + }, + }, +}); + +export const stepToolGroup = style({ + display: "inline-flex", + gap: space[1], + flexShrink: 0, +}); + +/** Header title for structural steps (condition, loop, fetch, search, + * variable) mirroring the ActionHeader visual weight. */ +export const stepKindTitle = style({ + display: "inline-flex", + alignItems: "center", + gap: space[2], + fontSize: fontSize.base, + fontWeight: fontWeight.medium, + color: vars.color.text, + minInlineSize: 0, +}); + +export const stepKindIcon = style({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + color: vars.color.textMuted, + flexShrink: 0, +}); + +/** Flexible cells for card top rows (name / type / remove) so the layout + * needs no inline styles. */ +export const rowCellName = style({ + flex: "1 1 160px", + minInlineSize: 0, + display: "flex", + flexDirection: "column", + gap: space[1], +}); + +export const rowCellWide = style({ + flex: "2 1 280px", + minInlineSize: 0, + display: "flex", + flexDirection: "column", + gap: space[1], +}); + +export const rowCellAuto = style({ + flex: "0 0 auto", + display: "flex", + flexDirection: "column", + gap: space[1], +}); + +/** Inline hint shown when an add/indent control is disabled by a cap. */ +export const capHint = style({ + fontSize: fontSize.xs, + color: vars.color.warning, +}); + +/** Empty-state hint inside a child container. */ +export const emptyContainerHint = style({ + fontSize: fontSize.xs, + color: vars.color.textMuted, + fontStyle: "italic", + margin: 0, +}); diff --git a/app/islands/AutomationForm.test.tsx b/app/islands/AutomationForm.test.tsx index 7cd8a80..23316ca 100644 --- a/app/islands/AutomationForm.test.tsx +++ b/app/islands/AutomationForm.test.tsx @@ -9,26 +9,45 @@ vi.mock("../../lib/auth/scope-errors.js", () => ({ import AutomationForm, { type AutomationInitial } from "./AutomationForm.js"; import { redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; +import type { Step } from "../../lib/automations/steps.js"; const mockRedirect = vi.mocked(redirectToScopeUpgrade); const fetchMock = vi.fn(); -// A fully-seeded automation. Without an `rkey` the form renders in create mode -// (the duplicate flow); with one it renders in edit mode. The seed makes the -// form valid so the submit buttons are enabled without UI interaction. +// A fully-seeded automation in the steps shape. Without an `rkey` the form +// renders in create mode (the duplicate flow); with one it renders in edit +// mode. The seed makes the form valid so the submit buttons are enabled +// without UI interaction. const SEED: AutomationInitial = { name: "Test Automation", description: null, lexicon: "app.bsky.feed.like", operations: ["create"], - actions: [{ $type: "webhook", callbackUrl: "https://example.com/hook", secret: "s" }], - fetches: [], - variables: [], - conditions: [], + steps: [ + { $type: "webhook", id: "wh1", callbackUrl: "https://example.com/hook", secret: "s" } as Step, + ], wantedDids: [], active: true, }; +// A migrated automation with a branch: condition -> then (named bsky-post) / +// else (notification), followed by a webhook after the rejoin. +const BRANCH_SEED: AutomationInitial = { + ...SEED, + steps: [ + { + $type: "condition", + id: "cond1", + assertions: [ + { field: "event.commit.record.subject.uri", operator: "contains", value: "bsky" }, + ], + then: [{ $type: "bsky-post", id: "post1", name: "mirror", textTemplate: "hi {{event.did}}" }], + else: [{ $type: "notification", id: "note1", title: "t", body: "b" }], + }, + { $type: "webhook", id: "wh1", callbackUrl: "https://example.com/hook", secret: "s" }, + ] as Step[], +}; + function renderForm(initial: AutomationInitial) { const container = document.createElement("div"); document.body.appendChild(container); @@ -44,6 +63,29 @@ function submitButton(container: HTMLElement, label: string): HTMLButtonElement return match; } +function buttonByText(container: HTMLElement, text: string, nth = 0): HTMLButtonElement { + const matches = [...container.querySelectorAll("button[type=button]")].filter( + (b) => b.textContent?.includes(text), + ); + const match = matches[nth]; + if (!match) throw new Error(`No button #${nth} matching "${text}" (${matches.length} found)`); + return match; +} + +function setInput(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function previewedPayload(container: HTMLElement): Record { + const details = [...container.querySelectorAll("details")].find((d) => + d.querySelector("summary")?.textContent?.includes("Preview JSON payload"), + ); + const pre = details?.querySelector("pre"); + if (!pre?.textContent) throw new Error("No preview payload rendered"); + return JSON.parse(pre.textContent); +} + // button.click() doesn't reliably populate SubmitEvent.submitter under // happy-dom, so we dispatch the submit event explicitly. hono/jsx/dom also // attaches its own form listener that reads `ev.detail` for non-trusted events, @@ -74,6 +116,10 @@ beforeEach(() => { json: async () => ({ error: "stub" }), })); vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); }); afterEach(() => { @@ -94,8 +140,8 @@ describe("AutomationForm submit", () => { expect(submitButton(container, "Create automation")).toBeTruthy(); }); - it("'Create in dry run' POSTs dryRun:true", async () => { - const container = renderForm(SEED); + it("'Create in dry run' POSTs dryRun:true with the steps tree", async () => { + const container = renderForm(BRANCH_SEED); await vi.advanceTimersByTimeAsync(20); submitVia(submitButton(container, "Create in dry run")); @@ -103,7 +149,20 @@ describe("AutomationForm submit", () => { const call = lastCreateCall(); expect(call).toBeTruthy(); - expect(JSON.parse(call![1].body).dryRun).toBe(true); + const body = JSON.parse(call![1].body); + expect(body.dryRun).toBe(true); + expect(body.steps).toHaveLength(2); + expect(body.steps[0].$type).toBe("condition"); + expect(body.steps[0].then[0]).toMatchObject({ + $type: "bsky-post", + id: "post1", + name: "mirror", + }); + expect(body.steps[0].else[0]).toMatchObject({ $type: "notification", id: "note1" }); + expect(body.steps[1]).toMatchObject({ $type: "webhook", id: "wh1" }); + // Client-only draft fields never reach the API payload. + expect(body.steps[1].secret).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain('"kind"'); }); it("'Create automation' POSTs dryRun:false (live)", async () => { @@ -186,6 +245,216 @@ describe("AutomationForm submit", () => { .reverse() .find(([url, opts]) => url === "/api/automations/rk1" && opts?.method === "PATCH"); expect(call).toBeTruthy(); - expect("dryRun" in JSON.parse(call![1].body)).toBe(false); + const body = JSON.parse(call![1].body); + expect("dryRun" in body).toBe(false); + expect(body.steps[0].$type).toBe("webhook"); + }); +}); + +describe("AutomationForm steps tree", () => { + it("a migrated automation with a branch loads with isDirty false", async () => { + // The dirty check drives the beforeunload guard: it must stay un-armed on + // load (isDirty false for a seeded edit) and arm on the first edit. A spy + // isolates this instance from listeners leaked by earlier renders (tests + // never unmount, so window listeners survive across tests). + const addSpy = vi.spyOn(window, "addEventListener"); + const beforeUnloadRegistrations = () => + addSpy.mock.calls.filter(([type]) => type === "beforeunload").length; + + const container = renderForm({ ...BRANCH_SEED, rkey: "rk1" }); + await vi.advanceTimersByTimeAsync(20); + expect(beforeUnloadRegistrations()).toBe(0); + + // Any edit arms the guard. + setInput(container.querySelector("#sub-name")!, "Renamed"); + await vi.advanceTimersByTimeAsync(20); + expect(beforeUnloadRegistrations()).toBe(1); + }); + + it("adding a gate with branches and a nested action POSTs the expected steps", async () => { + const container = renderForm(SEED); + await vi.advanceTimersByTimeAsync(20); + + // Open the root add-step picker and add a condition step (gate mode). + buttonByText(container, "Add step").click(); + await vi.advanceTimersByTimeAsync(20); + buttonByText(container, "Condition").click(); + await vi.advanceTimersByTimeAsync(20); + + // Fill the assertion row. + setInput( + container.querySelector('input[aria-label="Check field"]')!, + "event.commit.record.subject.uri", + ); + setInput(container.querySelector('input[aria-label="Check value"]')!, "bar"); + await vi.advanceTimersByTimeAsync(20); + + // Turn the gate into a branch, then add an action inside the then branch. + buttonByText(container, "+ Add then/else branches").click(); + await vi.advanceTimersByTimeAsync(20); + // The then container's add control renders before the root one. + buttonByText(container, "Add step", 0).click(); + await vi.advanceTimersByTimeAsync(20); + buttonByText(container, "Send notification").click(); + await vi.advanceTimersByTimeAsync(20); + + submitVia(submitButton(container, "Create in dry run")); + await vi.advanceTimersByTimeAsync(20); + + const call = lastCreateCall(); + expect(call).toBeTruthy(); + const body = JSON.parse(call![1].body); + expect(body.steps).toHaveLength(2); + // The webhook seed stays first; the condition was appended at the root. + expect(body.steps[0]).toMatchObject({ $type: "webhook", id: "wh1" }); + expect(body.steps[1].$type).toBe("condition"); + expect(body.steps[1].assertions).toEqual([ + { field: "event.commit.record.subject.uri", operator: "eq", value: "bar" }, + ]); + expect(body.steps[1].then).toHaveLength(1); + expect(body.steps[1].then[0].$type).toBe("notification"); + expect(body.steps[1].else).toBeUndefined(); + }); + + it("deleting a referenced step blocks submit with a step-named message", async () => { + const container = renderForm({ + ...SEED, + steps: [ + { + $type: "fetch", + id: "f1", + name: "post", + uri: "{{event.commit.record.subject}}", + }, + { + $type: "bsky-post", + id: "b1", + textTemplate: "quote: {{post.record.text}}", + }, + ] as Step[], + }); + await vi.advanceTimersByTimeAsync(20); + + // The fetch card's header Remove is the first Remove button in the tree. + buttonByText(container, "Remove").click(); + await vi.advanceTimersByTimeAsync(20); + + // The dangling reference is flagged inline on the referencing step. + expect(container.textContent).toContain('references "post"'); + + submitVia(submitButton(container, "Create automation")); + await vi.advanceTimersByTimeAsync(20); + + const posts = fetchMock.mock.calls.filter( + ([url, opts]) => url === "/api/automations" && opts?.method === "POST", + ); + expect(posts).toHaveLength(0); + expect(container.textContent).toContain('(bsky-post) references "post"'); + }); + + it("a numeric schema field offers numeric operators only", async () => { + fetchMock.mockImplementation(async (url: string) => + url.startsWith("/api/lexicons/") + ? { + ok: true, + status: 200, + json: async () => ({ + fields: [{ path: "count", type: "integer" }], + record: null, + }), + } + : { ok: false, status: 404, json: async () => ({ error: "stub" }) }, + ); + const container = renderForm({ + ...SEED, + steps: [ + { + $type: "condition", + id: "cond1", + assertions: [{ field: "event.commit.record.count", operator: "eq", value: "1" }], + }, + ...SEED.steps, + ] as Step[], + }); + // Flush hydration plus the 400ms lexicon-schema debounce. + await vi.advanceTimersByTimeAsync(500); + + const select = container.querySelector('select[aria-label="Operator"]')!; + const values = [...select.querySelectorAll("option")].map((o) => o.value); + expect(values).toEqual(["eq", "neq", "gt", "gte", "lt", "lte", "exists", "notExists"]); + expect(values).not.toContain("contains"); + expect(container.textContent).not.toContain("Schema unavailable"); + }); + + it("an unresolved trigger schema shows the all-operators hint", async () => { + // Default mock 404s the lexicon fetch -> schema unresolved. + const container = renderForm({ + ...SEED, + steps: [ + { + $type: "condition", + id: "cond1", + assertions: [{ field: "event.commit.record.foo", operator: "eq", value: "x" }], + }, + ...SEED.steps, + ] as Step[], + }); + await vi.advanceTimersByTimeAsync(500); + + const select = container.querySelector('select[aria-label="Operator"]')!; + const values = [...select.querySelectorAll("option")].map((o) => o.value); + expect(values).toContain("contains"); + expect(values).toContain("gt"); + expect(values).toContain("notExists"); + expect(container.textContent).toContain( + "Schema unavailable: all operators shown; comparisons use the value's runtime type.", + ); + }); + + it("indent and outdent move a step between levels preserving its id", async () => { + const container = renderForm({ + ...SEED, + steps: [ + { + $type: "loop", + id: "loop1", + name: "each", + path: "event.commit.record.links[]", + steps: [{ $type: "bsky-post", id: "post1", textTemplate: "x" }], + }, + { $type: "webhook", id: "wh1", callbackUrl: "https://example.com/hook", secret: "s" }, + ] as Step[], + }); + await vi.advanceTimersByTimeAsync(20); + + let payload = previewedPayload(container); + let steps = payload.steps as Array>; + expect(steps.map((st) => st.id)).toEqual(["loop1", "wh1"]); + + // Only the webhook (preceded by the loop) can be indented. + const indentables = [ + ...container.querySelectorAll('button[aria-label="Indent step"]'), + ].filter((b) => !b.disabled); + expect(indentables).toHaveLength(1); + indentables[0]!.click(); + await vi.advanceTimersByTimeAsync(20); + + payload = previewedPayload(container); + steps = payload.steps as Array>; + expect(steps).toHaveLength(1); + const body = steps[0]!.steps as Array>; + expect(body.map((st) => st.id)).toEqual(["post1", "wh1"]); + + // Outdent the webhook (appended last in the loop body) back to the root. + const outdentables = [ + ...container.querySelectorAll('button[aria-label="Outdent step"]'), + ].filter((b) => !b.disabled); + outdentables.at(-1)!.click(); + await vi.advanceTimersByTimeAsync(20); + + payload = previewedPayload(container); + steps = payload.steps as Array>; + expect(steps.map((st) => st.id)).toEqual(["loop1", "wh1"]); + expect((steps[0]!.steps as unknown[]).length).toBe(1); }); }); diff --git a/app/islands/AutomationForm.tsx b/app/islands/AutomationForm.tsx index d74bdd0..cb2374f 100644 --- a/app/islands/AutomationForm.tsx +++ b/app/islands/AutomationForm.tsx @@ -2,11 +2,14 @@ import { useState, useCallback, useRef, useMemo, useEffect } from "hono/jsx"; import type { RecordSchema, SchemaNode } from "../../lib/lexicons/schema-types.js"; import { nsidRequiresWantedDids } from "../../lib/lexicons/match.js"; import { - type Action, - type FetchStep, - type FollowTarget, - type Variable, -} from "../../lib/db/schema.js"; + MAX_STEP_DEPTH, + scopeAt, + type AssertionOperator, + type Step, + type StepScope, +} from "../../lib/automations/steps.js"; +import { STEP_LIMITS } from "../../lib/automations/limits.js"; +import type { FollowTarget } from "../../lib/automations/follow-targets.js"; import { ACTION_CATALOGUE, actionTypeKey, @@ -15,77 +18,84 @@ import { import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; import { ActionHeader } from "../components/ActionHeader/index.js"; import { Button } from "../components/Button/index.js"; -import { ChevronDown, GripVertical, Plus } from "../icons.js"; +import { + ArrowLeft, + ArrowRight, + ChevronDown, + Code, + Database, + Filter, + GripVertical, + Plus, + Repeat, + Search, +} from "../icons.js"; import { actionIcon } from "../styles/action-header.css.ts"; import * as s from "./AutomationForm.css.ts"; import { ACTION_UI_REGISTRY, - draftProcessorOutputName, isProcessorAction, isRecordProducingAction, - type ActionDraft, } from "./action-editors/registry.ts"; -import type { ForEachDraft, ScheduleDraft } from "./action-editors/types.ts"; import { ScheduleConfigEditor } from "./action-editors/schedule-config-editor.tsx"; - -type Field = { - path: string; - type: string; - description?: string; - knownValues?: string[]; -}; +import { + OPERATOR_LABELS, + ROOT_CONTAINER, + checkStepReferences, + collectNamedOutputs, + containerKeyString, + countDrafts, + draftsToSteps, + indentDraft, + indentTarget, + isValueLessOperator, + iterateDrafts, + canOutdent, + mintStepId, + moveWithinContainer, + namesDefinedInDrafts, + operatorsForFieldType, + outdentDraft, + removeDraft, + replaceDraft, + resolveAssertionFieldType, + stepDraftName, + stepsToDrafts, + subtreeStepCount, + appendToContainer, + type AssertionDraft, + type ConditionStepDraft, + type ContainerKey, + type FetchStepDraft, + type LoopStepDraft, + type NamedOutput, + type SchemaField, + type SearchStepDraft, + type StepDraft, + type VariableStepDraft, +} from "./step-drafts.ts"; + +type Field = SchemaField; type FetchSchemaState = { loading: boolean; unresolved: boolean; error: string; fields: Field[]; - /** Recursive record schema, used to surface array-typed paths in the - * forEach picker for fetch-rooted iterations. */ + /** Recursive record schema, used to surface array-typed paths in the loop + * path picker for fetch-rooted iterations. */ record?: RecordSchema | null; }; -type Condition = { - field: string; - operator: string; - value: string; - comment: string; -}; - -type FetchDraft = { - kind: "record" | "search"; - name: string; - uri: string; - repo: string; - collection: string; - whereField: string; - whereValue: string; - conditions: Condition[]; - comment: string; -}; - -type VariableDraft = { - name: string; - value: string; - note: string; - preserveOnDuplication: boolean; -}; - export type AutomationInitial = { rkey?: string; name: string; description: string | null; lexicon: string; operations: string[]; - actions: Action[]; - fetches: FetchStep[]; - variables: Variable[]; - conditions: Array<{ - field: string; - operator: string; - value: string; - comment?: string; - }>; + /** Step tree in the API shape. Duplication flows pass steps with blank ids + * (the form mints local ids; the server mints the real ones on save). */ + steps: Step[]; wantedDids: string[]; active: boolean; isBlueprint?: boolean; @@ -104,10 +114,11 @@ export type AutomationFormConfig = { }; const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; +const STEP_NAME_RE_SOURCE = "[a-zA-Z_][a-zA-Z0-9_]*"; /** Recursively walk a record schema and collect dotted paths whose effective * type is `array`. Each segment that crosses an array level is rendered as - * `name[]` so the final path is directly usable as a forEach.path value. */ + * `name[]` so the final path is directly usable as a loop path value. */ function collectArrayPaths( schema: RecordSchema | null | undefined, prefix = "event.commit.record", @@ -152,14 +163,14 @@ function walkArrayLevels(node: SchemaNode, path: string, out: string[], depth: n } } -/** Resolve the items schema at a forEach path, then flatten its leaf fields. - * Returns paths relative to the item (so `{{item.}}` is the placeholder). - * Empty list when the path can't be resolved (unknown root, missing key, or - * the leaf isn't an object/union we can introspect). */ +/** Resolve the items schema at a loop path, then flatten its leaf fields. + * Returns paths relative to the item (so `{{.}}` is the + * placeholder). Empty list when the path can't be resolved (unknown root, + * missing key, or the leaf isn't an object/union we can introspect). */ function resolveItemFields( path: string, triggerSchema: RecordSchema | null | undefined, - fetches: FetchDraft[], + collectionsByName: Map, fetchSchemas: Record, ): Field[] { let current: SchemaNode | undefined; @@ -177,9 +188,9 @@ function resolveItemFields( const recordIdx = path.indexOf(".record."); if (recordIdx <= 0) return []; const fname = path.slice(0, recordIdx); - const fetch = fetches.find((f) => f.name === fname); - if (!fetch?.collection) return []; - const schema = fetchSchemas[fetch.collection]?.record; + const collection = collectionsByName.get(fname); + if (!collection) return []; + const schema = fetchSchemas[collection]?.record; if (!schema) return []; current = { type: "object", required: schema.required, properties: schema.properties }; rest = path.slice(recordIdx + ".record.".length); @@ -251,27 +262,7 @@ const BUILTIN_CONDITION_FIELDS: Field[] = [ { path: "event.commit.cid", type: "string", description: "Content hash" }, ]; -const FETCH_ENTRY_FIELDS: Field[] = [ - { path: "found", type: "boolean", description: "Whether a record was returned" }, - { path: "uri", type: "string", description: "AT URI of the resolved record" }, - { path: "cid", type: "string", description: "Content hash" }, - { path: "did", type: "string", description: "DID of the record's owner" }, - { path: "collection", type: "string", description: "NSID of the record's collection" }, - { path: "rkey", type: "string", description: "Record key" }, -]; - -/** Shape of each entry in `actionN.results[]` (set by the jetstream handler - * when an action runs under forEach). Used by the per-item placeholder panel - * and per-item condition autocomplete when iterating an upstream forEach - * action's outputs via `forEach.path = actionN.results[]`. */ -const ACTION_RESULT_ITEM_FIELDS: Field[] = [ - { path: "uri", type: "string", description: "AT URI" }, - { path: "cid", type: "string", description: "Content hash" }, - { path: "did", type: "string", description: "Owner DID" }, - { path: "collection", type: "string", description: "Collection NSID" }, - { path: "rkey", type: "string", description: "Record key" }, - { path: "sourceItem", type: "unknown", description: "The forEach item that produced this" }, -]; +const ENTRY_SCALAR_FIELDS = ["uri", "cid", "did", "collection", "rkey"] as const; const BUILTIN_PLACEHOLDERS = [ "event.did", @@ -297,7 +288,7 @@ function getInitialParam(key: string): string { /** Renders a click-to-copy chip. Defaults to copying `{{value}}` (template * placeholder form). Set `bare` for things that aren't template placeholders, - * e.g. a `forEach.path` snippet that the validator expects unwrapped. */ + * e.g. a loop path snippet that the validator expects unwrapped. */ function CopyPlaceholder({ value, children, @@ -333,64 +324,6 @@ function CopyPlaceholder({ ); } -/** Scalar placeholders (`{{actionN.uri}}` etc.) shown for non-forEach - * record-producing actions. Each chip is click-to-copy. */ -function ActionResultScalarPlaceholders({ index }: { index: number }) { - const prefix = `action${index + 1}`; - return ( -
- - AT URI - - - Content hash - - - Owner DID - - - Collection NSID - - - Record key - -
- ); -} - -/** Iteration hint shown for forEach record-producing actions. Surfaces - * `actionN.results[]` as a forEach.path snippet plus the `{{item.*}}` fields - * available inside the downstream forEach loop. Showing the scalar - * `{{actionN.uri}}` here would invite the footgun of grabbing whichever - * iteration ran last; surface the iteration form instead. */ -function ActionResultIterationHint({ index }: { index: number }) { - const prefix = `action${index + 1}`; - return ( - <> -

- This action runs once per item. Iterate every successful run from the next action with{" "} - forEach.path: -

-
- - All successful iterations (use as forEach.path) - -
-

- Inside that forEach the current iteration is exposed as{" "} - {"{{item}}"} with fields{" "} - {"{{item.uri}}"},{" "} - {"{{item.cid}}"},{" "} - {"{{item.did}}"},{" "} - {"{{item.collection}}"},{" "} - {"{{item.rkey}}"}.
- The forEach item that drove this iteration is preserved as{" "} - {"{{item.sourceItem}}"}. -

- - ); -} - function FetchSchemaStatus({ state, }: { @@ -400,428 +333,157 @@ function FetchSchemaStatus({ if (state.loading) return Loading fields...; if (state.error) return {state.error}; if (state.unresolved) - return Schema unresolved — placeholders fall back to wildcard.; + return Schema unresolved. Placeholders fall back to wildcard.; if (state.fields.length > 0) return {state.fields.length} typed fields available.; return null; } // --------------------------------------------------------------------------- -// forEach (run per item) editor +// Per-step placeholders panel // --------------------------------------------------------------------------- -const COMPARE_OPERATOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ - { value: "eq", label: "equals" }, - { value: "startsWith", label: "starts with" }, - { value: "endsWith", label: "ends with" }, - { value: "contains", label: "contains" }, -]; - -type PresenceLabels = { exists: string; notExists: string }; - -const PRESENCE_DEFAULT: PresenceLabels = { exists: "exists", notExists: "does not exist" }; -const PRESENCE_FETCH: PresenceLabels = { exists: "is present", notExists: "is missing" }; - -function ConditionOperatorOptions({ presence = PRESENCE_DEFAULT }: { presence?: PresenceLabels }) { - return ( - <> - {COMPARE_OPERATOR_OPTIONS.map((op) => ( - - ))} - - - - ); -} - -function ForEachConfigEditor({ - index, - draft, - onChange, - arrayPathSuggestions, - itemFieldsByPath, -}: { - index: number; - draft: ForEachDraft | undefined; - onChange: (next: ForEachDraft | undefined) => void; - arrayPathSuggestions: string[]; - itemFieldsByPath: Record; -}) { - const enabled = draft !== undefined; - const pathId = `action-${index}-foreach-path`; - const pathListId = `action-${index}-foreach-path-suggestions`; - const itemFields = enabled && draft ? (itemFieldsByPath[draft.path] ?? []) : []; - - const updateCondition = (i: number, key: keyof Condition, val: string) => { - if (!draft) return; - onChange({ - ...draft, - conditions: draft.conditions.map((c, j) => (j === i ? { ...c, [key]: val } : c)), - }); - }; - const addCondition = () => { - if (!draft) return; - onChange({ - ...draft, - conditions: [...draft.conditions, { field: "", operator: "eq", value: "", comment: "" }], - }); - }; - const removeCondition = (i: number) => { - if (!draft) return; - onChange({ ...draft, conditions: draft.conditions.filter((_, j) => j !== i) }); - }; +type Chip = { value: string; desc?: string; bare?: boolean }; +type ChipGroup = { title: string; chips: Chip[] }; +function StepPlaceholdersPanel({ groups }: { groups: ChipGroup[] }) { + const nonEmpty = groups.filter((g) => g.chips.length > 0); + if (nonEmpty.length === 0) return null; return ( -
- - {enabled && ( -
-
- - - onChange({ ...draft, path: (e.target as HTMLInputElement).value }) - } - autocomplete="off" - /> - - Dotted path with [] segments to mark array levels. Roots:{" "} - event.*, a fetch name, or an upstream actionN. Must end in{" "} - []. - - {arrayPathSuggestions.length > 0 && ( - - {arrayPathSuggestions.map((p) => ( - - )} -
- -
- - Per-item conditions (optional) - - - Each item is filtered by these. Field paths are rooted at the item (e.g.{" "} - $type or features.0.uri). The optional item.{" "} - prefix is also accepted. - - {draft.conditions.map((c, i) => ( -
-
- - updateCondition(i, "field", (e.target as HTMLInputElement).value) - } - autocomplete="off" - aria-label="Item field" - /> -
-
- -
- {!VALUE_LESS_CONDITION_OPS.has(c.operator) && ( -
- - updateCondition(i, "value", (e.target as HTMLInputElement).value) - } - autocomplete="off" - aria-label="Value" - /> -
- )} - -
+
+ + Available placeholders (for this step) + +
+ {nonEmpty.map((g) => ( +
+
{g.title}
+ {g.chips.map((c) => ( + + {c.desc ? {c.desc} : null} + ))} -
- - {itemFields.length > 0 ? ( -
- - Item placeholders (available inside this action) - -
-
- - The whole item - - {itemFields.map((f) => ( - - {f.description ? ( - {f.description} - ) : null} - - ))} -
-
-
- ) : ( -

- Inside this action, templates can use {`{{item.*}}`} placeholders (e.g.{" "} - {`{{item.uri}}`}) to read fields off each matching item. -

- )} -
- )} -
+ ))} +
+ ); } // --------------------------------------------------------------------------- -// Main form +// Structural step tiles for the add-step picker // --------------------------------------------------------------------------- -function toForEachDraft(fe: Action["forEach"]): ForEachDraft | undefined { - if (!fe) return undefined; - return { - path: fe.path, - conditions: (fe.conditions ?? []).map((c) => ({ - field: c.field, - operator: c.operator, - value: c.value, - comment: c.comment ?? "", - })), - }; -} - -function toActionDrafts(actions: Action[]): ActionDraft[] { - return actions.map((a) => { - const draft = ACTION_UI_REGISTRY[a.$type].fromAction(a); - const forEach = toForEachDraft(a.forEach); - const schedule = toScheduleDraft(a.schedule); - return { - ...draft, - ...(forEach ? { forEach } : {}), - ...(schedule ? { schedule } : {}), - }; - }); -} - -function toScheduleDraft(s: Action["schedule"]): ScheduleDraft | undefined { - if (!s) return undefined; - return { - identifier: s.identifier, - delayUnit: s.delay.unit, - delayValue: formatStoredDelayValue(s.delay.value), - }; -} - -function formatStoredDelayValue(value: NonNullable["delay"]["value"]): string { - switch (value.kind) { - case "literal": - return String(value.n); - case "template": - return `{{${value.expr}}}`; - case "sum": - return `{{${value.expr}}} + ${value.offset}`; - } -} +type StructuralTileId = "condition" | "loop" | "fetch" | "search" | "variable"; -function forEachToPayload(fe: ForEachDraft | undefined) { - if (!fe) return undefined; - const path = fe.path.trim(); - if (!path) return undefined; - const completed = fe.conditions.filter(conditionIsComplete).map(conditionToPayload); - return { - path, - ...(completed.length > 0 ? { conditions: completed } : {}), - }; -} +const STRUCTURAL_TILES: Array<{ + id: StructuralTileId; + label: string; + description: string; + icon: typeof Filter; + /** Owns a child list, so it cannot be added at the maximum depth. */ + container: boolean; +}> = [ + { + id: "condition", + label: "Condition", + description: "Check fields, stop the run, or branch into then/else", + icon: Filter, + container: false, + }, + { + id: "loop", + label: "Loop", + description: "Run steps once per item of an array", + icon: Repeat, + container: true, + }, + { + id: "fetch", + label: "Fetch a record", + description: "Load a record by AT URI for later steps", + icon: Database, + container: false, + }, + { + id: "search", + label: "Search records", + description: "Find a record in a collection by field value", + icon: Search, + container: false, + }, + { + id: "variable", + label: "Variable", + description: "Name a value for reuse in later steps", + icon: Code, + container: false, + }, +]; -function scheduleToPayload(sd: ScheduleDraft | undefined) { - if (!sd) return undefined; - const identifier = sd.identifier.trim(); - const delayValue = sd.delayValue.trim(); - if (!identifier || !delayValue) return undefined; - // Server parses delayValue via parseRelativeDelay; send the raw text. - return { identifier, delay: { unit: sd.delayUnit, value: delayValue } }; -} +const STRUCTURAL_HEADERS: Record = { + condition: { label: "Condition", icon: Filter }, + loop: { label: "Loop", icon: Repeat }, + fetch: { label: "Fetch record", icon: Database }, + search: { label: "Search records", icon: Search }, + variable: { label: "Variable", icon: Code }, +}; -function toFetchDrafts(fetches: FetchStep[]): FetchDraft[] { - return fetches.map((f) => { - const conditions = (f.conditions ?? []).map((c) => ({ - field: c.field, - operator: c.operator, - value: c.value, - comment: c.comment ?? "", - })); - if (f.kind === "search") { - const first = f.where[0]; +function newStructuralDraft(id: StructuralTileId): StepDraft { + switch (id) { + case "condition": + return { + kind: "condition", + id: mintStepId(), + match: "all", + assertions: [{ field: "", operator: "eq", value: "", comment: "" }], + comment: "", + }; + case "loop": + return { + kind: "loop", + id: mintStepId(), + name: "", + path: "", + itemName: "", + steps: [], + comment: "", + }; + case "fetch": return { - kind: "search" as const, - name: f.name, + kind: "fetch", + id: mintStepId(), + name: "", uri: "", - repo: f.repo, - collection: f.collection, - whereField: first?.field ?? "subject", - whereValue: first?.value ?? "", - conditions, - comment: f.comment ?? "", + collection: "", + comment: "", + }; + case "search": + return { + kind: "search", + id: mintStepId(), + name: "", + repo: "", + collection: "", + whereField: "subject", + whereValue: "", + comment: "", + }; + case "variable": + return { + kind: "variable", + id: mintStepId(), + name: "", + value: "", + note: "", + preserveOnDuplication: false, }; - } - return { - kind: "record" as const, - name: f.name, - uri: f.uri, - repo: "", - collection: f.collection ?? "", - whereField: "", - whereValue: "", - conditions, - comment: f.comment ?? "", - }; - }); -} - -const VALUE_LESS_CONDITION_OPS = new Set(["exists", "not-exists"]); - -type FetchConditionPreset = "found" | "not-found" | "custom"; - -function fetchConditionPreset(c: Condition): FetchConditionPreset { - if (c.field === "found") { - if (c.operator === "exists") return "found"; - if (c.operator === "not-exists") return "not-found"; - } - return "custom"; -} - -function conditionIsComplete(c: Condition): boolean { - if (!c.field) return false; - if (VALUE_LESS_CONDITION_OPS.has(c.operator)) return true; - return !!c.value; -} - -function conditionToPayload(c: Condition) { - const valueLess = VALUE_LESS_CONDITION_OPS.has(c.operator); - return { - field: c.field, - operator: c.operator, - value: valueLess ? "" : c.value, - ...(c.comment ? { comment: c.comment } : {}), - }; -} - -function fetchIsComplete(f: FetchDraft): boolean { - if (!f.name) return false; - if (f.kind === "search") return !!(f.repo && f.collection && f.whereField && f.whereValue); - return !!f.uri; -} - -function fetchToPayload(f: FetchDraft) { - const comment = f.comment ? { comment: f.comment } : {}; - const completedConditions = f.conditions.filter(conditionIsComplete).map(conditionToPayload); - const conditions = completedConditions.length > 0 ? { conditions: completedConditions } : {}; - if (f.kind === "search") { - return { - kind: "search" as const, - name: f.name, - repo: f.repo, - collection: f.collection, - where: [{ field: f.whereField, operator: "eq", value: f.whereValue }], - ...conditions, - ...comment, - }; } - const collection = f.collection ? { collection: f.collection } : {}; - return { - kind: "record" as const, - name: f.name, - uri: f.uri, - ...collection, - ...conditions, - ...comment, - }; -} - -function toVariableDrafts(variables: Variable[] | undefined): VariableDraft[] { - if (!variables) return []; - return variables.map((v) => ({ - name: v.name, - value: v.value ?? "", - note: v.note ?? "", - preserveOnDuplication: !!v.preserveOnDuplication, - })); -} - -/** Variables are included in the save payload whenever they have a name — even - * if the value is blank. The server will return a `Variable "X" requires a - * value` error, which is the targeted cue duplicators need: the duplicate - * flow intentionally blanks values so the user must supply their own. */ -function variableIsSendable(v: VariableDraft): boolean { - return !!v.name.trim(); -} - -function variableToPayload(v: VariableDraft) { - return { - name: v.name.trim(), - value: v.value, - ...(v.note.trim() ? { note: v.note.trim() } : {}), - ...(v.preserveOnDuplication ? { preserveOnDuplication: true } : {}), - }; } -function toConditionDrafts( - conditions: Array<{ - field: string; - operator: string; - value: string; - comment?: string; - }>, -): Condition[] { - return conditions.map((c) => ({ - field: c.field, - operator: c.operator, - value: c.value, - comment: c.comment ?? "", - })); -} +// --------------------------------------------------------------------------- +// Main form +// --------------------------------------------------------------------------- export default function AutomationForm({ viewerHandle, @@ -847,19 +509,20 @@ export default function AutomationForm({ const [fetchSchemas, setFetchSchemas] = useState>({}); const fetchSchemaDebounceRef = useRef>>(new Map()); const fetchSchemaAbortRef = useRef>(new Map()); - const [conditions, setConditions] = useState( - initial ? toConditionDrafts(initial.conditions) : [], - ); const [wantedDids, setWantedDids] = useState(initial?.wantedDids ?? []); - const [fetches, setFetches] = useState( - initial ? toFetchDrafts(initial.fetches) : [], - ); - const [variables, setVariables] = useState( - initial ? toVariableDrafts(initial.variables) : [], - ); - const [actions, setActions] = useState( - initial ? toActionDrafts(initial.actions) : [], - ); + + // One recursive step tree replaces the legacy conditions/fetches/variables/ + // actions section states. The initial drafts are computed exactly once (the + // conversion mints local ids for blank ones, so recomputing would make the + // dirty check flap). + const initialStepDraftsRef = useRef(null); + if (initialStepDraftsRef.current === null) { + initialStepDraftsRef.current = initial ? stepsToDrafts(initial.steps) : []; + } + const [steps, setSteps] = useState(initialStepDraftsRef.current); + /** Transient per-step inline errors (blocked branch removal etc.). */ + const [stepErrors, setStepErrors] = useState>({}); + const [isBlueprint, setIsBlueprint] = useState(initial?.isBlueprint ?? false); // Once an automation is published as a Blueprint it can't be un-marked // (see PATCH /api/automations/[rkey]). Lock the checkbox to reflect that. @@ -871,7 +534,9 @@ export default function AutomationForm({ // clicked button alone show its "Creating..." label. null when idle or editing. const [submitKind, setSubmitKind] = useState<"run" | "dryRun" | null>(null); const [error, setError] = useState(""); - const [pickerOpen, setPickerOpen] = useState(actions.length === 0); + const [pickerFor, setPickerFor] = useState( + initialStepDraftsRef.current.length === 0 ? containerKeyString(ROOT_CONTAINER) : null, + ); const savedRef = useRef(false); // Guards against re-entrant submits. The `submitting` state disables the // fieldset, but its flip is deferred to the next render, leaving a window @@ -883,43 +548,94 @@ export default function AutomationForm({ const lastSuggestPrefix = useRef(""); const initialFetched = useRef(false); + // ------------------------------------------------------------------------- + // Derived tree data + // ------------------------------------------------------------------------- + + /** API-shaped tree; feeds the payload, scope computation, and the preview. */ + const payloadSteps = useMemo(() => draftsToSteps(steps), [steps]); + + /** Client half of KTD-7: step-named reference problems, shown inline and + * blocking submit. The server re-checks authoritatively. */ + const referenceIssues = useMemo(() => checkStepReferences(steps), [steps]); + const issuesByStep = useMemo(() => { + const map = new Map(); + for (const issue of referenceIssues) { + const list = map.get(issue.stepId) ?? []; + list.push(issue.message); + map.set(issue.stepId, list); + } + return map; + }, [referenceIssues]); + + const totalSteps = useMemo(() => countDrafts(steps), [steps]); + const namedOutputs = useMemo(() => collectNamedOutputs(steps), [steps]); + const collectionsByName = useMemo(() => { + const map = new Map(); + for (const [nm, out] of namedOutputs) { + if ((out.kind === "fetch" || out.kind === "search") && out.collection) { + map.set(nm, out.collection); + } + } + return map; + }, [namedOutputs]); + + const actionVisits = useMemo( + () => [...iterateDrafts(steps)].filter((v) => v.draft.kind === "action"), + [steps], + ); + const actionIndexById = useMemo(() => { + const map = new Map(); + actionVisits.forEach((v, i) => map.set(v.draft.id, i)); + return map; + }, [actionVisits]); + const actionTypeKeys = useMemo( + () => + actionVisits.map((v) => { + const a = v.draft.kind === "action" ? v.draft.action : null; + return actionTypeKey({ + $type: a!.type, + target: a!.type === "follow" ? a!.target : undefined, + }); + }), + [actionVisits], + ); + const hasActionStep = actionVisits.length > 0; + const hasSideEffectAction = actionVisits.some( + (v) => v.draft.kind === "action" && !isProcessorAction(v.draft.action.type), + ); + + const triggerLookup = useMemo( + () => ({ + loading: fieldsLoading, + unresolved: schemaUnresolved, + error: fieldsError, + fields, + }), + [fieldsLoading, schemaUnresolved, fieldsError, fields], + ); + const isDirty = useMemo(() => { + const initialSteps = initialStepDraftsRef.current ?? []; if (isEdit) { - if (name !== (initial.name ?? "")) return true; - if (description !== (initial.description ?? "")) return true; - if (JSON.stringify(operations) !== JSON.stringify(initial.operations ?? ["create"])) - return true; - if (JSON.stringify(conditions) !== JSON.stringify(toConditionDrafts(initial.conditions))) + if (name !== (initial!.name ?? "")) return true; + if (description !== (initial!.description ?? "")) return true; + if (JSON.stringify(operations) !== JSON.stringify(initial!.operations ?? ["create"])) return true; - if (JSON.stringify(wantedDids) !== JSON.stringify(initial.wantedDids ?? [])) return true; - if (JSON.stringify(fetches) !== JSON.stringify(toFetchDrafts(initial.fetches))) return true; - if (JSON.stringify(variables) !== JSON.stringify(toVariableDrafts(initial.variables))) - return true; - if (JSON.stringify(actions) !== JSON.stringify(toActionDrafts(initial.actions))) return true; - if (isBlueprint !== (initial.isBlueprint ?? false)) return true; - if (dedupeBySource !== (initial.dedupeBySource ?? true)) return true; + if (JSON.stringify(wantedDids) !== JSON.stringify(initial!.wantedDids ?? [])) return true; + if (JSON.stringify(steps) !== JSON.stringify(initialSteps)) return true; + if (isBlueprint !== (initial!.isBlueprint ?? false)) return true; + if (dedupeBySource !== (initial!.dedupeBySource ?? true)) return true; return false; } - return !!( - name || - description || - lexicon || - conditions.length || - wantedDids.length || - fetches.length || - variables.length || - actions.length - ); + return !!(name || description || lexicon || wantedDids.length || steps.length); }, [ name, description, lexicon, operations, - conditions, wantedDids, - fetches, - variables, - actions, + steps, isBlueprint, dedupeBySource, isEdit, @@ -1096,28 +812,16 @@ export default function AutomationForm({ } const initialFetchSchemasPrimed = useRef(false); - if (!initialFetchSchemasPrimed.current && initial) { + if (!initialFetchSchemasPrimed.current && initialStepDraftsRef.current) { initialFetchSchemasPrimed.current = true; - for (const f of initial.fetches) { - if (f.collection) loadFetchSchema(f.collection); + for (const visit of iterateDrafts(initialStepDraftsRef.current)) { + const d = visit.draft; + if ((d.kind === "fetch" || d.kind === "search") && d.collection) { + loadFetchSchema(d.collection); + } } } - const addCondition = useCallback(() => { - setConditions((prev) => [...prev, { field: "", operator: "eq", value: "", comment: "" }]); - }, []); - - const removeCondition = useCallback((index: number) => { - setConditions((prev) => prev.filter((_, i) => i !== index)); - }, []); - - const updateCondition = useCallback( - (index: number, key: "field" | "operator" | "value" | "comment", val: string) => { - setConditions((prev) => prev.map((c, i) => (i === index ? { ...c, [key]: val } : c))); - }, - [], - ); - const addWantedDid = useCallback((value: string) => { setWantedDids((prev) => (prev.includes(value) ? prev : [...prev, value])); }, []); @@ -1134,192 +838,144 @@ export default function AutomationForm({ [lexicon, nsidRequireDids], ); - const addFetch = useCallback(() => { - setFetches((prev) => [ - ...prev, - { - kind: "record", - name: "", - uri: "", - repo: "", - collection: "", - whereField: "subject", - whereValue: "", - conditions: [], - comment: "", - }, - ]); - }, []); + // ------------------------------------------------------------------------- + // Tree mutations + // ------------------------------------------------------------------------- - const removeFetch = useCallback((index: number) => { - setFetches((prev) => prev.filter((_, i) => i !== index)); + const updateStep = useCallback((id: string, fn: (d: StepDraft) => StepDraft) => { + setSteps((prev) => replaceDraft(prev, id, fn)); }, []); - const updateFetch = useCallback( - ( - index: number, - key: - | "kind" - | "name" - | "uri" - | "repo" - | "collection" - | "whereField" - | "whereValue" - | "comment", - val: string, - ) => { - setFetches((prev) => - prev.map((f, i) => { - if (i !== index) return f; - if (key === "kind") return { ...f, kind: val === "search" ? "search" : "record" }; - return { ...f, [key]: val }; - }), - ); - if (key === "collection" && val && NSID_RE.test(val)) loadFetchSchema(val); - }, - [loadFetchSchema], - ); - - const addFetchCondition = useCallback((fetchIndex: number) => { - setFetches((prev) => - prev.map((f, i) => - i !== fetchIndex - ? f - : { - ...f, - conditions: [ - ...f.conditions, - { field: "found", operator: "exists", value: "", comment: "" }, - ], - }, - ), - ); + const clearStepError = useCallback((id: string) => { + setStepErrors((prev) => { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); }, []); - const removeFetchCondition = useCallback((fetchIndex: number, condIndex: number) => { - setFetches((prev) => - prev.map((f, i) => - i !== fetchIndex ? f : { ...f, conditions: f.conditions.filter((_, j) => j !== condIndex) }, - ), - ); + const addStep = useCallback((key: ContainerKey, draft: StepDraft) => { + setSteps((prev) => appendToContainer(prev, key, draft)); + setPickerFor(null); }, []); - const updateFetchCondition = useCallback( - ( - fetchIndex: number, - condIndex: number, - key: "field" | "operator" | "value" | "comment", - val: string, - ) => { - setFetches((prev) => - prev.map((f, i) => - i !== fetchIndex - ? f - : { - ...f, - conditions: f.conditions.map((c, j) => - j !== condIndex ? c : { ...c, [key]: val }, - ), - }, - ), - ); - }, - [], - ); - - const setFetchConditionPreset = useCallback( - (fetchIndex: number, condIndex: number, preset: FetchConditionPreset) => { - setFetches((prev) => - prev.map((f, i) => - i !== fetchIndex - ? f - : { - ...f, - conditions: f.conditions.map((c, j) => { - if (j !== condIndex) return c; - if (preset === "found") - return { ...c, field: "found", operator: "exists", value: "" }; - if (preset === "not-found") - return { ...c, field: "found", operator: "not-exists", value: "" }; - return { ...c, field: "", operator: "eq", value: "" }; - }), - }, - ), - ); + const addActionStep = useCallback( + (key: ContainerKey, id: AddableActionId) => { + // Tile ids and action $types diverge for follow only — the picker + // exposes one tile per FOLLOW_TARGETS entry, all sharing `follow`. + const isFollow = id.startsWith("follow-"); + const $type = (isFollow ? "follow" : id) as keyof typeof ACTION_UI_REGISTRY; + const followTarget = isFollow ? (id.slice("follow-".length) as FollowTarget) : undefined; + const draft = ACTION_UI_REGISTRY[$type].newDraft({ followTarget }); + addStep(key, { kind: "action", id: mintStepId(), name: "", action: draft }); }, - [], + [addStep], ); - const addVariable = useCallback(() => { - setVariables((prev) => [ - ...prev, - { name: "", value: "", note: "", preserveOnDuplication: false }, - ]); - }, []); - - const removeVariable = useCallback((index: number) => { - setVariables((prev) => prev.filter((_, i) => i !== index)); - }, []); - - const updateVariable = useCallback( - ( - index: number, - key: K, - val: VariableDraft[K], - ) => { - setVariables((prev) => prev.map((v, i) => (i === index ? { ...v, [key]: val } : v))); + const removeStep = useCallback( + (d: StepDraft) => { + const contained = subtreeStepCount(d); + if (contained > 0) { + const noun = contained === 1 ? "step" : "steps"; + if (!confirm(`Remove this step and the ${contained} ${noun} inside it?`)) return; + } + setSteps((prev) => removeDraft(prev, d.id)); + clearStepError(d.id); }, - [], + [clearStepError], ); - const addAction = useCallback((id: AddableActionId) => { - // Tile ids and action $types diverge for follow only — picker exposes one - // tile per FOLLOW_TARGETS entry, but they all share the `follow` $type. - const isFollow = id.startsWith("follow-"); - const $type = (isFollow ? "follow" : id) as keyof typeof ACTION_UI_REGISTRY; - const followTarget = isFollow ? (id.slice("follow-".length) as FollowTarget) : undefined; - const draft = ACTION_UI_REGISTRY[$type].newDraft({ followTarget }); - setActions((prev) => [...prev, draft]); - setPickerOpen(false); - }, []); - - const removeAction = useCallback( - (index: number) => { - setActions((prev) => prev.filter((_, i) => i !== index)); - if (actions.length <= 1) setPickerOpen(true); + /** Remove a condition step's branch lists ("else" alone or both). Blocked + * while any contained step's name is still referenced later (KTD-7 client + * half); asks for confirmation when the branch holds steps. */ + const removeBranches = useCallback( + (d: ConditionStepDraft, which: "else" | "both") => { + const removedLists = which === "else" ? [d.else ?? []] : [d.then ?? [], d.else ?? []]; + const removedNames = namesDefinedInDrafts(removedLists.flat()); + const simulated = replaceDraft(steps, d.id, (cur) => { + if (cur.kind !== "condition") return cur; + if (which === "else") { + const { else: _dropped, ...rest } = cur; + return rest; + } + const { then: _t, else: _e, ...rest } = cur; + return rest; + }); + if (removedNames.length > 0) { + const issues = checkStepReferences(simulated); + const blocking = issues.find((issue) => + removedNames.some((nm) => issue.message.includes(`"${nm}"`)), + ); + if (blocking) { + setStepErrors((prev) => ({ + ...prev, + [d.id]: `Cannot remove this branch: ${blocking.message}`, + })); + return; + } + } + const contained = removedLists.flat().reduce((n, c) => n + 1 + subtreeStepCount(c), 0); + if (contained > 0) { + const noun = contained === 1 ? "step" : "steps"; + if (!confirm(`Remove this branch and the ${contained} ${noun} inside it?`)) return; + } + setSteps(simulated); + clearStepError(d.id); }, - [actions.length], + [steps, clearStepError], ); - const updateAction = useCallback((index: number, action: ActionDraft) => { - setActions((prev) => prev.map((a, i) => (i === index ? action : a))); + /** Swap a fetch step to search or back. A type change is a new step + * identity (KTD-2), so a fresh id is minted; shared fields carry over. */ + const swapFetchSearch = useCallback((d: FetchStepDraft | SearchStepDraft, kind: string) => { + if (kind === d.kind) return; + setSteps((prev) => + replaceDraft(prev, d.id, (cur) => { + if (cur.kind === "fetch" && kind === "search") { + return { + kind: "search", + id: mintStepId(), + name: cur.name, + repo: "", + collection: cur.collection, + whereField: "subject", + whereValue: "", + comment: cur.comment, + }; + } + if (cur.kind === "search" && kind === "fetch") { + return { + kind: "fetch", + id: mintStepId(), + name: cur.name, + uri: "", + collection: cur.collection, + comment: cur.comment, + }; + } + return cur; + }), + ); }, []); - const moveAction = useCallback((from: number, to: number) => { - setActions((prev) => { - if (from === to || from < 0 || to < 0 || from >= prev.length || to >= prev.length) { - return prev; - } - const next = [...prev]; - const [item] = next.splice(from, 1); - next.splice(to, 0, item!); - return next; - }); - }, []); + // ------------------------------------------------------------------------- + // Drag-reorder (within one sibling list only; depth changes go through the + // indent/outdent buttons) + // ------------------------------------------------------------------------- - const [dragSrcIndex, setDragSrcIndex] = useState(null); - const [dragOverIndex, setDragOverIndex] = useState(null); + const [dragSrc, setDragSrc] = useState<{ container: string; index: number } | null>(null); + const [dragOver, setDragOver] = useState<{ container: string; index: number } | null>(null); const endDrag = useCallback(() => { - setDragSrcIndex(null); - setDragOverIndex(null); + setDragSrc(null); + setDragOver(null); }, []); // Native HTML5 drag's built-in edge auto-scroll triggers only within ~20px of // the viewport edge, which is hard to hit on a long form. Widen the zone and // ramp speed proportionally to how close the cursor is to the edge. useEffect(() => { - if (dragSrcIndex === null) return; + if (dragSrc === null) return; const ZONE = 140; // px from each edge const MAX_SPEED = 22; // px per animation frame let velocity = 0; @@ -1349,38 +1005,216 @@ export default function AutomationForm({ window.removeEventListener("dragover", onDragOver); cancelAnimationFrame(raf); }; - }, [dragSrcIndex]); + }, [dragSrc]); + + // ------------------------------------------------------------------------- + // Placeholders and typed operators + // ------------------------------------------------------------------------- + + /** Expand a scope name into copyable placeholder chips based on what kind + * of step defines it. */ + const chipsForName = useCallback( + (nm: string, output: NamedOutput | undefined, mayBeUnset: boolean): Chip[] => { + const unsetDesc = mayBeUnset ? " (may be unset)" : ""; + if (!output) return [{ value: nm, desc: mayBeUnset ? "may be unset" : undefined }]; + if (output.kind === "variable") { + const desc = output.note || output.value || ""; + return [{ value: nm, desc: `${desc}${unsetDesc}`.trim() || undefined }]; + } + if (output.kind === "processor") { + return [{ value: nm, desc: `Collected list${unsetDesc}` }]; + } + if (output.kind === "fetch" || output.kind === "search") { + const chips: Chip[] = ENTRY_SCALAR_FIELDS.map((f) => ({ + value: `${nm}.${f}`, + desc: mayBeUnset ? "may be unset" : undefined, + })); + const schema = output.collection ? fetchSchemas[output.collection] : undefined; + if (schema && schema.fields.length > 0) { + chips.push( + ...schema.fields.map((f) => ({ + value: `${nm}.record.${f.path}`, + desc: f.description ? `${f.description}${unsetDesc}` : undefined, + })), + ); + } else { + chips.push({ value: `${nm}.record.*`, desc: `access nested fields${unsetDesc}` }); + } + return chips; + } + // loop and record-producing actions expose result scalars. + const chips: Chip[] = ENTRY_SCALAR_FIELDS.map((f) => ({ + value: `${nm}.${f}`, + desc: mayBeUnset ? "may be unset" : undefined, + })); + if (output.kind === "loop") { + chips.push({ + value: `${nm}.results[]`, + desc: "One entry per successful iteration (use as a loop path)", + bare: true, + }); + } + return chips; + }, + [fetchSchemas], + ); - const previewPayload = useMemo(() => { - const payload: Record = { name, lexicon, operations }; - if (description.trim()) payload.description = description.trim(); - const filteredFetches = fetches.filter((f) => fetchIsComplete(f)); - if (filteredFetches.length > 0 || isEdit) { - payload.fetches = filteredFetches.map((f) => fetchToPayload(f)); - } - const filteredVariables = variables.filter((v) => variableIsSendable(v)); - if (filteredVariables.length > 0 || isEdit) { - payload.variables = filteredVariables.map((v) => variableToPayload(v)); - } - const filteredConditions = conditions.filter((c) => conditionIsComplete(c)); - if (filteredConditions.length > 0 || isEdit) { - payload.conditions = filteredConditions.map((c) => conditionToPayload(c)); - } - const trimmedWantedDids = wantedDids.map((d) => d.trim()).filter(Boolean); - if (trimmedWantedDids.length > 0 || isEdit) { - payload.wantedDids = trimmedWantedDids; - } - payload.actions = actions.map((a) => { - const input = ACTION_UI_REGISTRY[a.type].toInput(a); - const forEach = forEachToPayload(a.forEach); - const schedule = scheduleToPayload(a.schedule); - return { - ...input, - ...(forEach ? { forEach } : {}), - ...(schedule ? { schedule } : {}), - ...(a.comment ? { comment: a.comment } : {}), - }; - }); + /** Flat placeholder list handed to action editor blocks (and + * RecordFormBuilder) for the given step's scope. */ + const placeholdersForScope = useCallback( + (scope: StepScope): string[] => { + const out = [...BUILTIN_PLACEHOLDERS, ...fields.map((f) => `event.commit.record.${f.path}`)]; + for (const nm of [...scope.names, ...scope.maybeUnset]) { + for (const chip of chipsForName(nm, namedOutputs.get(nm), false)) { + if (!chip.bare) out.push(chip.value); + } + } + for (const alias of scope.itemAliases) out.push(alias); + return out; + }, + [fields, namedOutputs, chipsForName], + ); + + /** Groups for the per-step "Available placeholders" panel (replaces the + * legacy global list; computed from `scopeAt`, KTD-3/KTD-18). */ + const placeholderGroupsForScope = useCallback( + (scope: StepScope): ChipGroup[] => { + const eventChips: Chip[] = [ + { value: "event.did", desc: "DID of the repo that emitted the event" }, + { value: "event.uri", desc: "AT URI of the event's record" }, + { value: "event.commit.collection", desc: "Lexicon NSID of the record" }, + { value: "event.commit.rkey", desc: "Record key" }, + { value: "event.commit.cid", desc: "Content hash of the record" }, + { value: "event.commit.operation", desc: "create, update, or delete" }, + ...fields.map((f) => ({ + value: `event.commit.record.${f.path}`, + desc: f.description, + })), + ]; + const stepChips: Chip[] = []; + for (const nm of scope.names) { + stepChips.push(...chipsForName(nm, namedOutputs.get(nm), false)); + } + for (const nm of scope.maybeUnset) { + stepChips.push(...chipsForName(nm, namedOutputs.get(nm), true)); + } + const itemChips: Chip[] = []; + for (const alias of scope.itemAliases) { + itemChips.push({ value: alias, desc: "The current loop item" }); + // Surface the item's typed fields when the innermost loop's path + // resolves against a known schema. + for (const visit of iterateDrafts(steps)) { + const d = visit.draft; + if (d.kind !== "loop") continue; + if ((d.itemName.trim() || "item") !== alias) continue; + const itemFields = resolveItemFields( + d.path.trim(), + triggerRecordSchema, + collectionsByName, + fetchSchemas, + ); + itemChips.push( + ...itemFields.map((f) => ({ value: `${alias}.${f.path}`, desc: f.description })), + ); + break; + } + } + return [ + { title: "Event", chips: eventChips }, + { title: "From earlier steps", chips: stepChips }, + { title: "Loop item", chips: itemChips }, + { + title: "Automation & helpers", + chips: [ + { value: "automation.id", desc: "Record key of this automation" }, + { value: "automation.name", desc: "Name of this automation" }, + { value: "automation.url", desc: "Public URL of this automation" }, + { value: "now", desc: "Current ISO 8601 datetime" }, + { value: "self", desc: "Your DID" }, + ], + }, + ]; + }, + [ + fields, + namedOutputs, + chipsForName, + steps, + triggerRecordSchema, + collectionsByName, + fetchSchemas, + ], + ); + + /** Condition-field suggestions (datalist) for a given scope. */ + const conditionFieldOptionsForScope = useCallback( + (scope: StepScope): Field[] => { + const out: Field[] = [ + ...BUILTIN_CONDITION_FIELDS, + ...fields.map((f) => ({ ...f, path: `event.commit.record.${f.path}` })), + ]; + for (const nm of [...scope.names, ...scope.maybeUnset]) { + const output = namedOutputs.get(nm); + if (!output) continue; + if (output.kind === "variable" || output.kind === "processor") { + out.push({ path: nm, type: "string" }); + continue; + } + if (output.kind === "fetch" || output.kind === "search") { + out.push({ + path: `${nm}.found`, + type: "boolean", + description: "Whether a record was returned", + }); + } + for (const f of ENTRY_SCALAR_FIELDS) out.push({ path: `${nm}.${f}`, type: "string" }); + if (output.kind === "fetch" || output.kind === "search") { + const schema = output.collection ? fetchSchemas[output.collection] : undefined; + if (schema) { + out.push(...schema.fields.map((f) => ({ ...f, path: `${nm}.record.${f.path}` }))); + } + } + } + for (const alias of scope.itemAliases) out.push({ path: alias, type: "unknown" }); + return out; + }, + [fields, namedOutputs, fetchSchemas], + ); + + /** Array-path suggestions for a loop step's path input, scoped to what is + * visible at that step. */ + const arrayPathSuggestionsForScope = useCallback( + (scope: StepScope): string[] => { + const out = collectArrayPaths(triggerRecordSchema); + for (const nm of [...scope.names, ...scope.maybeUnset]) { + const output = namedOutputs.get(nm); + if (!output) continue; + if (output.kind === "fetch" || output.kind === "search") { + const schema = output.collection ? fetchSchemas[output.collection]?.record : undefined; + if (schema) out.push(...collectArrayPaths(schema, `${nm}.record`)); + } else if (output.kind === "processor") { + out.push(`${nm}[]`); + } else if (output.kind === "loop") { + out.push(`${nm}.results[]`); + } + } + return out; + }, + [triggerRecordSchema, namedOutputs, fetchSchemas], + ); + + // ------------------------------------------------------------------------- + // Payload and submit + // ------------------------------------------------------------------------- + + const previewPayload = useMemo(() => { + const payload: Record = { name, lexicon, operations }; + if (description.trim()) payload.description = description.trim(); + payload.steps = payloadSteps; + const trimmedWantedDids = wantedDids.map((d) => d.trim()).filter(Boolean); + if (trimmedWantedDids.length > 0 || isEdit) { + payload.wantedDids = trimmedWantedDids; + } if (formConfig?.isPartnerApp) payload.isBlueprint = isBlueprint; payload.dedupeBySource = dedupeBySource; // originUri is set only at creation time. The PATCH handler ignores it @@ -1392,11 +1226,8 @@ export default function AutomationForm({ description, lexicon, operations, - fetches, - variables, - conditions, + payloadSteps, wantedDids, - actions, isBlueprint, dedupeBySource, formConfig?.isPartnerApp, @@ -1409,10 +1240,17 @@ export default function AutomationForm({ e.preventDefault(); if (submittingRef.current) return; setError(""); + // Client half of the reference-integrity rule (KTD-7): a dangling, + // forward, or cross-branch reference fails here with the step-named + // message before any network round trip. The server is authoritative. + if (referenceIssues.length > 0) { + setError(referenceIssues[0]!.message); + return; + } // Processors transform data but don't act on it. Block submit when the - // chain is processor-only; the API enforces the same rule, but failing + // tree is processor-only; the API enforces the same rule, but failing // early avoids a round trip. - if (actions.length > 0 && !actions.some((a) => !isProcessorAction(a.type))) { + if (hasActionStep && !hasSideEffectAction) { setError( "Add at least one non-processor action. Processors prepare data for downstream actions but do not act on it themselves.", ); @@ -1463,109 +1301,1205 @@ export default function AutomationForm({ } } }, - [previewPayload, isEdit, actions], + [previewPayload, isEdit, referenceIssues, hasActionStep, hasSideEffectAction], ); - const formInvalid = !name.trim() || operations.length === 0 || actions.length === 0; + const formInvalid = !name.trim() || operations.length === 0 || !hasActionStep; + + // ------------------------------------------------------------------------- + // Step tree rendering + // ------------------------------------------------------------------------- + + const stepsAtCap = totalSteps >= STEP_LIMITS.totalSteps; + + function renderAddControl(key: ContainerKey, depth: number) { + const keyStr = containerKeyString(key); + const open = pickerFor === keyStr; + const containersBlocked = depth >= MAX_STEP_DEPTH; + return ( +
+ + {stepsAtCap && ( + + Step limit reached ({STEP_LIMITS.totalSteps} steps max). Remove a step to add another. + + )} + {open && !stepsAtCap && ( + <> +
+
+ + Flow and data + - Branch, loop, and prepare data +
+
+ {STRUCTURAL_TILES.map((tile) => { + const Icon = tile.icon; + const blocked = tile.container && containersBlocked; + return ( + + ); + })} +
+ {containersBlocked && ( + + Loops cannot be added here: steps nest at most {MAX_STEP_DEPTH} levels deep. + + )} +
+ {ACTION_CATALOGUE.map((cat) => ( +
+
+ + {cat.label} + - {cat.description} +
+
+ {cat.actions.map((a) => { + const Icon = a.icon; + const colorKey = "colorKey" in a && a.colorKey ? a.colorKey : cat.id; + return ( + + ); + })} +
+
+ ))} + + )} +
+ ); + } - const arrayPathSuggestions = useMemo(() => { - const out = collectArrayPaths(triggerRecordSchema); - for (const f of fetches) { - if (!f.name || !f.collection) continue; - const schema = fetchSchemas[f.collection]?.record; - if (!schema) continue; - out.push(...collectArrayPaths(schema, `${f.name}.record`)); - } - return out; - }, [triggerRecordSchema, fetches, fetchSchemas]); - - /** Per-action variant: for action at index `i`, append `actionK.results[]` - * for every upstream record-producing action K (K < i+1) that itself runs - * under forEach, plus `[]` for every upstream processor. */ - const arrayPathSuggestionsForAction = useCallback( - (actionIndex: number): string[] => { - const extras: string[] = []; - for (let k = 0; k < actionIndex; k++) { - const a = actions[k]; - if (!a) continue; - if (a.forEach && isRecordProducingAction(a.type)) { - extras.push(`action${k + 1}.results[]`); - } - const procOut = draftProcessorOutputName(a); - if (procOut) extras.push(`${procOut}[]`); - } - return extras.length > 0 ? [...arrayPathSuggestions, ...extras] : arrayPathSuggestions; - }, - [arrayPathSuggestions, actions], - ); + function renderStepList(list: StepDraft[], key: ContainerKey, depth: number) { + return ( +
0 ? s.stepChildList : s.stepList}> + {list.map((d, i) => renderStep(d, key, i, depth))} + {list.length === 0 && depth > 0 && ( +

No steps here yet. Add the first one below.

+ )} + {renderAddControl(key, depth)} +
+ ); + } - const itemFieldsByPath = useMemo(() => { - const map: Record = {}; - for (const path of arrayPathSuggestions) { - map[path] = resolveItemFields(path, triggerRecordSchema, fetches, fetchSchemas); - } - // Hardcoded shape for `actionN.results[]` paths, only when actionN itself - // ran under forEach (matches the runtime contract). Hand-typing the path - // for a non-forEach action would surface a misleading "Item placeholders" - // panel before the validator rejects the save. - for (let k = 0; k < actions.length; k++) { - const a = actions[k]; - if (a?.forEach && isRecordProducingAction(a.type)) { - map[`action${k + 1}.results[]`] = ACTION_RESULT_ITEM_FIELDS; - } + function renderAssertionRow( + cond: ConditionStepDraft, + a: AssertionDraft, + ai: number, + scope: StepScope, + ) { + const updateAssertion = (patch: Partial) => { + updateStep(cond.id, (cur) => { + if (cur.kind !== "condition") return cur; + return { + ...cur, + assertions: cur.assertions.map((x, j) => (j === ai ? { ...x, ...patch } : x)), + }; + }); + }; + const removeAssertion = () => { + updateStep(cond.id, (cur) => { + if (cur.kind !== "condition") return cur; + return { ...cur, assertions: cur.assertions.filter((_, j) => j !== ai) }; + }); + }; + const resolution = resolveAssertionFieldType(a.field, { + trigger: triggerLookup, + outputs: namedOutputs, + collectionSchemas: fetchSchemas, + }); + const offered = operatorsForFieldType(resolution); + const loading = resolution.status === "loading"; + const degraded = resolution.status === "unknown" && !!a.field.trim(); + const fieldOptions = conditionFieldOptionsForScope(scope); + const selected = fieldOptions.find((f) => f.path === a.field.trim()); + const valueless = isValueLessOperator(a.operator); + const knownValues = resolution.status === "typed" ? resolution.knownValues : undefined; + const booleanValue = resolution.status === "typed" && resolution.type === "boolean"; + const fieldListId = `cond-${cond.id}-fields`; + return ( +
+
+
+ + updateAssertion({ field: (e.target as HTMLInputElement).value }) + } + aria-label="Check field" + autocomplete="off" + /> + {selected?.description && {selected.description}} +
+
+ +
+ {!valueless && ( +
+ {knownValues ? ( + + ) : booleanValue ? ( + + ) : ( + + updateAssertion({ value: (e.target as HTMLInputElement).value }) + } + aria-label="Check value" + autocomplete="off" + /> + )} +
+ )} + +
+ {loading && Loading field types...} + {degraded && ( + + Schema unavailable: all operators shown; comparisons use the value's runtime type. + + )} +
+ + + updateAssertion({ comment: (e.target as HTMLInputElement).value }) + } + autocomplete="off" + /> +
+
+ ); + } + + function renderConditionBody(d: ConditionStepDraft, depth: number, scope: StepScope) { + const fieldOptions = conditionFieldOptionsForScope(scope); + const fieldListId = `cond-${d.id}-fields`; + const branchesBlocked = depth >= MAX_STEP_DEPTH; + return ( + <> + {d.assertions.length > 1 && ( +
+ + +
+ )} + {d.assertions.map((a, ai) => renderAssertionRow(d, a, ai, scope))} + {d.assertions.length < STEP_LIMITS.assertionsPerCondition ? ( + + ) : ( + + At most {STEP_LIMITS.assertionsPerCondition} checks per condition step. + + )} + + {fieldOptions.map((f) => ( + + + {!d.then ? ( + <> + + Without branches, a failing check ends the run here (inside a loop, it skips the + current item). + +
+ +
+ {branchesBlocked && ( + + Branches cannot be added here: steps nest at most {MAX_STEP_DEPTH} levels deep. + + )} + + ) : ( + <> + + When the checks pass, Then runs; otherwise Else. The flow continues after this step + either way. + +
+
Then
+ {renderStepList(d.then, { parentId: d.id, branch: "then" }, depth + 1)} +
+ {d.else ? ( +
+
Else
+ {renderStepList(d.else, { parentId: d.id, branch: "else" }, depth + 1)} +
+ ) : ( +
+ +
+ )} +
+ {d.else && ( + + )} + +
+ + )} + + ); + } + + function renderFetchBody(d: FetchStepDraft) { + return ( + <> +
+
+
+ + + updateStep(d.id, (cur) => + cur.kind === "fetch" + ? { ...cur, name: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> +
+
+ + +
+
+ + Used in templates as{" "} + {`{{${d.name.trim() || "name"}.record.…}}`}. + +
+
+
+ + + updateStep(d.id, (cur) => + cur.kind === "fetch" + ? { ...cur, uri: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> + + Supports {"{{event.*}}"},{" "} + {"{{self}}"}, and any earlier step's outputs. + +
+
+ + { + const val = (e.target as HTMLInputElement).value; + updateStep(d.id, (cur) => + cur.kind === "fetch" ? { ...cur, collection: val } : cur, + ); + if (val && NSID_RE.test(val)) loadFetchSchema(val); + }} + autocomplete="off" + /> + + NSID hint for typed placeholders. +
+
+ + A record that is not found resolves to an empty entry: check{" "} + {`${d.name.trim() || "name"}.found`} with a condition + step to gate on it. + + {renderStepNote(d.id, d.comment, "What does this data source provide?", (cur, val) => + cur.kind === "fetch" ? { ...cur, comment: val } : cur, + )} + + ); + } + + function renderSearchBody(d: SearchStepDraft) { + const schemaState = d.collection ? fetchSchemas[d.collection] : undefined; + const recordFields = schemaState?.fields ?? []; + const useFreeForm = + !d.collection || + (!!schemaState && + (schemaState.unresolved || !!schemaState.error) && + recordFields.length === 0); + const hasCustomValue = !!d.whereField && !recordFields.some((rf) => rf.path === d.whereField); + return ( + <> +
+
+
+ + + updateStep(d.id, (cur) => + cur.kind === "search" + ? { ...cur, name: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> +
+
+ + +
+
+ + Used in templates as{" "} + {`{{${d.name.trim() || "name"}.record.…}}`}. + +
+
+
+ + + updateStep(d.id, (cur) => + cur.kind === "search" + ? { ...cur, repo: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> + + DID of the repo. Use {"{{self}}"} for yours. + +
+
+ + { + const val = (e.target as HTMLInputElement).value; + updateStep(d.id, (cur) => + cur.kind === "search" ? { ...cur, collection: val } : cur, + ); + if (val && NSID_RE.test(val)) loadFetchSchema(val); + }} + autocomplete="off" + /> + + NSID of the collection to search. +
+
+
+
Match records where
+
+
+ + {useFreeForm ? ( + + updateStep(d.id, (cur) => + cur.kind === "search" + ? { ...cur, whereField: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> + ) : ( + + )} +
+
+ + equals +
+
+ + + updateStep(d.id, (cur) => + cur.kind === "search" + ? { ...cur, whereValue: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> +
+
+ + First matching record wins. No match resolves to "not found": check{" "} + {`${d.name.trim() || "name"}.found`} with a condition + step. + +
+ {renderStepNote(d.id, d.comment, "What does this data source provide?", (cur, val) => + cur.kind === "search" ? { ...cur, comment: val } : cur, + )} + + ); + } + + function renderVariableBody(d: VariableStepDraft) { + return ( + <> +
+
+
+ + + updateStep(d.id, (cur) => + cur.kind === "variable" + ? { ...cur, name: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> +
+
+ + + updateStep(d.id, (cur) => + cur.kind === "variable" + ? { ...cur, value: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> +
+
+ + Use as {`{{${d.name.trim() || "name"}}}`} in later + steps. The value is itself a template: placeholders from earlier steps and{" "} + {"{{self}}"} resolve when the automation runs. + +
+
+ + + updateStep(d.id, (cur) => + cur.kind === "variable" + ? { ...cur, note: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> +
+
+ + + Off by default: when someone duplicates this automation, the value is cleared so they + have to supply their own. + +
+ + ); + } + + function renderLoopBody(d: LoopStepDraft, depth: number, scope: StepScope) { + const pathListId = `loop-${d.id}-path-suggestions`; + const suggestions = arrayPathSuggestionsForScope(scope); + const alias = d.itemName.trim() || "item"; + return ( + <> +
+
+
+ + + updateStep(d.id, (cur) => + cur.kind === "loop" + ? { ...cur, name: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> + + After the loop, later steps can read the last iteration's result via{" "} + {`{{${d.name.trim() || "name"}.uri}}`}. + +
+
+ + + updateStep(d.id, (cur) => + cur.kind === "loop" + ? { ...cur, path: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> + + Dotted path with [] marking array levels. Must end + in []. + + {suggestions.length > 0 && ( + + {suggestions.map((p) => ( + + )} +
+
+ + + updateStep(d.id, (cur) => + cur.kind === "loop" + ? { ...cur, itemName: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> +
+
+ + The steps below run once per item. Inside them the current item is{" "} + {`{{${alias}}}`} (fields via{" "} + {`{{${alias}.uri}}`} etc.). Names defined inside the + loop reset each iteration and are not visible after it. + +
+
+
For each item
+ {renderStepList(d.steps, { parentId: d.id, branch: "steps" }, depth + 1)} +
+ {renderStepNote(d.id, d.comment, "What does this loop do?", (cur, val) => + cur.kind === "loop" ? { ...cur, comment: val } : cur, + )} + + ); + } + + function renderStepNote( + id: string, + value: string, + placeholder: string, + apply: (cur: StepDraft, val: string) => StepDraft, + ) { + return ( +
+ + + updateStep(id, (cur) => apply(cur, (e.target as HTMLInputElement).value)) + } + autocomplete="off" + /> +
+ ); + } + + function renderActionBody(d: StepDraft & { kind: "action" }, scope: StepScope) { + const flatIndex = actionIndexById.get(d.id) ?? 0; + const def = ACTION_UI_REGISTRY[d.action.type]; + const EditorBlock = def.EditorBlock; + const processor = isProcessorAction(d.action.type); + const recordProducing = isRecordProducingAction(d.action.type); + const referenceName = stepDraftName(d); + return ( + <> + {!processor && ( + + updateStep(d.id, (cur) => + cur.kind === "action" ? { ...cur, action: { ...cur.action, schedule: next } } : cur, + ) + } + /> + )} + {recordProducing && ( +
+ + + updateStep(d.id, (cur) => + cur.kind === "action" + ? { ...cur, name: (e.target as HTMLInputElement).value } + : cur, + ) + } + autocomplete="off" + /> + + Name this step to reference its created record from later steps, e.g.{" "} + {`{{${d.name.trim() || "name"}.uri}}`}. + +
+ )} + + updateStep(d.id, (cur) => (cur.kind === "action" ? { ...cur, action: a } : cur)) + } + placeholders={placeholdersForScope(scope)} + /> +
+ + + updateStep(d.id, (cur) => + cur.kind === "action" + ? { + ...cur, + action: { ...cur.action, comment: (e.target as HTMLInputElement).value }, + } + : cur, + ) + } + autocomplete="off" + /> +
+ {recordProducing && referenceName && ( +
+ + Placeholders from {referenceName}{" "} + (available in later steps) + +
+
+ {ENTRY_SCALAR_FIELDS.map((f) => ( + + ))} +
+
+
+ )} + + ); + } + + function renderStepHeaderTitle(d: StepDraft) { + if (d.kind === "action") { + const flatIndex = actionIndexById.get(d.id) ?? 0; + const typeKey = actionTypeKeys[flatIndex]!; + const sameTypeIndex = actionTypeKeys.filter((k, j) => k === typeKey && j <= flatIndex).length; + const totalOfType = actionTypeKeys.filter((k) => k === typeKey).length; + return ( + + ); } - return map; - }, [arrayPathSuggestions, triggerRecordSchema, fetches, fetchSchemas, actions]); - - // Catalogue-tile identity: follows split into follow- so per-tile - // "#N" counters don't collapse the three tiles into one group. - const actionTypeKeys = actions.map((a) => - actionTypeKey({ - $type: a.type, - target: a.type === "follow" ? a.target : undefined, - }), - ); + const header = STRUCTURAL_HEADERS[d.kind]; + const Icon = header.icon; + const name = stepDraftName(d); + return ( + + + {header.label} + {d.kind === "condition" && {d.then ? "branch" : "gate"}} + {name && {name}} + + ); + } - const allPlaceholders = [ - ...BUILTIN_PLACEHOLDERS, - ...variables.filter((v) => v.name.trim()).map((v) => v.name.trim()), - ...fields.map((f) => `event.commit.record.${f.path}`), - ...fetches - .filter((f) => f.name) - .flatMap((f) => { - const base = [ - `${f.name}.uri`, - `${f.name}.cid`, - `${f.name}.did`, - `${f.name}.collection`, - `${f.name}.rkey`, - ]; - const schema = f.collection ? fetchSchemas[f.collection] : undefined; - const recordPaths = - schema && schema.fields.length > 0 - ? schema.fields.map((field) => `${f.name}.record.${field.path}`) - : [`${f.name}.record.*`]; - return [...base, ...recordPaths]; - }), - ...actions.flatMap((a, i) => { - if (isRecordProducingAction(a.type)) { - return [ - `action${i + 1}.uri`, - `action${i + 1}.cid`, - `action${i + 1}.did`, - `action${i + 1}.collection`, - `action${i + 1}.rkey`, - ]; - } - const procOut = draftProcessorOutputName(a); - return procOut ? [procOut] : []; - }), - ]; + function renderStep(d: StepDraft, containerKey: ContainerKey, index: number, depth: number) { + const keyStr = containerKeyString(containerKey); + const isDragSrc = dragSrc?.container === keyStr && dragSrc.index === index; + const isDragOver = + dragSrc !== null && + !isDragSrc && + dragOver?.container === keyStr && + dragOver.index === index && + dragSrc.container === keyStr; + const cardClass = [ + s.actionCard, + isDragSrc && s.actionCardDragging, + isDragOver && s.actionCardDragOver, + ] + .filter(Boolean) + .join(" "); + const indent = indentTarget(steps, d.id); + const outdentOk = canOutdent(steps, d.id); + const scope = scopeAt(payloadSteps, d.id) ?? { names: [], maybeUnset: [], itemAliases: [] }; + const issues = issuesByStep.get(d.id) ?? []; + const inlineError = stepErrors[d.id]; + + return ( +
{ + // Reorder is confined to the step's own sibling list; moves across + // containers go through indent/outdent (plan U11). + if (dragSrc === null || dragSrc.container !== keyStr || dragSrc.index === index) return; + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + if (dragOver?.container !== keyStr || dragOver.index !== index) { + setDragOver({ container: keyStr, index }); + } + }} + onDragLeave={(e: DragEvent) => { + const related = e.relatedTarget as Node | null; + const current = e.currentTarget as Node | null; + if (related && current?.contains(related)) return; + if (dragOver?.container === keyStr && dragOver.index === index) setDragOver(null); + }} + onDrop={(e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (dragSrc !== null && dragSrc.container === keyStr) { + setSteps((prev) => moveWithinContainer(prev, containerKey, dragSrc.index, index)); + } + endDrag(); + }} + > +
+
+ { + setDragSrc({ container: keyStr, index }); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + // Firefox refuses to start the drag unless dataTransfer + // carries data. + e.dataTransfer.setData("text/plain", String(index)); + // Use the whole card as the drag image so the ghost reflects + // the step being moved, not just the small handle. + const card = (e.currentTarget as HTMLElement | null)?.closest(`.${s.actionCard}`); + if (card) e.dataTransfer.setDragImage(card, 0, 0); + } + }} + onDragEnd={endDrag} + > + + + {renderStepHeaderTitle(d)} +
+
+ + +
+ +
+ {d.kind === "condition" && renderConditionBody(d, depth, scope)} + {d.kind === "fetch" && renderFetchBody(d)} + {d.kind === "search" && renderSearchBody(d)} + {d.kind === "variable" && renderVariableBody(d)} + {d.kind === "loop" && renderLoopBody(d, depth, scope)} + {d.kind === "action" && renderActionBody(d, scope)} + + {issues.map((m) => ( +
+ {m} +
+ ))} + {inlineError &&
{inlineError}
} +
+ ); + } - const conditionFields = [ - ...BUILTIN_CONDITION_FIELDS, - ...fields.map((f) => ({ ...f, path: `event.commit.record.${f.path}` })), - ]; + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- return (
@@ -1639,7 +2573,6 @@ export default function AutomationForm({ : (e: Event) => { const val = (e.target as HTMLInputElement).value; setLexicon(val); - setConditions([]); fetchFields(val); fetchSuggestions(val); } @@ -1797,1108 +2730,19 @@ export default function AutomationForm({
)} -
-
-

Variables

-

- Named values you can reuse as {"{{name}}"}{" "} - anywhere in this automation. -

-
- {variables.map((v, i) => ( -
-
-
-
- - - updateVariable(i, "name", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> -
-
- - - updateVariable(i, "value", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> -
-
- - -
-
- - Use as {`{{${v.name.trim() || "name"}}}`}{" "} - anywhere in this automation. -
- Type the exact value you want. Placeholders like {`{{self}}`} won't work inside - it. -
-
- -
- - - updateVariable(i, "note", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> -
- -
- - - Off by default: when someone duplicates this automation, the value is cleared so - they have to supply their own. - -
-
- ))} - {variables.length < 10 && ( - - )} -
- - {NSID_RE.test(lexicon) && ( -
- Available placeholders -
-
-
Event
- - DID of the repo that emitted the event - - - AT URI of the event's record - - - Lexicon NSID of the record - - - Record key - - - Content hash of the record - - - create, update, or delete - -
- - {fields.length > 0 && ( -
-
Record fields
- {fields.map((f) => ( - - {f.description && {f.description}} - - ))} -
- )} - - {fields.length === 0 && schemaUnresolved && ( -
-
Record fields
- - Schema not available. Use paths like{" "} - event.commit.record.subject.uri based on the record structure. - -
- )} - -
-
Automation
- - Record key of this automation - - - Name of this automation - - - Public URL of this automation - -
- - {variables.filter((v) => v.name.trim()).length > 0 && ( -
-
Variables
- {variables - .filter((v) => v.name.trim()) - .map((v) => ( - - {v.note ? ( - {v.note} - ) : v.value ? ( - {v.value} - ) : null} - - ))} -
- )} - -
-
Helpers
- - Current ISO 8601 datetime - - - Your DID - -
-
-
- )} - - {NSID_RE.test(lexicon) && ( -
-
-

Conditions

-

- Only run when all conditions match. Use{" "} - {"{{self}}"} for your own account. -

-
- {conditions.map((cond, i) => { - const selectedField = conditionFields.find((f) => f.path === cond.field); - const fieldHint = - schemaUnresolved && fields.length === 0 - ? "Enter a field path from the event, e.g. event.commit.record.subject.uri" - : (cond.field && selectedField?.description) || null; - return ( -
-
-
- {schemaUnresolved && fields.length === 0 ? ( - - updateCondition(i, "field", (e.target as HTMLInputElement).value) - } - aria-label="Condition field" - autocomplete="off" - /> - ) : ( - - )} -
- {cond.field && selectedField?.type !== "boolean" && ( -
- -
- )} - {!VALUE_LESS_CONDITION_OPS.has(cond.operator) && ( -
- {(() => { - if (selectedField?.knownValues) { - return ( - - ); - } - if (selectedField?.type === "boolean") { - return ( - - ); - } - return ( - - updateCondition(i, "value", (e.target as HTMLInputElement).value) - } - aria-label="Condition value" - autocomplete="off" - /> - ); - })()} -
- )} - -
- {fieldHint && {fieldHint}} -
- - - updateCondition(i, "comment", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> -
-
- ); - })} - -
- )} - - {NSID_RE.test(lexicon) && ( -
-
-

Data Sources

-

- Load extra records before actions run, usable as placeholders. -

-
- {fetches.map((f, i) => ( -
- {/* Top row: Variable name + Source type + Remove, all aligned on inputs */} -
-
-
- - - updateFetch(i, "name", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> -
-
- - -
-
- - -
-
- - Used in templates as{" "} - {`{{${f.name || "name"}.record.…}}`}. - -
- - {/* Source-specific config */} - {f.kind === "record" ? ( -
-
Record to fetch
-
-
- - - updateFetch(i, "uri", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> - - Supports {"{{event.*}}"},{" "} - {"{{self}}"}, and any{" "} - {"{{variable}}"} declared above. - -
-
- - - updateFetch(i, "collection", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> - - NSID hint for typed placeholders. -
-
-
- ) : ( - <> -
-
Where to search
-
-
- - - updateFetch(i, "repo", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> - - DID of the repo. Use {"{{self}}"}{" "} - for yours. - -
-
- - - updateFetch(i, "collection", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> - - NSID of the collection to search. -
-
-
- -
-
Match records where
-
-
- - {(() => { - const schemaState = f.collection - ? fetchSchemas[f.collection] - : undefined; - const recordFields = schemaState?.fields ?? []; - const useFreeForm = - !f.collection || - (!!schemaState && - (schemaState.unresolved || !!schemaState.error) && - recordFields.length === 0); - if (useFreeForm) { - return ( - - updateFetch( - i, - "whereField", - (e.target as HTMLInputElement).value, - ) - } - autocomplete="off" - /> - ); - } - const hasCustomValue = - !!f.whereField && - !recordFields.some((rf) => rf.path === f.whereField); - return ( - - ); - })()} -
-
- - equals -
-
- - - updateFetch(i, "whereValue", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> -
-
- - First matching record wins. No match resolves to "not found". - -
- - )} - - {/* Per-source conditions */} -
-
Continue only if…
- - Runs after this fetch. If any check fails, the automation skips. - - {(() => { - const schemaState = f.collection ? fetchSchemas[f.collection] : undefined; - const recordFields = schemaState?.fields ?? []; - const condOptions: Field[] = [ - ...FETCH_ENTRY_FIELDS, - ...recordFields.map((field) => ({ - ...field, - path: `record.${field.path}`, - })), - ]; - const useFreeFormField = - !f.collection || - (!!schemaState && - (schemaState.unresolved || !!schemaState.error) && - recordFields.length === 0); - return f.conditions.map((cond, ci) => { - const preset = fetchConditionPreset(cond); - const selectedField = condOptions.find((cf) => cf.path === cond.field); - const isBoolean = selectedField?.type === "boolean"; - return ( -
-
- - -
- {preset === "custom" && ( -
-
- {useFreeFormField ? ( - - updateFetchCondition( - i, - ci, - "field", - (e.target as HTMLInputElement).value, - ) - } - aria-label="Condition field" - autocomplete="off" - /> - ) : ( - <> - - {selectedField?.description && ( - {selectedField.description} - )} - - )} -
- {cond.field && !isBoolean && ( -
- -
- )} - {!VALUE_LESS_CONDITION_OPS.has(cond.operator) && ( -
- {selectedField?.knownValues ? ( - - ) : isBoolean ? ( - - ) : ( - - updateFetchCondition( - i, - ci, - "value", - (e.target as HTMLInputElement).value, - ) - } - aria-label="Condition value" - autocomplete="off" - /> - )} -
- )} -
- )} -
- ); - }); - })()} - -
- - {/* Note */} -
- - - updateFetch(i, "comment", (e.target as HTMLInputElement).value) - } - autocomplete="off" - /> -
- - {f.name && - (() => { - const schema = f.collection ? fetchSchemas[f.collection] : undefined; - const typedFields = schema?.fields ?? []; - return ( -
- - Placeholders from {f.name} - -
-
- - - - owner DID - - - -
- {typedFields.length > 0 ? ( -
-
Record fields
- {typedFields.map((field) => ( - - {field.description && ( - {field.description} - )} - - ))} -
- ) : ( -
- - access nested fields - -
- )} -
-
- ); - })()} -
- ))} - -
- )} -
-

Actions

+

Steps

- Actions run sequentially. Each action can reference the results of preceding ones. + Steps run in order after the trigger. Conditions gate or branch the flow, loops + repeat their steps per item, and each step can reference the outputs of earlier + ones.

- - {actions.map((action, i) => { - const typeKey = actionTypeKeys[i]!; - const sameTypeIndex = actionTypeKeys.filter((k, j) => k === typeKey && j <= i).length; - const totalOfType = actionTypeKeys.filter((k) => k === typeKey).length; - const cardClass = [ - s.actionCard, - dragSrcIndex === i && s.actionCardDragging, - dragSrcIndex !== null && - dragSrcIndex !== i && - dragOverIndex === i && - s.actionCardDragOver, - ] - .filter(Boolean) - .join(" "); - return ( -
{ - if (dragSrcIndex === null || dragSrcIndex === i) return; - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; - if (dragOverIndex !== i) setDragOverIndex(i); - }} - onDragLeave={(e: DragEvent) => { - const related = e.relatedTarget as Node | null; - const current = e.currentTarget as Node | null; - if (related && current?.contains(related)) return; - if (dragOverIndex === i) setDragOverIndex(null); - }} - onDrop={(e: DragEvent) => { - e.preventDefault(); - if (dragSrcIndex !== null) moveAction(dragSrcIndex, i); - }} - > -
-
- { - setDragSrcIndex(i); - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = "move"; - // Firefox refuses to start the drag unless dataTransfer - // carries data. - e.dataTransfer.setData("text/plain", String(i)); - // Use the whole card as the drag image so the ghost reflects - // the action being moved, not just the small handle. - const card = (e.currentTarget as HTMLElement | null)?.closest( - `.${s.actionCard}`, - ); - if (card) e.dataTransfer.setDragImage(card, 0, 0); - } - }} - onDragEnd={endDrag} - > - - - -
- -
- {!isProcessorAction(action.type) && ( - <> - updateAction(i, { ...action, schedule: next })} - /> - updateAction(i, { ...action, forEach: next })} - arrayPathSuggestions={arrayPathSuggestionsForAction(i)} - itemFieldsByPath={itemFieldsByPath} - /> - - )} - {(() => { - const EditorBlock = ACTION_UI_REGISTRY[action.type].EditorBlock; - return ( - updateAction(i, a)} - placeholders={allPlaceholders} - /> - ); - })()} -
- - - updateAction(i, { - ...action, - comment: (e.target as HTMLInputElement).value, - }) - } - autocomplete="off" - /> -
- {isRecordProducingAction(action.type) && ( -
- - Placeholders from action{i + 1}{" "} - - {action.forEach - ? "(runs per item; iterate via forEach.path)" - : "(available in following actions)"} - - -
- {action.forEach ? ( - - ) : ( - - )} -
-
- )} -
- ); - })} - -
- - {pickerOpen && - ACTION_CATALOGUE.map((cat) => ( -
-
- - {cat.label} - - {cat.description} -
-
- {cat.actions.map((a) => { - const Icon = a.icon; - const colorKey = "colorKey" in a && a.colorKey ? a.colorKey : cat.id; - return ( - - ); - })} -
-
- ))} -
+ {renderStepList(steps, ROOT_CONTAINER, 0)}
- {actions.length > 0 && ( + {steps.length > 0 && (
Preview JSON payload
{previewPayload}
@@ -2940,11 +2784,6 @@ export default function AutomationForm({