File size: 41,007 Bytes
bf8519f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 | // ---------------------------------------------------------------------------
// 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 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}
</>
);
}
|