| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
| |
| |
| |
| |
| 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>; |
| |
| |
| nouns?: string; |
| |
| lockedKey: string; |
| onColumnVisible: (key: string, show: boolean) => void; |
| onColumnsVisible: (keys: string[]) => void; |
| |
| |
| |
| orderedFields?: Field[]; |
| |
| onFieldOrder?: (keys: string[]) => void; |
| |
| |
| deletableKeys?: ReadonlySet<string>; |
| onDeleteField?: (key: string) => void; |
|
|
| colorOn: boolean; |
| onToggleColor: (on: boolean) => void; |
|
|
| rowHeightMode: RowHeightMode; |
| onRowHeightMode: (m: RowHeightMode) => void; |
|
|
| |
| filters: FilterNode[]; |
| onFilters: (f: FilterNode[]) => void; |
| |
| filterConj: Conjunction; |
| onFilterConj: (c: Conjunction) => void; |
| sorts: SortSpec; |
| onSorts: (s: SortSpec) => void; |
| |
| groupBy: string | null; |
| onGroupBy: (key: string | null) => void; |
| |
| statusValues: Record<string, string[]>; |
|
|
| search: string; |
| onSearch: (v: string) => void; |
|
|
| |
| |
| |
| |
| |
| measures?: Measure[]; |
| |
| |
| |
| |
| |
| pendingMeasureCount?: number; |
| |
| |
| |
| |
| |
| |
| unresolvedCount?: number; |
| |
| lists?: { id: string; name: string }[]; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| cohortLock?: { name?: string; count?: number }; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| copyViews?: { id: string; name: string; config: ViewConfig }[]; |
| |
| |
| onCopyConfig?: (patch: Partial<ViewConfig>) => void; |
|
|
| recordCount: number; |
| |
| |
| scopeCounts?: ScopeCounts; |
| |
| |
| pool?: PoolProgress; |
| |
| |
| |
| serverWindowed?: boolean; |
| |
| |
| |
| |
| |
| |
| cohortAction?: ReactNode; |
| |
| |
| |
| |
| |
| |
| |
| filterSeed?: { |
| key?: string; |
| measure?: { key: string; window: WindowSpec }; |
| n: number; |
| } | null; |
| |
| |
| |
| modeControl?: ReactNode; |
| } |
|
|
| |
| 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, |
| }; |
|
|
| |
| |
| |
| |
| 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> |
| ); |
| } |
| |
| |
| 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> |
| ); |
| } |
| |
| |
| |
| |
| |
| |
| const COPY_PART_ICONS: Record<CopyPart, ReactNode> = { |
| filters: <IconFilter />, |
| sorts: <IconSort />, |
| groupBy: <IconGroup />, |
| visible: <EyeOffIcon />, |
| order: <IconColumns />, |
| rowHeightMode: <IconRows />, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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>(""); |
| |
| |
| |
| |
| 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> |
| ); |
| } |
|
|