// --------------------------------------------------------------------------- // automation/AutomationFind.tsx — the discovery filter, as TOGGLES (owner item 6, // contract C4). // // WHAT CHANGED AND WHY. It was a condition BUILDER: an empty row, a field // dropdown listing 21 names, "Add a condition". That shape asks the user to // remember what is searchable before they can look, and it hid the only fact // that decides whether a search returns anything at all — which fields actually // carry values. The corpus is 620 million profiles and every run is billed, so // "I did not know I could filter on that" and "I filtered on a field that is // empty on every row" are both expensive mistakes made in silence. // // So every searchable field is ON SCREEN, off by default, and turning one on is // what creates its condition. The list leads with the fields MEASURED to carry // values; the rest are grouped under what is known about them, and neither group // is hidden — R3 already decided that "every filter" means the 21 we can stand // behind, so all 21 are visible. // // ⛔ THE VOCABULARY IS THE SERVER'S, ALL OF IT. Field names, operators, which // operators take no value, the record ceiling and (once C4 lands it) which // fields are populated and which narrow — every one of those rides on // `GET /automations`. This file holds no list of its own. The reason is the one // `automationApi.ts:11-15` gives for cron presets: the thing that ACCEPTS a // filter is Python, and a client copy of what it accepts is a copy that can // offer a search the server refuses. // // ⛔ AND IT DOES NOT RE-IMPLEMENT THE GUARD. `guard` carries the server's // numbers, so the rows can say which fields narrow — but the refusal is the // server's to make and this surface prints it VERBATIM when it comes back // (`DiscoverGuard`'s note). A client that predicts the refusal is a second copy // of the rule, free to disagree with the first, and when they disagree the user // gets a Save button that is disabled for a reason nobody can see. // --------------------------------------------------------------------------- import { useEffect, useState } from "react"; // ⭐ WAVE 27 item 29 — the overlay layer the grid already owns, consumed rather than // re-implemented: it is a body portal (so it escapes `.auto-panel`'s 380px), and it brings // the dismiss/focus/placement behaviour every other popover on this product already has. import { AnchoredOverlay } from "../customer-grid/OverlaySurface"; import type { AnchorRect } from "../customer-grid/OverlaySurface"; import type { DiscoverEstimate, DiscoverFieldMeta, DiscoverOperator, DiscoverVocab, Predicate, } from "./automationApi"; import { discoverCategories } from "./automationApi"; interface Props { /** The server-declared discovery vocabulary (absent until the list loads). */ discover?: DiscoverVocab; recordsLimit: number; onRecordsLimit: (n: number) => void; joinOp: string; onJoinOp: (v: string) => void; preds: Predicate[]; onPreds: (next: Predicate[]) => void; estimate: DiscoverEstimate | null; onEstimate: () => void; } /** * A FILTER FIELD'S ICON (owner item 5). Drawn, `currentColor`, never an emoji — and in the * existing `TriggerMark`/`ActionMark` language rather than a new one: same 16 viewBox, same 15px * box, same 1.4 stroke, so a row here and a card in the builder read as one product. * * ⚠ SHARED SHAPES WHERE THE FIELDS SHARE A MEANING, distinct everywhere else. `TriggerMark`'s * note settled the principle — a column of identical glyphs is decoration, the eye learns nothing * — but its converse matters just as much here: `bio_hashtags` and `post_hashtags` ARE both * hashtags, and drawing two different marks for them would invent a distinction the vendor does * not make. Twenty-one contrived glyphs would be twenty-one things to misread. * * ⛔ THE FALLBACK IS NOT A MEMBER OF THE SET, which is the `ActionMark` scar exactly: its default * used to BE the pencil, a real member, so every unmatched kind silently borrowed "edit" and a * fallback could not be told from a match. The vocabulary is the SERVER's — it can grow a field * tomorrow — so an unmatched name draws a neutral mark that is deliberately meaningless. */ function FilterMark({ name }: { name: string }) { 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, }; // Audience size — two figures, one behind the other. if (name === "followers") return ( ); // Who this account follows — a figure with an outbound arrow. if (name === "following") return ( ); if (name === "posts_count") return ( ); // A story highlight — the ring Instagram draws around one. if (name === "highlights_count") return ( ); if (name === "avg_engagement") return ( ); if (name === "biography") return ( ); if (name === "category_name" || name === "business_category_name") return ( ); if (name === "is_business_account" || name === "is_professional_account") return ( ); if (name === "is_verified") return ( ); // The handle, and the display name it is written under. if (name === "account" || name === "profile_name") return ( ); if (name === "full_name") return ( ); if (name === "external_url" || name === "profile_url") return ( ); if (name === "bio_hashtags" || name === "post_hashtags") return ( ); if (name === "related_accounts") return ( ); // The vendor's own record keys — the thing you hold to open exactly one row. if (name === "id" || name === "fbid") return ( ); return ( ); } /** One field's line in the list, with everything known about it. */ interface FieldRow { name: string; /** What the row is CALLED. Falls back to the raw name only if the server sent none. */ label: string; hint: string; kind: string; operators: DiscoverOperator[]; options: { value: string; label: string }[]; /** Every stored condition naming this field, with its index in the saved array. */ at: number[]; /** MEASURED to carry values. `null` = the server has not said (C4 not shipped yet). */ populated: boolean | null; /** A condition here cuts the corpus down. `null` = not said. */ narrowing: boolean | null; } /** A stored value → what the text box shows. A list is the "any of" shape. */ function valueText(v: Predicate["value"]): string { if (Array.isArray(v)) return v.join(", "); return v === undefined || v === null ? "" : String(v); } /** * What the user typed → what is stored. Comma-separated becomes a LIST for the operators that * accept one, and stays a plain string for the ones that do not. * * ⚠ A single value stays a SCALAR rather than a one-element list — the server sends scalars * flat and lists as a nested OR group, and a one-element group is a shape nothing has been * billed against. */ function parseValue(text: string, multi: boolean): Predicate["value"] { if (!multi) return text; const parts = text.split(",").map((s) => s.trim()).filter(Boolean); if (parts.length > 1) return parts; // Keep the RAW text while it is still being typed — trimming here would fight the cursor on // every keystroke, and the server trims anyway. return text; } /** * ⭐ WAVE 26 · DEBT D-69 — A THIRD VALUE COULD NOT BE TYPED, and the cause was one round trip * through this pair of functions. * * `parseValue` returns an ARRAY at 2+ values; `valueText` then renders that array as * `join(", ")` — which has NO TRAILING COMMA. So on a controlled input the separator was * normalised away between keystrokes: typing `,` onto `skincare, beauty` re-rendered as * `skincare, beauty`, and the next character landed as `skincare, beautym`. Two keywords * silently became one that matches nothing. ⚠ It hit the FIRST surface a new user meets (wave 24 * made this panel the only door) and it hit precisely the 3+ keyword list the feature was built * for — the owner's *"if i want to include many keywords like floral, flower, beauty"*. * * ⛔ THE FIX IS THE ONE `parseValue`'s OWN COMMENT ALREADY DESCRIBES FOR THE 1-VALUE CASE: * keep the raw text while it is being typed. That comment was right and its scope was too narrow * — it protected the cursor at one value and handed the 2+ case to `join`. So the raw text is * held for whichever input is FOCUSED, and the stored value is still parsed on every keystroke * (so nothing depends on blur to save) and re-parsed on blur (so the display settles). * * ⚠ KEYED BY THE ROW'S REACT KEY, not by index alone: two conditions on the same field are * ordinary here ("Another condition on Bio"), and an index-only key would leak one row's draft * into its sibling on a removal. */ interface Draft { key: string; text: string; } /** * The comparison a freshly toggled field starts with — ASKED FOR, never chosen here. * * ⛔ THE THIRD COSTUME OF ONE SCAR, and the reason this function no longer has an opinion. * Wave 21 made a new condition NULLARY (`is_not_null`) so it would be saveable without a * value, and called that "narrowing-in-the-right-direction". Wave 22 then wrote the narrowing * law server-side and `is_not_null` is not in it. From that moment, toggling ANY field on and * pressing Save returned "add at least one CONTENT condition…" — the error told the user to do * the exact thing they had just done. Wave 24 deleted the wizard and made this panel the only * door, so it stopped being a corner case and became the first thing a new user hits. * * The fix is not a better guess. It is that the module owning the guard also names the default * (`automation_engine.default_operator`, asserted against `predicate_narrows` in * `verify_automation.py`), and this file looks it up per FIELD. An absent one falls back to the * server's first operator — never to a nullary, which is the thing that could not narrow. */ function firstOperator(v: DiscoverVocab | undefined, name: string): string { const meta = (v?.filterMeta || []).find((m) => m.name === name); return meta?.defaultOperator || (v?.operators || [])[0] || ""; } /** * The rows, ordered: measured-populated first, then the rest. * * TWO SOURCES, AND THE FALLBACK IS NARROWER THAN THE REAL THING. With C4's * `filterMeta` the answer is per field and complete. Without it, `lead` names * the three fields seen carrying values and says NOTHING about the other * eighteen — so those come back `null`, not `false`, and the UI prints no claim * about them rather than an invented one. */ function buildRows(preds: Predicate[], v?: DiscoverVocab): FieldRow[] { const meta = new Map( (v?.filterMeta || []).map((m) => [m.name, m]) ); const lead = new Set(v?.lead || []); const byName = new Map(); preds.forEach((p, i) => { const list = byName.get(p.name); if (list) list.push(i); else byName.set(p.name, [i]); }); const rows = (v?.fields || []).map((name) => { const m = meta.get(name); return { name, label: m?.label || name, hint: m?.hint || "", kind: m?.kind || "text", // ⛔ NO FALLBACK TO THE GLOBAL OPERATOR LIST. An older server that sends no per-field list // gets a row with no comparisons rather than all fourteen on every field — visibly // unfinished beats quietly offering `at least` on a yes/no column. operators: m?.operators || [], options: m?.options || [], at: byName.get(name) || [], populated: m ? !!m.populated : lead.has(name) ? true : null, narrowing: m ? !!m.narrowing : null, }; }); // Stable partition, never a sort: the server's order inside each half is the // order the engine lists them in, and re-alphabetising it would be this file // having an opinion about a server list. return [...rows.filter((r) => r.populated === true), ...rows.filter((r) => r.populated !== true)]; } export default function AutomationFind({ discover, recordsLimit, onRecordsLimit, joinOp, onJoinOp, preds, onPreds, estimate, onEstimate, }: Props) { const rows = buildRows(preds, discover); const known = new Set(discover?.fields || []); // The GLOBAL operator list is gone from this component: comparisons are per field now // (`row.operators`). `nullary` survives for STRAYS only — a stored condition on a field the // vendor dropped has no per-field row to read from, and it still has to render to be removable. const nullary = new Set(discover?.nullaryOperators || []); const withValues = rows.filter((r) => r.populated === true); const rest = rows.filter((r) => r.populated !== true); const knownMeta = !!(discover?.filterMeta || []).length; // ⚠ THE STORED FILTER CAN NAME A FIELD THE LIST NO LONGER HAS — a vocabulary // that moved, an automation copied from elsewhere. It must stay VISIBLE and // REMOVABLE: a condition the user cannot see is one they cannot delete, and // every Save keeps sending it. Same defect as a ` setAt(i, { operator: e.target.value })} > {/* The stored value is ALWAYS an option. A select whose value matches no option renders the FIRST one, and the next Save writes a comparison nobody chose — this codebase has paid for that twice. */} {p.operator && !ops.some((o) => o.value === p.operator) ? ( ) : null} {ops.map((o) => ( ))} {isNullary ? null : options.length ? ( // A fixed vocabulary gets a dropdown. Owner item 2: a yes/no column was a box you // typed `true` into, beside a comparison list that offered "at least". ) : ( setDraft({ key: rowKey, text: valueText(p.value) })} onChange={(e) => { setDraft({ key: rowKey, text: e.target.value }); // The STORE still updates on every keystroke. Parsing only on blur would mean a // Save pressed straight from the field wrote the previous value — trading a typing // bug for a data-loss one. setAt(i, { value: parseValue(e.target.value, isMulti) }); }} onBlur={(e) => { setDraft(null); setAt(i, { value: parseValue(e.target.value, isMulti) }); }} /> )} {/* ⭐ WAVE 27 · OWNER ITEM 29 — A WIDE EDITOR FOR THE VALUES THAT DO NOT FIT. ⛔ THE COMPLAINT IS GEOMETRY, and the geometry is a CHAIN nothing here can widen: `.auto-panel` is 380px, `.autob-cond .auto-input` divides what is left across three controls, and `.is-small` trims it again — so a bio phrase list ("floral, flower, wedding florist, event styling") is typed six characters at a time through a box that scrolls sideways. The panel cannot grow; the EDITOR can leave it, and an overlay is drawn in a body portal, so it is not bound by that chain at all. ⚠ ALWAYS OFFERED, never "appears once the text is long": a control that materialises at some threshold is one people do not know exists at the moment they need it. ⚠ NOT on a ` { if (e.target.value) appendValue(e.target.value); }} > {cats.map((c) => ( // The COUNT is the honest half (D-59): a value seen once and one seen forty times // are different bets, and an alphabetical list of bare strings hides that. ))} ) : null} {isMulti && Array.isArray(p.value) && p.value.length > 1 ? ( /* * ⭐ ITEM 16 / D-71 — HOW MANY VALUES THIS CONDITION CARRIES, and against what ceiling. * * The COUNT is ours and is always shown. The CEILING is the server's and is shown only * when the server sends one (`guard.maxValues` — an open `ASK ->A:`; `guard` ships * `{minNarrowing, maxRecords}` today). * ⛔ NO CLIENT-SIDE `4`. D-71's exit says "how many of D-68's 4 value slots" — but A * shipped `depth_refusal()`, which computes the depth of the shape actually emitted * rather than budgeting fixed slots, so the number 4 describes a model that no longer * exists. Printing it would be a client copy of a server rule that has already changed * once, which is the specific failure this file's header refuses to commit * ([[measure-the-real-call]]: a correct-looking answer about the wrong subject). */ any of {p.value.length} {discover?.guard?.maxValues ? ` of ${discover.guard.maxValues}` : ""} ) : null} ); }; const fieldRow = (row: FieldRow) => { const on = row.at.length > 0; return (
{/* ⭐ THE HUMAN LABEL (owner item 1: "wtf is id/fbid"). This used to render the vendor's own column name verbatim, and the comment defending it was RIGHT about the reason — a refusal names the field, and a label that reads differently makes the error unmatchable. So the label did not move alone: `field_label()` is what the SERVER's refusals use too, which is the only version of this change that does not trade one confusion for another. */} {row.label} {/* ⚠ ONE CHIP, AND IT MARKS THE MINORITY. The first version chipped every non-narrowing field "broad" AND every unpopulated one "no values seen" — which put two chips on 18 of the 21 rows, and one of them repeated the group heading the row was already sitting under. A mark that is on almost every row stops being a mark. Read off the screenshot; no assertion could have said it. So: the group label carries POPULATED (it is what the groups ARE), and the chip carries NARROWING, inverted to mark the five that DO narrow. That is the actionable half — the server refuses a filter with no narrowing condition, so "these are the ones that satisfy it" is the sentence a user needs, and "this one does not" is not. */} {row.narrowing === true ? ( narrows ) : null}
{on ? (
{row.at.map((i) => condition(i, row.label, row))} {row.hint ?

{row.hint}

: null}
) : null}
); }; return ( <>
onRecordsLimit(Number(e.target.value) || 1)} />
{/* Owner item 5: "Stop at the 'A search must be at most 500 profiles a run'". The two sentences that followed explained the vendor's billing model to somebody who wants a list of florists. */}

