| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { useEffect, useState } from "react"; |
| |
| |
| |
| 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 { |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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, |
| }; |
| |
| 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> |
| ); |
| |
| 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> |
| ); |
| |
| 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> |
| ); |
| |
| 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> |
| ); |
| |
| 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> |
| ); |
| } |
|
|
| |
| interface FieldRow { |
| name: string; |
| |
| label: string; |
| hint: string; |
| kind: string; |
| operators: DiscoverOperator[]; |
| options: { value: string; label: string }[]; |
| |
| at: number[]; |
| |
| populated: boolean | null; |
| |
| narrowing: boolean | null; |
| } |
|
|
| |
| function valueText(v: Predicate["value"]): string { |
| if (Array.isArray(v)) return v.join(", "); |
| return v === undefined || v === null ? "" : String(v); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| return text; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| interface Draft { |
| key: string; |
| text: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function firstOperator(v: DiscoverVocab | undefined, name: string): string { |
| const meta = (v?.filterMeta || []).find((m) => m.name === name); |
| return meta?.defaultOperator || (v?.operators || [])[0] || ""; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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", |
| |
| |
| |
| operators: m?.operators || [], |
| options: m?.options || [], |
| at: byName.get(name) || [], |
| populated: m ? !!m.populated : lead.has(name) ? true : null, |
| narrowing: m ? !!m.narrowing : null, |
| }; |
| }); |
| |
| |
| |
| 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 || []); |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| const strays = preds |
| .map((p, i) => ({ p, i })) |
| .filter(({ p }) => !known.has(p.name)); |
|
|
| const maxRecords = discover?.guard?.maxRecords || discover?.maxRecords || 500; |
|
|
| |
| const [draft, setDraft] = useState<Draft | null>(null); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const [big, setBig] = useState<{ |
| key: string; |
| index: number; |
| label: string; |
| multi: boolean; |
| text: string; |
| anchor: AnchorRect; |
| } | null>(null); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 : [])) |
| |
| |
| |
| .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) }]); |
| }; |
|
|
| |
| const condition = (i: number, label: string, row?: FieldRow) => { |
| const p = preds[i]; |
| if (!p) return null; |
| |
| |
| 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}`; |
| |
| const shown = draft && draft.key === rowKey ? draft.text : valueText(p.value); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const wantsObserved = row?.kind === "choice" && !options.length; |
| |
| const appendValue = (v: string) => { |
| const has = shown |
| .split(",") |
| .map((s) => s.trim().toLowerCase()) |
| .filter(Boolean); |
| |
| |
| 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 group — so 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 yet — type 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} |
| </> |
| ); |
| } |
|
|