| 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;
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| pane?: "edit";
|
| }
|
|
|
| interface FieldConfigExtra {
|
| formula?: string;
|
| max?: number;
|
| scope?: FieldScope;
|
| label?: string;
|
| colorCodeOptions?: boolean;
|
| optionColors?: Record<string, string>;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| renames?: { from: string; to: string }[];
|
| |
|
|
| automation?: { kind: string; source: "record_url_field" | "self"; urlField?: string };
|
| |
|
|
| link?: { table: string; single?: boolean };
|
| |
| |
| |
| |
|
|
| rollup?: {
|
| link?: string; field?: string; fn?: string;
|
| limit?: number; sortBy?: string; sortDir?: "asc" | "desc";
|
| distinctBy?: string;
|
| |
| |
|
|
| where?: RollupCondition[];
|
| whereConj?: "and" | "or";
|
| conditions?: RollupCondition[];
|
| conditionConj?: "and" | "or";
|
| source?: RollupSource;
|
| };
|
| |
| |
|
|
| code?: { language: string };
|
| }
|
|
|
| interface ColumnMenuProps {
|
| state: ColumnMenuState;
|
| field: Field;
|
| |
|
|
| fields: Field[];
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| linkTargets?: LinkTarget[];
|
| |
| |
| |
| |
| |
| |
| |
|
|
| rollupSourceOffer?: RollupSourceOffer;
|
| locked: boolean;
|
|
|
| schemaLocked?: boolean;
|
|
|
| viewer?: Viewer;
|
| |
|
|
| sortedDir: "asc" | "desc" | null;
|
| isFiltered: boolean;
|
| isGrouped: boolean;
|
|
|
| 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;
|
|
|
| onChangeField: (newKey: string) => void;
|
| |
| |
| |
| |
| |
|
|
| onCreateAndSwap: (
|
| label: string,
|
| type: FieldType,
|
| options?: string[],
|
| extra?: FieldConfigExtra
|
| ) => void;
|
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| onProfileFlag?: (key: string, on: boolean) => void;
|
| onRetype?: (
|
| type: FieldType,
|
| options?: string[],
|
| extra?: FieldConfigExtra
|
| ) => void;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| onFieldConfig?: (patch: {
|
| label?: string;
|
| link?: Record<string, unknown>;
|
| rollup?: Record<string, unknown>;
|
| }) => void;
|
| |
|
|
| onRename?: (label: string) => void;
|
| |
| |
| |
| |
| |
|
|
| onFormula?: (formula: string, label?: string) => void;
|
| |
|
|
| initialPosition?: CreatePosition;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| initialPane?: MenuPane;
|
| |
|
|
| scopeChoice?: boolean;
|
| |
|
|
| pinnedTo?: boolean;
|
| onPinTo?: () => void;
|
| onUnpin?: () => void;
|
| |
| |
| |
| |
| |
|
|
| onPeriod?: (window: WindowSpec) => void;
|
| |
| |
| |
| |
|
|
| onDelete?: () => void;
|
| |
|
|
| onDuplicate?: () => void;
|
| |
|
|
| onPermissions?: (edit: "everyone" | "creator" | "admins") => void;
|
| |
|
|
| onFormat?: (format: FieldFormat) => void;
|
| |
| |
|
|
| onAggregate?: (agg: AggName | undefined) => void;
|
| |
|
|
| onSort: (dir: "asc" | "desc") => void;
|
| onClearSort: () => void;
|
| onFilterBy: () => void;
|
| onClearFilter: () => void;
|
| onGroupByField: () => void;
|
| onClearGroup: () => void;
|
| |
|
|
| userOptions?: string[];
|
| |
| |
| |
| |
|
|
| measures?: Measure[];
|
| }
|
|
|
| type CreatePosition = "left" | "right" | "end";
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| type MenuPane = "menu" | "note" | "edit" | "permissions" | "format" | "summary";
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const FIELD_TYPES = CREATABLE_TYPES.map((value) => ({ value, label: TYPE_LABELS[value] }));
|
|
|
|
|
| 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"];
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const MAX_SIGMAS = 10;
|
|
|
|
|
| const DEFAULT_SIGMAS = 2;
|
|
|
| const REF_OP_LABELS: Record<RollupRefOp, string> = {
|
| 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",
|
| };
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 (
|
| <div className="cg-rollup-conditions">
|
| <div className="cg-rollup-conditions__head">
|
| <span>{title}</span>
|
| <button
|
| type="button"
|
| className="cg-btn"
|
| disabled={targetFields.length === 0 || conditions.length >= 20}
|
| onClick={() => onConditions([
|
| ...conditions,
|
| { field: targetFields[0]?.key ?? "", op: "eq", value: "" },
|
| ])}
|
| >
|
| Add condition
|
| </button>
|
| </div>
|
| {conditions.length > 1 ? (
|
| <select
|
| className="cg-input"
|
| aria-label={`Match all or any ${title.toLowerCase()} conditions`}
|
| value={conj}
|
| onChange={(event) => onConj(event.target.value as "and" | "or")}
|
| >
|
| <option value="and">All conditions must match</option>
|
| <option value="or">Any condition may match</option>
|
| </select>
|
| ) : null}
|
| {conditions.map((condition, index) => (
|
| <div className="cg-rollup-condition" key={`${idPrefix}-${index}-${condition.field}`}>
|
| <select
|
| className="cg-input"
|
| aria-label={`${title} condition ${index + 1} column`}
|
| value={condition.field}
|
| onChange={(event) => patch(index, { ...condition, field: event.target.value })}
|
| >
|
| {targetFields.map((targetField) => (
|
| <option key={targetField.key} value={targetField.key}>{targetField.label}</option>
|
| ))}
|
| </select>
|
| {/*
|
| ββ 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 `<select>` whose value matches nothing renders the FIRST option while
|
| the stored key says otherwise, and the next patch writes the lie back
|
| ([[cg-condition-builder-items]]).
|
| */}
|
| <select
|
| className="cg-input"
|
| aria-label={`${title} condition ${index + 1} operator`}
|
| value={condition.ref ? `ref:${condition.op}` : condition.op}
|
| onChange={(event) => {
|
| 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 ?? "" });
|
| }}
|
| >
|
| <optgroup label="Compared with a value">
|
| <option value="eq">is</option>
|
| <option value="neq">is not</option>
|
| <option value="contains">contains</option>
|
| <option value="not_contains">does not contain</option>
|
| <option value="is_empty">is empty</option>
|
| <option value="is_not_empty">is not empty</option>
|
| <option value="gt">is greater than</option>
|
| <option value="gte">is at least</option>
|
| <option value="lt">is less than</option>
|
| <option value="lte">is at most</option>
|
| </optgroup>
|
| {allowRef ? (
|
| <optgroup label="Compared with the set's own spread">
|
| {ROLLUP_REF_OPS.map((op) => (
|
| <option key={op} value={`ref:${op}`}>{REF_OP_LABELS[op]}</option>
|
| ))}
|
| </optgroup>
|
| ) : null}
|
| </select>
|
| {condition.ref ? (
|
| <label className="cg-cond-ref">
|
| {/* β A BARE NUMBER BOX WOULD BE A CONTROL THAT WILL NOT SAY WHAT IT SETS. The
|
| unit is sigmas and the sign is meaningful (negative selects the LOW tail), so
|
| both are printed beside the input rather than left to be inferred from a
|
| placeholder. */}
|
| <input
|
| className="cg-input"
|
| type="number"
|
| step="0.5"
|
| /* β THE SERVER'S OWN BOUND, BOTH WAYS. Outside it `_clean_rollup` refuses the
|
| whole FIELD, so an unbounded box would invite a 400 naming a cap it never
|
| showed β `maxPosts`'s scar, in a control built the same day it was quoted. */
|
| min={-MAX_SIGMAS}
|
| max={MAX_SIGMAS}
|
| aria-label={`${title} condition ${index + 1} standard deviations from the mean`}
|
| /* β UNCONTROLLED + COMMIT ON BLUR, the idiom `maxPosts` uses and for its
|
| reason: a controlled box patching per keystroke stores "-" as 0 and "1" on
|
| the way to "1.5" as a legal value, and `Number("")` is 0 β so simply
|
| CLEARING the box would silently store "0 sigma", a real but different
|
| query, and the field could never be retyped because it snaps back. */
|
| defaultValue={String(condition.ref.sigmas)}
|
| onBlur={(event) => {
|
| const raw = Number(event.target.value);
|
| // Empty/garbage falls back to the default rather than to 0 β 0 means "at
|
| // the mean" and is a value somebody must choose, never one they land on by
|
| // deleting a character.
|
| const parsed = event.target.value.trim() === "" || !Number.isFinite(raw)
|
| ? DEFAULT_SIGMAS
|
| : raw;
|
| // CLAMPED, not refused: the box is a dial, and a dial that rejects is a
|
| // dead control. The clamp is what makes "displays what it will send" true.
|
| const sigmas = Math.max(-MAX_SIGMAS, Math.min(MAX_SIGMAS, parsed));
|
| if (String(sigmas) !== event.target.value) event.target.value = String(sigmas);
|
| patch(index, { field: condition.field, op: condition.op, ref: { sigmas } });
|
| }}
|
| />
|
| <span>Ο from the mean of this column (negative reads below it)</span>
|
| </label>
|
| ) : !(["is_empty", "is_not_empty"] as string[]).includes(condition.op) ? (
|
| <input
|
| className="cg-input"
|
| aria-label={`${title} condition ${index + 1} value`}
|
| value={condition.value ?? ""}
|
| onChange={(event) => patch(index, {
|
| field: condition.field, op: condition.op, value: event.target.value,
|
| })}
|
| />
|
| ) : null}
|
| <button
|
| type="button"
|
| className="cg-icon-btn"
|
| aria-label={`Remove ${title.toLowerCase()} condition ${index + 1}`}
|
| onClick={() => onConditions(conditions.filter((_item, i) => i !== index))}
|
| >
|
| Γ
|
| </button>
|
| </div>
|
| ))}
|
| <div className="cg-field-hint">{hint}</div>
|
| </div>
|
| );
|
| }
|
|
|
| let optionDraftSequence = 0;
|
|
|
| function nextOptionDraftId(): string {
|
| optionDraftSequence += 1;
|
| return `choice-${optionDraftSequence}`;
|
| }
|
|
|
| function optionDrafts(
|
| labels: string[],
|
| saved: Record<string, string> | 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<string, string> } {
|
| const clean = drafts.map((draft) =>
|
| type === "multiselect" ? draft.label.replace(/,/g, " ") : draft.label
|
| );
|
| const options = parseOptions(clean.join("\n"));
|
| const firstByLabel = new Map<string, OptionDraft>();
|
| drafts.forEach((draft, index) => {
|
| const label = clean[index].trim();
|
| if (label && !firstByLabel.has(label.toLowerCase()))
|
| firstByLabel.set(label.toLowerCase(), draft);
|
| });
|
| const colors: Record<string, string> = {};
|
| 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<string, string> | 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 `<input type="color">`, 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<HTMLButtonElement>(null);
|
| const current = nearestSwatch(value);
|
| return (
|
| <>
|
| <button
|
| ref={ref}
|
| type="button"
|
| className="cg-option-color"
|
| disabled={disabled}
|
| style={{ background: current?.bg ?? normalizeOptionColor(value) ?? "#FFFFFF" }}
|
| aria-label={`Colour for ${label}${current ? ` β ${current.label}` : ""}`}
|
| aria-haspopup="dialog"
|
| aria-expanded={open}
|
| title="Change option colour"
|
| onClick={() => setOpen((o) => !o)}
|
| />
|
| {open && ref.current && (
|
| <AnchoredOverlay
|
| anchor={ref.current}
|
| className="cg-pop cg-swatch-pop"
|
| onDismiss={() => setOpen(false)}
|
| role="dialog"
|
| ariaLabel={`Colour for ${label}`}
|
| dataKind="option-colour"
|
| >
|
| <div className="cg-swatch-grid">
|
| {BRAND_SWATCHES.map((swatch) => (
|
| <button
|
| key={swatch.id}
|
| type="button"
|
| className={"cg-swatch" + (current?.id === swatch.id ? " is-on" : "")}
|
| style={{ background: swatch.bg, color: swatch.fg }}
|
| aria-label={swatch.label}
|
| aria-pressed={current?.id === swatch.id}
|
| title={swatch.label}
|
| onClick={() => {
|
| onPick(swatch.bg);
|
| setOpen(false);
|
| }}
|
| >
|
| {/* The check is drawn in the swatch's OWN ink, which is also the ink its
|
| chips will use β so the mark doubles as a preview of readability. */}
|
| {current?.id === swatch.id && (
|
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
| <path
|
| d="m3.5 8.5 3 3 6-6.5"
|
| stroke="currentColor"
|
| strokeWidth="2"
|
| strokeLinecap="round"
|
| strokeLinejoin="round"
|
| />
|
| </svg>
|
| )}
|
| </button>
|
| ))}
|
| </div>
|
| </AnchoredOverlay>
|
| )}
|
| </>
|
| );
|
| }
|
|
|
| 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<OptionDraft>) =>
|
| onDrafts(drafts.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft)));
|
| const settings = optionSettings(drafts, type);
|
| return (
|
| <div className="cg-options-editor">
|
| <div className="cg-options-head">
|
| <strong>Options</strong>
|
| <label className="cg-option-toggle">
|
| <input
|
| type="checkbox"
|
| checked={colorCode}
|
| onChange={(event) => onColorCode(event.target.checked)}
|
| />
|
| <span>Color-code options</span>
|
| </label>
|
| </div>
|
| <div className="cg-option-list">
|
| {drafts.map((draft, index) => (
|
| <div className="cg-option-row" key={draft.id}>
|
| <SwatchPicker
|
| value={
|
| normalizeOptionColor(draft.color) ??
|
| OPTION_PALETTE[index % OPTION_PALETTE.length].bg
|
| }
|
| disabled={!colorCode}
|
| label={draft.label || `option ${index + 1}`}
|
| onPick={(hex) => update(draft.id, { color: hex })}
|
| />
|
| <input
|
| id={`${idPrefix}-${draft.id}`}
|
| className="cg-input cg-option-name"
|
| type="text"
|
| maxLength={120}
|
| value={draft.label}
|
| placeholder="Option name"
|
| aria-label={`Option ${index + 1}`}
|
| onChange={(event) => {
|
| 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,
|
| },
|
| ]);
|
| }}
|
| />
|
| <button
|
| type="button"
|
| className="cg-option-remove"
|
| aria-label={`Remove ${draft.label || `option ${index + 1}`}`}
|
| title="Remove option"
|
| onClick={() => onDrafts(drafts.filter((item) => item.id !== draft.id))}
|
| >
|
| <svg viewBox="0 0 16 16" aria-hidden="true">
|
| <path d="M4 4l8 8M12 4l-8 8" />
|
| </svg>
|
| </button>
|
| </div>
|
| ))}
|
| </div>
|
| <button
|
| type="button"
|
| className="cg-option-add"
|
| onClick={() =>
|
| onDrafts([
|
| ...drafts,
|
| {
|
| id: nextOptionDraftId(),
|
| label: "",
|
| color: OPTION_PALETTE[drafts.length % OPTION_PALETTE.length].bg,
|
| },
|
| ])
|
| }
|
| >
|
| Add option
|
| </button>
|
| <span className="cg-field-hint">
|
| {settings.options.length} option{settings.options.length === 1 ? "" : "s"}.
|
| {type === "multiselect" && " Commas inside an option are not supported."}
|
| </span>
|
| </div>
|
| );
|
| }
|
|
|
| /**
|
| * 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 (
|
| <div className="cg-type-picker">
|
| <div className="cg-type-search">
|
| <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| <circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" />
|
| <path
|
| d="m10.4 10.4 3.1 3.1"
|
| stroke="currentColor"
|
| strokeWidth="1.35"
|
| strokeLinecap="round"
|
| />
|
| </svg>
|
| <input
|
| type="text"
|
| value={q}
|
| placeholder="Find a field type"
|
| aria-label="Find a field type"
|
| onChange={(event) => setQ(event.target.value)}
|
| />
|
| </div>
|
| <div className="cg-type-list" role="listbox" aria-label="Field type">
|
| {rows.map((r) => (
|
| <button
|
| key={r.value}
|
| type="button"
|
| role="option"
|
| aria-selected={value === r.value}
|
| className={"cg-type-row" + (value === r.value ? " is-on" : "")}
|
| onClick={() => onPick(r.value)}
|
| >
|
| <FieldTypeIcon type={r.value === "measure" ? "currency" : r.value} size={16} />
|
| <span className="cg-type-label">{r.label}</span>
|
| {value === r.value && (
|
| <svg
|
| className="cg-type-check"
|
| width="14"
|
| height="14"
|
| viewBox="0 0 16 16"
|
| fill="none"
|
| aria-hidden="true"
|
| >
|
| <path
|
| d="m3.5 8.5 3 3 6-6.5"
|
| stroke="currentColor"
|
| strokeWidth="1.6"
|
| strokeLinecap="round"
|
| strokeLinejoin="round"
|
| />
|
| </svg>
|
| )}
|
| </button>
|
| ))}
|
| {rows.length === 0 && <div className="cg-pop-note">No matching type.</div>}
|
| </div>
|
| </div>
|
| );
|
| }
|
|
|
| /** 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 (
|
| <div className="cg-column-head">
|
| <div>
|
| <div className="cg-column-title">{title}</div>
|
| {sub && <div className="cg-column-type">{sub}</div>}
|
| </div>
|
| <button
|
| type="button"
|
| className="cg-icon-btn"
|
| onClick={onClose}
|
| aria-label="Close column menu"
|
| >
|
| Γ
|
| </button>
|
| </div>
|
| );
|
| }
|
|
|
| /**
|
| * 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 (
|
| <label>
|
| <span>Scope</span>
|
| {/* β wave17 R1 β "cohort" scope means *served ONLY on the cohort TABLE*, and that table
|
| (the retired `#/cohort` surface) no longer exists. A field created with it today would
|
| be reachable from nowhere. So it is DISABLED for new selection.
|
|
|
| β It is still RENDERED, and only disabled when it is not the current value. A `<select>`
|
| whose `value` matches no option renders the FIRST option instead
|
| ([[cg-condition-builder-items]]) β dropping the row outright would silently re-scope
|
| every existing cohort-scoped field to global on the next save, which is a data change
|
| nobody asked for wearing a cosmetic edit's clothes. */}
|
| <select
|
| className="cg-select cg-scope-select"
|
| value={value}
|
| aria-label="Field scope"
|
| onChange={(event) => onValue(event.target.value as FieldScope)}
|
| >
|
| <option value="cohort" disabled={value !== "cohort"}>
|
| The cohort table only (retired)
|
| </option>
|
| <option value="global">All customer tables</option>
|
| </select>
|
| </label>
|
| );
|
| }
|
|
|
| /**
|
| * 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 <select> for a `code` kind, and
|
| * an unwired one would be a control that configures nothing, which is worse than no control
|
| * ([[wrong-parent-not-broken-control]]). Every mount can reach the kind, so every mount says
|
| * which draft it is editing. */
|
| codeLanguage: string;
|
| onCodeLanguage: (v: string) => void;
|
| idPrefix: string;
|
| showFormulaHelp?: boolean;
|
| /** C5-AUTOFIELD β the automation column's config drafts. Optional so the call sites that
|
| * cannot reach an automation type (the Change-field "new field" form) need no change. */
|
| automationKind?: string;
|
| onAutomationKind?: (v: string) => void;
|
| automationUrlField?: string;
|
| onAutomationUrlField?: (v: string) => void;
|
| /** β 2026-08-07 β the relational drafts. Optional for the same reason the automation ones
|
| * are: the Change-field "new field" form reaches these kinds too, and a call site that does
|
| * not offer them should need no change. */
|
| linkTargets?: LinkTarget[];
|
| linkTable?: string;
|
| onLinkTable?: (v: string) => void;
|
| linkSingle?: boolean;
|
| onLinkSingle?: (v: boolean) => void;
|
| /** β 2026-08-09 β which KIND of rollup is being built. `"link"` folds workspace rows;
|
| * `"source"` reads through to a governed Odoo topic without copying a row into the store. */
|
| rollupMode?: "link" | "source";
|
| onRollupMode?: (v: "link" | "source") => void;
|
| rollupSourceOffer?: RollupSourceOffer;
|
| rollupSource?: RollupSource;
|
| onRollupSource?: (v: RollupSource) => void;
|
| rollupLink?: string;
|
| onRollupLink?: (v: string) => void;
|
| rollupField?: string;
|
| onRollupField?: (v: string) => void;
|
| rollupFn?: RollupFn;
|
| onRollupFn?: (v: RollupFn) => void;
|
| rollupLimit?: number;
|
| onRollupLimit?: (v: number) => void;
|
| rollupSortBy?: string;
|
| onRollupSortBy?: (v: string) => void;
|
| rollupDistinctBy?: string;
|
| onRollupDistinctBy?: (v: string) => void;
|
| /** ββ 2026-08-09 β the PRE-filter (`rollup.where`): which linked records the window may
|
| * spend its slots on. Separate from `rollupConditions`, which runs after it. */
|
| rollupWhere?: RollupCondition[];
|
| onRollupWhere?: (v: RollupCondition[]) => void;
|
| rollupWhereConj?: "and" | "or";
|
| onRollupWhereConj?: (v: "and" | "or") => void;
|
| rollupConditions?: RollupCondition[];
|
| onRollupConditions?: (v: RollupCondition[]) => void;
|
| rollupConditionConj?: "and" | "or";
|
| onRollupConditionConj?: (v: "and" | "or") => void;
|
| }) {
|
| if (kind === "formula") {
|
| // 2026-07-31 (owner item 2): other FORMULA fields are referencable now β evaluation is
|
| // topological and a cycle is refused at validation, so the old exclusion is gone.
|
| const referencable = fields.filter((f) => f.key !== "__proto__");
|
| return (
|
| <>
|
| <label>
|
| <span>Formula</span>
|
| <textarea
|
| className="cg-input cg-formula-editor"
|
| rows={3}
|
| value={formulaText}
|
| placeholder={"MAX(0, {revenue_ly} - {revenue_ytd})"}
|
| aria-label="Formula"
|
| onChange={(event) => onFormulaText(event.target.value)}
|
| />
|
| </label>
|
| <div className="cg-cond-line cg-formula-insert">
|
| <FieldSelectButton
|
| ariaLabel="Insert a field reference"
|
| placeholder="Insert a field referenceβ¦"
|
| // An ACTION picker, not a value one: it never holds a selection, so it stays in
|
| // the placeholder state and `keepOpen` lets a formula take three references
|
| // without three trips through the button.
|
| value={undefined}
|
| keepOpen
|
| onChange={(key) =>
|
| onFormulaText((formulaText + " {" + key + "}").trimStart())
|
| }
|
| fields={referencable.map((f) => ({
|
| key: f.key, label: f.label, type: f.type,
|
| }))}
|
| />
|
| </div>
|
| {/*
|
| β 2026-08-10 β THE BLANK TRAP, said before it is hit rather than after.
|
|
|
| `{a} - {b}` is BLANK when `b` is blank, because arithmetic propagates a missing
|
| operand β so the trimmed-average chain the owner built ("views minus the outliers")
|
| blanks its whole column on exactly the profiles that have NO outlier to subtract,
|
| which is the majority of them. That reads as "the formula is broken" and the column
|
| it blanks is the one it was built for.
|
|
|
| The idiom is Excel's own: the AGGREGATE functions skip blanks, so `SUM({b}, 0)` is
|
| "b, or nothing at all". Shown ALWAYS, not folded into the function list under
|
| `showFormulaHelp` β a list of 30 names is reference, and this is the one sentence
|
| that changes what somebody types. Kept to one line (DESIGN.md Β§4).
|
| */}
|
| <div className="cg-field-hint cg-formula-blank-hint">
|
| A blank in any part blanks the whole result. Wrap an optional column as{" "}
|
| {"SUM({field}, 0)"} to read it as zero.
|
| </div>
|
| {formulaText.trim() !== "" && formulaError ? (
|
| <div className="cg-field-hint cg-formula-error">{formulaError}</div>
|
| ) : showFormulaHelp ? (
|
| <div className="cg-field-hint">
|
| Excel-style: numbers, text in quotes, {"{field}"} references, + β Γ Γ· ^ & ( ),
|
| IF Β· AND Β· OR Β· SUM Β· AVERAGE Β· COUNT Β· MIN Β· MAX Β· ROUND(UP/DOWN) Β· ABS Β· MOD Β·
|
| SQRT Β· CONCATENATE Β· LEFT Β· RIGHT Β· MID Β· LEN Β· TRIM Β· UPPER Β· LOWER Β· PROPER Β·
|
| TEXT Β· VALUE Β· TODAY Β· DAYS Β· YEAR Β· MONTH Β· DAY Β· IFERROR Β· ISBLANK.
|
| Errors show a blank cell.
|
| </div>
|
| ) : null}
|
| </>
|
| );
|
| }
|
| if (kind === "rating") {
|
| return (
|
| <label>
|
| <span>Number of stars</span>
|
| <input
|
| id={`${idPrefix}-rating-max`}
|
| className="cg-input cg-rating-max"
|
| type="number"
|
| min={2}
|
| max={10}
|
| value={maxValue}
|
| aria-label="Number of stars"
|
| onChange={(event) => {
|
| const n = Math.round(Number(event.target.value));
|
| onMaxValue(Number.isFinite(n) ? Math.max(2, Math.min(10, n)) : 5);
|
| }}
|
| />
|
| </label>
|
| );
|
| }
|
| if (kind === "code") {
|
| // β Wave-27 item 13 (R13) β the create form's only code-specific question. The hint is not
|
| // decoration: "code" in a product that also runs automations invites the assumption that
|
| // picking a language makes the snippet executable, and R13 is explicit that there is no
|
| // execution engine. Saying so at the moment of choosing is cheaper than a support answer.
|
| return (
|
| <label>
|
| <span>Language</span>
|
| <select
|
| id={`${idPrefix}-code-language`}
|
| className="cg-select"
|
| value={codeLanguage}
|
| aria-label="Code language"
|
| onChange={(event) => onCodeLanguage(event.target.value)}
|
| >
|
| {CODE_LANGUAGES.map((lang) => (
|
| <option key={lang} value={lang}>
|
| {CODE_LANGUAGE_LABELS[lang]}
|
| </option>
|
| ))}
|
| </select>
|
| <span className="cg-field-hint">
|
| Chooses how the snippet is coloured. It is never run.
|
| </span>
|
| </label>
|
| );
|
| }
|
| if (kind === "created_time") {
|
| return (
|
| <div className="cg-field-hint">
|
| Read-only. When the record was created in the source system.
|
| </div>
|
| );
|
| }
|
| if (kind === "automation") {
|
| // C5-AUTOFIELD β the field's GEAR. An automation column has no value the user types, so
|
| // this pane configures what a RUN does instead: which job, and which column carries the
|
| // thing it reads. The schedule and the Run-now button are NOT here β they belong to the
|
| // automation itself, on the Automation surface, because one job can fill a column across
|
| // many records and its cadence is a property of the job, not of the column.
|
| const urlish = fields.filter((f) => f.type === "url" || f.type === "text");
|
| const chosen = AUTOMATION_KINDS.find((k) => k.key === automationKind)
|
| ?? AUTOMATION_KINDS[0];
|
| return (
|
| <>
|
| <label>
|
| <span>What it reads</span>
|
| <select
|
| id={`${idPrefix}-automation-kind`}
|
| className="cg-input"
|
| value={automationKind}
|
| aria-label="Automation kind"
|
| onChange={(event) => onAutomationKind?.(event.target.value)}
|
| >
|
| {AUTOMATION_KINDS.map((k) => (
|
| <option key={k.key} value={k.key}>
|
| {k.label}
|
| </option>
|
| ))}
|
| </select>
|
| </label>
|
| <label>
|
| <span>URL column</span>
|
| <select
|
| id={`${idPrefix}-automation-url`}
|
| className="cg-input"
|
| value={automationUrlField}
|
| aria-label="URL column"
|
| onChange={(event) => onAutomationUrlField?.(event.target.value)}
|
| >
|
| <option value="">Choose a columnβ¦</option>
|
| {urlish.map((f) => (
|
| <option key={f.key} value={f.key}>
|
| {f.label}
|
| </option>
|
| ))}
|
| </select>
|
| </label>
|
| <div className="cg-field-hint">{chosen.hint}</div>
|
| <div className="cg-field-hint">
|
| Cells here are written by the run and cannot be typed into β a value you entered
|
| would be replaced by the next run without saying so.
|
| </div>
|
| {/* A v1 LIMIT, said out loud rather than discovered. The column can be created on any
|
| table, but an Instagram automation only accepts a blank database as its target
|
| (`clean_config('field_instagram')`), because the run writes rows and an Odoo-sourced
|
| table is read-only at the source. Without this line a user creates the column on
|
| Customer, goes to Automation, and finds no way to point anything at it. */}
|
| <div className="cg-field-hint">
|
| Runs target a blank database. On a connector-backed table this column can be created
|
| but no automation can write to it yet.
|
| </div>
|
| </>
|
| );
|
| }
|
| if (kind === "link") {
|
| // β 2026-08-07 β Airtable's "Link to another record", and the whole editor is ONE question:
|
| // which database. The relation itself is then the cell (a set of linked rows).
|
| return (
|
| <>
|
| <label>
|
| <span>Database</span>
|
| <select
|
| id={`${idPrefix}-link-table`}
|
| className="cg-input"
|
| value={linkTable}
|
| aria-label="Database to link to"
|
| onChange={(event) => onLinkTable?.(event.target.value)}
|
| >
|
| <option value="">Choose a databaseβ¦</option>
|
| {linkTargets.map((t) => (
|
| <option key={t.key} value={t.key}>{t.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| {linkTargets.length === 0 ? (
|
| <div className="cg-field-hint">
|
| There is no other database to link to yet. Create one first, then come back.
|
| </div>
|
| ) : null}
|
| <label className="cg-cond-line">
|
| <input
|
| type="checkbox"
|
| checked={linkSingle}
|
| aria-label="Allow only one linked record"
|
| onChange={(event) => onLinkSingle?.(event.target.checked)}
|
| />
|
| <span>Allow only one linked record</span>
|
| </label>
|
| <div className="cg-field-hint">
|
| A cell here holds the records you pick. Add a Rollup column afterwards to summarise a
|
| column of those records β an average, a total, a count.
|
| </div>
|
| </>
|
| );
|
| }
|
| if (kind === "rollup") {
|
| // β 2026-08-07 β Airtable's rollup, plus the LAST-N window Airtable does not have.
|
| //
|
| // The editor reads as the sentence the column answers, in order: through WHICH relation, over
|
| // WHICH column, folded HOW, and across WHICH records. That last control is the owner's
|
| // headline ("average Views over last N posts") and Airtable cannot express it β its rollup
|
| // conditions filter by predicate, never by rank.
|
| const linkFields = fields.filter((f) => f.type === "link");
|
| const chosenLink = linkFields.find((f) => f.key === rollupLink);
|
| const target = linkTargets.find((t) => t.key === chosenLink?.link?.table);
|
| const targetFields = target?.fields ?? [];
|
| // β COUNTALL IS THE ONE FUNCTION WITH NO SOURCE COLUMN β it counts linked RECORDS, so the
|
| // field picker would be a control that configures nothing. Airtable's rule, and ours.
|
| const needsField = rollupFn !== "countall";
|
| // ββ 2026-08-09 β THE SECOND KIND OF ROLLUP. Everything above summarises rows that live in
|
| // this workspace; a SOURCE rollup summarises rows that were never copied here β one grouped
|
| // SQL query over the governed Odoo model answers every parent row at once. That is the only
|
| // way a column can total 256,810 order lines, which is 4x what a database here may hold.
|
| const sourceTopics = rollupSourceOffer.topics;
|
| const topic = sourceTopics.find((t) => t.key === rollupSource.topic);
|
| const dim = topic?.dims.find((d) => d.key === rollupSource.groupBy);
|
| const setSource = (patch: Partial<RollupSource>) =>
|
| onRollupSource?.({ ...rollupSource, ...patch });
|
| // β A join column must hold a SCALAR the group key can equal. Link and rollup columns hold a
|
| // comma-joined id list and a computed value respectively, so neither can ever match.
|
| const joinable = fields.filter(
|
| (f) => f.type !== "link" && f.type !== "rollup" && f.key !== "__proto__"
|
| );
|
| const sourceEditor = (
|
| <>
|
| <label>
|
| <span>Odoo data</span>
|
| <select
|
| id={`${idPrefix}-rollup-topic`}
|
| className="cg-input"
|
| value={rollupSource.topic}
|
| aria-label="Odoo topic"
|
| onChange={(event) =>
|
| // β CLEAR THE MEASURE AND THE DIM WITH THE TOPIC. Both are keys OF the topic, so
|
| // carrying them across a topic change leaves a bag naming a measure the new topic
|
| // does not have β which the save door refuses, on a form that looks complete.
|
| setSource({ topic: event.target.value, measure: "", groupBy: "" })
|
| }
|
| >
|
| <option value="">Choose what to summariseβ¦</option>
|
| {sourceTopics.map((t) => (
|
| <option key={t.key} value={t.key}>{t.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| {sourceTopics.length === 0 ? (
|
| <div className="cg-field-hint">
|
| No live data sources are available for this workspace. A source rollup reads a
|
| connected system; this one has none connected yet.
|
| </div>
|
| ) : null}
|
| {topic ? (
|
| <>
|
| <label>
|
| <span>Measure</span>
|
| <select
|
| id={`${idPrefix}-rollup-measure`}
|
| className="cg-input"
|
| value={rollupSource.measure}
|
| aria-label="Measure"
|
| onChange={(event) => setSource({ measure: event.target.value })}
|
| >
|
| <option value="">Choose a measureβ¦</option>
|
| {topic.measures.map((m) => (
|
| <option key={m.key} value={m.key}>{m.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| <label>
|
| <span>One row per</span>
|
| <select
|
| id={`${idPrefix}-rollup-dim`}
|
| className="cg-input"
|
| value={rollupSource.groupBy}
|
| aria-label="Group by"
|
| onChange={(event) => setSource({ groupBy: event.target.value })}
|
| >
|
| <option value="">Choose a groupingβ¦</option>
|
| {topic.dims.map((d) => (
|
| <option key={d.key} value={d.key}>{d.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| <label>
|
| <span>Matched on</span>
|
| <select
|
| id={`${idPrefix}-rollup-on`}
|
| className="cg-input"
|
| value={rollupSource.on}
|
| aria-label="Column to match on"
|
| onChange={(event) => setSource({ on: event.target.value })}
|
| >
|
| <option value="">Choose this database's columnβ¦</option>
|
| {joinable.map((f) => (
|
| <option key={f.key} value={f.key}>{f.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| {/* β THE JOIN IS THE ONE THING THE SERVER CANNOT CHECK. It knows the measure exists
|
| and the dim is groupable; it cannot know that THIS column holds the same kind of
|
| value the grouping keys by. A wrong match is not an error β it is a column of
|
| blanks. So the control says which kind of value it needs. */}
|
| {dim ? (
|
| <div className="cg-field-hint">
|
| {dim.keyedBy === "id"
|
| ? `Pick the column holding the Odoo ID for ${dim.label.toLowerCase()}. Matching on a name instead leaves every cell blank.`
|
| : `Pick the column holding the ${dim.label.toLowerCase()} value itself β this grouping keys by its own value, not by an ID.`}
|
| </div>
|
| ) : null}
|
| <label>
|
| <span>Over</span>
|
| <select
|
| id={`${idPrefix}-rollup-window`}
|
| className="cg-input"
|
| value={rollupSource.window || "all_time"}
|
| aria-label="Date window"
|
| onChange={(event) => setSource({ window: event.target.value })}
|
| >
|
| {rollupSourceOffer.windows.map((w) => (
|
| <option key={w.key} value={w.key}>{w.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| <div className="cg-field-hint">
|
| Recomputed against live data β the window moves with today's date rather than
|
| freezing the dates it was created on.
|
| </div>
|
| </>
|
| ) : null}
|
| </>
|
| );
|
| const linkEditor = (
|
| <>
|
| <label>
|
| <span>Through</span>
|
| <select
|
| id={`${idPrefix}-rollup-link`}
|
| className="cg-input"
|
| value={rollupLink}
|
| aria-label="Link column"
|
| onChange={(event) => onRollupLink?.(event.target.value)}
|
| >
|
| <option value="">Choose a link columnβ¦</option>
|
| {linkFields.map((f) => (
|
| <option key={f.key} value={f.key}>{f.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| {linkFields.length === 0 ? (
|
| <div className="cg-field-hint">
|
| This database has no link column yet. A rollup summarises records reached through a
|
| link, so add one of those first.
|
| </div>
|
| ) : null}
|
| <label>
|
| <span>Fold</span>
|
| <select
|
| id={`${idPrefix}-rollup-fn`}
|
| className="cg-input"
|
| value={rollupFn}
|
| aria-label="Rollup function"
|
| onChange={(event) => onRollupFn?.(event.target.value as RollupFn)}
|
| >
|
| {ROLLUP_FNS.map((fn) => (
|
| <option key={fn} value={fn}>{ROLLUP_FN_LABELS[fn]}</option>
|
| ))}
|
| </select>
|
| </label>
|
| {needsField ? (
|
| <label>
|
| <span>Column</span>
|
| <select
|
| id={`${idPrefix}-rollup-field`}
|
| className="cg-input"
|
| value={rollupField}
|
| aria-label="Column to roll up"
|
| onChange={(event) => onRollupField?.(event.target.value)}
|
| >
|
| <option value="">Choose a columnβ¦</option>
|
| {targetFields.map((f) => (
|
| <option key={f.key} value={f.key}>{f.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| ) : null}
|
| {/*
|
| ββ 2026-08-09 Β· D-99 β THE STATE THE OWNER'S REPORT DESCRIBES, SAID OUT LOUD.
|
| Owner: *"if we want to use Rollup field to apply to the related database, it doesn't
|
| work. Because the linked Field can't be selected yet."*
|
|
|
| β WHAT IS ACTUALLY REACHABLE HERE, measured rather than guessed. The `Through` picker
|
| offers EVERY link column β `linkFields` filters on `f.type === "link"` and excludes
|
| nothing preset, derived or `automation:`-tagged, so the scouted hypothesis ("the picker
|
| omits preset links") is refuted at source. What CAN happen is one step later: the
|
| target is resolved out of `linkTargets`, which is `GET /tables` filtered by
|
| `user_tables.may_open` (creator, admin, or an explicit share). An automation-OWNED child
|
| dataset is stamped with a machine owner, so for a user who is neither its creator nor an
|
| admin it is simply ABSENT from that list β and then `targetFields` is `[]`, this Column
|
| picker holds nothing but its placeholder, and `Add condition` below is disabled.
|
|
|
| β AND UNTIL NOW IT SAID NOTHING. An empty picker beside a dead button reads as "this
|
| feature is broken"; it is in fact a permission fact about ONE database, and the fix for
|
| it is a share, not a bug report. This is the absent-vs-empty third state the automation
|
| panel already distinguishes ("No view list was offered for that database") β the same
|
| discipline, applied to the surface that actually got reported.
|
| β IT NAMES THE TABLE RATHER THAN APOLOGISING. `chosenLink.link.table` is the key the
|
| bag points at, and printing it is what turns "it doesn't work" into a thing to go and
|
| grant.
|
| */}
|
| {chosenLink && !target ? (
|
| <div className="cg-field-hint">
|
| This link points at <b>{chosenLink.link?.table}</b>, which is not among the databases
|
| you can open β so its columns cannot be listed here and the conditions below stay
|
| unavailable. Ask an admin to share that database with you, then reopen this editor.
|
| </div>
|
| ) : null}
|
| {/*
|
| ββ 2026-08-09 (owner) β THE PRE-FILTER, ABOVE THE WINDOW, AND THE SCREEN ORDER IS THE
|
| ORDER OF OPERATIONS. Owner: *"instead of last 12 posts, we also want to make it so its
|
| last N record, where the record's Status is video."*
|
|
|
| Everything below this block narrows a set that this block has already chosen:
|
| include β order β how many β deduplicate β then keep only. Reading top to bottom is
|
| reading what the server does, in sequence. The post-filter list used to sit HERE, above
|
| the window it runs after, which is the one arrangement that teaches the wrong model.
|
| */}
|
| <RollupConditionList
|
| idPrefix={`${idPrefix}-rollup-where`}
|
| title="Include records"
|
| hint={"Applied BEFORE the window below, so βlast 10β counts only records that "
|
| + "match. View filters never change a Rollupβs result."}
|
| conditions={rollupWhere}
|
| onConditions={(v) => onRollupWhere?.(v)}
|
| conj={rollupWhereConj}
|
| onConj={(v) => onRollupWhereConj?.(v)}
|
| targetFields={targetFields}
|
| allowRef={false}
|
| />
|
| <label>
|
| <span>Across</span>
|
| <select
|
| id={`${idPrefix}-rollup-sort`}
|
| className="cg-input"
|
| value={rollupSortBy}
|
| aria-label="Which records to include"
|
| onChange={(event) => onRollupSortBy?.(event.target.value)}
|
| >
|
| <option value="">Every linked record</option>
|
| {targetFields.map((f) => (
|
| <option key={f.key} value={f.key}>Most recent by {f.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| {rollupSortBy ? (
|
| <label>
|
| <span>How many</span>
|
| <input
|
| type="number"
|
| className="cg-input"
|
| min={1}
|
| value={rollupLimit || 1}
|
| aria-label="How many records"
|
| onChange={(event) =>
|
| onRollupLimit?.(Math.max(1, Number(event.target.value) || 1))
|
| }
|
| />
|
| </label>
|
| ) : null}
|
| <label>
|
| <span>Deduplicate by</span>
|
| <select
|
| id={`${idPrefix}-rollup-distinct`}
|
| className="cg-input"
|
| value={rollupDistinctBy}
|
| aria-label="Deduplicate linked records by"
|
| onChange={(event) => onRollupDistinctBy?.(event.target.value)}
|
| >
|
| <option value="">Keep every linked row</option>
|
| {targetFields.map((f) => (
|
| <option key={f.key} value={f.key}>One row per {f.label}</option>
|
| ))}
|
| </select>
|
| </label>
|
| {/* β THE RULE STATED AT THE CONTROL, because the server refuses the pair and a refusal
|
| the user meets after pressing Create is a refusal they had no way to avoid. "The
|
| last N" with no declared order is not a measurement β it is whichever N rows happen
|
| to sit first in the store. */}
|
| <div className="cg-field-hint">
|
| {rollupSortBy
|
| ? `The ${rollupLimit || 1} most recent linked records, newest first.`
|
| : "Every linked record. Choose an order above to summarise just the most recent few."}
|
| </div>
|
| {rollupDistinctBy ? (
|
| <div className="cg-field-hint">
|
| Duplicate linked rows with the same identity count once; the newest row wins when an
|
| order is selected.
|
| </div>
|
| ) : null}
|
| {/*
|
| ββ WAVE 28 Β· C1 β THE POST-FILTER, BELOW THE WINDOW, WHICH IS WHERE IT ALWAYS RAN.
|
| Only its POSITION on screen moved (2026-08-09), and the move is the point: this list
|
| selects among the records the window above already kept, so a threshold measured "over
|
| this set" is measured over exactly those. That is why the Ο operators live here and are
|
| refused in the pre-filter β before the window there is no set for a mean to be about.
|
|
|
| β THE HINT HOLDS LITERAL CHARACTERS, never `\u`-escapes. It shipped to the live Space
|
| printing `it kept β which is what a βΟ from the meanβ` ON SCREEN,
|
| because the escapes were written with a DOUBLED backslash and the JS string then had
|
| nothing left to interpret. `tsc` is happy β a wrong string is valid TypeScript β and no
|
| gate reads prose. It was caught by opening the deployed pane and LOOKING at it
|
| ([[ui-invisible-to-assertions]]).
|
| */}
|
| <RollupConditionList
|
| idPrefix={`${idPrefix}-rollup-cond`}
|
| title="Then keep only"
|
| hint={"Applied AFTER the window above, over exactly the records it kept β which is "
|
| + "what a βΟ from the meanβ threshold is measured against."}
|
| conditions={rollupConditions}
|
| onConditions={(v) => onRollupConditions?.(v)}
|
| conj={rollupConditionConj}
|
| onConj={(v) => onRollupConditionConj?.(v)}
|
| targetFields={targetFields}
|
| allowRef
|
| />
|
| <div className="cg-field-hint">
|
| Computed for you and refreshed as the linked records change β the cell cannot be typed
|
| into.
|
| </div>
|
| </>
|
| );
|
| // β THE MODE SWITCH RENDERS ONLY WHEN THERE IS A CHOICE TO MAKE. On a workspace with no
|
| // connected source the offer is empty, and a two-way toggle whose second option can never
|
| // work is a control that configures nothing ([[wrong-parent-not-broken-control]]). The link
|
| // editor is then simply what a rollup is, exactly as it was before this existed.
|
| if (!sourceTopics.length) return linkEditor;
|
| return (
|
| <>
|
| <label>
|
| <span>Summarise</span>
|
| <select
|
| id={`${idPrefix}-rollup-mode`}
|
| className="cg-input"
|
| value={rollupMode}
|
| aria-label="What this rollup summarises"
|
| onChange={(event) => onRollupMode?.(event.target.value as "link" | "source")}
|
| >
|
| <option value="link">Records in this workspace</option>
|
| <option value="source">Live Odoo data</option>
|
| </select>
|
| </label>
|
| <div className="cg-field-hint">
|
| {rollupMode === "source"
|
| ? "Answered straight from the source β it can total hundreds of thousands of rows because none of them are copied into this database."
|
| : "Folds the records a link column reaches, which must already exist in this workspace."}
|
| </div>
|
| {rollupMode === "source" ? sourceEditor : linkEditor}
|
| </>
|
| );
|
| }
|
| return null;
|
| }
|
|
|
| export default function ColumnMenu({
|
| state,
|
| field,
|
| fields,
|
| linkTargets = [],
|
| rollupSourceOffer = { topics: [], windows: [] },
|
| locked,
|
| schemaLocked = false,
|
| viewer,
|
| sortedDir,
|
| isFiltered,
|
| isGrouped,
|
| groupable,
|
| onClose,
|
| onNote,
|
| onHide,
|
| onCreate,
|
| onChangeField,
|
| onCreateAndSwap,
|
| onProfileFlag,
|
| onRetype,
|
| onFieldConfig,
|
| onRename,
|
| onFormula,
|
| initialPosition,
|
| initialPane,
|
| scopeChoice = false,
|
| pinnedTo = false,
|
| onPinTo,
|
| onUnpin,
|
| onPeriod,
|
| onDelete,
|
| onDuplicate,
|
| onPermissions,
|
| onFormat,
|
| onAggregate,
|
| onSort,
|
| onClearSort,
|
| onFilterBy,
|
| onClearFilter,
|
| onGroupByField,
|
| onClearGroup,
|
| userOptions = [],
|
| measures = [],
|
| }: ColumnMenuProps) {
|
| const [pane, setPane] = useState<MenuPane>(schemaLocked ? "menu" : initialPane ?? "menu");
|
| const [note, setNote] = useState(field.note ?? "");
|
| const [position, setPosition] = useState<CreatePosition | null>(initialPosition ?? null);
|
| /** Delete is DESTRUCTIVE (a custom field's stored values go with it) β first click arms,
|
| * second click fires. Reopening the menu disarms (state is per-mount, keyed by field). */
|
| const [confirmDelete, setConfirmDelete] = useState(false);
|
| const [label, setLabel] = useState("");
|
| /** Whether the user has typed their own name β an auto-suggested measure label must never
|
| * overwrite one, but should keep tracking the measure/window until they do. */
|
| const [labelTouched, setLabelTouched] = useState(false);
|
| const [kind, setKind] = useState<CreateKind>("text");
|
| const [createOptionDrafts, setCreateOptionDrafts] = useState<OptionDraft[]>(() =>
|
| optionDrafts(DEFAULT_OPTION_LABELS)
|
| );
|
| const [createColorCode, setCreateColorCode] = useState(true);
|
| const [measureKey, setMeasureKey] = useState(measures[0]?.key ?? "");
|
| const [measureWindow, setMeasureWindow] = useState<WindowSpec>(DEFAULT_MEASURE_WINDOW);
|
| /** Wave-5 item 9/11 β the create form's formula source and rating star count. */
|
| const [formulaText, setFormulaText] = useState("");
|
| const [maxValue, setMaxValue] = useState(5);
|
| /** β Wave-27 item 13 (R13) β the create form's code LANGUAGE. Seeded to `plain`, which is
|
| * also what an absent bag means, so creating without touching this stores nothing extra. */
|
| const [codeLanguage, setCodeLanguage] = useState<string>("plain");
|
| /** C5-AUTOFIELD β the create form's automation config. */
|
| const [automationKind, setAutomationKind] = useState(AUTOMATION_KINDS[0].key);
|
| const [automationUrlField, setAutomationUrlField] = useState(
|
| () => fields.find((f) => f.type === "url")?.key ?? ""
|
| );
|
| /**
|
| * β 2026-08-07 β the create form's LINK and ROLLUP configuration.
|
| *
|
| * β BOTH KINDS ARE UNCREATABLE WITHOUT THEIR BAG β `core.user_tables._clean_field` returns
|
| * None for a `link` with no `link` bag and for a `rollup` with no `rollup` bag β so `canCreate`
|
| * gates on these below rather than letting the server refuse a column the user just named.
|
| * That refusal would be silent in the worst way: created, named, configured, gone on reload.
|
| */
|
| const [linkTable, setLinkTable] = useState("");
|
| const [linkSingle, setLinkSingle] = useState(false);
|
| /** β 2026-08-09 β the read-through half. `rollupMode` decides which bag `extraFor` builds;
|
| * the two are mutually exclusive because the host's `_clean_rollup` returns the `source`
|
| * shape before it ever reads `link`. */
|
| const [rollupMode, setRollupMode] = useState<"link" | "source">("link");
|
| const [rollupSource, setRollupSource] = useState<RollupSource>({
|
| topic: "", measure: "", groupBy: "", on: "", window: "ytd",
|
| });
|
| const [rollupLink, setRollupLink] = useState("");
|
| const [rollupField, setRollupField] = useState("");
|
| const [rollupFn, setRollupFn] = useState<RollupFn>("sum");
|
| /** 0 = every linked record (Airtable's only behaviour). Non-zero needs a sort β see below. */
|
| const [rollupLimit, setRollupLimit] = useState(0);
|
| const [rollupSortBy, setRollupSortBy] = useState("");
|
| const [rollupDistinctBy, setRollupDistinctBy] = useState("");
|
| const [rollupWhere, setRollupWhere] = useState<RollupCondition[]>([]);
|
| const [rollupWhereConj, setRollupWhereConj] = useState<"and" | "or">("and");
|
| const [rollupConditions, setRollupConditions] = useState<RollupCondition[]>([]);
|
| const [rollupConditionConj, setRollupConditionConj] = useState<"and" | "or">("and");
|
| const [swapTo, setSwapTo] = useState("");
|
| /** The "New field" half of Change-field keeps its own name/options β a half-typed insert
|
| * form must not leak into a swap and vice versa. */
|
| const [swapLabel, setSwapLabel] = useState("");
|
| const [swapOptionDrafts, setSwapOptionDrafts] = useState<OptionDraft[]>(() =>
|
| optionDrafts(DEFAULT_OPTION_LABELS)
|
| );
|
| const [swapColorCode, setSwapColorCode] = useState(true);
|
| const [swapFormulaText, setSwapFormulaText] = useState("");
|
| const [swapMaxValue, setSwapMaxValue] = useState(5);
|
| /** β Wave-27 item 13 (R13) β the swap form's OWN language draft. It gets one rather than
|
| * reading the create form's, because `extraFor` used to close over the create drafts and the
|
| * swap path passes its own `src`/`starCount` in for exactly that reason: two forms sharing a
|
| * draft means changing a language in one silently changes what the other will save. */
|
| const [swapCodeLanguage, setSwapCodeLanguage] = useState<string>("plain");
|
| /** Item 8c β the period DRAFT. null = untouched (the field's own window shows). Buffered
|
| * behind an Apply rather than emitted per keystroke: a custom range is typed one date at a
|
| * time, and each emit is a full host round trip that would repaint the column half-ready. */
|
| const [periodDraft, setPeriodDraft] = useState<WindowSpec | null>(null);
|
| /** Permissions pane draft. */
|
| const [permDraft, setPermDraft] = useState<"everyone" | "creator" | "admins">(
|
| field.permissions?.edit ?? "everyone"
|
| );
|
| /** Format pane draft β seeded from the field, saved whole. */
|
| const [fmtDraft, setFmtDraft] = useState<FieldFormat>(() => ({ ...(field.format ?? {}) }));
|
| /** Item 9c β the create forms' Scope draft. 'cohort' is the DEFAULT per the contract. */
|
| const [scopeDraft, setScopeDraft] = useState<FieldScope>("cohort");
|
| /** The Edit pane's NAME draft, seeded from the current label (wave-6 item 5's rename,
|
| * re-homed into the Edit-field pane by owner item 8). */
|
| const [renameDraft, setRenameDraft] = useState(field.label);
|
| /** Owner item 8 β the Edit pane's TYPE draft plus the per-type config drafts: options
|
| * prefilled from the field (a select retyped to multi select keeps its choices), star
|
| * count from the field's own max, formula source from the field's own source. */
|
| const [editType, setEditType] = useState<FieldType>(field.type);
|
| const [editOptionDrafts, setEditOptionDrafts] = useState<OptionDraft[]>(() =>
|
| optionDrafts(
|
| choiceOptions(field).length ? choiceOptions(field) : DEFAULT_OPTION_LABELS,
|
| field.optionColors
|
| )
|
| );
|
| /** Item 15 (C-RENAME) β what each option ROW said when the editor opened, by draft id. A ref,
|
| * not state: it is the fixed point the renames are measured against and it must not move
|
| * while the user types. Seeded from the same call that seeds the drafts, so the two cannot
|
| * disagree about which row is which. */
|
| const editOptionOrigin = useRef<Map<string, string>>(new Map());
|
| if (editOptionOrigin.current.size === 0 && editOptionDrafts.length)
|
| editOptionOrigin.current = new Map(
|
| editOptionDrafts.map((draft) => [draft.id, draft.label.trim()])
|
| );
|
| const [editColorCode, setEditColorCode] = useState(field.colorCodeOptions !== false);
|
| const [retypeMaxValue, setRetypeMaxValue] = useState(() => ratingMax(field));
|
| const [editFormulaText, setEditFormulaText] = useState(() =>
|
| typeof field.formula === "string" ? field.formula : ""
|
| );
|
| /** C5-AUTOFIELD β the Edit pane's automation drafts, seeded from the field's own config. */
|
| const [editAutomationKind, setEditAutomationKind] = useState(
|
| () => field.automation?.kind ?? AUTOMATION_KINDS[0].key
|
| );
|
| const [editAutomationUrlField, setEditAutomationUrlField] = useState(
|
| () => field.automation?.urlField ?? ""
|
| );
|
| /** β Wave-27 item 13 (R13) β the Edit pane's language draft, seeded from the field's own bag
|
| * through the shared resolver (never `field.code!.language`: an unconfigured code column is
|
| * the common case, and `codeLanguageOf` is the one place that answers what absent means). */
|
| const [editCodeLanguage, setEditCodeLanguage] = useState<string>(() => codeLanguageOf(field));
|
| /**
|
| * ββ 2026-08-09 (owner) β THE EDIT PANE'S OWN RELATIONAL DRAFT, seeded from the field's bag.
|
| *
|
| * Owner: *"even when i click edit field for rollup, it doesn't actually show me the
|
| * configuration that I use to generate it, it only shows change to a different field."*
|
| * Exactly right, and it was worse than missing: `RETYPE_TYPES` includes `rollup`, so the pane
|
| * offered a type picker whose "Rollup" option would re-save the column with NO bag.
|
| *
|
| * β ONE OBJECT, NOT TWELVE `useState`s, and the reason is seeding. Every draft here must start
|
| * as what the STORE holds β a pane that opens on defaults and then saves them is a pane that
|
| * silently rewrites a working column the moment somebody opens it to look. One object seeded
|
| * once from `field.rollup` makes "opened but unchanged" literally equal to the stored bag, so
|
| * the dirty check below is an object comparison rather than twelve remembered pairs.
|
| */
|
| type RollupBag = NonNullable<Field["rollup"]>;
|
| const [editRollup, setEditRollup] = useState<RollupBag>(() => ({ ...(field.rollup ?? {}) }));
|
| const patchEditRollup = (patch: Partial<RollupBag>) =>
|
| setEditRollup((current) => {
|
| const next = { ...current, ...patch } as RollupBag;
|
| // β AN EMPTY LIST IS AN ABSENT KEY, matching what `extraFor` sends on the create path and
|
| // what `_clean_rollup` stores. Otherwise "remove the last condition" would save
|
| // `conditions: []` while the stored bag has no such key, and the column would read as
|
| // dirty forever.
|
| for (const key of ["where", "conditions"] as const) {
|
| if (Array.isArray(next[key]) && next[key]!.length === 0) {
|
| delete next[key];
|
| delete next[key === "where" ? "whereConj" : "conditionConj"];
|
| }
|
| }
|
| if (!next.sortBy) {
|
| // The server refuses a limit with no declared order; dropping both together keeps the
|
| // draft in a shape that can always be saved.
|
| delete next.limit;
|
| delete next.sortDir;
|
| }
|
| return next;
|
| });
|
| const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
|
| const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
|
|
|
| const swapToNewType = swapTo.startsWith(NEW_PREFIX)
|
| ? (swapTo.slice(NEW_PREFIX.length) as FieldType)
|
| : null;
|
|
|
| const createChoice = optionSettings(createOptionDrafts, kind);
|
| const options = createChoice.options;
|
| const swapChoice = swapToNewType
|
| ? optionSettings(swapOptionDrafts, swapToNewType)
|
| : { options: [] as string[], colors: {} as Record<string, string> };
|
| const swapNewOptions = swapChoice.options;
|
| /** Owner item 8 β the Edit pane's parsed choices for the drafted type. */
|
| const editChoice = optionSettings(editOptionDrafts, editType);
|
| const editOptions = editChoice.options;
|
|
|
| // Wave-5 item 9, amended 2026-07-31 (owner item 2) β live formula validation for BOTH
|
| // create surfaces. Refs may now name OTHER formula fields (evaluation is topological);
|
| // what is refused is a CYCLE, checked against every formula's SOURCE. Both surfaces here
|
| // CREATE a field, so no cycle is possible yet and no selfKey is passed β the retype path
|
| // (in-place formula edit) would pass the edited field's key.
|
| const knownKeys = useMemo(
|
| () => new Set(fields.filter((f) => f.type !== "formula").map((f) => f.key)),
|
| [fields]
|
| );
|
| const formulaSources = useMemo(() => {
|
| const out = new Map<string, string>();
|
| for (const f of fields) {
|
| if (f.type !== "formula") continue;
|
| const src = typeof f.formula === "string" ? f.formula : "";
|
| if (src) out.set(f.key, src);
|
| }
|
| return out;
|
| }, [fields]);
|
| const formulaCheck = useMemo(
|
| () =>
|
| formulaText.trim() === ""
|
| ? { ok: false, error: "Type a formula", refs: [] as string[] }
|
| : validateFormula(formulaText, knownKeys, formulaSources),
|
| [formulaText, knownKeys, formulaSources]
|
| );
|
| const swapFormulaCheck = useMemo(
|
| () =>
|
| swapFormulaText.trim() === ""
|
| ? { ok: false, error: "Type a formula", refs: [] as string[] }
|
| : validateFormula(swapFormulaText, knownKeys, formulaSources),
|
| [swapFormulaText, knownKeys, formulaSources]
|
| );
|
|
|
| const chosenMeasure = measures.find((m) => m.key === measureKey);
|
| /** The suggested name reads as the sentence the column answers: "Sales Β· the last 90 days". */
|
| const suggestedLabel = chosenMeasure
|
| ? `${chosenMeasure.label} Β· ${windowLabel(measureWindow)}`
|
| : "";
|
| const effectiveLabel =
|
| kind === "measure" && !labelTouched && !label.trim() ? suggestedLabel : label;
|
|
|
| // A select with no choices is a column nothing can ever be put in, so creation is blocked
|
| // until it has at least one. An assignee takes its choices from the host, not from here.
|
| // A measure column needs a measure; a formula column needs a formula that PARSES β an
|
| // unparseable formula would be a permanently blank column, refused here rather than shipped.
|
| const canCreate =
|
| effectiveLabel.trim() !== "" &&
|
| (!needsOptions(kind) || options.length > 0) &&
|
| (kind !== "measure" || !!chosenMeasure) &&
|
| (kind !== "formula" || formulaCheck.ok) &&
|
| // C5-AUTOFIELD: an automation column with no URL column to read is a column no run can
|
| // ever fill β the same "permanently blank column" the formula rule above refuses, so it
|
| // is refused here rather than created and then discovered.
|
| (kind !== "automation" || automationUrlField !== "") &&
|
| // β 2026-08-07 β both relational kinds are UNCREATABLE without their bag: `_clean_field`
|
| // returns None for a `link` with no target and a `rollup` with no link/function, so
|
| // creating one without this gate produces a column that is named, configured and simply
|
| // absent on the next read. The same "permanently blank column" argument as the two rules
|
| // above, one step worse β the column does not exist at all.
|
| (kind !== "link" || linkTable !== "") &&
|
| (kind !== "rollup" || (rollupMode === "source"
|
| // β 2026-08-09 β the read-through bag. ALL FOUR keys are required by `_clean_rollup`
|
| // (`if not (topic and measure and group_by and on): return None`), and `on` is the one a
|
| // user forgets: without it the bag names a real measure grouped a real way with nothing
|
| // to match it to any row, and the field would be refused after Create.
|
| ? (rollupSource.topic !== "" && rollupSource.measure !== "" &&
|
| rollupSource.groupBy !== "" && rollupSource.on !== "")
|
| : (
|
| rollupLink !== "" &&
|
| (rollupFn === "countall" || rollupField !== "") &&
|
| // `latest` without an order is store order wearing a deterministic name.
|
| (rollupFn !== "latest" || rollupSortBy !== "")
|
| )));
|
|
|
| const extraFor = (
|
| t: CreateKind,
|
| src: string,
|
| starCount: number,
|
| lang: string
|
| ): FieldConfigExtra | undefined => {
|
| if (t === "formula") return { formula: src.trim() };
|
| if (t === "rating") return { max: starCount };
|
| // β Wave-27 item 13 (R13). The bag is ALWAYS sent, including `plain` β the host's
|
| // `_clean_code` evaluates plain to None and therefore CLEARS the key, which is what makes
|
| // "switch this column back to Plain text" a change that saves. Omitting it instead would
|
| // inherit the previous language (the patch path's omit-means-keep rule).
|
| if (t === "code") return { code: { language: lang } };
|
| if (t === "automation")
|
| return {
|
| automation: {
|
| kind: automationKind,
|
| source: "record_url_field",
|
| urlField: automationUrlField,
|
| },
|
| };
|
| // β 2026-08-07 β the relational bags, shaped exactly as `_clean_link` / `_clean_rollup`
|
| // store them. β NO `on` KEY: a link created from this menu is an ORDINARY one (the user
|
| // picks records). The DERIVED mode is the automation's, spawned with its join declared β
|
| // offering "join on a column of the other table" here would be a second, harder mental
|
| // model for the same button.
|
| if (t === "link") return { link: { table: linkTable, ...(linkSingle ? { single: true } : {}) } };
|
| if (t === "rollup" && rollupMode === "source")
|
| // β THE SOURCE BAG TRAVELS ALONE. `_clean_rollup` reads `source` first and returns
|
| // immediately, so sending `link`/`fn` alongside it would ship keys the store drops β
|
| // harmless today and exactly the kind of dead payload that later reads as configuration.
|
| return {
|
| rollup: {
|
| source: {
|
| topic: rollupSource.topic,
|
| measure: rollupSource.measure,
|
| groupBy: rollupSource.groupBy,
|
| on: rollupSource.on,
|
| // β `all_time` is sent as an ABSENT window, which is what it means: the validator
|
| // accepts the literal too, but the bag is smaller and reads as "no window".
|
| ...(rollupSource.window && rollupSource.window !== "all_time"
|
| ? { window: rollupSource.window }
|
| : {}),
|
| },
|
| },
|
| };
|
| if (t === "rollup")
|
| return {
|
| rollup: {
|
| link: rollupLink,
|
| ...(rollupFn === "countall" ? {} : { field: rollupField }),
|
| fn: rollupFn,
|
| // β `limit` AND `sortBy` TRAVEL TOGETHER OR NEITHER TRAVELS. The host refuses a limit
|
| // with no declared order, so sending one would turn a valid-looking form into a 400.
|
| ...(rollupSortBy
|
| ? { sortBy: rollupSortBy, sortDir: "desc" as const,
|
| limit: rollupLimit || 1 }
|
| : {}),
|
| ...(rollupDistinctBy ? { distinctBy: rollupDistinctBy } : {}),
|
| // β EACH LIST TRAVELS WITH ITS OWN CONJUNCTION OR NEITHER TRAVELS β `_clean_rollup`
|
| // stores the pair together, so sending a conj with no list would be a stored key
|
| // nothing reads, and a list with no conj would take the server's default rather than
|
| // the one this editor showed.
|
| ...(rollupWhere.length
|
| ? { where: rollupWhere, whereConj: rollupWhereConj }
|
| : {}),
|
| ...(rollupConditions.length
|
| ? { conditions: rollupConditions, conditionConj: rollupConditionConj }
|
| : {}),
|
| },
|
| };
|
| return undefined;
|
| };
|
| const optionExtraFor = (
|
| t: CreateKind,
|
| colors: Record<string, string>,
|
| colorCode: boolean
|
| ): FieldConfigExtra =>
|
| needsOptions(t)
|
| ? {
|
| colorCodeOptions: colorCode,
|
| ...(Object.keys(colors).length ? { optionColors: colors } : {}),
|
| }
|
| : {};
|
| /** Item 9c β the Scope choice rides the create extras only where the page offers it. */
|
| const withScope = (e?: FieldConfigExtra): FieldConfigExtra | undefined =>
|
| scopeChoice ? { ...(e ?? {}), scope: scopeDraft } : e;
|
|
|
| const create = () => {
|
| const clean = effectiveLabel.trim();
|
| if (!clean || !position || !canCreate) return;
|
| if (kind === "measure") {
|
| if (!chosenMeasure) return;
|
| onCreate(clean, chosenMeasure.type, position === "end" ? null : field.key, position,
|
| undefined, { key: chosenMeasure.key, window: measureWindow },
|
| withScope(undefined));
|
| onClose();
|
| return;
|
| }
|
| onCreate(clean, kind, position === "end" ? null : field.key, position,
|
| needsOptions(kind) ? options : undefined, undefined,
|
| withScope({
|
| ...extraFor(kind, formulaText, maxValue, codeLanguage),
|
| ...optionExtraFor(kind, createChoice.colors, createColorCode),
|
| }));
|
| onClose();
|
| };
|
|
|
| const canCreateSwap =
|
| swapToNewType != null &&
|
| swapLabel.trim() !== "" &&
|
| (!needsOptions(swapToNewType) || swapNewOptions.length > 0) &&
|
| (swapToNewType !== "formula" || swapFormulaCheck.ok);
|
|
|
| const createAndSwap = () => {
|
| if (!swapToNewType || !canCreateSwap) return;
|
| onCreateAndSwap(
|
| swapLabel.trim(),
|
| swapToNewType,
|
| needsOptions(swapToNewType) ? swapNewOptions : undefined,
|
| withScope({
|
| ...extraFor(swapToNewType, swapFormulaText, swapMaxValue, swapCodeLanguage),
|
| ...optionExtraFor(swapToNewType, swapChoice.colors, swapColorCode),
|
| })
|
| );
|
| onClose();
|
| };
|
|
|
| // ------------------------------------------------------- owner item 8: Edit-field dirties
|
| // ONE Save applies whatever changed β name, type, choices, stars, formula source β and it
|
| // must leave as ONE upsert when several changed together: sequential emits each read the
|
| // stale def and the second reverts the first (label rides extra.label / onFormula's label).
|
| const cleanRename = renameDraft.trim();
|
| // β 2026-08-09 β `onFieldConfig` is a SECOND door that can rename. `onRename` writes the
|
| // per-user overlay and is supplied for `custom_` fields only, so a pre-set Rollup β now
|
| // reconfigurable by owner ruling β had a configuration it could change and a name it could
|
| // not. Both ride the same PATCH; the label is part of the definition either way.
|
| const editNameDirty =
|
| (!!onRename || !!onFieldConfig) && cleanRename !== "" && cleanRename !== field.label;
|
| const editOptionsChanged =
|
| needsOptions(editType) &&
|
| editOptions.join("\n") !== choiceOptions(field).join("\n");
|
| const editAppearanceChanged =
|
| needsOptions(editType) &&
|
| (editColorCode !== (field.colorCodeOptions !== false) ||
|
| optionAppearanceSignature(editOptions, editChoice.colors) !==
|
| optionAppearanceSignature(choiceOptions(field), field.optionColors));
|
| const editStarsChanged = editType === "rating" && retypeMaxValue !== ratingMax(field);
|
| /** The type half was touched: a different type, or the SAME type with edited choices/stars
|
| * (the owner's named case β "change the list of the single selections"). */
|
| /** C5-AUTOFIELD β the automation config is a property of the column exactly as the choice
|
| * list is, so a change to it makes the type half DIRTY and rides the same one upsert. */
|
| const editAutomationChanged =
|
| editType === "automation" &&
|
| field.type === "automation" &&
|
| (editAutomationKind !== (field.automation?.kind ?? AUTOMATION_KINDS[0].key) ||
|
| editAutomationUrlField !== (field.automation?.urlField ?? ""));
|
| const editRetypeTouched =
|
| onRetype != null &&
|
| (editType !== field.type || editOptionsChanged || editAppearanceChanged ||
|
| editStarsChanged || editAutomationChanged);
|
| const editRetypeValid =
|
| (!needsOptions(editType) || editOptions.length > 0) &&
|
| (editType !== "automation" || editAutomationUrlField !== "");
|
| const editFormulaCheck = useMemo(
|
| () =>
|
| editFormulaText.trim() === ""
|
| ? { ok: false, error: "Type a formula", refs: [] as string[] }
|
| : validateFormula(editFormulaText, knownKeys, formulaSources, field.key),
|
| [editFormulaText, knownKeys, formulaSources, field.key]
|
| );
|
| const editFormulaTouched =
|
| !!onFormula &&
|
| field.type === "formula" &&
|
| editFormulaText.trim() !==
|
| (typeof field.formula === "string" ? field.formula.trim() : "");
|
| // A touched-but-invalid half BLOCKS the whole save β otherwise Save would apply the name
|
| // and silently drop the half-finished type change beside it.
|
| const editBlocked =
|
| (editRetypeTouched && !editRetypeValid) ||
|
| (editFormulaTouched && !editFormulaCheck.ok);
|
| // Item 21 (2026-08-02) β the PERIOD moved into this pane, so its dirty-check has to be
|
| // computed before the pane's save gate rather than beside the menu's own button. Same three
|
| // lines, hoisted: `canSaveEdit` reads `periodChanged`, and a `const` read before its
|
| // initialiser is a TDZ crash at render, not a compile error.
|
| const currentWindow = field.measure ? normalizeWindow(field.measure.window) : null;
|
| const draftWindow = periodDraft ? normalizeWindow(periodDraft) : null;
|
| const periodChanged =
|
| draftWindow != null &&
|
| JSON.stringify(draftWindow) !== JSON.stringify(currentWindow);
|
| const canPeriod = !!field.measure && !!onPeriod;
|
| /**
|
| * ββ 2026-08-09 β THE RELATIONAL EDIT: is this pane driving a `link`/`rollup` bag?
|
| *
|
| * β GATED ON `onFieldConfig`, which the host supplies for USER DATABASES ONLY. These bags live
|
| * in the table's shared definition (`PATCH /tables/{key}/fields/{key}`); there is no such door
|
| * on the Odoo-backed Customer/Product grids, so offering the editor there would be a form whose
|
| * Save has nowhere to go.
|
| */
|
| const editingRelational =
|
| !!onFieldConfig && (field.type === "rollup" || field.type === "link");
|
| const editRollupValid =
|
| field.type !== "rollup" ||
|
| (editRollup.source
|
| ? !!(editRollup.source.topic && editRollup.source.measure &&
|
| editRollup.source.groupBy && editRollup.source.on)
|
| : !!editRollup.link &&
|
| (editRollup.fn === "countall" || !!editRollup.field) &&
|
| (editRollup.fn !== "latest" || !!editRollup.sortBy));
|
| const editRelationalTouched =
|
| editingRelational &&
|
| (field.type === "rollup"
|
| ? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
|
| : editLinkTable !== (field.link?.table ?? "") ||
|
| editLinkSingle !== (field.link?.single === true));
|
| const canSaveEdit =
|
| !editBlocked &&
|
| (editingRelational
|
| ? (editRollupValid && (editRelationalTouched || editNameDirty) &&
|
| (field.type !== "link" || editLinkTable !== ""))
|
| : (editNameDirty || editRetypeTouched || editFormulaTouched ||
|
| (canPeriod && periodChanged)));
|
| /**
|
| * Item 15 β the renames, by ROW IDENTITY. A row that kept its id and changed its label was
|
| * renamed; a row with an id nobody has seen is new; an id that is gone was deleted. Empty
|
| * labels are skipped (a row being cleared before it is retyped is not a rename to ""), and a
|
| * rename to a label that already existed is skipped too β that is a MERGE, and merging two
|
| * choices into one is a different operation from renaming, with a different answer for the
|
| * cells that held either.
|
| */
|
| const optionRenames = (): { from: string; to: string }[] =>
|
| // A field that was not a choice field before has no values to migrate, and one being
|
| // retyped AWAY from choices is a different question (its cells stop being options at all).
|
| needsOptions(editType) && needsOptions(field.type)
|
| ? choiceRenames(editOptionDrafts, editOptionOrigin.current, choiceOptions(field))
|
| : [];
|
| const applyEdit = () => {
|
| if (!canSaveEdit) return;
|
| if (editingRelational && onFieldConfig) {
|
| // β THE WHOLE BAG, EVERY SAVE. `_clean_rollup` is a pure cleaner over what it receives β
|
| // it does not merge with the stored bag β so a partial patch is a REBUILD, and an omitted
|
| // `limit` would silently become "no limit" rather than "unchanged".
|
| onFieldConfig({
|
| ...(editNameDirty ? { label: cleanRename } : {}),
|
| ...(field.type === "rollup"
|
| ? { rollup: editRollup as Record<string, unknown> }
|
| : { link: { table: editLinkTable,
|
| ...(editLinkSingle ? { single: true } : {}) } }),
|
| });
|
| onClose();
|
| return;
|
| }
|
| if (editRetypeTouched && onRetype) {
|
| const renames = optionRenames();
|
| onRetype(
|
| editType,
|
| needsOptions(editType) ? editOptions : undefined,
|
| {
|
| ...(renames.length ? { renames } : {}),
|
| ...(editType === "rating" ? { max: retypeMaxValue } : {}),
|
| // β Wave-27 item 13 (R13) β always sent for a code column, `plain` included: the
|
| // host clears the key on plain, so this is what makes the picker reversible.
|
| ...(editType === "code" ? { code: { language: editCodeLanguage } } : {}),
|
| ...(needsOptions(editType)
|
| ? {
|
| colorCodeOptions: editColorCode,
|
| ...(Object.keys(editChoice.colors).length
|
| ? { optionColors: editChoice.colors }
|
| : {}),
|
| }
|
| : {}),
|
| ...(editType === "automation"
|
| ? {
|
| automation: {
|
| kind: editAutomationKind,
|
| source: "record_url_field" as const,
|
| urlField: editAutomationUrlField,
|
| },
|
| }
|
| : {}),
|
| ...(editNameDirty ? { label: cleanRename } : {}),
|
| }
|
| );
|
| } else if (editFormulaTouched && onFormula) {
|
| onFormula(editFormulaText.trim(), editNameDirty ? cleanRename : undefined);
|
| } else if (editNameDirty && onRename) {
|
| onRename(cleanRename);
|
| }
|
| // Item 21 β the period is an INDEPENDENT property of a measure column, not a branch of
|
| // the identity edit, so it applies ALONGSIDE whatever the chain above did rather than
|
| // instead of it: renaming "Sales YTD" to "Sales" and re-pointing it at the last 90 days
|
| // is one edit to the user and must not silently drop half of itself.
|
| if (canPeriod && periodChanged && draftWindow) onPeriod!(draftWindow);
|
| onClose();
|
| };
|
|
|
| /**
|
| * Fields this column can be CHANGED to, split per wave-2 item 5: the "Pre-set fields" group
|
| * (the shared contract's predicate, `source === 'odoo' && !custom`) versus "Your fields" β
|
| * the overlay stratum and user-created columns. Hidden fields included in both: "change this
|
| * to Notes" is the whole point, and swapping BACK to a hidden custom field is the documented
|
| * restore path. The locked primary column cannot be swapped.
|
| */
|
| const swapOptions = fields.filter((f) => f.key !== field.key);
|
| const presetSwap = swapOptions.filter((f) => f.source === "odoo" && !f.custom);
|
| const yourSwap = swapOptions.filter((f) => !(f.source === "odoo" && !f.custom));
|
|
|
| // Item 8c β the period draft is valid when it normalizes and differs from what the field
|
| // already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
|
| // read as the same window.
|
| // β Wave-23 C8 (owner item 4 / R9) β FIRST in the chain, because it is the most specific true
|
| // thing about the column and the branches below it were saying something false. An
|
| // `automation` or `metric` column, and every column an automation SPAWNED (the `automation`
|
| // tag), is overlay-sourced and non-derived, so it fell through to "Editable overlay field" β
|
| // which is what the wash now visibly contradicts. ONE line, no second sentence about what an
|
| // automation is (R13 / DESIGN.md Β§4): the person reading a column menu is deciding whether to
|
| // type in it, and "filled by automation" is the whole answer.
|
| // β Wave-25 C3/R7 β AHEAD of the machine line, because a profile column is the one cell on
|
| // the row a person is SUPPOSED to fill, and "filled by automation" would say the opposite. It
|
| // states the consequence rather than the mechanism: clearing this cell also clears the
|
| // columns the enrichment wrote, and a person who does not know that reads the wipe as a bug.
|
| // That is the one fact worth a menu line here (DESIGN.md Β§4 β never over-explain).
|
| /**
|
| * ββ 2026-08-10 β WHICH SIDE OF THE RELATION IS THIS? (owner: "a linked field should spawn in
|
| * both databases")
|
| *
|
| * The spawn itself was already there β `user_tables.sync_reciprocal_link` creates the backlink
|
| * on the target for every ordinary link, and the two engines declare BOTH directions of their
|
| * derived links themselves. What was missing is the half a user can actually see: three
|
| * genuinely different columns all rendered as chips of linked rows and all described as
|
| * "Editable overlay field", including the computed reciprocal, which is not editable at all.
|
| *
|
| * The three, and the one fact that separates them:
|
| * ordinary β no `on`, no `inverse`. YOU pick the records. This is the stored truth.
|
| * reciprocal β `inverse` names the source field. Computed from the other side's picks;
|
| * typing here is refused (`is_computed_cell`), so saying "editable" was a lie.
|
| * derived β `on` names a column in the target that already holds this row's key. The
|
| * engine owns it; there is no picking on either side.
|
| *
|
| * β Read through `isDerivedLink`, never a fourth hand-rolled "does the bag have an `on`" β
|
| * `user_tables`' own note warns that a fourth reader is how one of them keeps answering yes
|
| * after the others stop.
|
| * β AHEAD of `isMachineOwned` for links only. "Filled by automation" is TRUE of a preset
|
| * derived link and it is the less specific true thing: the reader is deciding whether they can
|
| * click into this cell and pick something, and which side they are on is that answer.
|
| */
|
| const linkTargetLabel =
|
| field.type === "link"
|
| ? linkTargets.find((t) => t.key === field.link?.table)?.label ?? ""
|
| : "";
|
| const linkSideLine =
|
| field.type !== "link"
|
| ? null
|
| : field.link?.inverse
|
| ? `Linked from ${linkTargetLabel || "another database"} Β· rows that point here`
|
| : isDerivedLink(field)
|
| ? `Links to ${linkTargetLabel || "another database"} Β· matched automatically`
|
| : `Links to ${linkTargetLabel || "another database"} Β· you pick the records`;
|
| const typeLineBase = isProfileField(field)
|
| ? "Instagram profile Β· clearing it clears the enriched columns"
|
| : linkSideLine
|
| ? linkSideLine
|
| : isMachineOwned(field)
|
| ? "Filled by automation"
|
| : field.measure
|
| ? "Metric, read-only"
|
| : field.type === "formula"
|
| ? "Formula field, computed"
|
| : field.type === "created_time"
|
| ? "Created time, read-only"
|
| : field.derived
|
| ? "Read-only, derived"
|
| : field.source === "odoo"
|
| ? "Read-only source field"
|
| : "Editable overlay field";
|
| // Item 9c β a cohort-scoped def says so wherever its identity is stated (contract:
|
| // "defs may come back carrying scope: 'cohort' (label it)").
|
| const typeLine =
|
| field.scope === "cohort" ? `${typeLineBase} Β· this cohort only` : typeLineBase;
|
|
|
| /** Wave-5 item 10 β which format editor this field gets, if any. */
|
| const formatKind: "number" | "date" | null =
|
| field.type === "int" || field.type === "currency" || field.type === "formula" ||
|
| field.type === "pct" || field.type === "rollup"
|
| ? "number"
|
| : field.type === "date" || field.type === "created_time"
|
| ? "date"
|
| : null;
|
|
|
| /**
|
| * Item 6 β Insert left/right (and Add at end) jump STRAIGHT to the create form: name + type
|
| * autofocused, and none of the note/change-field/other-column chrome rendered around it.
|
| */
|
| if (position) {
|
| return (
|
| <AnchoredOverlay
|
| anchor={state.anchor}
|
| className="cg-column-menu"
|
| onDismiss={onClose}
|
| role="dialog"
|
| ariaLabel={
|
| position === "end" ? "Add field" : `Insert field ${position} of ${field.label}`
|
| }
|
| // The SAME selector as the full-menu branch, so React reconciling the two states does
|
| // not re-run the overlay's focus effect (its deps see one constant) β which would steal
|
| // focus to the first button a frame after the name input autofocused.
|
| initialFocus="[data-overlay-autofocus]"
|
| dataKind="column-menu"
|
| >
|
| <PaneHead
|
| title={
|
| position === "end"
|
| ? "Add field"
|
| : position === "left"
|
| ? "Insert field left"
|
| : "Insert field right"
|
| }
|
| sub={position === "end" ? "Added as the last column" : `Next to ${field.label}`}
|
| onClose={onClose}
|
| />
|
| <div className="cg-column-create">
|
| <label>
|
| <span>Name</span>
|
| <input
|
| className="cg-input"
|
| autoFocus
|
| data-overlay-autofocus
|
| value={effectiveLabel}
|
| onChange={(event) => {
|
| setLabel(event.target.value);
|
| setLabelTouched(true);
|
| }}
|
| onKeyDown={(event) => event.key === "Enter" && create()}
|
| />
|
| </label>
|
| <div className="cg-type-block">
|
| <span className="cg-type-title">Field type</span>
|
| {/* Owner item 8 (2026-07-31): every type wears its own mark β the same TYPE_SHAPES
|
| glyph its column header wears. A native <option> cannot hold an SVG (the ruling
|
| iconShapes.ts records), so the control is a find box over a listbox of real
|
| rows, Airtable-style. The measure row appears exactly when the host offers
|
| measures β the same availability rule as the filter's measure conditions. */}
|
| <TypePicker
|
| value={kind}
|
| onPick={setKind}
|
| offerMeasure={measures.length > 0}
|
| />
|
| </div>
|
| {kind === "measure" && (
|
| <>
|
| {/* C-NAME β the row asked "Measure"/"Choose a measureβ¦", which named the
|
| INTERNAL concept at the user. What the person is picking is the business
|
| number ("Sales", "Gross margin"), so the label says that. */}
|
| <label>
|
| <span>Number</span>
|
| {/* Item 20 (C-FLDSEL) β "Choose a numberβ¦" was an `<option value="">` that
|
| only rendered while nothing was chosen; it is the picker's PLACEHOLDER
|
| now, so the prompt is a state of the control rather than a row of the
|
| vocabulary that could be picked. */}
|
| <FieldSelectButton
|
| ariaLabel="Number to summarise"
|
| placeholder="Choose a numberβ¦"
|
| value={chosenMeasure ? measureKey : undefined}
|
| onChange={setMeasureKey}
|
| fields={measures.map((m) => ({
|
| key: m.key, label: m.label, type: "measure" as const,
|
| }))}
|
| />
|
| </label>
|
| <label>
|
| <span>Period</span>
|
| <span className="cg-measure-window">
|
| <WindowPicker window={measureWindow} onWindow={setMeasureWindow} />
|
| </span>
|
| </label>
|
| {/* The helper line now carries what the old label's parenthetical was doing β
|
| "(over a period)" β plus an example, because "Metric" alone does not say
|
| that the answer moves with the window above it. */}
|
| <div className="cg-field-hint">
|
| A total, average or count over the period you choose β e.g. Sales, last 90
|
| days. Computed for every customer, and read-only.
|
| </div>
|
| </>
|
| )}
|
| <ExtraTypeEditor
|
| kind={kind}
|
| fields={fields}
|
| formulaText={formulaText}
|
| onFormulaText={setFormulaText}
|
| formulaError={formulaCheck.ok ? null : formulaCheck.error ?? null}
|
| maxValue={maxValue}
|
| onMaxValue={setMaxValue}
|
| codeLanguage={codeLanguage}
|
| onCodeLanguage={setCodeLanguage}
|
| idPrefix="cg-create"
|
| automationKind={automationKind}
|
| onAutomationKind={setAutomationKind}
|
| automationUrlField={automationUrlField}
|
| onAutomationUrlField={setAutomationUrlField}
|
| linkTargets={linkTargets}
|
| linkTable={linkTable}
|
| onLinkTable={setLinkTable}
|
| linkSingle={linkSingle}
|
| onLinkSingle={setLinkSingle}
|
| rollupMode={rollupMode}
|
| onRollupMode={setRollupMode}
|
| rollupSourceOffer={rollupSourceOffer}
|
| rollupSource={rollupSource}
|
| onRollupSource={setRollupSource}
|
| rollupLink={rollupLink}
|
| onRollupLink={(value) => {
|
| setRollupLink(value);
|
| setRollupField("");
|
| setRollupSortBy("");
|
| setRollupDistinctBy("");
|
| // Every condition names a column of the OLD target, so both lists are cleared for
|
| // the same reason: a leaf pointing at a field the new table does not have is a
|
| // filter that can only ever match nothing.
|
| setRollupWhere([]);
|
| setRollupConditions([]);
|
| }}
|
| rollupField={rollupField}
|
| onRollupField={setRollupField}
|
| rollupFn={rollupFn}
|
| onRollupFn={setRollupFn}
|
| rollupLimit={rollupLimit}
|
| onRollupLimit={setRollupLimit}
|
| rollupSortBy={rollupSortBy}
|
| onRollupSortBy={setRollupSortBy}
|
| rollupDistinctBy={rollupDistinctBy}
|
| onRollupDistinctBy={setRollupDistinctBy}
|
| rollupWhere={rollupWhere}
|
| onRollupWhere={setRollupWhere}
|
| rollupWhereConj={rollupWhereConj}
|
| onRollupWhereConj={setRollupWhereConj}
|
| rollupConditions={rollupConditions}
|
| onRollupConditions={setRollupConditions}
|
| rollupConditionConj={rollupConditionConj}
|
| onRollupConditionConj={setRollupConditionConj}
|
| />
|
| {needsOptions(kind) && (
|
| <OptionsEditor
|
| idPrefix="cg-create-option"
|
| drafts={createOptionDrafts}
|
| onDrafts={setCreateOptionDrafts}
|
| colorCode={createColorCode}
|
| onColorCode={setCreateColorCode}
|
| type={kind}
|
| />
|
| )}
|
| {kind === "user" && (
|
| <div className="cg-field-hint">
|
| {userOptions.length
|
| ? `${userOptions.length} people can be assigned, from your user list.`
|
| : "No user list yet, so there is no one to assign."}
|
| </div>
|
| )}
|
| {scopeChoice && <ScopeControl value={scopeDraft} onValue={setScopeDraft} />}
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={!canCreate}
|
| onClick={create}
|
| >
|
| Create field
|
| </button>
|
| <button
|
| type="button"
|
| className="cg-btn"
|
| // Opened straight into the form (the header "+")? Cancel means close β
|
| // there is no action list behind it to fall back to.
|
| onClick={() => (initialPosition ? onClose() : setPosition(null))}
|
| >
|
| Cancel
|
| </button>
|
| </div>
|
| </div>
|
| </AnchoredOverlay>
|
| );
|
| }
|
|
|
| // ------------------------------------------------------------ EDIT FIELD pane (item 8)
|
| // Owner item 8 (2026-07-31): ONE window owning the field's identity β name, type (with the
|
| // per-type choices/stars/formula editors), and the Change-field control. Absorbs the former
|
| // Rename and Change-field panes.
|
| if (pane === "edit") {
|
| return (
|
| <AnchoredOverlay
|
| anchor={state.anchor}
|
| className="cg-column-menu"
|
| onDismiss={onClose}
|
| role="dialog"
|
| ariaLabel={`Edit field ${field.label}`}
|
| initialFocus="[data-overlay-autofocus]"
|
| dataKind="column-menu"
|
| >
|
| <PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
|
| <div className="cg-column-create">
|
| {onRename || editingRelational ? (
|
| <label>
|
| <span>Name</span>
|
| <input
|
| className="cg-input"
|
| autoFocus
|
| data-overlay-autofocus
|
| value={renameDraft}
|
| onChange={(event) => setRenameDraft(event.target.value)}
|
| onKeyDown={(event) => event.key === "Enter" && applyEdit()}
|
| />
|
| </label>
|
| ) : (
|
| <label>
|
| <span>Name</span>
|
| <input
|
| className="cg-input"
|
| value={field.label}
|
| disabled
|
| aria-label="Field name (read-only)"
|
| />
|
| <span className="cg-field-hint">
|
| A source field keeps its name and type from the data source.
|
| </span>
|
| </label>
|
| )}
|
| {/* C5-AUTOFIELD β an EXISTING automation column edits its CONFIG, not its type.
|
| β This guard is load-bearing, not tidiness. `automation` is excluded from
|
| RETYPE_TYPES (see its note), so rendering the picker for one would show a
|
| listbox whose current value is not among its options β the failure recorded in
|
| [[cg-condition-builder-items]], where a control missing its own value silently
|
| presents the FIRST option as if it were the selection. One Save later the column
|
| is a different type and its statuses are editable text. */}
|
| {/*
|
| ββ 2026-08-09 (owner) β AN EXISTING ROLLUP/LINK EDITS ITS CONFIGURATION HERE.
|
|
|
| Owner: *"even when i click edit field for rollup, it doesn't actually show me the
|
| configuration that I use to generate it, it only shows change to a different field."*
|
| This branch is that configuration, seeded from the stored bag, and it takes the same
|
| `ExtraTypeEditor` the create pane uses so the two can never offer different controls
|
| for the same column.
|
|
|
| β IT REPLACES THE TYPE PICKER, exactly as `automation` does one branch down, and for
|
| the sharper version of that reason. `RETYPE_TYPES` contains `rollup`, so the picker
|
| rendered here would have let Save re-emit the column with no bag β and the host's
|
| `retypeField` refuses a non-`custom` field outright, so on a pre-set rollup the Save
|
| button was wired to nothing at all. Changing a rollup INTO another type stays
|
| available through "Change field" below, which is the door that means "show something
|
| else in this column".
|
| */}
|
| {editingRelational ? (
|
| <div className="cg-type-block">
|
| <span className="cg-type-title">
|
| {field.type === "rollup" ? "Rollup" : "Linked records"}
|
| </span>
|
| <ExtraTypeEditor
|
| kind={field.type}
|
| fields={fields}
|
| linkTargets={linkTargets}
|
| formulaText={editFormulaText}
|
| onFormulaText={setEditFormulaText}
|
| formulaError={null}
|
| maxValue={retypeMaxValue}
|
| onMaxValue={setRetypeMaxValue}
|
| codeLanguage={editCodeLanguage}
|
| onCodeLanguage={setEditCodeLanguage}
|
| idPrefix="cg-edit"
|
| linkTable={editLinkTable}
|
| onLinkTable={setEditLinkTable}
|
| linkSingle={editLinkSingle}
|
| onLinkSingle={setEditLinkSingle}
|
| rollupSourceOffer={rollupSourceOffer}
|
| rollupMode={editRollup.source ? "source" : "link"}
|
| // β A MODE SWITCH REPLACES THE BAG, it does not merge into it. `_clean_rollup`
|
| // reads `source` first and returns before it ever looks at `link`, so a bag
|
| // carrying both would ship keys the store silently drops β dead payload that
|
| // later reads as configuration.
|
| onRollupMode={(mode) =>
|
| setEditRollup(mode === "source"
|
| ? { source: { topic: "", measure: "", groupBy: "", on: "", window: "ytd" } }
|
| : { link: "", fn: "sum" })
|
| }
|
| rollupSource={editRollup.source ?? {
|
| topic: "", measure: "", groupBy: "", on: "", window: "ytd",
|
| }}
|
| onRollupSource={(source) => patchEditRollup({ source })}
|
| rollupLink={editRollup.link ?? ""}
|
| onRollupLink={(value) =>
|
| // Same reset the create pane performs: every other key names a column of the
|
| // OLD target, and a leaf pointing at a field the new table lacks is a filter
|
| // that can only ever match nothing.
|
| setEditRollup({ link: value, fn: editRollup.fn ?? "sum" })
|
| }
|
| rollupField={editRollup.field ?? ""}
|
| onRollupField={(value) => patchEditRollup({ field: value })}
|
| rollupFn={editRollup.fn ?? "sum"}
|
| onRollupFn={(value) => patchEditRollup({ fn: value })}
|
| rollupLimit={editRollup.limit ?? 0}
|
| onRollupLimit={(value) => patchEditRollup({ limit: value })}
|
| rollupSortBy={editRollup.sortBy ?? ""}
|
| onRollupSortBy={(value) =>
|
| patchEditRollup(value
|
| ? { sortBy: value, sortDir: editRollup.sortDir ?? "desc",
|
| limit: editRollup.limit || 1 }
|
| : { sortBy: "" })
|
| }
|
| rollupDistinctBy={editRollup.distinctBy ?? ""}
|
| onRollupDistinctBy={(value) => patchEditRollup({ distinctBy: value })}
|
| rollupWhere={editRollup.where ?? []}
|
| onRollupWhere={(value) =>
|
| patchEditRollup({ where: value,
|
| whereConj: editRollup.whereConj ?? "and" })
|
| }
|
| rollupWhereConj={editRollup.whereConj ?? "and"}
|
| onRollupWhereConj={(value) => patchEditRollup({ whereConj: value })}
|
| rollupConditions={editRollup.conditions ?? []}
|
| onRollupConditions={(value) =>
|
| patchEditRollup({ conditions: value,
|
| conditionConj: editRollup.conditionConj ?? "and" })
|
| }
|
| rollupConditionConj={editRollup.conditionConj ?? "and"}
|
| onRollupConditionConj={(value) => patchEditRollup({ conditionConj: value })}
|
| />
|
| </div>
|
| ) : onRetype && field.type === "automation" ? (
|
| <div className="cg-type-block">
|
| <span className="cg-type-title">Automation</span>
|
| <ExtraTypeEditor
|
| kind="automation"
|
| fields={fields}
|
| formulaText={editFormulaText}
|
| onFormulaText={setEditFormulaText}
|
| formulaError={null}
|
| maxValue={retypeMaxValue}
|
| onMaxValue={setRetypeMaxValue}
|
| codeLanguage={editCodeLanguage}
|
| onCodeLanguage={setEditCodeLanguage}
|
| idPrefix="cg-edit"
|
| automationKind={editAutomationKind}
|
| onAutomationKind={setEditAutomationKind}
|
| automationUrlField={editAutomationUrlField}
|
| onAutomationUrlField={setEditAutomationUrlField}
|
| />
|
| </div>
|
| ) : onRetype ? (
|
| <>
|
| <div className="cg-type-block">
|
| <span className="cg-type-title">Field type</span>
|
| {/* The same find-box-over-icon-listbox the create pane wears (owner item 8 of
|
| the PREVIOUS wave) β one control for "pick a type" everywhere. */}
|
| <TypePicker
|
| value={editType}
|
| onPick={(k) => setEditType(k as FieldType)}
|
| offerMeasure={false}
|
| only={RETYPE_TYPES}
|
| />
|
| </div>
|
| {/*
|
| ββ 2026-08-07 (D-79's last half) β MARK THIS COLUMN AS AN INSTAGRAM PROFILE.
|
| Wave 25 shipped this flag's READER everywhere (the type line above, the cell
|
| validator, R6's clear-on-write, the one-per-table refusal) and never its WRITER,
|
| so `enrich_instagram` could not be bound to any database somebody made
|
| themselves β the owner's *"on a schedule, enrich these sets of influencer
|
| names"*. One checkbox; the server door already existed.
|
|
|
| β `text` ONLY, and it mirrors `_clean_field` rather than restating a rule: a
|
| flag on a select or an int would promise a validated handle to a cell nothing
|
| validates, and the server REFUSES it. Offering it there would be a control whose
|
| only outcome is a 400.
|
| β The one-per-table rule is NOT re-implemented here. The server refuses a second
|
| one with a sentence naming the column that already has it, and `patchTableField`
|
| surfaces that sentence verbatim β a client copy of the rule is how two doors
|
| start disagreeing about which column is the profile.
|
| */}
|
| {onProfileFlag && editType === "text" && (
|
| <label className="cg-check-row">
|
| <input
|
| type="checkbox"
|
| checked={isProfileField(field)}
|
| onChange={(event) => onProfileFlag(field.key, event.target.checked)}
|
| />
|
| <span>Instagram profile column</span>
|
| </label>
|
| )}
|
| {onProfileFlag && editType === "text" && (
|
| <div className="cg-field-hint">
|
| {isProfileField(field)
|
| ? "Automations enrich from this column. Clearing a cell also clears that "
|
| + "row's enriched columns."
|
| : "Lets an Enrich step know which column holds the handle. A database has "
|
| + "at most one."}
|
| </div>
|
| )}
|
| {editType === "code" && (
|
| <label>
|
| <span>Language</span>
|
| <select
|
| className="cg-select"
|
| value={editCodeLanguage}
|
| aria-label="Code language"
|
| onChange={(event) => setEditCodeLanguage(event.target.value)}
|
| >
|
| {/* Explicit `value` on every option: a <select> whose value names nothing
|
| renders its FIRST option and reports a choice nobody made. */}
|
| {CODE_LANGUAGES.map((lang) => (
|
| <option key={lang} value={lang}>
|
| {CODE_LANGUAGE_LABELS[lang]}
|
| </option>
|
| ))}
|
| </select>
|
| <span className="cg-field-hint">
|
| Chooses how the snippet is coloured. It is never run.
|
| </span>
|
| </label>
|
| )}
|
| {editType === "rating" && (
|
| <label>
|
| <span>Number of stars</span>
|
| <input
|
| className="cg-input cg-rating-max"
|
| type="number"
|
| min={2}
|
| max={10}
|
| value={retypeMaxValue}
|
| aria-label="Number of stars"
|
| onChange={(event) => {
|
| const n = Math.round(Number(event.target.value));
|
| setRetypeMaxValue(
|
| Number.isFinite(n) ? Math.max(2, Math.min(10, n)) : 5
|
| );
|
| }}
|
| />
|
| </label>
|
| )}
|
| {needsOptions(editType) && (
|
| <OptionsEditor
|
| idPrefix="cg-edit-option"
|
| drafts={editOptionDrafts}
|
| onDrafts={setEditOptionDrafts}
|
| colorCode={editColorCode}
|
| onColorCode={setEditColorCode}
|
| type={editType}
|
| />
|
| )}
|
| {editType !== field.type && (
|
| <div className="cg-field-hint">
|
| Changes {field.label} into a {TYPE_LABELS[editType].toLowerCase()} field,
|
| keeping its name and its values as entered. Cells that cannot be read in
|
| the new type show blank.
|
| </div>
|
| )}
|
| </>
|
| ) : null}
|
| {/* Item 21 (owner, 2026-08-02) β the PERIOD lives here, with the field's other
|
| properties, instead of floating above the action list on the menu pane. A metric
|
| column IS "a number over a period": the window is as much a part of its
|
| definition as its name is, and it was the one property you edited from a
|
| different window than every other one.
|
| It shares this pane's ONE Save (`applyEdit` applies it alongside a rename) β
|
| the old "Apply period" button became a second primary action in a pane that
|
| already had one, which is the ambiguity the move was supposed to remove. */}
|
| {canPeriod && (
|
| <label className="cg-edit-period">
|
| <span>Period</span>
|
| <span className="cg-measure-window">
|
| <WindowPicker
|
| window={periodDraft ?? field.measure!.window}
|
| onWindow={setPeriodDraft}
|
| />
|
| </span>
|
| <span className="cg-field-hint">
|
| The window this number is computed over, for every customer.
|
| </span>
|
| </label>
|
| )}
|
| {!onRetype && field.type === "formula" && onFormula && (
|
| <ExtraTypeEditor
|
| kind="formula"
|
| fields={fields}
|
| formulaText={editFormulaText}
|
| onFormulaText={setEditFormulaText}
|
| formulaError={editFormulaCheck.ok ? null : editFormulaCheck.error ?? null}
|
| maxValue={retypeMaxValue}
|
| onMaxValue={setRetypeMaxValue}
|
| codeLanguage={editCodeLanguage}
|
| onCodeLanguage={setEditCodeLanguage}
|
| idPrefix="cg-edit"
|
| showFormulaHelp={false}
|
| />
|
| )}
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={!canSaveEdit}
|
| onClick={applyEdit}
|
| >
|
| Save
|
| </button>
|
| <button
|
| type="button"
|
| className="cg-btn"
|
| // Item 12 β opened STRAIGHT into this pane (the kanban's "Add option")? Back means
|
| // close, exactly as Cancel does in the directly-opened create form: there is no
|
| // action list behind a pane nobody navigated to.
|
| onClick={() => (initialPane ? onClose() : setPane("menu"))}
|
| >
|
| Back
|
| </button>
|
| </div>
|
| {/* -------- Change field (owner item 9, folded in here by owner item 8) -------- */}
|
| {!locked && swapOptions.length > 0 && (
|
| <div className="cg-edit-swap">
|
| <span className="cg-type-title">Change field</span>
|
| <div className="cg-field-hint">
|
| Show a different field in this column β the current one is hidden, not lost.
|
| </div>
|
| <div className="cg-column-swap">
|
| {/* Item 20 β the ONE picker on this surface that keeps its groups, and it is
|
| not a contradiction of the filter list's flatness: those three headings
|
| separate DIFFERENT KINDS OF ACT (show a pre-set column Β· show one of yours Β·
|
| make a new one), not three flavours of the same noun. The type mark does
|
| the work the "New field" labels were doing on their own. */}
|
| <FieldSelectButton
|
| className="cg-swap-select"
|
| ariaLabel="Change this column to another field"
|
| placeholder="Choose a fieldβ¦"
|
| // Focus falls here when the pane has no editable Name (a read-only source
|
| // field): the swap picker is then the pane's first live control.
|
| overlayAutofocus={!onRename}
|
| value={swapTo || undefined}
|
| onChange={setSwapTo}
|
| fields={[
|
| ...presetSwap.map((f) => ({
|
| key: f.key, label: f.label, type: f.type, group: "Pre-set fields",
|
| })),
|
| ...yourSwap.map((f) => ({
|
| key: f.key, label: f.label, type: f.type, group: "Your fields",
|
| })),
|
| ...FIELD_TYPES.map((t) => ({
|
| key: NEW_PREFIX + t.value,
|
| label: t.label,
|
| type: t.value,
|
| group: "New field",
|
| })),
|
| ]}
|
| />
|
| {!swapToNewType && (
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={!swapTo}
|
| onClick={() => {
|
| onChangeField(swapTo);
|
| onClose();
|
| }}
|
| >
|
| Change
|
| </button>
|
| )}
|
| </div>
|
| {/* The "New field" half: name it (and give it choices/its formula/its stars
|
| when the type needs them), then create-and-swap in one motion. */}
|
| {swapToNewType && (
|
| <div className="cg-column-create cg-swap-create">
|
| <label>
|
| <span>New {TYPE_LABELS[swapToNewType].toLowerCase()} field</span>
|
| <input
|
| className="cg-input"
|
| autoFocus
|
| value={swapLabel}
|
| placeholder="Field name"
|
| onChange={(event) => setSwapLabel(event.target.value)}
|
| onKeyDown={(event) => event.key === "Enter" && createAndSwap()}
|
| />
|
| </label>
|
| <ExtraTypeEditor
|
| kind={swapToNewType}
|
| fields={fields}
|
| formulaText={swapFormulaText}
|
| onFormulaText={setSwapFormulaText}
|
| formulaError={
|
| swapFormulaCheck.ok ? null : swapFormulaCheck.error ?? null
|
| }
|
| maxValue={swapMaxValue}
|
| onMaxValue={setSwapMaxValue}
|
| codeLanguage={swapCodeLanguage}
|
| onCodeLanguage={setSwapCodeLanguage}
|
| idPrefix="cg-swap"
|
| />
|
| {needsOptions(swapToNewType) && (
|
| <OptionsEditor
|
| idPrefix="cg-swap-option"
|
| drafts={swapOptionDrafts}
|
| onDrafts={setSwapOptionDrafts}
|
| colorCode={swapColorCode}
|
| onColorCode={setSwapColorCode}
|
| type={swapToNewType}
|
| />
|
| )}
|
| {swapToNewType === "user" && (
|
| <div className="cg-field-hint">
|
| {userOptions.length
|
| ? `${userOptions.length} people can be assigned.`
|
| : "No user list was supplied by the host yet."}
|
| </div>
|
| )}
|
| {scopeChoice && (
|
| <ScopeControl value={scopeDraft} onValue={setScopeDraft} />
|
| )}
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={!canCreateSwap}
|
| onClick={createAndSwap}
|
| >
|
| Create and change
|
| </button>
|
| <button
|
| type="button"
|
| className="cg-btn"
|
| onClick={() => setSwapTo("")}
|
| >
|
| Cancel
|
| </button>
|
| </div>
|
| </div>
|
| )}
|
| </div>
|
| )}
|
| </div>
|
| </AnchoredOverlay>
|
| );
|
| }
|
|
|
| // ------------------------------------------------------------------ NOTE pane (item 2)
|
| // "Edit field description" edits the USER's note β the canonical `description` is the
|
| // contract's default and is shown as context, never written from here.
|
| if (pane === "note") {
|
| return (
|
| <AnchoredOverlay
|
| anchor={state.anchor}
|
| className="cg-column-menu"
|
| onDismiss={onClose}
|
| role="dialog"
|
| ariaLabel={`Edit description for ${field.label}`}
|
| initialFocus="[data-overlay-autofocus]"
|
| dataKind="column-menu"
|
| >
|
| <PaneHead title="Edit field description" sub={field.label} onClose={onClose} />
|
| <div className="cg-column-create">
|
| {field.description && (
|
| <div className="cg-field-hint cg-desc-default">
|
| Default description: {field.description}
|
| </div>
|
| )}
|
| <label>
|
| <span>Your description</span>
|
| <textarea
|
| className="cg-input"
|
| data-overlay-autofocus
|
| autoFocus
|
| rows={4}
|
| value={note}
|
| placeholder={
|
| field.description
|
| ? "Write your own to replace the defaultβ¦"
|
| : "Explain how this field should be usedβ¦"
|
| }
|
| onChange={(event) => setNote(event.target.value)}
|
| />
|
| </label>
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={note === (field.note ?? "")}
|
| onClick={() => {
|
| onNote(note);
|
| onClose();
|
| }}
|
| >
|
| Save
|
| </button>
|
| <button type="button" className="cg-btn" onClick={() => setPane("menu")}>
|
| Back
|
| </button>
|
| </div>
|
| </div>
|
| </AnchoredOverlay>
|
| );
|
| }
|
|
|
| // ------------------------------------------------------------- PERMISSIONS pane (item 1)
|
| if (pane === "permissions") {
|
| return (
|
| <AnchoredOverlay
|
| anchor={state.anchor}
|
| className="cg-column-menu"
|
| onDismiss={onClose}
|
| role="dialog"
|
| ariaLabel={`Edit permissions for ${field.label}`}
|
| initialFocus="[data-overlay-autofocus]"
|
| dataKind="column-menu"
|
| >
|
| <PaneHead title="Edit field permissions" sub={field.label} onClose={onClose} />
|
| <div className="cg-column-create">
|
| <div className="cg-field-hint">
|
| Who can edit this field's values{field.createdBy ? ` (created by ${field.createdBy})` : ""}.
|
| </div>
|
| {(
|
| [
|
| ["everyone", "Everyone"],
|
| ["creator", "Only the creator"],
|
| ["admins", "Only admins"],
|
| ] as const
|
| ).map(([value, text]) => (
|
| <label key={value} className="cg-radio-row">
|
| <input
|
| type="radio"
|
| name="cg-perm"
|
| data-overlay-autofocus={value === "everyone" ? true : undefined}
|
| checked={permDraft === value}
|
| onChange={() => setPermDraft(value)}
|
| />
|
| <span>{text}</span>
|
| </label>
|
| ))}
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={permDraft === (field.permissions?.edit ?? "everyone")}
|
| onClick={() => {
|
| onPermissions?.(permDraft);
|
| onClose();
|
| }}
|
| >
|
| Save
|
| </button>
|
| <button type="button" className="cg-btn" onClick={() => setPane("menu")}>
|
| Back
|
| </button>
|
| </div>
|
| </div>
|
| </AnchoredOverlay>
|
| );
|
| }
|
|
|
| // ------------------------------------------------------------------ FORMAT pane (item 10)
|
| /**
|
| * ββ WAVE-29 T33 (owner item 17) β THE COLUMN SUMMARY PANE.
|
| *
|
| * A flat list of the six aggregates plus "None", because that is the whole decision β there is
|
| * no draft to reconcile and no Save button to press, so the click IS the answer and the pane
|
| * closes on it (the same shape as Sort, one group up, and unlike Format, which composes four
|
| * settings into one bag).
|
| *
|
| * β THE OPTIONS COME FROM `aggOptions`, never from a list written here. `sum Β· average Β·
|
| * median Β· min Β· max` are numeric-only and `count` counts ROWS, so a text column offers exactly
|
| * one row β and it offers it, rather than hiding the pane, because "how many records is this
|
| * group" is the summary a text column CAN answer.
|
| */
|
| if (pane === "summary" && onAggregate) {
|
| const options = aggOptions(field);
|
| return (
|
| <AnchoredOverlay
|
| anchor={state.anchor}
|
| className="cg-column-menu"
|
| onDismiss={onClose}
|
| role="dialog"
|
| ariaLabel={`Summarize ${field.label}`}
|
| initialFocus="[data-overlay-autofocus]"
|
| dataKind="column-menu"
|
| >
|
| <PaneHead title="Summarize" sub={field.label} onClose={onClose} />
|
| <div className="cg-menu-list" role="none">
|
| <button
|
| type="button"
|
| data-overlay-autofocus={field.agg ? undefined : true}
|
| aria-pressed={!field.agg}
|
| onClick={() => {
|
| onAggregate(undefined);
|
| onClose();
|
| }}
|
| >
|
| <MenuLabel icon="off" text="None" />
|
| </button>
|
| {options.map((name) => (
|
| <button
|
| key={name}
|
| type="button"
|
| data-overlay-autofocus={field.agg === name ? true : undefined}
|
| aria-pressed={field.agg === name}
|
| onClick={() => {
|
| onAggregate(name);
|
| onClose();
|
| }}
|
| >
|
| <MenuLabel icon="format" text={AGG_LABELS[name]} />
|
| </button>
|
| ))}
|
| </div>
|
| </AnchoredOverlay>
|
| );
|
| }
|
|
|
| if (pane === "format" && formatKind) {
|
| return (
|
| <AnchoredOverlay
|
| anchor={state.anchor}
|
| className="cg-column-menu"
|
| onDismiss={onClose}
|
| role="dialog"
|
| ariaLabel={`Format ${field.label}`}
|
| initialFocus="[data-overlay-autofocus]"
|
| dataKind="column-menu"
|
| >
|
| <PaneHead title="Field format" sub={field.label} onClose={onClose} />
|
| <div className="cg-column-create">
|
| {formatKind === "number" ? (
|
| <>
|
| <label className="cg-radio-row">
|
| <input
|
| type="checkbox"
|
| data-overlay-autofocus
|
| checked={fmtDraft.thousands !== false}
|
| onChange={(e) =>
|
| setFmtDraft((d) => ({ ...d, thousands: e.target.checked }))
|
| }
|
| />
|
| <span>Thousands separator (12,345)</span>
|
| </label>
|
| <label>
|
| <span>Decimal places</span>
|
| <select
|
| className="cg-select"
|
| value={
|
| Number.isInteger(fmtDraft.decimals) ? String(fmtDraft.decimals) : ""
|
| }
|
| aria-label="Decimal places"
|
| onChange={(e) =>
|
| setFmtDraft((d) => {
|
| const next = { ...d };
|
| if (e.target.value === "") delete next.decimals;
|
| else next.decimals = Number(e.target.value);
|
| return next;
|
| })
|
| }
|
| >
|
| <option value="">As entered</option>
|
| {[0, 1, 2, 3, 4].map((n) => (
|
| <option key={n} value={n}>
|
| {n}
|
| </option>
|
| ))}
|
| </select>
|
| </label>
|
| <label className="cg-radio-row">
|
| <input
|
| type="checkbox"
|
| checked={fmtDraft.abbrev === true}
|
| onChange={(e) =>
|
| setFmtDraft((d) => ({ ...d, abbrev: e.target.checked }))
|
| }
|
| />
|
| <span>Abbreviate large numbers (34.0M)</span>
|
| </label>
|
| </>
|
| ) : (
|
| <>
|
| <label className="cg-radio-row">
|
| <input
|
| type="checkbox"
|
| data-overlay-autofocus
|
| checked={fmtDraft.time ?? field.type === "created_time"}
|
| onChange={(e) => setFmtDraft((d) => ({ ...d, time: e.target.checked }))}
|
| />
|
| <span>Include the time of day</span>
|
| </label>
|
| <label>
|
| <span>Time zone</span>
|
| <select
|
| className="cg-select"
|
| value={fmtDraft.tz === "utc" ? "utc" : "local"}
|
| aria-label="Time zone"
|
| onChange={(e) =>
|
| setFmtDraft((d) => ({ ...d, tz: e.target.value as "local" | "utc" }))
|
| }
|
| >
|
| <option value="local">Your local time</option>
|
| <option value="utc">UTC</option>
|
| </select>
|
| </label>
|
| </>
|
| )}
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={JSON.stringify(fmtDraft) === JSON.stringify(field.format ?? {})}
|
| onClick={() => {
|
| onFormat?.(fmtDraft);
|
| onClose();
|
| }}
|
| >
|
| Save
|
| </button>
|
| <button type="button" className="cg-btn" onClick={() => setPane("menu")}>
|
| Back
|
| </button>
|
| </div>
|
| </div>
|
| </AnchoredOverlay>
|
| );
|
| }
|
|
|
| // ------------------------------------------------------------------ the MENU pane
|
| // Wave-5 items 1/2/3: a flat action list. Editors open their OWN windows (the panes above);
|
| // nothing edits inline here. Conditional entries render IFF they apply to the current view.
|
| const canPermissions =
|
| !!onPermissions &&
|
| !!viewer &&
|
| (viewer.isAdmin || (field.createdBy != null && field.createdBy === viewer.name));
|
|
|
| return (
|
| <AnchoredOverlay
|
| anchor={state.anchor}
|
| className="cg-column-menu"
|
| onDismiss={onClose}
|
| role="dialog"
|
| ariaLabel={`Column settings for ${field.label}`}
|
| initialFocus="[data-overlay-autofocus]"
|
| dataKind="column-menu"
|
| >
|
| <PaneHead title={field.label} sub={typeLine} onClose={onClose} />
|
|
|
| {/* Item 8c β any measure-carrying column exposes its PERIOD, the property that defines
|
| it. Item 21 (2026-08-02) moved the EDITOR into the Edit-field pane; what stays here
|
| is one line that STATES the period and opens that pane, because the window is the
|
| first thing you want to know about a metric column and the menu had become the only
|
| place it was written down. A row, not a control: the menu pane edits nothing inline. */}
|
| {canPeriod && !schemaLocked && (
|
| <button
|
| type="button"
|
| className="cg-column-periodline"
|
| onClick={() => {
|
| setRenameDraft(field.label);
|
| setPane("edit");
|
| }}
|
| >
|
| <span className="cg-swap-label">Period</span>
|
| <span className="cg-column-periodval">{windowLabel(field.measure!.window)}</span>
|
| </button>
|
| )}
|
|
|
| {/* Wave-7 item W4 (contract C6): Airtable's grouping, thin dividers between groups,
|
| an icon on EVERY action. Presentation only β every handler and event is the one it
|
| was before; entries not in Airtable's list slot into the nearest sensible group. */}
|
| <div className="cg-column-actions">
|
| {/* Owner item 8 (2026-07-31) β EDIT FIELD sits at the very top, above Duplicate:
|
| the one window for the field's name, type (with per-type choices/stars/formula)
|
| and the Change-field control. Offered on EVERY field β what a read-only field
|
| cannot change, the pane says honestly instead of hiding the door. */}
|
| {!schemaLocked && (
|
| <button type="button" data-overlay-autofocus onClick={() => {
|
| setRenameDraft(field.label);
|
| setPane("edit");
|
| }}>
|
| <MenuLabel icon="rename" text="Edit field" />
|
| </button>
|
| )}
|
|
|
| <div className="cg-menu-sep" role="separator" aria-hidden />
|
|
|
| {/* Group 1 β Duplicate Β· Insert left Β· Insert right (+ Add at end, same family). */}
|
| {!schemaLocked && onDuplicate && (
|
| <button type="button" onClick={() => { onDuplicate(); onClose(); }}>
|
| <MenuLabel icon="duplicate" text="Duplicate field" />
|
| </button>
|
| )}
|
| <button type="button" onClick={() => setPosition("left")}>
|
| <MenuLabel icon="insertLeft" text="Insert field left" />
|
| </button>
|
| <button type="button" onClick={() => setPosition("right")}>
|
| <MenuLabel icon="insertRight" text="Insert field right" />
|
| </button>
|
| <button type="button" onClick={() => setPosition("end")}>
|
| <MenuLabel icon="addEnd" text="Add field at end" />
|
| </button>
|
|
|
| <div className="cg-menu-sep" role="separator" aria-hidden />
|
|
|
| {/* Group 2 β the rest of the def-editing family: description Β· permissions Β· format.
|
| Rename and Change field live INSIDE Edit field now (owner item 8) β their old
|
| rows are gone, not duplicated. */}
|
| {!schemaLocked && (
|
| <button type="button" onClick={() => setPane("note")}>
|
| <MenuLabel icon="description" text="Edit field description" />
|
| </button>
|
| )}
|
| {!schemaLocked && canPermissions && (
|
| <button type="button" onClick={() => setPane("permissions")}>
|
| <MenuLabel icon="permissions" text="Edit field permissions" />
|
| </button>
|
| )}
|
| {!schemaLocked && formatKind && (
|
| <button type="button" onClick={() => setPane("format")}>
|
| <MenuLabel icon="format" text="Field format" />
|
| </button>
|
| )}
|
| {/* β WAVE-29 T33 (owner item 17) β the COLUMN SUMMARY. Offered on every column, because
|
| `count` applies to any type; the numeric folds are filtered per type inside the pane
|
| by `aggOptions`. β NOT gated on `schemaLocked`: a summary is a reading of the column,
|
| not a change to its schema, so it belongs on a pre-set column too β the same reason
|
| Sort and Filter sit below without that gate. */}
|
| {onAggregate && (
|
| <button type="button" onClick={() => setPane("summary")}>
|
| <MenuLabel
|
| icon="format"
|
| text={field.agg ? `Summary: ${AGG_LABELS[field.agg]}` : "Summarize this column"}
|
| />
|
| </button>
|
| )}
|
|
|
| <div className="cg-menu-sep" role="separator" aria-hidden />
|
|
|
| {/* Group 3 β sort. Direction wording stays the shipped per-type directionLabel. */}
|
| <button type="button" onClick={() => { onSort("asc"); onClose(); }}>
|
| <MenuLabel
|
| icon="sortAsc"
|
| text={`Sort ${directionLabel(field.type, "asc")}`}
|
| />
|
| </button>
|
| <button type="button" onClick={() => { onSort("desc"); onClose(); }}>
|
| <MenuLabel
|
| icon="sortDesc"
|
| text={`Sort ${directionLabel(field.type, "desc")}`}
|
| />
|
| </button>
|
| {sortedDir != null && (
|
| <button type="button" onClick={() => { onClearSort(); onClose(); }}>
|
| <MenuLabel icon="off" text="Don't sort by this field" />
|
| </button>
|
| )}
|
|
|
| <div className="cg-menu-sep" role="separator" aria-hidden />
|
| {/* Group 4 β the view-config group: filter Β· group (+ pin, which also shapes
|
| the view). */}
|
| {/* Item 6c: a measure-carrying column's Filter-by seeds the equivalent MEASURE
|
| condition β the designed replacement for filtering the (filterable:false)
|
| column itself (offered only while the host serves measures, the same
|
| availability rule as measure conditions). Everything else keeps the plain
|
| filterable check. */}
|
| {(field.filterable !== false ||
|
| (field.measure != null && measures.length > 0)) && (
|
| <button type="button" onClick={() => { onFilterBy(); onClose(); }}>
|
| <MenuLabel icon="filter" text="Filter by this field" />
|
| </button>
|
| )}
|
| {isFiltered && (
|
| <button type="button" onClick={() => { onClearFilter(); onClose(); }}>
|
| <MenuLabel icon="off" text="Don't filter by this field" />
|
| </button>
|
| )}
|
| {groupable && !isGrouped && (
|
| <button type="button" onClick={() => { onGroupByField(); onClose(); }}>
|
| <MenuLabel icon="group" text="Group by this field" />
|
| </button>
|
| )}
|
| {isGrouped && (
|
| <button type="button" onClick={() => { onClearGroup(); onClose(); }}>
|
| <MenuLabel icon="off" text="Don't group by this field" />
|
| </button>
|
| )}
|
| {/* Item 11c β pin: freeze the columns up to this one; Unpin restores the identity
|
| column alone. */}
|
| {onPinTo && !pinnedTo && (
|
| <button type="button" onClick={() => { onPinTo(); onClose(); }}>
|
| <MenuLabel icon="pin" text="Pin up to this field" />
|
| </button>
|
| )}
|
| {onUnpin && (
|
| <button type="button" onClick={() => { onUnpin(); onClose(); }}>
|
| <MenuLabel icon="unpin" text="Unpin all fields" />
|
| </button>
|
| )}
|
|
|
| <div className="cg-menu-sep" role="separator" aria-hidden />
|
|
|
| {/* Group 5 β hide Β· delete. */}
|
| <button
|
| type="button"
|
| disabled={locked}
|
| title={locked ? "The primary field stays visible" : undefined}
|
| onClick={onHide}
|
| >
|
| <MenuLabel icon="hide" text="Hide field" />
|
| </button>
|
| {!schemaLocked && onDelete && (
|
| <button
|
| type="button"
|
| className="is-danger"
|
| title={
|
| /**
|
| * β 2026-08-10 β THE PRE-SET ROLLUP COMES BACK, and the honest place to say so is
|
| * here rather than in a tombstone.
|
| *
|
| * A pre-set column carries `automation.preset`, and BOTH reconcilers
|
| * (`_reconcile_ig_graph_fields`, `odoo_relational`'s forward migration) re-declare
|
| * every field they own on the next run. RECONFIGURING one sticks β the custody
|
| * stamp (`userEdited`) switches the reconciler off for that column β but DELETING
|
| * one leaves nothing behind to carry a stamp, so it is re-added within a tick.
|
| * β NOT FIXED WITH A TOMBSTONE, deliberately: that is a new store key two
|
| * reconcilers must both consult, and the custody stamp is the precedent for how
|
| * that goes wrong when only one of them reads it. The delete is not broken β it is
|
| * a delete of something the product re-declares, and the user is told which.
|
| */
|
| field.measure
|
| ? "Removes this formula column from the workspace."
|
| : field.automation?.preset === true
|
| ? "Part of this database's built-in schema β it will be re-created on the " +
|
| "next run. Hide it to keep it off the grid."
|
| : field.type === "rollup" || field.type === "link" ||
|
| field.type === "formula"
|
| ? "Removes this column. No values are lost β it holds none of its own."
|
| : "Removes this field AND its values on every row."
|
| }
|
| onClick={() => (confirmDelete ? onDelete() : setConfirmDelete(true))}
|
| >
|
| <MenuLabel
|
| icon="trash"
|
| text={confirmDelete ? "Delete permanently?" : "Delete field"}
|
| />
|
| </button>
|
| )}
|
| </div>
|
| </AnchoredOverlay>
|
| );
|
| }
|
|
|