At most {maxRecords.toLocaleString()} profiles a run.

Conditions

{!rows.length ? (

The searchable fields have not loaded yet.

) : null} {withValues.length ? (

Usually filled in

{withValues.map(fieldRow)}
) : null} {rest.length ? (

{knownMeta ? "Often blank" : "Not measured"}

{rest.map(fieldRow)}
) : null} {strays.length ? (

Not in the searchable list

{strays.map(({ p, i }) => (
{/* A stray gets a mark too — "every filter row" includes the ones the vendor no longer offers, and a row missing the icon its neighbours have would read as a different KIND of thing rather than as a field that fell out of the list. It draws the neutral fallback by construction: a stray is, by definition, not in the vocabulary this component has arms for. */} {p.name} not searchable
{condition(i, p.name)}
))}
) : null} {/* The consequence kept, the mechanism dropped: a condition on an often-blank field returning nothing is a thing that will happen to somebody and confuse them. Why the search is slow when broad is our problem, not theirs. */}

Conditions on often-blank fields can come back with nothing.

What it costs

{estimate ? (

About ${estimate.usd} for {estimate.records} profiles, at $ {estimate.unitUsd} each.{" "} {/* THE CAVEAT IS NOT OPTIONAL. The vendor never quotes a price before a run and this deployment cannot read its own balance, so presenting this as a billed figure would be inventing a measurement. */} This is an estimate — {estimate.note}.

) : null} {/* ⭐ WAVE 27 · OWNER ITEM 29 — THE BIG EDITOR, rendered ONCE for the whole panel. ⛔ Not one overlay per condition row: `AnchoredOverlay` mounts a body portal and installs document-level dismiss/focus handlers, so N of them would be N listener stacks fighting over one Escape key. One overlay, told which row it is editing. ⚠ `role="dialog"`, not "menu": it holds a text field, and the menu layer's arrow-key handling would take the cursor keys away from the text being typed. */} {big ? ( setBig(null)} dataKind="find-value-editor" >