// ---------------------------------------------------------------------------
// 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 `