loopable / web /src /customer-grid /Toolbar.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
0c7b86d verified
Raw
History Blame Contribute Delete
41.2 kB
// ---------------------------------------------------------------------------
// customer-grid / Toolbar.tsx
// The ~44px control strip above the grid — Airtable-style: quiet text+icon
// buttons with popovers, a hairline underline, tabular-nums count.
//
// PURELY PRESENTATIONAL. Every piece of state and every setter is a prop; this
// file owns no view-state and knows nothing about glide. The color on/off ->
// status-key mapping lives in CustomerGrid (Toolbar just gets colorOn + toggle).
//
// Controls left -> right: Fields (hide/show), Filter+Sort (disabled, next
// batch), Color (by status), Rows (height) | spacer | Search | "N records".
// Styling: scoped `.cg-*` classes in src/index.css.
// ---------------------------------------------------------------------------
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import type {
Conjunction,
Field,
FilterNode,
FilterTree,
Measure,
RowHeightMode,
ScopeCounts,
SortRule,
SortSpec,
ViewConfig,
} from "./types";
import { viewDisplayMode } from "./types";
import { COPY_PARTS, COPY_PART_KEYS, copyViewConfig } from "./copyConfig";
import type { CopyPart } from "./copyConfig";
import { directionLabel, filterTreeConj, filterTreeNodes, isFilterGroup, isGroupableField }
from "./types";
import type { WindowSpec } from "./windows";
import { AnchoredOverlay } from "./OverlaySurface";
import { EyeOffIcon, FieldTypeIcon, ModeIcon } from "./icons";
import { FieldSelectButton } from "./FieldSelect";
import { TYPE_LABELS } from "./iconShapes";
import { countLabel, countLabelCompact, poolProgressLabel } from "./counts";
import type { PoolProgress } from "./counts";
// Wave-15 (C-KIT) — the condition builder and the hide-fields list live in `filter-kit/` now,
// so the admin permission editor mounts the SAME controls instead of a lookalike. This file
// keeps everything that is genuinely toolbar: the strip, its buttons, sort, group, colour,
// rows, search, the count, and the copy-configuration door it hands the builder as a slot.
import { FilterBuilderPanel, FieldsHidePanel, Popover } from "../filter-kit";
import { MEASURE_OP_LIST, countConditions, newRuleId, opsForType } from "../filter-kit/ops";
import "./filters.css";
export interface ToolbarProps {
fields: Field[];
visible: Set<string>;
/** Wave 16 C-TOPIC — the plural noun for what a row IS ("customers" / "products"). Only
* copy that NAMES the row uses it; structural words ("record", "view") stay shared. */
nouns?: string;
/** the one field that can't be hidden (rendered locked in the Fields list) */
lockedKey: string;
onColumnVisible: (key: string, show: boolean) => void;
onColumnsVisible: (keys: string[]) => void;
/** Owner item 23 — the fields in the view's COLUMN ORDER, for the Hide-fields panel only.
* `fields` above stays definition-ordered because the Filter / Sort / Group builders read
* it as a vocabulary, where the column sequence carries no meaning. */
orderedFields?: Field[];
/** Owner item 23 — a drag (or an arrow key) in that panel hands back the whole new order. */
onFieldOrder?: (keys: string[]) => void;
/** Wave-7 item W9 — permanent delete from the Fields panel. Same wall as the column
* menu's Delete: only keys in `deletableKeys` (created strata) render the control. */
deletableKeys?: ReadonlySet<string>;
onDeleteField?: (key: string) => void;
colorOn: boolean;
onToggleColor: (on: boolean) => void;
rowHeightMode: RowHeightMode;
onRowHeightMode: (m: RowHeightMode) => void;
// filter (recursive condition builder w/ groups) + multi-level sort
filters: FilterNode[];
onFilters: (f: FilterNode[]) => void;
/** conjunction joining the ROOT-level conditions */
filterConj: Conjunction;
onFilterConj: (c: Conjunction) => void;
sorts: SortSpec;
onSorts: (s: SortSpec) => void;
// grouping — Batch 4: bucket rows by a text/status field (null = ungrouped)
groupBy: string | null;
onGroupBy: (key: string | null) => void;
/** distinct values per status field, for the "is / is not" value dropdown */
statusValues: Record<string, string[]>;
search: string;
onSearch: (v: string) => void;
/**
* CG-8 — measures the condition builder may offer alongside the columns. A measure is a
* question about a period you choose ("Sales, in the last 90 days"); it appears in the FILTER
* field list and nowhere else, because it is not a column of this table.
*/
measures?: Measure[];
/**
* How many ACTIVE measure conditions have no answer yet. Rendered as a marker beside the
* count, because a pending condition matches nothing and "0 records" alone is
* indistinguishable from a filter that genuinely matches nobody.
*/
pendingMeasureCount?: number;
/**
* How many ACTIVE conditions cannot be answered AT ALL — a relative date in a payload with no
* `today`, a cohort whose membership never arrived, an unparseable date in the value box.
* Distinct from `pendingMeasureCount`, which resolves on its own in one round trip; these do
* not, so the marker says something different and does not go away.
*/
unresolvedCount?: number;
/** Owner item 5 — the cohorts this user has, for `Where [Cohort] [is part of] […]`. */
lists?: { id: string; name: string }[];
/**
* Item 12 (C-LOCK) — this view is LOCKED to a cohort. Present = say so in the filter
* builder, because the lock narrows the list and is not one of the conditions shown there:
* without the banner the count and the conditions disagree with no visible reason.
*
* `name`/`count` ABSENT means the reader cannot see the set — a shared view locked to
* somebody else's cohort. The engine matches nothing in that case (fail-closed, the
* cohort-leaf law), so the banner has to say which kind of nothing this is.
*/
cohortLock?: { name?: string; count?: number };
/**
* Item 13 (C-COPYVIEW) — the OTHER views of this table the reader can see, so the filter
* builder can offer "Copy from another view". Pass `views.filter(v => v.id !== activeViewId)`.
*
* BOTH of these are optional and the door renders only when both are present. That is the
* house posture for a control whose host half may not be wired yet (`onCohortLock` on the
* view rail, `onFieldOrder` on the Fields panel): a missing prop makes the action ABSENT,
* never present-and-silently-inert.
*/
copyViews?: { id: string; name: string; config: ViewConfig }[];
/** Apply the chosen slices to the ACTIVE view, as ONE update. The patch is already computed
* (`copyConfig.ts`, gated by `verify_copy_config.py`) — the caller only persists it. */
onCopyConfig?: (patch: Partial<ViewConfig>) => void;
recordCount: number;
/** Server-windowed tables only. When present the count is rendered from the SCOPE, never
* from the rows in hand — see countLabel(). */
scopeCounts?: ScopeCounts;
/** Wave-7 W1c (C1) — while the pool is PARTIAL the count renders "<loaded> of <total>"
* with a quiet progress affordance instead of a number that reads as a total. */
pool?: PoolProgress;
/** CG-3 mode flag (see types.ts `TableMode`). The filter/sort controls still EDIT the view —
* the host re-runs the query from it — but anything that computes over the rows in hand
* (grouping) is off, because the rows in hand are one page. */
serverWindowed?: boolean;
/**
* Cohort mode's "+ Add customers" control (wave-2 item 2c), rendered after the row-height
* popover. A ReactNode slot rather than more state props: the picker needs the pool, the
* active cohort and the event plumbing, all of which live in CustomerGrid — the toolbar
* stays presentational.
*/
cohortAction?: ReactNode;
/**
* Wave-5 item 1 — "Filter by this field" from a column menu. `n` is a monotonic click
* counter (so the same field seeds twice); on change the toolbar appends one fresh
* condition and opens the Filter builder. Wave-6 item 6c: a MEASURE-carrying column
* seeds the equivalent MEASURE condition instead — `measure` carries the measure key and
* the COLUMN'S OWN window, value empty (half-typed, exactly like a column seed).
*/
filterSeed?: {
key?: string;
measure?: { key: string; window: WindowSpec };
n: number;
} | null;
/** Wave-6 item 10 — the view-mode switcher (Grid · List · Calendar · Kanban), built by
* CustomerGrid (it owns the display config); rendered leftmost. A slot, like
* `cohortAction`, so the toolbar stays presentational. */
modeControl?: ReactNode;
}
// --- tiny inline icons (14px, stroke = currentColor) -----------------------
const ic = { width: 14, height: 14, viewBox: "0 0 16 16", fill: "none" } as const;
const stroke = {
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
// ⚠ Item 12: this three-column-rules mark used to open the Hide-fields panel as `IconFields`,
// where it named the NOUN and left the verb to be guessed. The button wears the struck-through
// eye now (`icons.tsx`). The glyph itself was right for something else, so it is RENAMED
// rather than deleted-and-reinvented: it is the copy-configuration modal's "Field order".
function IconColumns() {
return (
<svg {...ic} aria-hidden>
<rect x="2" y="3" width="12" height="10" rx="1.5" {...stroke} />
<line x1="6.5" y1="3" x2="6.5" y2="13" {...stroke} />
<line x1="10.5" y1="3" x2="10.5" y2="13" {...stroke} />
</svg>
);
}
function IconFilter() {
return (
<svg {...ic} aria-hidden>
<path d="M2.5 4h11L9.5 9v3.5L6.5 14V9L2.5 4Z" {...stroke} />
</svg>
);
}
function IconSort() {
return (
<svg {...ic} aria-hidden>
<path d="M5 3v10M5 13l-2-2M5 13l2-2" {...stroke} />
<path d="M11 13V3M11 3l-2 2M11 3l2 2" {...stroke} />
</svg>
);
}
function IconColor() {
return (
<svg {...ic} aria-hidden>
<path d="M8 2s4.5 4.2 4.5 7a4.5 4.5 0 0 1-9 0C3.5 6.2 8 2 8 2Z" {...stroke} />
</svg>
);
}
function IconRows() {
return (
<svg {...ic} aria-hidden>
<line x1="2.5" y1="5" x2="13.5" y2="5" {...stroke} />
<line x1="2.5" y1="8" x2="13.5" y2="8" {...stroke} />
<line x1="2.5" y1="11" x2="13.5" y2="11" {...stroke} />
</svg>
);
}
function IconGroup() {
return (
<svg {...ic} aria-hidden>
<line x1="2.5" y1="4" x2="13.5" y2="4" {...stroke} />
<line x1="5.5" y1="8" x2="13.5" y2="8" {...stroke} />
<line x1="5.5" y1="12" x2="13.5" y2="12" {...stroke} />
</svg>
);
}
/** Item 5c — a closed padlock. The same mark the view rail puts on a locked row
* (`cg-view-lockset`), so the rail and the toolbar say "locked" with one glyph. */
function LockIcon() {
return (
<svg {...ic} aria-hidden>
<rect x="3.5" y="7" width="9" height="6" rx="1.2" {...stroke} />
<path d="M5.75 7V5.25a2.25 2.25 0 0 1 4.5 0V7" {...stroke} />
</svg>
);
}
function IconSearch() {
return (
<svg {...ic} aria-hidden>
<circle cx="7" cy="7" r="4" {...stroke} />
<line x1="10.2" y1="10.2" x2="13.5" y2="13.5" {...stroke} />
</svg>
);
}
/**
* Item 13 — the mark on each copy-configuration row. Every one is a glyph this toolbar
* ALREADY paints for the same concept (the Filter / Sort / Group / Rows buttons, and the eye
* the Hide-fields button now wears), so the modal teaches nothing new: you recognise the row
* by the control it will overwrite.
*/
const COPY_PART_ICONS: Record<CopyPart, ReactNode> = {
filters: <IconFilter />,
sorts: <IconSort />,
groupBy: <IconGroup />,
visible: <EyeOffIcon />,
order: <IconColumns />,
rowHeightMode: <IconRows />,
};
/**
* Item 13 (contract C-COPYVIEW) — "Copy configuration from <view> to this view".
*
* Shape per `reference/Airtable 13.png`: a source picker in the title sentence, one row per
* copyable slice, Select all / Clear all, Cancel / Copy.
*
* TWO deliberate departures from the screenshot, both booked rather than improvised:
* - Airtable's rows are PILL TOGGLES; these are the app's own `.cg-check-row` checkboxes.
* A toggle would be a new control vocabulary (and a new a11y surface) for a list that is
* exactly what a checkbox list is for — several independent yes/no choices applied by one
* button, not seven settings that take effect as you flip them.
* - "Column widths and row height" is not offered: `widths` records how wide THIS reader
* dragged THIS view's columns, and importing that re-lays-out a table nobody complained
* about. Row height is offered on its own, which is the half of that row people mean.
*
* The merge itself lives in `copyConfig.ts` and is gated (`verify_copy_config.py`): this panel
* only collects the choice. That split is on purpose — the panel is invisible to every node
* gate in the repo, and the arithmetic is the part that writes durable state.
*/
function CopyConfigPanel({
views,
lockedKey,
onApply,
onClose,
}: {
views: { id: string; name: string; config: ViewConfig }[];
lockedKey: string;
onApply: (patch: Partial<ViewConfig>) => void;
onClose: () => void;
}) {
const [sourceId, setSourceId] = useState<string>("");
// "Filter conditions" starts on: this modal opens from the Filter popover, so it is the
// slice the person was already looking at. Everything else starts off — a copy that
// silently replaced a view's column order would be the surprise this whole panel exists
// to avoid.
const [parts, setParts] = useState<Set<CopyPart>>(() => new Set<CopyPart>(["filters"]));
const source = views.find((v) => v.id === sourceId);
const toggle = (key: CopyPart) =>
setParts((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return (
<div className="cg-pop-body cg-copycfg">
<div className="cg-copycfg-head">
<span>Copy configuration from</span>
<FieldSelectButton
className="cg-copycfg-source"
ariaLabel="View to copy from"
placeholder="Choose a view…"
value={sourceId || undefined}
onChange={setSourceId}
fields={views.map((v) => ({
key: v.id,
label: v.name,
// Views wear their DISPLAY MODE mark, not a field type — the picker takes an
// `icon` for exactly this. `viewDisplayMode` asks only for `Pick<SavedView,
// "config">`, which is what the caller already has to pass here.
icon: <ModeIcon mode={viewDisplayMode(v)} />,
}))}
/>
<span>to this view</span>
</div>
<div className="cg-pop-note">Select what to copy. This view keeps everything else.</div>
<div className="cg-pop-list">
{COPY_PARTS.map((part) => (
<label key={part.key} className="cg-check-row" title={part.blurb}>
<input
type="checkbox"
checked={parts.has(part.key)}
onChange={() => toggle(part.key)}
/>
<span className="cg-mi-ic" aria-hidden>
{COPY_PART_ICONS[part.key]}
</span>
<span className="cg-check-label">{part.label}</span>
</label>
))}
</div>
<div className="cg-pop-footer cg-copycfg-bulk">
<button
type="button"
className="cg-link-btn"
onClick={() => setParts(new Set(COPY_PART_KEYS))}
>
Select all
</button>
<button type="button" className="cg-link-btn" onClick={() => setParts(new Set())}>
Clear all
</button>
</div>
<div className="cg-form-actions cg-copycfg-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
disabled={!source || parts.size === 0}
title={
!source
? "Choose the view to copy from"
: parts.size === 0
? "Choose at least one thing to copy"
: undefined
}
onClick={() => {
if (!source) return;
onApply(copyViewConfig(source.config, parts, lockedKey));
onClose();
}}
>
Copy configuration
</button>
<button type="button" className="cg-btn" onClick={onClose}>
Cancel
</button>
</div>
</div>
);
}
/**
* Item 13 (C-COPYVIEW) — the "Copy from another view" door, mounted into the filter builder's
* footer as `footerExtra`.
*
* Wave-15 (C-KIT): it used to be inline in `FilterBuilder`. It stayed HERE when the builder
* moved to `filter-kit/` because copying one view's configuration onto another is a
* view-rail concept — a permission editor has no other view to copy from — and putting it in
* the kit would have dragged `ViewConfig`, `copyConfig.ts` and the mode icons along with it.
* A slot, exactly like `cohortAction` and `modeControl` above it.
*/
function CopyFromViewDoor({
views,
lockedKey,
onApply,
}: {
views: { id: string; name: string; config: ViewConfig }[];
lockedKey: string;
onApply: (patch: Partial<ViewConfig>) => void;
}) {
const [copying, setCopying] = useState(false);
const copyRef = useRef<HTMLButtonElement>(null);
return (
<>
<button
ref={copyRef}
type="button"
className={"cg-link-btn cg-copy-link" + (copying ? " is-on" : "")}
aria-expanded={copying}
onClick={() => setCopying((v) => !v)}
>
Copy from another view
</button>
{copying && copyRef.current && (
<AnchoredOverlay
anchor={copyRef.current}
className="cg-pop cg-copycfg-pop"
placement="bottom-end"
onDismiss={() => setCopying(false)}
role="dialog"
ariaLabel="Copy configuration from another view"
dataKind="copy-config"
>
<CopyConfigPanel
views={views}
lockedKey={lockedKey}
onApply={onApply}
onClose={() => setCopying(false)}
/>
</AnchoredOverlay>
)}
</>
);
}
// --- Sort: multi-level, ordered = precedence -------------------------------
function SortBuilder({
fields,
fieldByKey,
sorts,
onSorts,
}: {
fields: Field[];
fieldByKey: Map<string, Field>;
sorts: SortSpec;
onSorts: (s: SortSpec) => void;
}) {
const update = (i: number, patch: Partial<SortRule>) =>
onSorts(sorts.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const remove = (i: number) => onSorts(sorts.filter((_, j) => j !== i));
const add = () => {
const used = new Set(sorts.map((s) => s.colId));
const f = fields.find((fl) => !used.has(fl.key)) ?? fields[0];
if (!f) return;
onSorts([...sorts, { colId: f.key, dir: "asc" }]);
};
return (
<div className="cg-pop-body cg-builder">
<div className="cg-pop-title">Sort</div>
{sorts.length === 0 && (
<div className="cg-builder-empty">
No sorts. Add one to order the list.
</div>
)}
{sorts.map((rule, i) => {
const t = fieldByKey.get(rule.colId)?.type ?? "text";
return (
<div className="cg-cond-line" key={i}>
<span className="cg-cond-prefix">{i === 0 ? "Sort by" : "then by"}</span>
<span className="cg-cond-row">
{/* Item 20 — and it closes a latent `withCurrent` hole on the way: a sort on a
field the table no longer has used to render as this list's FIRST option,
so the rule said one thing and the row showed another. The picker renders
the unresolvable key in its `is-missing` slot instead. */}
<FieldSelectButton
className="cg-cond-field"
ariaLabel="Sort field"
value={rule.colId}
onChange={(key) => update(i, { colId: key })}
fields={fields.map((fl) => ({
key: fl.key, label: fl.label, type: fl.type,
}))}
/>
<select
className="cg-select cg-cond-op"
value={rule.dir}
aria-label="Sort direction"
onChange={(e) =>
update(i, { dir: e.target.value as "asc" | "desc" })
}
>
<option value="asc">{directionLabel(t, "asc")}</option>
<option value="desc">{directionLabel(t, "desc")}</option>
</select>
<button
type="button"
className="cg-cond-x"
aria-label="Remove sort"
onClick={() => remove(i)}
>
×
</button>
</span>
</div>
);
})}
<div className="cg-pop-footer cg-builder-footer">
<button type="button" className="cg-link-btn" onClick={add}>
+ Add another sort
</button>
{sorts.length > 0 && (
<button
type="button"
className="cg-link-btn"
onClick={() => onSorts([])}
>
Clear
</button>
)}
</div>
</div>
);
}
const ROW_OPTIONS: { mode: RowHeightMode; label: string }[] = [
{ mode: "short", label: "Short" },
{ mode: "medium", label: "Medium" },
{ mode: "tall", label: "Tall" },
];
export default function Toolbar({
fields,
visible,
nouns = "customers",
lockedKey,
onColumnVisible,
onColumnsVisible,
orderedFields,
onFieldOrder,
deletableKeys,
onDeleteField,
colorOn,
onToggleColor,
rowHeightMode,
onRowHeightMode,
filters,
onFilters,
filterConj,
onFilterConj,
sorts,
onSorts,
groupBy,
onGroupBy,
statusValues,
measures = [],
pendingMeasureCount = 0,
unresolvedCount = 0,
lists = [],
cohortLock,
copyViews,
onCopyConfig,
search,
onSearch,
recordCount,
scopeCounts,
pool,
serverWindowed = false,
cohortAction,
filterSeed = null,
modeControl,
}: ToolbarProps) {
const shownCount = fields.filter((f) => visible.has(f.key)).length;
const fieldByKey = useMemo(() => {
const m = new Map<string, Field>();
for (const f of fields) m.set(f.key, f);
return m;
}, [fields]);
const measureByKey = useMemo(() => {
const m = new Map<string, Measure>();
for (const item of measures) m.set(item.key, item);
return m;
}, [measures]);
// --- C-KIT adapters: this toolbar's state shape -> the kit's -------------
/** Owner item 23 — the Hide-fields list renders in COLUMN order when the host supplies one. */
const panelFields = orderedFields ?? fields;
/** The kit's polarity. Complement of `visible` over exactly the list the panel renders, so
* `hidden.has(k)` is `!visible.has(k)` for every row it can draw. */
const hiddenKeys = useMemo(
() => new Set(panelFields.filter((f) => !visible.has(f.key)).map((f) => f.key)),
[panelFields, visible]
);
/** A filter as ONE value (types.FilterTree) — what the kit takes and what a permission
* record stores. The view keeps storing the two halves separately; this is the join. */
const filterTree = useMemo(
() => ({ conj: filterConj, nodes: filters }),
[filterConj, filters]
);
/** ...and the split on the way back. Exactly one half changes per edit (a level calls either
* `onNodes` or `onConj`, never both), so comparing before calling keeps this a single state
* update — the NO-BLIP law, which two unconditional setters would break. */
const onFilterTree = (next: FilterTree) => {
const nodes = filterTreeNodes(next);
const conj = filterTreeConj(next);
if (nodes !== filters) onFilters(nodes);
if (conj !== filterConj) onFilterConj(conj);
};
/**
* Item 5c → wave17 owner item 1 (R1) — **the chip is a STATE, not a sentence.**
*
* It read "«name» · 1,204 customers", or "Locked to a list you cannot see" when the reader
* could not count the set — three sentences from `cohortRail.cohortLockChipText`, which is why
* that projection existed. R1 replaces all three with a lock mark and the word "Locked".
*
* That is not merely shorter. Under R1 the locked view is IN THE RAIL, named, selected, with
* its own row — so the chip repeating its name told you what you had just clicked, and the
* count duplicated the record count sitting six inches to its right. What the chip is for is
* the one thing neither of those says: *this table is narrowed by something that is not a
* filter*. The name and the explanation move to the `title`.
*/
const lockOn = !!cohortLock;
/** The copy door renders only when BOTH halves are wired — the house posture for an action
* whose host half may not exist yet. */
const canCopyConfig = !!onCopyConfig && (copyViews?.length ?? 0) > 0;
// ONE groupable predicate for the whole app (types.isGroupableField): closed vocabularies
// (status/select/multiselect/checkbox) + non-overlay text attributes other than the locked
// identity column. The column menu's "Group by this field" consumes the same function — two
// copies of this predicate would drift exactly like operator vocabularies.
const groupable = useMemo(
() => fields.filter((f) => isGroupableField(f, lockedKey)),
[fields, lockedKey]
);
// Wave-5 item 1 — "Filter by this field" from a column menu: seed ONE fresh (inactive,
// half-typed) condition on that field and open the builder mid-sentence. The signal is a
// monotonic counter so the same field can be seeded twice; the ref is what makes the effect
// once-per-click rather than once-per-render. Wave-6 item 6c: a measure seed appends the
// equivalent MEASURE condition — same measure key, the COLUMN'S OWN window, value empty
// (it activates when typed, then resolves through the ordinary pending path).
const seedRef = useRef(0);
useEffect(() => {
if (!filterSeed || filterSeed.n === seedRef.current) return;
seedRef.current = filterSeed.n;
if (filterSeed.measure) {
const m = measureByKey.get(filterSeed.measure.key);
if (!m) return;
onFilters([
...filters,
{
id: newRuleId(),
colId: m.key,
op: MEASURE_OP_LIST[0],
value: "",
window: filterSeed.measure.window,
},
]);
return;
}
const f = filterSeed.key ? fieldByKey.get(filterSeed.key) : undefined;
if (!f || f.filterable === false) return;
onFilters([...filters, { colId: f.key, op: opsForType(f.type)[0], value: "" }]);
}, [filterSeed, fieldByKey, measureByKey, filters, onFilters]);
// Count LEAF conditions across the whole tree, so a nested group reads as its
// real condition count rather than as a single node.
const conditionCount = useMemo(() => countConditions(filters), [filters]);
// Does any condition compare against another attribute? Only then is the wider popover
// needed, and only then is it worth the jump in width.
const hasComparand = useMemo(() => {
const walk = (nodes: FilterNode[]): boolean =>
nodes.some((n) => (isFilterGroup(n)
? walk(n.children)
: n.rhs != null && n.rhs.kind !== "stat"));
return walk(filters);
}, [filters]);
const filterLabel = conditionCount ? `Filter · ${conditionCount}` : "Filter";
const sortLabel = sorts.length ? `Sort · ${sorts.length}` : "Sort";
const groupLabel = groupBy
? `Group: ${fieldByKey.get(groupBy)?.label ?? "?"}`
: "Group";
return (
<div className="cg-toolbar">
{/* Wave-6 item 10 — the view-mode switcher, leftmost: it decides what everything to
its right operates on. */}
{modeControl}
{/* Fields — show / hide columns, searchable, Pre-set chips (item 7 remainder) */}
{/* Owner item 23 — "Hide fields", the panel's actual job and Airtable's wording. */}
<Popover label="Hide fields" icon={<EyeOffIcon />} active={shownCount < fields.length}>
{() => (
<FieldsHidePanel
fields={panelFields}
// C-KIT: the kit speaks HIDDEN and the grid stores VISIBLE, so the complement is
// taken HERE — once, over the same list the panel renders — rather than making
// every other caller of the panel invert twice.
hidden={hiddenKeys}
onToggle={(key) => onColumnVisible(key, !visible.has(key))}
onShowAll={() => onColumnsVisible(fields.map((field) => field.key))}
onHideAll={() => onColumnsVisible([lockedKey])}
lockedKey={lockedKey}
onFieldOrder={onFieldOrder}
deletableKeys={deletableKeys}
onDeleteField={onDeleteField}
/>
)}
</Popover>
{/* Filter — condition builder */}
<Popover
label={filterLabel}
icon={<IconFilter />}
active={conditionCount > 0}
tone="filter"
wide
measureWide={measures.length > 0}
pairWide={hasComparand}
forceOpenSignal={filterSeed?.n}
>
{() => (
<FilterBuilderPanel
// The COMPLETE list: the kit narrows the picker to the filterable ones itself and
// keeps the full map for resolving a condition saved against a column that has
// since stopped being filterable (see FilterBuilderPanelProps.fields).
fields={fields}
measures={measures}
cohorts={lists}
filters={filterTree}
onChange={onFilterTree}
statusValues={statusValues}
cohortLock={cohortLock}
footerExtra={
canCopyConfig ? (
<CopyFromViewDoor
views={copyViews!}
lockedKey={lockedKey}
onApply={(patch) => onCopyConfig!(patch)}
/>
) : undefined
}
/>
)}
</Popover>
{/* Sort — multi-level. Deliberately over ALL fields: "rank by estimated missed dollars"
is exactly what a score is FOR, even though a condition on it is not. */}
<Popover label={sortLabel} icon={<IconSort />} active={sorts.length > 0} tone="sort" wide>
{() => (
<SortBuilder
fields={fields}
fieldByKey={fieldByKey}
sorts={sorts}
onSorts={onSorts}
/>
)}
</Popover>
{/* Group — collapsible sections by a text/status field */}
<Popover label={groupLabel} icon={<IconGroup />} active={groupBy !== null} tone="group">
{() => (
<div className="cg-pop-body">
<div className="cg-pop-title">Group by</div>
{serverWindowed ? (
// CG-3. Grouping runs over the rows in hand; on a windowed table those are ONE
// PAGE, so every group header would report a count and subtotals for the page
// while presenting them as the group's. Say why, rather than silently omitting
// the control — a missing button reads as a bug.
<div className="cg-pop-note">
Not available on this table. It loads one page at a time, so a group would
count only the rows currently fetched.
</div>
) : (
<>
<label className="cg-radio-row">
<input
type="radio"
name="cg-group"
checked={groupBy === null}
onChange={() => onGroupBy(null)}
/>
<span>No grouping</span>
</label>
{groupable.map((f) => (
<label key={f.key} className="cg-radio-row">
<input
type="radio"
name="cg-group"
checked={groupBy === f.key}
onChange={() => onGroupBy(f.key)}
/>
{/* I20 — grouping behaves differently per type (buckets vs values),
so the type mark earns its place in this list too. */}
<FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
<span>{f.label}</span>
</label>
))}
</>
)}
</div>
)}
</Popover>
<span className="cg-tb-divider" aria-hidden />
{/* Color — by status */}
<Popover label="Color" icon={<IconColor />} active={colorOn}>
{() => (
<div className="cg-pop-body">
<div className="cg-pop-title">Color</div>
<label className="cg-radio-row">
<input
type="radio"
name="cg-color"
checked={!colorOn}
onChange={() => onToggleColor(false)}
/>
<span>No color</span>
</label>
<label className="cg-radio-row">
<input
type="radio"
name="cg-color"
checked={colorOn}
onChange={() => onToggleColor(true)}
/>
<span>Color by status</span>
</label>
</div>
)}
</Popover>
{/* Rows — height */}
<Popover label="Rows" icon={<IconRows />} active={rowHeightMode !== "short"}>
{() => (
<div className="cg-pop-body">
<div className="cg-pop-title">Row height</div>
{ROW_OPTIONS.map((o) => (
<label key={o.mode} className="cg-radio-row">
<input
type="radio"
name="cg-rowh"
checked={rowHeightMode === o.mode}
onChange={() => onRowHeightMode(o.mode)}
/>
<span>{o.label}</span>
</label>
))}
</div>
)}
</Popover>
{/* ⭐ THE LOCK, SAID IN THE TOOLBAR (wave-15 item 5c / C-LOCK / R10).
A cohort lock used to be stated only INSIDE the filter popover, which meant the one
control that explains a narrowed table was invisible until you opened something: the
count disagreed with the conditions and nothing on screen said why. It is NOT a
button — the lock is set from the view rail's menu, and a chip that looked clickable
here would promise an action this strip does not have.
⚠ POSITION IS THE OWNER'S, 2026-08-03: immediately RIGHT of "Rows". It used to sit
left of the divider beside Filter / Sort / Group, on the reasoning that it states the
same kind of fact. Beside three CONTROLS, a thing that cannot be clicked reads as a
disabled fourth; over here it is plainly a status mark on the table rather than a
broken button. It also only renders on a locked view, so on every other view "Rows"
and the search box stay adjacent exactly as item 14 (2026-08-02) placed them — that
ruling is narrowed by this one, not overturned. */}
{lockOn && (
<span
className="cg-tb-lock"
// The name and the count left the CHIP (R1) — they did not leave the product. The
// rail row is named and selected; this is the hover that explains what the mark means.
//
// ⚠ Branches on `name`, NOT on `count`, and that is the honest test. A set the reader
// may not see arrives with NEITHER field (`CustomerGrid.cohortLockChip` returns `{}`
// for a lock whose set is not in `lists`) — while a set that IS found always reports a
// number, because that producer reads `pids?.length ?? 0`. So `count == null` is not a
// state this chip can observe, and testing it would be a branch that never runs. The
// "unreadable set" sentence still has to exist: the table is empty for a reason that
// is not a filter, and the reader has to be told which kind of nothing this is.
title={
(cohortLock?.name
? `Locked to ${cohortLock.name}. `
: "Locked to a set of records you cannot see. ") +
"Only the records locked into that view are shown. Filters and sort narrow WITHIN " +
"the lock; the lock itself is changed from the view menu in the rail."
}
>
<LockIcon />
<span className="cg-tb-label">Locked</span>
</span>
)}
{/* Item 14 (owner, 2026-08-02) — SEARCH sits immediately right of "Rows", inside the
control cluster, not marooned across the spacer. Deliberately BEFORE `cohortAction`:
that slot is empty on the customer surface and holds "+ Add customers" on the cohort
one, so putting search after it would make "immediately right of Rows" true on one
page and false on the other. The spacer below now pushes only the COUNT to the far
edge, which is the one thing in this strip that belongs there. */}
<div className="cg-search">
<span className="cg-search-icon" aria-hidden>
<IconSearch />
</span>
<input
type="text"
className="cg-search-input"
placeholder={`Search ${nouns}…`}
value={search}
onChange={(e) => onSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="cg-search-clear"
aria-label="Clear search"
onClick={() => onSearch("")}
>
×
</button>
)}
</div>
{cohortAction}
<span className="cg-tb-spacer" />
{pendingMeasureCount > 0 && (
// A measure condition is answered by the server. Between the keystroke and the answer
// the condition matches NOTHING — never everything, which would show every row under a
// count the user has no reason to doubt. Saying so is what makes the empty table
// readable rather than alarming, and it lasts exactly one round trip.
<span
className="cg-count cg-count-pending"
title={
`${pendingMeasureCount} measure condition${pendingMeasureCount === 1 ? "" : "s"} ` +
"still being calculated over the period you chose. Rows are hidden until the " +
"answer arrives rather than shown under a count that would be wrong."
}
>
Calculating…
</span>
)}
{unresolvedCount > 0 && (
// NOT the same state as "Calculating…". A pending measure is an answer on its way; this
// is a condition nothing will answer — a relative date with no tenant `today`, or a
// cohort that has been deleted. Both hide rows, so both have to be said out loud, but
// saying "calculating" about something that will never arrive is worse than silence.
<span
className="cg-count cg-count-pending"
title={
`${unresolvedCount} condition${unresolvedCount === 1 ? "" : "s"} cannot be ` +
"evaluated — a date that does not resolve, or a list this view refers to that no " +
"longer exists. Rows are hidden rather than shown under a count that would be wrong."
}
>
Filter unresolved
</span>
)}
{poolProgressLabel(pool) != null ? (
// W1c (C1): the pool is a PARTIAL fast slice — the count says so instead of
// presenting the slice as a total.
//
// wave17 GRID — item 3 / owner R6: the affordance is now the ONE app-wide spinner
// (`.lp-spin`, C-SPIN) rather than this chip's own pulsing dot. R6's point is that every
// wait in the product looks the same; a bespoke dot here was the last place it did not.
//
// ⚠ What R6 does NOT take: the COUNT stays, and so does the sentence in the `title`.
// "No words" is about the loading indicator, and "100 of 1,550" is a number, not a word
// — it is the disclosure that this slice is partial, and dropping it would leave a count
// silently capped, which the owner constants forbid outright. The title is a hover
// affordance, not text on screen.
<span
className="cg-count cg-count-partial"
title={
`Loading the rest of your ${nouns} in the background — you can browse, ` +
"filter and sort what is already here."
}
>
<span className="lp-spin" role="status" aria-label="Loading" />
{poolProgressLabel(pool)}
</span>
) : (
<span className="cg-count" title={countLabel(recordCount, scopeCounts)}>
<span className="cg-count-full">{countLabel(recordCount, scopeCounts)}</span>
<span className="cg-count-compact">
{countLabelCompact(recordCount, scopeCounts)}
</span>
</span>
)}
</div>
);
}