| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { useEffect, useRef, useState } from "react"; |
|
|
| import { brandForConnector, brandForKind, hasBrandKind } from "../connectors/brandMarks"; |
|
|
| import type { TriggerOption } from "./automationApi"; |
| import { groupTriggers, selectedTrigger } from "./steps"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function TriggerMark({ kind = "" }: { kind?: string }) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const brand = brandForKind(kind); |
| if (brand) return brand; |
| const common = { |
| width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", |
| strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, |
| "aria-hidden": true, |
| }; |
| if (kind === "schedule") |
| return ( |
| <svg {...common}> |
| <circle cx="8" cy="8" r="5.6" /> |
| <path d="M8 5.2V8l2 1.4" /> |
| </svg> |
| ); |
| if (kind === "event_field") |
| return ( |
| <svg {...common}> |
| <path d="M2.8 3.4h10.4l-4 4.6v4.2l-2.4 1.2V8z" /> |
| </svg> |
| ); |
| if (kind === "record_created") |
| return ( |
| <svg {...common}> |
| <rect x="3" y="2.6" width="10" height="10.8" rx="1.8" /> |
| <path d="M8 5.8v4.4M5.8 8h4.4" /> |
| </svg> |
| ); |
| if (kind === "record_updated") |
| return ( |
| <svg {...common}> |
| <rect x="3" y="2.6" width="10" height="10.8" rx="1.8" /> |
| <path d="M5.8 6.4h4.4M5.8 9.2h2.6" /> |
| </svg> |
| ); |
| if (kind === "enters_view") |
| return ( |
| <svg {...common}> |
| <path d="M1.8 8s2.4-4 6.2-4 6.2 4 6.2 4-2.4 4-6.2 4-6.2-4-6.2-4z" /> |
| <circle cx="8" cy="8" r="1.7" /> |
| </svg> |
| ); |
| if (kind === "webhook") |
| return ( |
| <svg {...common}> |
| <path d="M6.6 9.4 4.9 11a2.6 2.6 0 1 1-1.5-4.4" /> |
| <path d="M9.4 6.6 11.1 5a2.6 2.6 0 1 1 1.5 4.4" /> |
| <path d="M6.2 8h3.6" /> |
| </svg> |
| ); |
| if (kind === "email") |
| return ( |
| <svg {...common}> |
| <rect x="2.2" y="3.6" width="11.6" height="8.8" rx="1.6" /> |
| <path d="m2.6 4.6 5.4 3.8 5.4-3.8" /> |
| </svg> |
| ); |
| if (kind === "form_submitted") |
| return ( |
| <svg {...common}> |
| <rect x="3" y="2.6" width="10" height="10.8" rx="1.8" /> |
| <path d="M5.8 6h4.4M5.8 8.6h2.2M9 11l1.2 1.2L12.6 9.8" /> |
| </svg> |
| ); |
| if (kind === "button_clicked") |
| return ( |
| <svg {...common}> |
| <path d="M6 3.2v6.2l1.6-1.4 1.4 3 1.6-.8-1.4-2.8 2.2-.2z" /> |
| </svg> |
| ); |
| if (kind === "comment_added") |
| return ( |
| <svg {...common}> |
| <path d="M13.4 9.4a1.6 1.6 0 0 1-1.6 1.6H5.4L2.6 13.4V4.2a1.6 1.6 0 0 1 1.6-1.6h7.6a1.6 1.6 0 0 1 1.6 1.6z" /> |
| </svg> |
| ); |
| |
| |
| |
| |
| |
| |
| |
| if (kind === "ig_profile_match") |
| return ( |
| <svg {...common}> |
| <circle cx="7" cy="7" r="4.4" /> |
| <path d="m10.4 10.4 3.2 3.2" /> |
| <circle cx="7" cy="5.9" r="1.3" /> |
| <path d="M4.9 9.7c.4-1.1 1.2-1.7 2.1-1.7s1.7.6 2.1 1.7" /> |
| </svg> |
| ); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (kind === "web_page_changed") |
| return ( |
| <svg {...common}> |
| <path d="M9 2.6H4.4a1.4 1.4 0 0 0-1.4 1.4v8a1.4 1.4 0 0 0 1.4 1.4h7.2a1.4 1.4 0 0 0 1.4-1.4V6.6z" /> |
| <path d="M9 2.6v4h4" /> |
| <path d="M5.6 9.4h4.2M5.6 11.4h2.4" /> |
| </svg> |
| ); |
| |
| |
| |
| |
| |
| |
| |
| |
| if (kind === "tiktok_profile_match") |
| return ( |
| <svg {...common}> |
| <circle cx="7" cy="7" r="4.4" /> |
| <path d="m10.4 10.4 3.2 3.2" /> |
| <path d="M8.4 4.6v3.8a1.3 1.3 0 1 1-1.3-1.3" /> |
| <path d="M8.4 5.6c.3.5.8.8 1.4.8" /> |
| </svg> |
| ); |
| return ( |
| <svg {...common}> |
| <path d="M3 3v6.2a2 2 0 0 0 2 2h7.4" /> |
| <path d="M10.2 9.2 12.8 11.2 10.2 13.2" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function rowNote(t: TriggerOption): string { |
| if (t.planned) return "Coming soon"; |
| if (t.ready === false) return "Needs setting up"; |
| return ""; |
| } |
|
|
| |
| |
| |
| |
| function TriggerList({ |
| options, |
| value, |
| disabled, |
| onPick, |
| }: { |
| options: TriggerOption[]; |
| value: string; |
| disabled: boolean; |
| onPick: (key: string) => void; |
| }) { |
| const row = (t: TriggerOption) => { |
| const on = t.key === value; |
| return ( |
| <button |
| key={t.key} |
| type="button" |
| role="option" |
| aria-selected={on} |
| // ⛔ `is-planned` ONLY FOR PLANNED, and this was wrong in the first draft. That class |
| // carries `cursor: default` — it exists to say "this row is not pickable" — and a |
| // not-ready trigger IS pickable (`clean_trigger` accepts it; readiness is a deployment |
| // fact, not a validity one). Fading both would have put a default cursor on a live |
| // control, which reads as broken rather than as unavailable. The old picker made the |
| // same distinction; the two states are worded apart in `rowNote` instead. |
| className={ |
| "autox-menu-row autox-trigrow" + |
| (t.planned ? " is-planned" : "") + |
| (on ? " is-on" : "") |
| } |
| // ⛔ A PLANNED ROW IS DISABLED, A NOT-READY ONE IS NOT. `clean_trigger` refuses the first |
| // at the door and ACCEPTS the second — readiness is a deployment fact, not a validity |
| // one (the engine's own split for `email` and `ig_profile_match`). Disabling both would |
| // put a wall in front of a trigger the server would happily store, and the reader would |
| // have no way to configure the thing they are about to connect. |
| disabled={disabled || !!t.planned} |
| title={t.detail || ""} |
| onClick={() => onPick(t.key)} |
| > |
| <span className={"autox-card-mark" + (hasBrandKind(t.key) ? " is-brand" : "")}> |
| <TriggerMark kind={t.key} /> |
| </span> |
| <span className="autox-menu-text"> |
| <span className="autox-menu-label"> |
| {t.label} |
| {rowNote(t) ? <span className="autox-soon">{rowNote(t)}</span> : null} |
| </span> |
| {/* THE SERVER'S OWN SENTENCE, printed verbatim and never composed with anything — the |
| same law that put `detail` on the wire instead of a client blurb table. */} |
| {t.detail ? <span className="autox-menu-detail">{t.detail}</span> : null} |
| </span> |
| {/* The tick is the ONLY thing that says which one is chosen, so it is markup rather than |
| a background tint: a tint at rest is indistinguishable from a hover on a surface the |
| reader is already moving the mouse across. `aria-selected` carries it for everyone |
| else. */} |
| {on ? ( |
| <span className="autox-trigrow-on" aria-hidden="true"> |
| <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" |
| strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> |
| <path d="m3.4 8.4 3 3 6.2-6.8" /> |
| </svg> |
| </span> |
| ) : null} |
| </button> |
| ); |
| }; |
|
|
| return ( |
| <> |
| {groupTriggers(options).map((g) => ( |
| <div className="autox-menu-group" key={g.key || "_"}> |
| {/* An absent caption renders NO heading rather than the group id — a server older than |
| this client gets one unlabelled section, which is honest, instead of a heading |
| reading "connector" in lower case. */} |
| {g.label ? <p className="autox-menu-head">{g.label}</p> : null} |
| {g.rows.map(row)} |
| {/* |
| ⭐⭐ WAVE 30 · ITEM 7 / R3 — THE CONNECTOR NEST IS A COLLAPSED DISCLOSURE NOW. |
| The owner, verbatim and for the third wave: *"Only when I click 'Scraper' under each |
| automation trigger and actions would I see the option to choose either Instagram OR |
| TikTok. That's it."* |
|
|
| ⛔ WHAT WAS HERE WAS THE COMPLAINT ITSELF, and it was an ASYMMETRY rather than a |
| missing feature: this rendered `<div className="autox-menu-sub">` with a `<p>` heading |
| — always open, merely indented — while `AutomationBuilder`'s action menu four inches |
| away already used exactly the markup below. One menu asked you to click; the other |
| showed you everything. So this is not a new pattern, it is the OTHER menu's pattern, |
| copied verbatim so the two cannot drift apart again. |
|
|
| ⚠ `<details>` and not a hand-built disclosure: it opens on Enter and Space with no |
| keydown handler, it needs no open/closed state of its own to get wrong, and it is |
| CLOSED by default, which is the whole ruling. |
| ⚠ The summary wears the same 26px `autox-card-mark` the rows beneath it wear (T19's |
| marks). A smaller slot here was the first version and it made the heading look like a |
| different KIND of thing from its own children — which is the asymmetry again, one |
| level down. |
| */} |
| {g.sub.map((s) => ( |
| <details className="autox-menu-nest" key={s.key}> |
| <summary className="autox-menu-row autox-menu-sum"> |
| {brandForConnector(s.key) ? ( |
| <span className="autox-card-mark is-brand">{brandForConnector(s.key)}</span> |
| ) : null} |
| <span className="autox-menu-text"> |
| <span className="autox-menu-label">{s.label}</span> |
| <span className="autox-menu-detail"> |
| {s.rows.length} trigger{s.rows.length === 1 ? "" : "s"} |
| </span> |
| </span> |
| </summary> |
| {s.rows.map(row)} |
| </details> |
| ))} |
| </div> |
| ))} |
| </> |
| ); |
| } |
|
|
| /** |
| * ONE PICKER, TWO PRESENTATIONS. |
| * |
| * · `menu` — the open flyout the empty state's "+ Add trigger" box opens. The CALLER owns |
| * whether it is open, because it is anchored to that box. |
| * · `field` — the compact control in Properties → Trigger details → Trigger type. It shows the |
| * current choice and opens the same list beneath itself. |
| * |
| * ⚠ `onPick` IS THE CALLER'S DECISION TO CONFIRM OR NOT. Changing a trigger can invalidate the |
| * configuration under it, so the builder asks first when there IS something configured — that |
| * rule lives with the automation, not in a picker, and duplicating it here would be a second |
| * copy free to disagree with the dialog's own wording. |
| */ |
| export default function TriggerPicker({ |
| variant, |
| options, |
| value, |
| disabled, |
| onPick, |
| onDismiss, |
| }: { |
| variant: "menu" | "field"; |
| options: TriggerOption[]; |
| /** The stored trigger key. `""` = nothing picked yet. */ |
| value: string; |
| disabled: boolean; |
| onPick: (key: string) => void; |
| /** `menu` only: the caller closes itself. Ignored by `field`, which owns its own popover. */ |
| onDismiss?: () => void; |
| }) { |
| const [open, setOpen] = useState(false); |
| const wrap = useRef<HTMLDivElement | null>(null); |
| const picked = selectedTrigger(options, value); |
|
|
| // ⛔ ESCAPE AND AN OUTSIDE CLICK BOTH CLOSE IT, or the popover is a trap — the shell has |
| // already paid for a scrim with no Escape (wave 24: every subsequent click swallowed by an |
| // invisible full-screen div). Bound only while open, so the app carries no idle listeners. |
| useEffect(() => { |
| if (variant !== "field" || !open) return; |
| const onKey = (e: KeyboardEvent) => { |
| if (e.key === "Escape") setOpen(false); |
| }; |
| const onDown = (e: MouseEvent) => { |
| if (!wrap.current?.contains(e.target as Node)) setOpen(false); |
| }; |
| window.addEventListener("keydown", onKey); |
| window.addEventListener("mousedown", onDown); |
| return () => { |
| window.removeEventListener("keydown", onKey); |
| window.removeEventListener("mousedown", onDown); |
| }; |
| }, [variant, open]); |
|
|
| if (variant === "menu") { |
| return ( |
| <div className="autox-menu is-trigger" role="listbox" aria-label="Trigger"> |
| <TriggerList |
| options={options} |
| value={value} |
| disabled={disabled} |
| onPick={(k) => { |
| onPick(k); |
| onDismiss?.(); |
| }} |
| /> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="autox-trigfield" ref={wrap}> |
| <button |
| type="button" |
| className="auto-input autox-trigbtn" |
| aria-haspopup="listbox" |
| aria-expanded={open} |
| disabled={disabled} |
| onClick={() => setOpen((v) => !v)} |
| > |
| {/* ⛔ THE STORED KEY IS ALWAYS SHOWN, even when the server did not offer it — |
| `selectedTrigger` synthesises a "(not offered here)" row rather than returning null. |
| The `<select>` this replaces carried the same guard, and without it a trigger pointed |
| at something withheld from this session would LOOK unconfigured and the next patch |
| would write the blank over it. */} |
| <span |
| className={"autox-card-mark" + (hasBrandKind(picked?.key || "") ? " is-brand" : "")} |
| > |
| <TriggerMark kind={picked?.key || ""} /> |
| </span> |
| <span className="autox-trigbtn-label"> |
| {picked ? picked.label : "Select a trigger…"} |
| </span> |
| <span className="autox-trigbtn-caret" aria-hidden="true"> |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" |
| strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> |
| <path d="m4.4 6.4 3.6 3.6 3.6-3.6" /> |
| </svg> |
| </span> |
| </button> |
| {open ? ( |
| <div className="autox-menu is-trigfly" role="listbox" aria-label="Trigger type"> |
| <TriggerList |
| options={options} |
| value={value} |
| disabled={disabled} |
| onPick={(k) => { |
| setOpen(false); |
| onPick(k); |
| }} |
| /> |
| </div> |
| ) : null} |
| </div> |
| ); |
| } |
|
|