diff --git "a/web/src/customer-grid/ColumnMenu.tsx" "b/web/src/customer-grid/ColumnMenu.tsx" --- "a/web/src/customer-grid/ColumnMenu.tsx" +++ "b/web/src/customer-grid/ColumnMenu.tsx" @@ -1,3908 +1,4068 @@ -import { useMemo, useRef, useState } from "react"; -// ⭐ W36-T07 (owner item 10) — the field agent. Its own module for `ColumnMenu`'s own reason: -// this file pulls the whole grid and cannot load under a node gate, so the chat had to sit -// where a test can render it. -import { FieldAgentChat } from "./FieldAgentChat"; -import type { FieldProposal } from "./fieldAgent"; -import { AnchoredOverlay } from "./OverlaySurface"; -import type { AnchorRect } from "./OverlaySurface"; -import { FieldTypeIcon, MenuLabel } from "./icons"; -import { FieldSelectButton } from "./FieldSelect"; -import { CODE_LANGUAGE_LABELS, CODE_LANGUAGES, CREATABLE_TYPES, choiceOptions, choiceRenames, - codeLanguageOf, directionLabel, isDerivedLink, isMachineOwned, - isProfileField, parseOptions, ratingMax, ROLLUP_FN_LABELS, ROLLUP_FNS, - ROLLUP_REF_OPS } from "./types"; -import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn, - RollupRefOp, RollupSource, Viewer } from "./types"; -import type { LinkTarget, RollupSourceOffer } from "./apiBridge"; -import type { WindowSpec } from "./windows"; -import { normalizeWindow, windowLabel } from "./windows"; -import { WindowPicker } from "../filter-kit"; -import { TYPE_LABELS } from "./iconShapes"; -import { AGG_LABELS, aggOptions } from "./aggregations"; -import { validateFormula } from "./formulaEngine"; -import { BRAND_SWATCHES, defaultOptionColor, nearestSwatch, normalizeOptionColor, - OPTION_PALETTE } from "./choiceColors"; - -export interface ColumnMenuState { - fieldKey: string; - anchor: AnchorRect; - /** - * Item 12 (R8) — which pane this opening lands on. Absent = the action list, i.e. every - * pre-wave-26 opening. `"edit"` is the kanban's "Add option" door. - * - * ⚠ Narrowed to the ONE pane that has a door rather than the whole `MenuPane` union: widening - * it would let any future caller deep-link into `permissions`, whose entry is gated on the - * viewer inside the menu's own action list — a state you can only reach through a check would - * become a state you can reach around it. - */ - pane?: "edit"; -} - -interface FieldConfigExtra { - formula?: string; - max?: number; - scope?: FieldScope; - label?: string; - colorCodeOptions?: boolean; - optionColors?: Record; - /** - * ⭐ Wave-20 owner item 15 (contract C-RENAME) — **AN EXPLICIT MAPPING, NEVER A DIFF.** - * - * Renaming a choice used to change the DECLARED list and nothing else, so every cell still - * held the old label and the column went blank-looking: "Blue" was no longer a choice, and - * the rows that said "Blue" said something the field did not offer. The migration needs to - * know that Blue BECAME Navy — and neither end can infer that from two lists, because - * "renamed Blue to Navy" and "deleted Blue, added Navy" produce the identical pair - * (`routes_tables.py` says the same thing from the server's side). - * - * It is knowable HERE, and only here: every option row carries a stable `OptionDraft.id` - * from the moment the editor opens, so a row whose label changed is a RENAME by identity, a - * new row is an addition, and a removed row is a removal. No guessing, at either end. - */ - renames?: { from: string; to: string }[]; - /** Wave-18 C5-AUTOFIELD — an automation column's configuration, the same way `formula` - * carries an expression. Validated host-side (`aios_grid._clean_automation`). */ - automation?: { kind: string; source: "record_url_field" | "self"; urlField?: string }; - /** ⭐ 2026-08-07 — a `link` column's target. REQUIRED for the type: `_clean_field` returns - * None for a link with no bag, so a create without this is a column that never appears. */ - link?: { table: string; single?: boolean }; - /** ⭐ 2026-08-07 — a `rollup` column's aggregate. Same requirement, same reason. - * ⭐ 2026-08-09 — or, INSTEAD, a `source` binding: the read-through kind, which summarises - * rows that were never copied into the workspace. The host's `_clean_rollup` reads `source` - * first and returns before it ever looks at `link`, so the two are exclusive by construction - * rather than by a rule someone has to remember. */ - rollup?: { - link?: string; field?: string; fn?: string; - limit?: number; sortBy?: string; sortDir?: "asc" | "desc"; - distinctBy?: string; - /** ⭐⭐ 2026-08-09 — the PRE-filter: applied before the most-recent window, so "the last 10" - * counts only records that already match. `_clean_rollup` refuses a `ref` leaf here (there - * is no fixed set yet for a statistic to describe). */ - where?: RollupCondition[]; - whereConj?: "and" | "or"; - conditions?: RollupCondition[]; - conditionConj?: "and" | "or"; - source?: RollupSource; - }; - /** ⭐ Wave-27 item 13 (R13) — a `code` column's language. OPTIONAL, unlike the two bags above - * it: `_clean_field` accepts a code column without one and it renders as plain text, so this - * is an ordinary edit rather than a create requirement. */ - code?: { language: string }; - /** ⭐⭐ Wave-34 (owner ruling R13) — an AI ENRICHMENT column's config. REQUIRED, like `link` - * and `rollup` and unlike `code`: `_clean_field` returns None for an `ai_enrich` column with - * no bag, because a prompt is the only thing that can ever produce a value here. Shaped - * exactly as `core.user_tables._clean_ai_enrich` stores it, so the panel and the store cannot - * disagree about a key name. */ - aiEnrich?: { - prompt: string; - model?: string; - maxTokens: number; - trigger: { mode: string; cron?: string }; - overwrite: string; - }; -} - -interface ColumnMenuProps { - state: ColumnMenuState; - field: Field; - /** Every field of the table, for "Change field" (owner item 9): the clicked column can be - * swapped for any other field — including hidden ones like Notes — in place. */ - fields: Field[]; - /** - * ⭐ 2026-08-07 — the OTHER databases a `link` column may point at, with their fields (so the - * rollup editor can offer real column names rather than a free-text key). Supplied by the host - * from `GET /tables`, which already filters by `may_open`. - * - * ⚠ ABSENT on the Odoo-backed Customer/Product grids, and that is what withdraws the two - * relational types from their create menu — there is no second user table to point at there, - * so offering "Link to another record" would be offering a column that can never resolve. - */ - linkTargets?: LinkTarget[]; - /** - * ⭐⭐ 2026-08-09 — the READ-THROUGH rollup's offer (topic → measure → dim → window), fetched - * by the host from `GET /tables/rollup-sources`. - * - * ⚠ EMPTY IS MEANINGFUL AND IS THE COMMON CASE. A tenant with no connected source gets `[]`, - * and the editor then never renders the mode switch at all — a rollup is simply a link fold, - * as it was before this existed. So this prop is what turns the second kind ON, not a flag. - */ - rollupSourceOffer?: RollupSourceOffer; - locked: boolean; - /** Product-owned preset fields may still drive view actions, but their schema is immutable. */ - schemaLocked?: boolean; - /** Wave-5 item 1 — who is looking. Gates the permissions entry (creator-or-admin). */ - viewer?: Viewer; - /** Wave-5 item 3 — what the CURRENT VIEW does with this field, so the conditional - * "Don't sort/filter/group by this field" entries render exactly when they apply. */ - sortedDir: "asc" | "desc" | null; - isFiltered: boolean; - isGrouped: boolean; - /** types.isGroupableField, computed by the caller (it owns lockedKey). */ - groupable: boolean; - onClose: () => void; - onNote: (note: string) => void; - onHide: () => void; - onCreate: ( - label: string, - type: FieldType, - anchorKey: string | null, - side: "left" | "right" | "end", - options?: string[], - measureSpec?: { key: string; window: WindowSpec }, - extra?: FieldConfigExtra - ) => void; - /** Swap THIS column for another field, in place (owner item 9). */ - onChangeField: (newKey: string) => void; - /** - * Wave-2 item 5: the Change-field window's "New field" half — create a fresh overlay field - * of the chosen type and swap this column for it in one motion. The old field hides exactly - * as an ordinary change does. Wave-6 item 2 REPLACES this flow on created (`custom_`) - * fields with `onRetype` below. - */ - onCreateAndSwap: ( - label: string, - type: FieldType, - options?: string[], - extra?: FieldConfigExtra - ) => void; - /** - * Wave-6 item 2, re-homed by owner item 8 (2026-07-31): supplied for CREATED (`custom_`) - * fields only — the EDIT FIELD pane's type picker RETYPES THIS FIELD IN PLACE (same key, - * new type/options; values never converted — unreadable cells show blank, disclosed in the - * pane). `extra.label` rides along when the name changed in the same save, so rename+retype - * is ONE upsert — two sequential emits would each read the stale def and revert the other. - */ - /** ⭐ 2026-08-07 (D-79) — flip the C3 profile flag on a `text` column of a `ut_*` table. - * Absent on every surface that is not a user table, which is what hides the control. */ - onProfileFlag?: (key: string, on: boolean) => void; - onRetype?: ( - type: FieldType, - options?: string[], - extra?: FieldConfigExtra - ) => void; - /** - * ⭐⭐ 2026-08-09 (owner) — RECONFIGURE an existing `link`/`rollup` column's bag. - * - * A DIFFERENT DOOR FROM `onRetype`, not a variant of it. `onRetype` writes the per-user overlay - * stratum and refuses anything that is not a `custom_` overlay field; these bags live in the - * table's SHARED definition and reach it through `PATCH /tables/{key}/fields/{fkey}`. Wiring - * this through `onRetype` would have meant a Save button that runs and does nothing on exactly - * the columns the owner was trying to edit. - * - * ⚠ Supplied for USER DATABASES only — its absence is what withdraws the editor from the - * Odoo-backed grids, where there is no such endpoint. - */ - onFieldConfig?: (patch: { - label?: string; - link?: Record; - rollup?: Record; - }) => void; - /** Wave-6 item 5 — rename this created field (ordinary field_upsert with a new label). - * Supplied for creatable strata only. */ - onRename?: (label: string) => void; - /** - * Owner item 8 (2026-07-31) — edit a FORMULA field's source in place (the Edit-field pane's - * per-type editor for the formula type, validated with the field itself as selfKey so a - * cycle is refused at authoring). `label` rides along exactly like onRetype's — one upsert. - * Supplied for created formula fields only. - */ - onFormula?: (formula: string, label?: string) => void; - /** Wave-6 item 4 — open straight into the create form at this position (the header "+" - * passes "end"). Cancel then CLOSES rather than falling back to the action list. */ - initialPosition?: CreatePosition; - /** - * ⭐ WAVE-26 ITEM 12 (owner ruling R8) — open straight into a PANE, the way `initialPosition` - * opens straight into the create form. - * - * The kanban's "Add option" door passes `"edit"`. The owner's words were *"its literally the - * same thing as adding a new option when you click edit field on multi-select"*, and this prop - * is what makes that literal rather than approximate: the lane editor IS this component's Edit - * pane, so it writes the SHARED field definition through the same `onRetype` upsert, obeys the - * same permission wall, carries the same rename-by-row-identity migration and the same colour - * swatches. A second options editor would have had to re-earn every one of those. - * - * Back then CLOSES — same rule as `initialPosition`: there is no action list behind a pane - * that was opened directly. - */ - initialPane?: MenuPane; - /** Wave-6 item 9c — true on the cohort page: the create forms offer the Scope control - * ("This cohort table only" = DEFAULT vs "All customer tables"). */ - scopeChoice?: boolean; - /** Wave-6 item 11c — pin state + actions. `onPinTo` absent when pinning would be a no-op - * (identity column, or already the boundary); `onUnpin` absent when nothing is pinned. */ - pinnedTo?: boolean; - onPinTo?: () => void; - onUnpin?: () => void; - /** - * Wave-2 item 8c: change the PERIOD of a measure-carrying column. Supplied for any field with - * `.measure` — user-created `measure_*` columns and the pre-set measure fields alike. The - * caller emits the ordinary `field_upsert` (and auto-renames the header when the user never - * renamed it). - */ - onPeriod?: (window: WindowSpec) => void; - /** - * Delete this USER-CREATED column outright (owner gap closed 2026-07-27). Supplied ONLY for - * the deletable strata (`custom_` overlay fields, `measure_` formula columns) — absent means - * the control is not rendered at all, so a base field cannot even be offered the action. - */ - onDelete?: () => void; - /** ⭐⭐ Wave-34 (R13) — run this AI enrichment column over many rows. - * `scope` is `"blank"` (only cells still empty) or `"always"` (every row the AI - * wrote). OPTIONAL for `onDelete`'s reason: a host that has not wired the door shows - * no row, rather than a row that does nothing. */ - onEnrich?: (scope: "blank" | "always") => void; - /** Wave-5 item 1 — Duplicate field. Supplied for creatable strata only (base Odoo fields - * offer no Duplicate — cloning the source of truth into an editable copy is out of scope). */ - onDuplicate?: () => void; - /** Wave-5 item 1 — save a permissions change. Supplied iff the VIEWER may change them - * (isAdmin || createdBy === viewer.name, and only on creatable strata). */ - onPermissions?: (edit: "everyone" | "creator" | "admins") => void; - /** Wave-5 item 10 — save a display format. Supplied for number/currency/formula and - * date/created_time fields. */ - onFormat?: (format: FieldFormat) => void; - /** ⭐ WAVE-29 T33 (owner item 17) — set (or clear, with `undefined`) this column's SUMMARY: the - * value its group footers and the table's totals row show. Supplied wherever a field write is - * possible; the pane offers only what `aggregations.aggOptions` allows for the type. */ - onAggregate?: (agg: AggName | undefined) => void; - /** Wave-5 item 1 — Sort by this field (replaces the view's sort), and the conditional - * clears (item 3). All of these mutate the EXISTING view config — no new host surface. */ - onSort: (dir: "asc" | "desc") => void; - onClearSort: () => void; - onFilterBy: () => void; - onClearFilter: () => void; - onGroupByField: () => void; - onClearGroup: () => void; - /** Assignable people, from the host's real user list. Empty = the host did not supply one, - * and "Assignee" is offered without choices rather than with invented ones. */ - userOptions?: string[]; - /** - * CG-8's measures, reused as the vocabulary of a FORMULA-MEASURE column (owner item 7): - * `Sales · the last 90 days` as a column the user creates. Empty = the semantic store is - * unavailable here, and the option is simply not offered (same rule as measure conditions). - */ - measures?: Measure[]; -} - -type CreatePosition = "left" | "right" | "end"; - -// ⚠ wave-7 item W4's menu-action icon set + `MenuLabel` MOVED to `icons.tsx` (wave-14 -// item 19): the view menu needs the same vocabulary, and two hand-drawn padlocks in two -// files is how one of them ends up a different padlock. `icon=` takes a NAME now. - -/** - * The menu's panes (wave-5 item 2). The NOTE editor and the editors used to render inline in - * the menu body; each opens its OWN dedicated window. One overlay, one pane at a time; - * "menu" is the action list. - * - * Owner item 8 (2026-07-31): "edit" ABSORBS the former "rename" and "swap" panes — one - * Airtable-grade Edit-field window owning the field's name, its type (with the per-type - * choices/stars/formula config), and the Change-field control that swaps the column to show - * another field. - */ -type MenuPane = "menu" | "note" | "edit" | "permissions" | "format" | "summary"; - -/** - * How each creatable type reads in the menu. DERIVED from CREATABLE_TYPES rather than listed - * again: a hand-maintained copy is a third place the vocabulary lives, and the failure mode is - * silent — add a type to the contract, forget this list, and the type is accepted by the host, - * asserted by the drift gate, and simply never offered to anyone. - * - * `Record` is what forces it: adding a member to the union without a label - * here fails `tsc`, so the compiler is the gate and no test has to click a canvas header menu. - * - * Wave-8 I20: the map itself MOVED to icons.tsx, beside the per-type glyphs — a type's label - * and its icon are one vocabulary, and splitting them across two files is how they drift. - * Imported here; the totality guarantee above is unchanged (icons.tsx types it identically). - */ -const FIELD_TYPES = CREATABLE_TYPES.map((value) => ({ value, label: TYPE_LABELS[value] })); - -/** Types whose choices are declared in an options editor at creation time. */ -function needsOptions(t: FieldType | "measure"): boolean { - return t === "select" || t === "multiselect"; -} - -interface OptionDraft { - id: string; - label: string; - color: string; -} - -const DEFAULT_OPTION_LABELS = ["Not started", "In progress", "Blocked", "Done"]; - -/** - * ⭐ WAVE 28 · R4 / C1 — how a SET-STATISTIC comparison reads in the operator list. - * - * ⛔ KEYED BY `RollupRefOp`, so the day `core.user_tables.ROLLUP_REF_OPS` gains or loses an - * operator this map fails to compile rather than rendering a bare identifier — the same - * discipline `ROLLUP_FN_LABELS` uses, and the reason the σ picker maps `ROLLUP_REF_OPS` instead - * of hand-filtering the full operator list. - * ⚠ The wording names the STATISTIC, not the arithmetic: "2 σ above the mean" is the sentence a - * person is trying to write, and "is greater than mean + k·stdev" is the implementation of it. - */ -/** - * ⭐ MIRRORS `core.user_tables.ROLLUP_MAX_SIGMAS`, and `verify_rollup_editor.py` HOLDS THE TWO IN - * STEP by importing the server's constant and comparing (a real enforcer this time — `types.ts` - * spent a wave claiming one that contained zero occurrences of the word it guarded, - * [[limit-with-no-enforcer]]). - * - * ⛔ WHY THE CONTROL MUST KNOW THE BOUND AT ALL. `_clean_rollup` REFUSES the whole field outside - * this range — not the leaf, the FIELD — so an unbounded box invites a value and then answers a - * 400 naming a cap the control never mentioned. That is `maxPosts` exactly: it offered 200 while - * the save door refused anything over 12 ([[default-must-pass-its-own-guard]]). A control must - * display what it will SEND. - */ -const MAX_SIGMAS = 10; - -/** The default when a σ row is born or its box is left empty — C1's worked example uses 2. */ -const DEFAULT_SIGMAS = 2; - -const REF_OP_LABELS: Record = { - gt: "is more than … σ above the mean", - gte: "is at least … σ above the mean", - lt: "is less than … σ above the mean", - lte: "is at most … σ above the mean", -}; - -/** - * ⭐⭐ 2026-08-09 (owner) — ONE CONDITION LIST, RENDERED TWICE, because a rollup now filters at - * two different moments and they mean different things. - * - * Owner: *"instead of last 12 posts, we also want to make it so its last N record, where the - * record's Status is video."* That is a filter the WINDOW must respect — pick the reels first, - * then take the last 10 of them. The existing list is the opposite by design (wave 28 / C1 moved - * it below the window so a `ref: {sigmas}` threshold is a statistic OF the rows being folded), so - * the two cannot be one control. - * - * ⛔ EXTRACTED RATHER THAN COPIED. Two near-identical 130-line JSX blocks is how one of them - * keeps offering an operator the other dropped — and the operator list here is already the - * server's (`ROLLUP_REF_OPS`), so a hand-edited second copy would drift from `_clean_rollup` - * silently ([[one-evaluator-per-question]]). - * ⛔ `allowRef` IS A REFUSAL MIRRORED, NOT A UI PREFERENCE. `_clean_rollup` refuses a `ref` leaf - * in `where` — before the window there is no fixed set for a mean to be about — so offering the - * σ operators there would be a control whose only outcome is a 400 on the whole field. - */ -function RollupConditionList({ - title, - hint, - conditions, - onConditions, - conj, - onConj, - targetFields, - allowRef, - idPrefix, -}: { - title: string; - hint: string; - conditions: RollupCondition[]; - onConditions: (v: RollupCondition[]) => void; - conj: "and" | "or"; - onConj: (v: "and" | "or") => void; - targetFields: { key: string; label: string }[]; - allowRef: boolean; - idPrefix: string; -}) { - const patch = (index: number, next: RollupCondition) => - onConditions(conditions.map((item, i) => (i === index ? next : item))); - return ( -
-
- {title} - -
- {conditions.length > 1 ? ( - - ) : null} - {conditions.map((condition, index) => ( -
- - {/* - ⭐⭐ WAVE 28 · R4 / C1 — THE THRESHOLD MAY BE THE SET'S OWN STATISTICS. - Owner's request (Nurilab): an average of the last 10 posts with outliers trimmed — - "anything beyond 2 sigma". That is not a new aggregator, it is a CONDITION whose - threshold is computed from the scoped rows themselves: mean + k*stdev of the same - column, over the window `sortBy`+`limit` kept, BEFORE these conditions filter. - - ⛔ ONE CONTROL, NOT TWO, AND THAT IS WHAT MAKES THE XOR SAFE. `value` and `ref` may - never both be present — the server refuses the whole FIELD, not just the leaf — so - a separate "compare against…" switch beside the operator would leave the editor - free to write a `ref` while a stale `value` still rode along, producing a 400 on - Save that names a key the user never typed. Folding the choice into the operator - makes the illegal state unrepresentable: picking a plain op WRITES `value` and - drops `ref`, picking a statistic op does the reverse, and there is no third path. - - ⚠ THE OPS ARE `ROLLUP_REF_OPS`, IMPORTED, never a hand-filtered copy of the full - list. `eq`/`neq` against a computed float is a coin flip on binary representation - and `contains` against a number never matches — the server draws that line and a - second copy here would be free to drift from it. - ⚠ THE `value` IS COMPOUND (`ref:gt`) so the select always resolves to exactly one - option. A ` { - const raw = event.target.value; - const isRef = raw.startsWith("ref:"); - const op = (isRef ? raw.slice(4) : raw) as RollupCondition["op"]; - // ⛔ THE LEAF IS REBUILT, NEVER SPREAD-AND-PATCHED. `{ ...item, ref: undefined }` - // leaves the key present-and-undefined; `JSON.stringify` drops it today, but the - // contract is "the key is absent", and relying on a serialiser's treatment of - // `undefined` to enforce a server-side XOR is a guarantee held by accident. - patch(index, isRef - ? { field: condition.field, op, - ref: { sigmas: condition.ref?.sigmas ?? DEFAULT_SIGMAS } } - : { field: condition.field, op, value: condition.value ?? "" }); - }} - > - - - - - - - - - - - - - {allowRef ? ( - - {ROLLUP_REF_OPS.map((op) => ( - - ))} - - ) : null} - - {condition.ref ? ( - - ) : !(["is_empty", "is_not_empty"] as string[]).includes(condition.op) ? ( - patch(index, { - field: condition.field, op: condition.op, value: event.target.value, - })} - /> - ) : null} - -
- ))} -
{hint}
-
- ); -} - -let optionDraftSequence = 0; - -function nextOptionDraftId(): string { - optionDraftSequence += 1; - return `choice-${optionDraftSequence}`; -} - -function optionDrafts( - labels: string[], - saved: Record | undefined = undefined -): OptionDraft[] { - return labels.map((label) => ({ - id: nextOptionDraftId(), - label, - color: - normalizeOptionColor(saved?.[label]) ?? - normalizeOptionColor(defaultOptionColor(label))!, - })); -} - -function optionSettings( - drafts: OptionDraft[], - type: FieldType | "measure" -): { options: string[]; colors: Record } { - const clean = drafts.map((draft) => - type === "multiselect" ? draft.label.replace(/,/g, " ") : draft.label - ); - const options = parseOptions(clean.join("\n")); - const firstByLabel = new Map(); - drafts.forEach((draft, index) => { - const label = clean[index].trim(); - if (label && !firstByLabel.has(label.toLowerCase())) - firstByLabel.set(label.toLowerCase(), draft); - }); - const colors: Record = {}; - for (const option of options) { - const draft = firstByLabel.get(option.toLowerCase()); - const color = normalizeOptionColor(draft?.color); - // The deterministic fallback is not durable data. Persist only a genuine user override; - // this keeps an untouched legacy field byte-stable while a renamed row retains its colour. - if (color && color !== normalizeOptionColor(defaultOptionColor(option))) - colors[option] = color; - } - return { options, colors }; -} - -function optionAppearanceSignature( - options: string[], - colors: Record | undefined -): string { - return options - .map((option) => { - const custom = colors - ? Object.entries(colors).find( - ([label]) => label.trim().toLowerCase() === option.toLowerCase() - )?.[1] - : undefined; - return `${option}\u0000${ - normalizeOptionColor(custom) ?? normalizeOptionColor(defaultOptionColor(option)) - }`; - }) - .join("\u0001"); -} - -/** - * Item 22 (R5) — the option COLOUR control: ten brand swatches, no free RGB. - * - * It was an ``, which offered sixteen million colours in a product whose - * whole visual argument is five. The trigger keeps the old 24px circle's footprint (it is the - * same affordance in the same place) and opens a 5×2 grid: each hue's light `-tint` on the top - * row, its `-pastel` beneath. A stored colour that is not on the palette marks its NEAREST - * swatch — so the picker says what the value reads as — and re-picking is what rewrites it. - */ -function SwatchPicker({ - value, - disabled, - label, - onPick, -}: { - value: string | undefined; - disabled?: boolean; - /** The option this colours, for the accessible name. */ - label: string; - onPick: (hex: string) => void; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const current = nearestSwatch(value); - return ( - <> - - ))} - - - )} - - ); -} - -function OptionsEditor({ - idPrefix, - drafts, - onDrafts, - colorCode, - onColorCode, - type, -}: { - idPrefix: string; - drafts: OptionDraft[]; - onDrafts: (next: OptionDraft[]) => void; - colorCode: boolean; - onColorCode: (next: boolean) => void; - type: FieldType | "measure"; -}) { - const update = (id: string, patch: Partial) => - onDrafts(drafts.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft))); - const settings = optionSettings(drafts, type); - return ( -
-
- Options - -
-
- {drafts.map((draft, index) => ( -
- update(draft.id, { color: hex })} - /> - { - const label = - type === "multiselect" - ? event.target.value.replace(/,/g, " ") - : event.target.value; - update(draft.id, { label }); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - onDrafts([ - ...drafts, - { - id: nextOptionDraftId(), - label: "", - color: OPTION_PALETTE[drafts.length % OPTION_PALETTE.length].bg, - }, - ]); - }} - /> - -
- ))} -
- - - {settings.options.length} option{settings.options.length === 1 ? "" : "s"}. - {type === "multiselect" && " Commas inside an option are not supported."} - -
- ); -} - -/** - * The create-picker's kind: a creatable overlay type, or the FORMULA-MEASURE pseudo-kind. - * "measure" is deliberately NOT a FieldType — a measure column's real type comes from the - * measure itself (currency/int/pct), and widening the union would let it leak into every - * switch that renders cells. - */ -type CreateKind = FieldType | "measure"; - -/** - * Owner item 8 (2026-07-31) — the create pane's field-type picker: a find box over an icon - * listbox, replacing the icon-less native select. Each row wears the SAME mark its column - * header will wear (`FieldTypeIcon` ← TYPE_SHAPES), so the vocabulary teaches itself. The - * "measure" pseudo-kind borrows the currency mark: a measure column's real type is - * currency/int/pct, and inventing a 17th glyph for a pseudo-kind would put a mark on screen - * that no column ever wears. - */ -function TypePicker({ - value, - onPick, - offerMeasure, - only, -}: { - value: CreateKind; - onPick: (k: CreateKind) => void; - offerMeasure: boolean; - /** Restrict the list (the Edit-field pane passes RETYPE_TYPES — the overlay-editable - * strata a created field may become). Absent = every creatable type. */ - only?: readonly FieldType[]; -}) { - const [q, setQ] = useState(""); - const needle = q.trim().toLowerCase(); - const base = only - ? FIELD_TYPES.filter((r) => (only as readonly string[]).includes(r.value)) - : FIELD_TYPES; - const rows: { value: CreateKind; label: string }[] = [ - ...base, - // C-NAME (item 9, 2026-08-02): "Formula measure (over a period)" -> "Metric". Two words - // of jargon and a parenthetical, replaced by the word people already use for the thing. - // The internal key stays `measure` — see the C-NAME amendment in the wave doc. - ...(offerMeasure - ? [{ value: "measure" as CreateKind, label: "Metric" }] - : []), - ].filter((r) => !needle || r.label.toLowerCase().includes(needle)); - return ( -
-
- - setQ(event.target.value)} - /> -
-
- {rows.map((r) => ( - - ))} - {rows.length === 0 &&
No matching type.
} -
-
- ); -} - -/** The Change-field dropdown's "New field" entries. A colon cannot occur in a real field key - * (keys are slugs), so the prefix cannot collide with one. */ -const NEW_PREFIX = "new:"; - -/** - * The types a created field may be retyped TO (the Edit-field pane's type picker): the - * overlay-editable strata only. `formula` and `created_time` are a DIFFERENT stratum - * (source 'odoo', host-computed shape) — turning an overlay column into one is not a retype, - * and the host accepts in-place rebuilds on `custom_` overlay defs only. - */ -// Wave-18 C5-AUTOFIELD: `automation` joins the exclusion, and for a reason the two above do not -// share. It IS an overlay type, so the host would accept the retype — which is exactly the -// hazard. Retyping a Notes column to Automation hands every row's typed value to the next run to -// overwrite; retyping the other way strands machine-written statuses in a column a person can -// now edit. Neither is a change of TYPE, both are data loss with a tidy name. -const RETYPE_TYPES = CREATABLE_TYPES.filter( - (t) => t !== "formula" && t !== "created_time" && t !== "automation" -); - -/** The automation kinds a column can carry. v1 is one; the shape is a list so the second is a - * line rather than a refactor. Mirrors `aios_grid.AUTOMATION_KINDS` (host-whitelisted). */ -const AUTOMATION_KINDS: { key: string; label: string; hint: string }[] = [ - { - key: "instagram_profile", - label: "Instagram profile", - hint: "Reads what is anonymously public about the profile in the chosen URL column, and " - + "writes the result here. Runs from the Automation surface, on demand or on a schedule.", - }, -]; - -const DEFAULT_MEASURE_WINDOW: WindowSpec = { kind: "ltm" }; - -/** The head every pane shares: title line, context line, close X. */ -function PaneHead({ - title, - sub, - onClose, -}: { - title: string; - sub?: string; - onClose: () => void; -}) { - return ( -
-
-
{title}
- {sub &&
{sub}
} -
- -
- ); -} - -/** - * Wave-6 item 9c — the Scope control, rendered by BOTH create surfaces on the cohort page - * (one component so the two doors cannot drift). "This cohort table only" is the DEFAULT — - * the owner's ask: a field made while working a cohort belongs to that work unless the user - * says otherwise. - */ -function ScopeControl({ - value, - onValue, -}: { - value: FieldScope; - onValue: (v: FieldScope) => void; -}) { - return ( - - ); -} - -/** - * Wave-5 items 9/11 — the extra editors a new field's type may need, shared VERBATIM by the - * two create surfaces (insert-field and Change-field's "New field" half) so the two doors - * cannot drift: a FORMULA gets its source editor with live validation and a reference picker; - * a RATING gets its star count (the contract's 2..10). - */ -/** - * ⭐⭐ Wave-34 (R13) — the AI enrichment draft, as the panel holds it. - * - * FLAT, while the wire bag nests `trigger`. That is deliberate: a form is edited one control at a - * time and a nested draft makes every setter a two-level spread, which is where a patcher drops a - * sibling key. `extraFor` does the one nesting at the moment of sending. - */ -interface AiEnrichDraft { - prompt: string; - model: string; - maxTokens: number; - mode: string; - cron: string; - overwrite: string; -} - -/** The three moments an enrichment column can run. Mirrors `AI_ENRICH_TRIGGER_MODES`. */ -const AI_ENRICH_MODES: ReadonlyArray<{ value: string; label: string }> = [ - { value: "manual", label: "Only when I ask" }, - { value: "on_change", label: "When a referenced column changes" }, - { value: "schedule", label: "On a schedule" }, -]; - -/** - * What an AUTOMATIC run may replace. ⛔ There is no "overwrite anything" option and there must - * not be: a cell somebody typed into is never overwritten by an agent, whatever is chosen here, - * so "Every row" means every row THE AI WROTE. The labels say that rather than leaving a user to - * discover it, because a control that quietly means less than it says is worse than a narrower - * control (`core.user_tables.ai_enrich_human_authored` is the law it is describing). - */ -const AI_ENRICH_OVERWRITE: ReadonlyArray<{ value: string; label: string }> = [ - { value: "blank", label: "Only rows that are still empty" }, - { value: "stale", label: "Empty rows, and AI answers whose inputs have changed" }, - { value: "always", label: "Every row the AI wrote, every run" }, -]; - -function ExtraTypeEditor({ - kind, - fields, - formulaText, - onFormulaText, - formulaError, - maxValue, - onMaxValue, - codeLanguage, - onCodeLanguage, - aiEnrich, - onAiEnrich, - idPrefix, - showFormulaHelp = true, - automationKind = "instagram_profile", - onAutomationKind, - automationUrlField = "", - onAutomationUrlField, - linkTargets = [], - linkTable = "", - onLinkTable, - linkSingle = false, - onLinkSingle, - rollupMode = "link", - onRollupMode, - rollupSourceOffer = { topics: [], windows: [] }, - rollupSource = { topic: "", measure: "", groupBy: "", on: "", window: "ytd" }, - onRollupSource, - rollupLink = "", - onRollupLink, - rollupField = "", - onRollupField, - rollupFn = "sum", - onRollupFn, - rollupLimit = 0, - onRollupLimit, - rollupSortBy = "", - onRollupSortBy, - rollupDistinctBy = "", - onRollupDistinctBy, - rollupWhere = [], - onRollupWhere, - rollupWhereConj = "and", - onRollupWhereConj, - rollupConditions = [], - onRollupConditions, - rollupConditionConj = "and", - onRollupConditionConj, -}: { - kind: FieldType | "measure"; - fields: Field[]; - formulaText: string; - onFormulaText: (v: string) => void; - formulaError: string | null; - maxValue: number; - onMaxValue: (n: number) => void; - /** ⭐ Wave-27 item 13 (R13) — the code column's language draft. REQUIRED at every call site, - * unlike the `automation` drafts below it: this pane RENDERS a