import { useMemo, useRef, useState } from "react"; 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 }; } 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-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). */ function ExtraTypeEditor({ kind, fields, formulaText, onFormulaText, formulaError, maxValue, onMaxValue, codeLanguage, onCodeLanguage, 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