loopable / web /src /automation /AutomationFind.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
41 kB
// ---------------------------------------------------------------------------
// 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 (
<svg {...common}>
<circle cx="6.2" cy="5.6" r="2.4" />
<path d="M2.2 13c.5-2 2.1-3.2 4-3.2s3.5 1.2 4 3.2" />
<path d="M10.6 3.6a2.4 2.4 0 0 1 0 4M11.4 9.9c1.4.4 2.4 1.5 2.8 3.1" />
</svg>
);
// Who this account follows — a figure with an outbound arrow.
if (name === "following")
return (
<svg {...common}>
<circle cx="6" cy="5.6" r="2.4" />
<path d="M1.8 13c.5-2 2.1-3.2 4.2-3.2 .6 0 1.2.1 1.7.3" />
<path d="M9.8 10.6h4.2M12.2 8.8l1.8 1.8-1.8 1.8" />
</svg>
);
if (name === "posts_count")
return (
<svg {...common}>
<rect x="2.6" y="2.6" width="4.6" height="4.6" rx="1" />
<rect x="8.8" y="2.6" width="4.6" height="4.6" rx="1" />
<rect x="2.6" y="8.8" width="4.6" height="4.6" rx="1" />
<rect x="8.8" y="8.8" width="4.6" height="4.6" rx="1" />
</svg>
);
// A story highlight — the ring Instagram draws around one.
if (name === "highlights_count")
return (
<svg {...common}>
<circle cx="8" cy="8" r="5.6" strokeDasharray="2.6 1.8" />
<circle cx="8" cy="8" r="2.2" />
</svg>
);
if (name === "avg_engagement")
return (
<svg {...common}>
<path d="M8 13.2 3.4 8.8a2.9 2.9 0 0 1 4.6-3.4 2.9 2.9 0 0 1 4.6 3.4z" />
</svg>
);
if (name === "biography")
return (
<svg {...common}>
<rect x="2.4" y="2.8" width="11.2" height="10.4" rx="1.6" />
<path d="M4.8 6h6.4M4.8 8.4h6.4M4.8 10.8h3.6" />
</svg>
);
if (name === "category_name" || name === "business_category_name")
return (
<svg {...common}>
<path d="M8.4 2.6H13v4.6l-6.2 6.2-4.6-4.6z" />
<circle cx="10.8" cy="5.2" r="0.9" />
</svg>
);
if (name === "is_business_account" || name === "is_professional_account")
return (
<svg {...common}>
<rect x="2.2" y="5" width="11.6" height="8.2" rx="1.6" />
<path d="M6 5V3.6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V5" />
</svg>
);
if (name === "is_verified")
return (
<svg {...common}>
<path d="M8 2.2l1.7 1.2 2-.2.6 2 1.7 1.1-.9 1.9.9 1.9-1.7 1.1-.6 2-2-.2L8 14.2l-1.7-1.2-2 .2-.6-2L2 10.1l.9-1.9L2 6.3l1.7-1.1.6-2 2 .2z" />
<path d="m5.8 8.1 1.5 1.5 3-3.1" />
</svg>
);
// The handle, and the display name it is written under.
if (name === "account" || name === "profile_name")
return (
<svg {...common}>
<circle cx="8" cy="8" r="2.4" />
<path d="M10.4 5.6v3.2a1.9 1.9 0 0 0 3.2 1.3A5.8 5.8 0 1 0 11 13.1" />
</svg>
);
if (name === "full_name")
return (
<svg {...common}>
<circle cx="8" cy="5.6" r="2.6" />
<path d="M3 13.4c.7-2.3 2.6-3.6 5-3.6s4.3 1.3 5 3.6" />
</svg>
);
if (name === "external_url" || name === "profile_url")
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 (name === "bio_hashtags" || name === "post_hashtags")
return (
<svg {...common}>
<path d="M6.2 2.8 4.8 13.2M11.2 2.8 9.8 13.2M2.8 6h10.4M2.2 10h10.4" />
</svg>
);
if (name === "related_accounts")
return (
<svg {...common}>
<circle cx="8" cy="3.6" r="1.8" />
<circle cx="3.6" cy="12" r="1.8" />
<circle cx="12.4" cy="12" r="1.8" />
<path d="M6.8 5.2 4.6 10.4M9.2 5.2l2.2 5.2M5.4 12h5.2" />
</svg>
);
// The vendor's own record keys — the thing you hold to open exactly one row.
if (name === "id" || name === "fbid")
return (
<svg {...common}>
<circle cx="5.4" cy="10.6" r="2.6" />
<path d="M7.2 8.8 13 3M10.6 5.4l1.6 1.6M9.2 6.8l1.6 1.6" />
</svg>
);
return (
<svg {...common}>
<rect x="3.2" y="3.2" width="9.6" height="9.6" rx="2.2" />
<circle cx="8" cy="8" r="1.4" />
</svg>
);
}
/** 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<string, DiscoverFieldMeta>(
(v?.filterMeta || []).map((m) => [m.name, m])
);
const lead = new Set(v?.lead || []);
const byName = new Map<string, number[]>();
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 `<select>` whose value matches
// no option, one level up.
const strays = preds
.map((p, i) => ({ p, i }))
.filter(({ p }) => !known.has(p.name));
const maxRecords = discover?.guard?.maxRecords || discover?.maxRecords || 500;
/** D-69 — the raw text of the input a person is typing in right now (see `Draft`). */
const [draft, setDraft] = useState<Draft | null>(null);
/**
* ⭐ WAVE 27 · OWNER ITEM 29 — which condition's value is open in the BIG editor, if any.
*
* ⛔ THE TEXT IS HELD HERE, NOT WRITTEN THROUGH ON EVERY KEYSTROKE, and that is the one place
* this differs from the rail input beside it. The rail input writes per keystroke on purpose
* (a Save pressed straight from the field must not store the previous value — D-69's note).
* A modal has its own Save, so per-keystroke writes would buy nothing and would make Cancel
* a lie: the value would already be in the config by the time it was pressed.
* ⚠ The ANCHOR is captured at click time and stored, because the button it came from is
* inside a panel that scrolls — re-reading it later would place the overlay against a rect
* that has moved.
*/
const [big, setBig] = useState<{
key: string;
index: number;
label: string;
multi: boolean;
text: string;
anchor: AnchorRect;
} | null>(null);
/**
* ⭐ ITEM 16 / D-71 — THE OBSERVED CATEGORY VALUES, fetched WHEN THIS PANEL OPENS.
*
* ⛔ ON MOUNT, ONCE, AND NEVER ON THE POLLED PAYLOAD. This component is rendered only while the
* Find panel is open, so mounting IS the panel opening — and the server moved these off
* `GET /automations` after measuring that deriving them reads two user tables plus the platform
* master, i.e. a network round-trip per open tab every 2.5 s. Putting the request here is the
* client half of that same decision, not an optimisation.
*
* ⚠ AN EMPTY LIST IS AN ANSWER AND IS RENDERED AS ONE. It means this deployment has not seen a
* category value yet — which is TRUE for a fresh tenant — and the field keeps its free-text
* input either way. It must never read as "loading forever", and it must never be filled in
* with a guess: D-59 is explicit that an invented taxonomy is worse than no dropdown.
*/
const [cats, setCats] = useState<{ value: string; count: number }[]>([]);
useEffect(() => {
const ac = new AbortController();
discoverCategories(ac.signal)
.then((r) => setCats(Array.isArray(r?.options) ? r.options : []))
// Fail-quiet, on purpose: the picker is an ASSIST over a control that works without it.
// An error banner here would report a broken panel when the only thing missing is a
// convenience — and the free-text input beside it is unaffected.
.catch(() => setCats([]));
return () => ac.abort();
}, []);
const setAt = (i: number, patch: Partial<Predicate>) =>
onPreds(preds.map((p, j) => (j === i ? { ...p, ...patch } : p)));
const removeAt = (i: number) => onPreds(preds.filter((_p, j) => j !== i));
const toggleField = (row: FieldRow) => {
if (row.at.length) {
onPreds(preds.filter((p) => p.name !== row.name));
return;
}
onPreds([...preds, { name: row.name, operator: firstOperator(discover, row.name) }]);
};
/** One condition line — the comparison, its value, and a way out. */
const condition = (i: number, label: string, row?: FieldRow) => {
const p = preds[i];
if (!p) return null;
// The field's OWN comparisons. `row` is absent only for a stray (a stored condition on a
// field the vendor no longer offers), which keeps the raw token so it stays removable.
const ops = row?.operators || [];
const cur = ops.find((o) => o.value === p.operator);
const isNullary = cur ? cur.nullary : nullary.has(p.operator);
const isMulti = !!cur?.multi;
const options = row?.options || [];
const rowKey = `${p.name}-${i}`;
// D-69: the focused input shows what was TYPED; every other one shows the stored value.
const shown = draft && draft.key === rowKey ? draft.text : valueText(p.value);
/*
* ⭐ ITEM 16 / D-71 — WHICH FIELDS GET THE OBSERVED-VALUES PICKER, DERIVED OFF THE WIRE.
*
* ⛔ NOT A CLIENT-SIDE LIST OF FIELD NAMES. Naming the vendor's two category columns here
* would be the D-55 class verbatim — the wave-9 silent-drop shape, a client copy of a server
* vocabulary that is free to drift the day the engine adds a third one. (Their literal keys
* are deliberately not written anywhere in this file: the gate asserts the tokens are ABSENT,
* and an absence check cannot tell a hard-coded list from the comment forbidding one.)
* The server already publishes the answer twice over: `kind: "choice"` is its own declaration
* (`BD_FIELD_KINDS`), and `filter_meta` deliberately ships `options: []` for those fields
* because their vocabulary is OBSERVED rather than declared and arrives on its own route.
* So the rule is exactly that: a choice-kind field with no declared options is one whose
* values we can only have learned by seeing them. A new choice field inherits this for free.
*/
const wantsObserved = row?.kind === "choice" && !options.length;
/** D-71 — APPEND, never replace: the picker has to compose with the comma list. */
const appendValue = (v: string) => {
const has = shown
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
// Picking the same value twice is a no-op rather than a duplicate: the server expands a
// multi-value condition into an OR, and `beauty OR beauty` costs a slot to say nothing.
if (has.includes(v.trim().toLowerCase())) return;
const next = shown.trim() ? `${shown.replace(/[\s,]+$/, "")}, ${v}` : v;
setDraft(null);
setAt(i, { value: parseValue(next, isMulti) });
};
return (
<div className="autob-cond" key={rowKey}>
<select
className="auto-input is-small"
aria-label={`Comparison for ${label}`}
value={p.operator}
onChange={(e) => 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) ? (
<option value={p.operator}>{p.operator}</option>
) : null}
{ops.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{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".
<select
className="auto-input is-small"
aria-label={`Value for ${label}`}
value={valueText(p.value)}
onChange={(e) => setAt(i, { value: e.target.value })}
>
<option value="">Choose…</option>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
className="auto-input is-small"
type={row?.kind === "number" ? "number" : "text"}
aria-label={`Value for ${label}`}
// ⭐ OWNER ITEM 3. Several comma-separated values become an "any of" condition, which
// the server sends as its own OR groupso it does NOT drag Followers into a union
// the way the Match dropdown would.
placeholder={isMulti ? "floral, flower, beauty" : ""}
// D-69: `shown` is the RAW text while this input has focus, so a trailing comma
// survives long enough to type the next value after it.
value={shown}
onFocus={() => 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 `<select>` value (a fixed vocabulary has nothing to expand) and not on a
nullary comparison (which has no value at all). */}
{!isNullary && !options.length ? (
<button
type="button"
className="auto-input is-small autoc-expand"
aria-label={`Open a bigger editor for ${label}`}
title="Edit in a bigger box"
onClick={(e) => {
// ⚠ READ SYNCHRONOUSLY from the event. `currentTarget` is null by the time React
// re-invokes a state updater, and a null rect here paints the overlay at 0,0 —
// measured, in this repo, on a different control ([[react-event-currenttarget-updater]]).
const r = e.currentTarget.getBoundingClientRect();
setBig({
key: rowKey,
index: i,
label,
multi: isMulti,
text: valueText(p.value),
anchor: { left: r.left, top: r.top, right: r.right, bottom: r.bottom,
width: r.width, height: r.height },
});
}}
>
{/* Drawn, never a glyph: the design constitution's "no emojis in the UI" covers
the arrow characters too, and every other mark in this product is a path in the
same 16x16 stroke vocabulary. Two corners pulling apart = "make this bigger". */}
<svg viewBox="0 0 16 16" aria-hidden className="autoc-expand-icon">
<path d="M9.5 2.5h4v4M13.5 2.5 9.6 6.4" />
<path d="M6.5 13.5h-4v-4M2.5 13.5l3.9-3.9" />
</svg>
</button>
) : null}
{/* ⭐ ITEM 16 / D-71 — THE APPEND-PICKER, BESIDE the input and never instead of it.
Free text stays the primary control: these are the values this deployment has SEEN,
not the values that exist, so the picker is a shortcut over a superset it cannot
enumerate. Appending in code also sidesteps D-69 entirely — a picked value never has
to survive a round trip through the text box. */}
{wantsObserved && !isNullary ? (
<select
className="auto-input is-small autoc-catpick"
aria-label={`Add a value seen before, to ${label}`}
title={
cats.length
? "Values this workspace has actually seen, most-seen first. Adds to the list."
: "No category values have been seen in your data yettype one."
}
value=""
disabled={!cats.length}
onChange={(e) => {
if (e.target.value) appendValue(e.target.value);
}}
>
<option value="">{cats.length ? "Seen before…" : "None seen yet"}</option>
{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.
<option key={c.value} value={c.value}>
{c.value} ({c.count.toLocaleString()})
</option>
))}
</select>
) : 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).
*/
<span className="autob-cond-any" title="Any one of these is a match">
any of {p.value.length}
{discover?.guard?.maxValues ? ` of ${discover.guard.maxValues}` : ""}
</span>
) : null}
<button
type="button"
className="autob-cond-x"
aria-label={`Remove this condition on ${label}`}
title="Remove this condition"
onClick={() => removeAt(i)}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
);
};
const fieldRow = (row: FieldRow) => {
const on = row.at.length > 0;
return (
<div className={"autob-tgl" + (on ? " is-on" : "")} key={row.name}>
<div className="autob-tgl-head">
<button
type="button"
className={"auto-step-switch autob-tgl-switch" + (on ? " is-on" : "")}
aria-pressed={on}
aria-label={`${on ? "Stop searching" : "Search"} on ${row.label}`}
title={`${on ? "Stop searching" : "Search"} on ${row.label}`}
onClick={() => toggleField(row)}
>
<span className="auto-step-switch-knob" />
</button>
<span className="autob-tgl-mark">
<FilterMark name={row.name} />
</span>
{/* ⭐ 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. */}
<span className="autob-tgl-name" title={row.hint || undefined}>
{row.label}
</span>
{/*
⚠ 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 ? (
<span
className="autob-tgl-flag is-narrow"
title="A search needs at least one condition like this."
>
narrows
</span>
) : null}
</div>
{on ? (
<div className="autob-tgl-body">
{row.at.map((i) => condition(i, row.label, row))}
{row.hint ? <p className="autob-tgl-hint">{row.hint}</p> : null}
<button
type="button"
className="autob-cond-add"
onClick={() =>
onPreds([...preds, { name: row.name, operator: firstOperator(discover, row.name) }])
}
>
Another condition on {row.label}
</button>
</div>
) : null}
</div>
);
};
return (
<>
<div className="auto-field">
<label htmlFor="auto-records">How many profiles to fetch</label>
<input
id="auto-records"
className="auto-input"
type="number"
min={1}
max={maxRecords}
value={recordsLimit}
onChange={(e) => onRecordsLimit(Number(e.target.value) || 1)}
/>
</div>
{/* 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. */}
<p className="auto-hint">At most {maxRecords.toLocaleString()} profiles a run.</p>
<h3>Conditions</h3>
<div className="auto-field autob-match">
<label htmlFor="auto-joinop">Match</label>
<select
id="auto-joinop"
className="auto-input"
value={joinOp}
onChange={(e) => onJoinOp(e.target.value)}
>
<option value="and">All of them</option>
<option value="or">Any of them</option>
</select>
</div>
{!rows.length ? (
<p className="auto-note">
The searchable fields have not loaded yet.
</p>
) : null}
{withValues.length ? (
<div className="autob-tglgroup">
<p
className="autob-tglgroup-label"
title="Most accounts have this filled in."
>
Usually filled in
</p>
{withValues.map(fieldRow)}
</div>
) : null}
{rest.length ? (
<div className="autob-tglgroup">
<p
className="autob-tglgroup-label"
title={
knownMeta
? "Most accounts leave these blank, so a condition here can return nothing."
: "We have not measured how often these are filled in."
}
>
{knownMeta ? "Often blank" : "Not measured"}
</p>
{rest.map(fieldRow)}
</div>
) : null}
{strays.length ? (
<div className="autob-tglgroup">
<p className="autob-tglgroup-label">Not in the searchable list</p>
{strays.map(({ p, i }) => (
<div className="autob-tgl is-on is-stray" key={`stray-${i}`}>
<div className="autob-tgl-head">
{/* 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. */}
<span className="autob-tgl-mark">
<FilterMark name={p.name} />
</span>
<span className="autob-tgl-name">{p.name}</span>
<span className="autob-tgl-flag is-stray" title="The server will refuse this condition by name.">
not searchable
</span>
</div>
<div className="autob-tgl-body">{condition(i, p.name)}</div>
</div>
))}
</div>
) : 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. */}
<p className="auto-hint">
Conditions on often-blank fields can come back with nothing.
</p>
<h3>What it costs</h3>
<div className="auto-head-actions">
<button type="button" className="auto-btn" onClick={onEstimate}>
Estimate this search
</button>
</div>
{estimate ? (
<p className="auto-hint">
About <strong>${estimate.usd}</strong> 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.
*/}
<strong>This is an estimate</strong> — {estimate.note}.
</p>
) : 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 ? (
<AnchoredOverlay
anchor={big.anchor}
className="autoc-bigedit"
placement="right-start"
role="dialog"
ariaLabel={`Value for ${big.label}`}
onDismiss={() => setBig(null)}
dataKind="find-value-editor"
>
<label htmlFor="autoc-bigedit-text">{big.label}</label>
<textarea
id="autoc-bigedit-text"
className="autoc-bigedit-text"
data-overlay-autofocus
autoFocus
rows={7}
spellCheck={false}
value={big.text}
placeholder={big.multi ? "floral, flower, wedding florist, event styling" : ""}
onChange={(e) => setBig((cur) => (cur ? { ...cur, text: e.target.value } : cur))}
onKeyDown={(e) => {
// Escape abandons; Ctrl/Cmd+Enter commits. A bare Enter types a NEWLINE, because
// this is a textarea and the whole reason it exists is that the value is long.
if (e.key === "Escape") setBig(null);
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
setAt(big.index, { value: parseValue(big.text, big.multi) });
setBig(null);
}
}}
/>
{/* The same sentence the rail input's placeholder makes, said where there is room
for it: a comma list is several values, and the server turns them into an OR. */}
{big.multi ? (
<p className="auto-hint">
Separate values with commas — the search matches any of them.
</p>
) : null}
<div className="auto-head-actions">
<button
type="button"
className="auto-btn auto-btn--primary"
onClick={() => {
setAt(big.index, { value: parseValue(big.text, big.multi) });
setBig(null);
}}
>
Done
</button>
<button type="button" className="auto-btn" onClick={() => setBig(null)}>
Cancel
</button>
</div>
</AnchoredOverlay>
) : null}
</>
);
}