| import {
|
| memo,
|
| useCallback,
|
| useEffect,
|
| useLayoutEffect,
|
| useMemo,
|
| useRef,
|
| useState,
|
| } from "react";
|
| import { DataEditor, GridCellKind } from "@glideapps/glide-data-grid";
|
| import type {
|
| CellClickedEventArgs,
|
| DataEditorRef,
|
| EditableGridCell,
|
| GridKeyEventArgs,
|
| GridMouseEventArgs,
|
| GridSelection,
|
| DrawHeaderCallback,
|
| HeaderClickedEventArgs,
|
| Item,
|
| Rectangle,
|
| Theme,
|
| } from "@glideapps/glide-data-grid";
|
| import "@glideapps/glide-data-grid/dist/index.css";
|
|
|
| import { useCustomerData } from "./useCustomerData";
|
| import type { SurfaceScope } from "./apiBridge";
|
|
|
|
|
|
|
| import { WINDOW_ROWS } from "./apiBridge";
|
| import {
|
| EMPTY_WINDOW_PREDICATE, limitSummary, nextWindowOffset, windowedCapabilityNote,
|
| windowedFoldNote, windowPredicateKey,
|
| } from "./counts";
|
| import { defaultViewConfig, useGridColumns } from "./useGridColumns";
|
| import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
|
| unresolvedConditions, useVisibleRows } from "./useVisibleRows";
|
| import { computeAggs } from "./aggregations";
|
| import type { CohortSets } from "./useVisibleRows";
|
| import type { MeasureSets } from "./useVisibleRows";
|
| import { useGetCellContent } from "./useGetCellContent";
|
| import { useGridSelection } from "./useGridSelection";
|
| import Toolbar from "./Toolbar";
|
| import JsonViewer from "./JsonViewer";
|
| import RecordDetail from "./RecordDetail";
|
| import ViewSidebar from "./ViewSidebar";
|
| import ColumnMenu from "./ColumnMenu";
|
| import type { ColumnMenuState } from "./ColumnMenu";
|
| import { HEADER_ICONS } from "./iconShapes";
|
| import { emitHostEvent, eventId } from "./hostBridge";
|
|
|
|
|
| import { fetchAlerts } from "../alerts/alertsApi";
|
| import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, VIEW_OPEN_EVENT,
|
| WORKSPACE_STALE_EVENT, signal }
|
| from "../apiContract";
|
| import type { ViewOpenDetail } from "../apiContract";
|
| import { addTableField, addTableRow, deleteTableField, deleteTableRow, fetchLinkTargets,
|
| fetchRollupSources, patchTableField } from "./apiBridge";
|
| import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
|
| import SelectFromFile from "./SelectFromFile";
|
| import ImportDialog from "./ImportDialog";
|
| import { FormInterface } from "./FormInterface";
|
| import type { FormSpec } from "./FormInterface";
|
|
|
| import { describe, directed, popRedo, popUndo, pushUndo, stackFor } from "./undoStack";
|
| import type { CellChange, UndoBook, UndoEntry, UndoRow, UndoValue } from "./undoStack";
|
| import { exportFilename, runExport, triggerDownload } from "./export";
|
| import type { ExportFormat } from "./export";
|
|
|
| import { buildTsCsv, tsSheetToTable } from "./timeSeriesData";
|
| import type { TsSheet } from "./timeSeriesData";
|
| import { echoReemit, reconcileEchoView } from "./viewEcho";
|
| import { pruneStamps, reconcileFields } from "./optimism";
|
| import { applyViewOrder, newFolderId, pruneFolderStamps, reconcileFolders,
|
| resolveFolderId } from "./folders";
|
| import type { FolderStamps } from "./folders";
|
|
|
| import { filterSeedValues } from "./filterSeed";
|
| import { adoptNewFields, adoptNewViews, pruneTombstones, seedLocalViews, stampTombstone }
|
| from "./liveWorkspace";
|
| import type { Tombstones } from "./liveWorkspace";
|
| import type { GridFolder, ViewPermissions } from "./types";
|
| import type { FieldStamps } from "./optimism";
|
| import { evalFormula, orderFormulas, parseFormula } from "./formulaEngine";
|
| import type { FormulaAst } from "./formulaEngine";
|
| import { CalendarView, KanbanView, ListView, ModeSwitch } from "./viewModes";
|
| import { SwipeView } from "./SwipeView";
|
| import type { SwipeSpec } from "./SwipeView";
|
|
|
|
|
| import { DashboardView } from "../viz/DashboardView";
|
| import { cleanCharts } from "../viz/chartData";
|
| import type { ChartSpec } from "../viz/chartData";
|
| import { MapView } from "./MapView";
|
|
|
| import { CatalogView } from "./CatalogView";
|
| import { CATALOG_CODE_FIELD } from "./catalogData";
|
| import type { CatalogSpec } from "./types";
|
|
|
| import { lightTheme, STATUS_ROW_THEME, HOVER_ROW_THEME, HOVER_NEUTRAL,
|
| ACTIVE_ROW_NEUTRAL, CUSTOM_FIELD_MARK } from "./theme";
|
| import { avatarInitials, formatDisplay, imageCellRenderer, ratingCellRenderer, setAvatarRepaint,
|
| userCellRenderer } from "./cells";
|
| import { optionTint, pickTint } from "./choiceColors";
|
| import {
|
| EMPTY_GRID_COPY_PROVENANCE,
|
| markGridCopy,
|
| observeCopyEvent,
|
| planFieldPaste,
|
| pasteRowCount,
|
| } from "./clipboard";
|
| import { cellTipText, expandButtonRect, GROUP_HEADER_FONT, GROUP_LABEL_PAD, headerMarkLayout,
|
| headerMarkSizes, tipLeft } from "./overlayPlacement";
|
| import { AnchoredOverlay, BodyPortal, useOverlayLayer } from "./OverlaySurface";
|
| import type { AnchorRect } from "./OverlaySurface";
|
| import { StarIcon } from "./Stars";
|
| import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName,
|
| MAX_FROZEN, choiceOptions, choiceVocabulary, clampFrozenCount, cleanDisplay, formulaOf,
|
| topicForScope,
|
| isDateFamilyType, isFilterGroup, isGroupableField, isMachineOwned,
|
| isDerivedLink, isUserSchemaField, TOTAL_GROUP_KEY,
|
| isNumericFieldType,
|
| machineFoundRows, reFindConsequences,
|
| isPickType, mayEditField,
|
| isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
|
| measureColumnIndex, ratingMax, ruleColumnKeys,
|
| tableMode, uniqueDisplayName } from "./types";
|
| import type {
|
| DisplayMode,
|
| DisplaySpec,
|
| Field,
|
| HostEvent,
|
| FieldScope,
|
| FieldType,
|
| FilterNode,
|
| FilterRule,
|
| Row,
|
| RowHeightMode,
|
| SavedView,
|
| ViewConfig,
|
| } from "./types";
|
|
|
|
|
| import TimeSeriesPanel from "./TimeSeriesPanel";
|
| import type { WindowSpec } from "./windows";
|
| import { windowLabel } from "./windows";
|
|
|
| const ROW_PX: Record<RowHeightMode, number> = { short: 28, medium: 34, tall: 48 };
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const ONE_LINE = { whiteSpace: "nowrap" } as const;
|
|
|
| |
| |
| |
| |
| |
|
|
| function frozenCountOf(config: ViewConfig): number {
|
| return Math.min(MAX_FROZEN, Math.max(1, config.frozenCount ?? 1));
|
| }
|
| |
| |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const PULSE_MS = 140;
|
| const PULSE_MAX_TICKS = Math.round(60_000 / PULSE_MS);
|
| |
|
|
| const NO_PENDING_KEYS: ReadonlySet<string> = new Set<string>();
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| let _tipCtx: CanvasRenderingContext2D | null | undefined;
|
| function measureCellText(text: string): number {
|
| if (_tipCtx === undefined) {
|
| _tipCtx = document.createElement("canvas").getContext("2d");
|
|
|
| if (_tipCtx) _tipCtx.font = `13px ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
|
| }
|
| return _tipCtx ? _tipCtx.measureText(text).width : 0;
|
| }
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| let _groupCtx: CanvasRenderingContext2D | null | undefined;
|
| function measureGroupText(text: string): number {
|
| if (_groupCtx === undefined) {
|
| _groupCtx = document.createElement("canvas").getContext("2d");
|
| if (_groupCtx)
|
| _groupCtx.font = `${GROUP_HEADER_FONT} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
|
| }
|
| return _groupCtx ? _groupCtx.measureText(text).width : 0;
|
| }
|
| |
| |
| |
| |
| |
|
|
| const DISPLAY_PAGE = 50;
|
| const DISPLAY_STEP = 250;
|
|
|
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
|
|
| const HEADER_PX = 36;
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| function rowMarkerPx(rows: number): number {
|
| return rows > 10_000 ? 48 : rows > 1000 ? 44 : rows > 100 ? 36 : 32;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| let scrollbarGutterPx: number | null = null;
|
| function scrollbarGutter(): number {
|
| if (scrollbarGutterPx !== null) return scrollbarGutterPx;
|
| if (typeof document === "undefined") return 0;
|
| const probe = document.createElement("div");
|
| probe.style.cssText =
|
| "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll";
|
| document.body.append(probe);
|
| scrollbarGutterPx = probe.offsetWidth - probe.clientWidth;
|
| probe.remove();
|
| return scrollbarGutterPx;
|
| }
|
|
|
|
|
| interface LocalWorkspace {
|
| fields: Field[];
|
| views: SavedView[];
|
| activeViewId: string;
|
| |
|
|
| savedAt?: number;
|
| |
|
|
| fieldStamps?: FieldStamps;
|
| |
| |
| |
|
|
| reemitted?: Record<string, string>;
|
| |
| |
| |
|
|
| viewTombstones?: Tombstones;
|
| |
| |
| |
|
|
| viewWrites?: Tombstones;
|
| }
|
|
|
| function sameConfig(a: ViewConfig, b: ViewConfig): boolean {
|
| return JSON.stringify(a) === JSON.stringify(b);
|
| }
|
|
|
| function normalizeConfig(config: Partial<ViewConfig> | undefined, fields: Field[]): ViewConfig {
|
| const base = defaultViewConfig(fields);
|
| return {
|
| ...base,
|
| ...config,
|
| filters: Array.isArray(config?.filters) ? config.filters : [],
|
|
|
|
|
| filterConj: config?.filterConj === "or" ? "or" : "and",
|
| sorts: Array.isArray(config?.sorts) ? config.sorts : [],
|
| order: Array.isArray(config?.order) && config.order.length ? config.order : base.order,
|
| visible:
|
| Array.isArray(config?.visible) && config.visible.length
|
| ? config.visible
|
| : base.visible,
|
| widths: config?.widths && typeof config.widths === "object" ? config.widths : {},
|
| memberPids: Array.isArray(config?.memberPids) ? config.memberPids : [],
|
|
|
|
|
|
|
| display: cleanDisplay(config?.display),
|
| frozenCount: clampFrozenCount(config?.frozenCount),
|
| };
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| function allRecordsView(fields: Field[], scope: string): SavedView {
|
| return {
|
| id: ALL_VIEW_ID,
|
| name: allViewName(scope),
|
| kind: "system",
|
| locked: true,
|
| config: defaultViewConfig(fields),
|
| };
|
| }
|
|
|
| function readLocal(storageKey: string): LocalWorkspace | null {
|
| try {
|
| const raw = localStorage.getItem(`aios-grid:${storageKey}`);
|
| return raw ? (JSON.parse(raw) as LocalWorkspace) : null;
|
| } catch {
|
| return null;
|
| }
|
| }
|
|
|
| function writeLocal(storageKey: string, value: LocalWorkspace): void {
|
| try {
|
| localStorage.setItem(`aios-grid:${storageKey}`, JSON.stringify(value));
|
| } catch {
|
|
|
| }
|
| }
|
|
|
| function nextViewId(): string {
|
| return `view_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
| }
|
|
|
|
|
| function splitMulti(v: string): string[] {
|
| return v
|
| .split(",")
|
| .map((s) => s.trim())
|
| .filter((s) => s !== "");
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function treeNamesField(
|
| nodes: FilterNode[],
|
| key: string,
|
| measureCols: Map<string, string[]>
|
| ): boolean {
|
| for (const n of nodes) {
|
| if (isFilterGroup(n)) {
|
| if (treeNamesField(n.children, key, measureCols)) return true;
|
| } else if (ruleColumnKeys(n as FilterRule, measureCols).includes(key)) return true;
|
| }
|
| return false;
|
| }
|
|
|
| |
|
|
| function dropFieldFromTree(
|
| nodes: FilterNode[],
|
| key: string,
|
| measureCols: Map<string, string[]>
|
| ): FilterNode[] {
|
| const out: FilterNode[] = [];
|
| for (const n of nodes) {
|
| if (isFilterGroup(n)) {
|
| const children = dropFieldFromTree(n.children, key, measureCols);
|
| if (children.length) out.push({ ...n, children });
|
| } else if (!ruleColumnKeys(n as FilterRule, measureCols).includes(key)) {
|
| out.push(n);
|
| }
|
| }
|
| return out;
|
| }
|
|
|
|
|
|
|
|
|
|
|
| function slugify(label: string): string {
|
| return (
|
| label
|
| .toLowerCase()
|
| .replace(/[^a-z0-9]+/g, "_")
|
| .replace(/^_|_$/g, "")
|
| .slice(0, 28) || "field"
|
| );
|
| }
|
|
|
| interface FieldBuildExtra {
|
| formula?: string;
|
| max?: number;
|
| scope?: FieldScope;
|
| label?: string;
|
| colorCodeOptions?: boolean;
|
| optionColors?: Record<string, string>;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| link?: Record<string, unknown>;
|
| rollup?: Record<string, unknown>;
|
| |
| |
|
|
| renames?: { from: string; to: string }[];
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function isSchemaLocked(field: Field): boolean {
|
| return field.automation?.preset === true && field.type !== "rollup";
|
| }
|
|
|
| function buildOverlayField(
|
| label: string,
|
| type: FieldType,
|
| options?: string[],
|
| extra?: FieldBuildExtra
|
| ): Field {
|
| const key = `custom_${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`;
|
| if (type === "formula" || type === "created_time") {
|
| return {
|
| key,
|
| label,
|
| type,
|
| source: "odoo",
|
| derived: true,
|
| filterable: true,
|
| default: true,
|
| custom: true,
|
| ...(type === "formula"
|
| ? { agg: "sum", ...(extra?.formula ? { formula: extra.formula } : {}) }
|
| : {}),
|
| };
|
| }
|
| return {
|
| key,
|
| label,
|
| type,
|
| source: "overlay",
|
| default: true,
|
| custom: true,
|
| agg: ["currency", "int"].includes(type) ? "sum" : undefined,
|
|
|
|
|
|
|
| ...(extra?.link ? { link: extra.link as Field["link"] } : {}),
|
| ...(extra?.rollup ? { rollup: extra.rollup as Field["rollup"] } : {}),
|
| ...((type === "select" || type === "multiselect") && options?.length ? { options } : {}),
|
| ...((type === "select" || type === "multiselect")
|
| ? {
|
| colorCodeOptions: extra?.colorCodeOptions !== false,
|
| ...(extra?.optionColors && Object.keys(extra.optionColors).length
|
| ? { optionColors: extra.optionColors }
|
| : {}),
|
| }
|
| : {}),
|
| ...(type === "multiselect" ? { multi: true } : {}),
|
| ...(type === "rating" ? { max: extra?.max ?? 5 } : {}),
|
| };
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| interface CustomerGridProps {
|
| scope?: SurfaceScope;
|
| |
|
|
| embedded?: boolean;
|
|
|
| embeddedRecordIds?: readonly number[];
|
|
|
| embeddedSelectable?: boolean;
|
| embeddedSelectedIds?: readonly number[];
|
| onEmbeddedSelectionChange?: (recordIds: number[]) => void;
|
| }
|
|
|
| interface LinkGridModalProps {
|
| label: string;
|
| table: SurfaceScope;
|
| recordIds: readonly number[];
|
| editable?: boolean;
|
| single?: boolean;
|
| onSave?: (recordIds: number[]) => void;
|
| onClose: () => void;
|
| }
|
|
|
| function LinkGridModal({ label, table, recordIds, editable = false, single = false,
|
| onSave, onClose }: LinkGridModalProps) {
|
| const panelRef = useRef<HTMLDivElement>(null);
|
| const [selectedIds, setSelectedIds] = useState<number[]>(() => [...recordIds]);
|
| const changeSelectedIds = useCallback((ids: number[]) => {
|
| const next = single ? ids.slice(-1) : ids;
|
| setSelectedIds((current) =>
|
| current.join(",") === next.join(",") ? current : next
|
| );
|
| }, [single]);
|
| useOverlayLayer({
|
| panelRef,
|
| onDismiss: onClose,
|
| dismissOnOutside: true,
|
| initialFocus: "[data-overlay-autofocus]",
|
| trapFocus: true,
|
| });
|
| return (
|
| <BodyPortal>
|
| <div className="cg-record-backdrop">
|
| <section
|
| className="cg-link-grid-modal"
|
| ref={panelRef}
|
| role="dialog"
|
| aria-modal="true"
|
| aria-label={label}
|
| data-overlay-kind="linked-record-grid"
|
| tabIndex={-1}
|
| >
|
| <header className="cg-link-grid-head">
|
| <div>
|
| <div className="cg-link-grid-title">{label}</div>
|
| <div className="cg-link-grid-sub">
|
| {(editable ? selectedIds.length : recordIds.length).toLocaleString()} linked{
|
| single ? " (one allowed)" : ""
|
| } {(editable ? selectedIds.length : recordIds.length) === 1 ? "record" : "records"}
|
| </div>
|
| </div>
|
| <button
|
| type="button"
|
| className="cg-icon-btn"
|
| aria-label="Close linked records"
|
| data-overlay-autofocus
|
| onClick={onClose}
|
| >
|
| Γ
|
| </button>
|
| </header>
|
| <div className="cg-link-grid-body">
|
| <CustomerGrid
|
| scope={table}
|
| embedded
|
| embeddedRecordIds={editable ? undefined : recordIds}
|
| embeddedSelectable={editable}
|
| embeddedSelectedIds={selectedIds}
|
| onEmbeddedSelectionChange={changeSelectedIds}
|
| />
|
| </div>
|
| {editable ? (
|
| <footer className="cg-link-grid-foot">
|
| <button type="button" className="cg-btn" onClick={onClose}>Cancel</button>
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| onClick={() => { onSave?.(selectedIds); onClose(); }}
|
| >
|
| Save links
|
| </button>
|
| </footer>
|
| ) : null}
|
| </section>
|
| </div>
|
| </BodyPortal>
|
| );
|
| }
|
|
|
| function CustomerGrid({
|
| scope = "customer",
|
| embedded = false,
|
| embeddedRecordIds,
|
| embeddedSelectable = false,
|
| embeddedSelectedIds = [],
|
| onEmbeddedSelectionChange,
|
| }: CustomerGridProps = {}) {
|
|
|
| const topic = topicForScope(scope);
|
| const {
|
| fields: payloadFields,
|
| rawRows: fetchedRows,
|
| payload,
|
| loading,
|
| overlayEdits,
|
| setOverlayEdits,
|
| patchOverlay,
|
| requestWindow,
|
| } = useCustomerData(scope, {
|
| bindSurface: !embedded,
|
| includeWorkspace: !embedded,
|
| writable: !embedded,
|
| });
|
| const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [pendingRows, setPendingRows] = useState<Row[]>([]);
|
| const rawRows = useMemo(() => {
|
| const base = embeddedRecordIds
|
| ? fetchedRows.filter((row) => new Set(embeddedRecordIds).has(row.pid))
|
| : fetchedRows;
|
| if (!pendingRows.length) return base;
|
| const known = new Set(base.map((r) => r.pid));
|
| const extra = pendingRows.filter((r) => !known.has(r.pid));
|
| return extra.length ? [...base, ...extra] : base;
|
|
|
|
|
| }, [fetchedRows, embeddedIdsKey, pendingRows]);
|
| useEffect(() => {
|
|
|
|
|
|
|
| if (!pendingRows.length) return;
|
| const known = new Set(fetchedRows.map((r) => r.pid));
|
| if (!pendingRows.some((r) => known.has(r.pid))) return;
|
| setPendingRows((cur) => cur.filter((r) => !known.has(r.pid)));
|
| }, [fetchedRows, pendingRows]);
|
| |
| |
| |
| |
| |
|
|
| const rowsRef = useRef<Row[]>(rawRows);
|
| rowsRef.current = rawRows;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const nextRidRef = useRef(0);
|
| const mintRid = useCallback(() => {
|
| const storeMax = rowsRef.current.reduce((m, r) => Math.max(m, r.pid), 0);
|
| nextRidRef.current = Math.max(nextRidRef.current, storeMax) + 1;
|
| return nextRidRef.current;
|
| }, []);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const withdrawPending = useCallback((rids: (string | number)[]) => {
|
| if (!rids.length) return;
|
| const gone = new Set(rids.map((r) => Number(r)));
|
| setPendingRows((cur) => (cur.some((r) => gone.has(r.pid))
|
| ? cur.filter((r) => !gone.has(r.pid))
|
| : cur));
|
| }, []);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [alerted, setAlerted] = useState<string[]>([]);
|
| useEffect(() => {
|
| if (embedded) return;
|
| let live = true;
|
| const pull = () => {
|
| void fetchAlerts().then((r) => {
|
| if (!live || !r.ok) return;
|
| setAlerted(r.value.filter((a) => a.topic === scope).map((a) => a.viewId));
|
| });
|
| };
|
| pull();
|
| window.addEventListener("focus", pull);
|
| return () => {
|
| live = false;
|
| window.removeEventListener("focus", pull);
|
| };
|
| }, [embedded, scope]);
|
|
|
| const [fields, setFields] = useState<Field[]>([]);
|
| const [views, setViews] = useState<SavedView[]>([]);
|
| const [activeViewId, setActiveViewId] = useState(ALL_VIEW_ID);
|
| const [config, setConfig] = useState<ViewConfig>(() => defaultViewConfig([]));
|
| const [workspaceReady, setWorkspaceReady] = useState(false);
|
| const [saveState, setSaveState] = useState<"saved" | "saving">("saved");
|
| const [search, setSearch] = useState("");
|
| const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
| const [hoverRow, setHoverRow] = useState<number | undefined>(undefined);
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [expandAt, setExpandAt] = useState<
|
| { pid: number; x: number; y: number; size: number } | null
|
| >(null);
|
| const [detailPid, setDetailPid] = useState<number | null>(null);
|
| const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
|
|
|
| const [picker, setPicker] = useState<{
|
| pid: number;
|
| fieldKey: string;
|
| anchor: AnchorRect;
|
| } | null>(null);
|
| |
|
|
| const closePicker = useCallback(() => {
|
| setPicker(null);
|
| requestAnimationFrame(() => gridRef.current?.focus());
|
| }, []);
|
| |
| |
|
|
| const [jsonAt, setJsonAt] = useState<{ pid: number; fieldKey: string } | null>(null);
|
| const closeJson = useCallback(() => {
|
| setJsonAt(null);
|
| requestAnimationFrame(() => gridRef.current?.focus());
|
| }, []);
|
| |
| |
|
|
| const [linkAt, setLinkAt] = useState<{ pid: number; fieldKey: string } | null>(null);
|
| const closeLink = useCallback(() => {
|
| setLinkAt(null);
|
| requestAnimationFrame(() => gridRef.current?.focus());
|
| }, []);
|
| |
|
|
| const [selectFromFile, setSelectFromFile] = useState(false);
|
| const [importOpen, setImportOpen] = useState(false);
|
|
|
| const [selAddOpen, setSelAddOpen] = useState(false);
|
| const [selListName, setSelListName] = useState("");
|
| const selAddRef = useRef<HTMLButtonElement>(null);
|
|
|
| const [selRemoveOpen, setSelRemoveOpen] = useState(false);
|
| const selRemoveRef = useRef<HTMLButtonElement>(null);
|
| |
|
|
| const [newRowPid, setNewRowPid] = useState<number | null>(null);
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [delArmed, setDelArmed] = useState("");
|
| |
|
|
| const [activeCohortId, setActiveCohortId] = useState<string | null>(null);
|
| const [addCustOpen, setAddCustOpen] = useState(false);
|
| const [addCustQuery, setAddCustQuery] = useState("");
|
| const [addCustPicked, setAddCustPicked] = useState<Set<number>>(new Set());
|
| const addCustRef = useRef<HTMLButtonElement>(null);
|
| |
| |
|
|
| const [filterSeed, setFilterSeed] = useState<
|
| | { key?: string; measure?: { key: string; window: WindowSpec }; n: number }
|
| | null
|
| >(null);
|
|
|
| const [plusMenu, setPlusMenu] = useState<AnchorRect | null>(null);
|
| |
| |
| |
| |
|
|
| const fieldStampsRef = useRef<{ edited: Record<string, number>; deleted: Record<string, number> }>(
|
| { edited: {}, deleted: {} }
|
| );
|
| |
|
|
| const [headerTip, setHeaderTip] = useState<{ text: string; x: number; y: number } | null>(
|
| null
|
| );
|
| |
| |
| |
|
|
| const [cellTip, setCellTip] = useState<{ text: string; x: number; y: number } | null>(null);
|
|
|
| |
|
|
| const reemittedRef = useRef<Record<string, string>>({});
|
| |
| |
|
|
| const viewTombstonesRef = useRef<Tombstones>({});
|
|
|
| const viewWritesRef = useRef<Tombstones>({});
|
| const initializedKey = useRef<string | null>(null);
|
| const saveTimer = useRef<number | null>(null);
|
| const gridRef = useRef<DataEditorRef>(null);
|
| const gridBoxRef = useRef<HTMLDivElement>(null);
|
| |
| |
|
|
| const copyProvenanceRef = useRef(EMPTY_GRID_COPY_PROVENANCE);
|
| useEffect(() => {
|
| const onCopy = () => {
|
|
|
|
|
| copyProvenanceRef.current = observeCopyEvent(copyProvenanceRef.current, Date.now());
|
| };
|
| const onWindowBlur = () => {
|
| copyProvenanceRef.current = EMPTY_GRID_COPY_PROVENANCE;
|
| };
|
| document.addEventListener("copy", onCopy, true);
|
| window.addEventListener("blur", onWindowBlur);
|
| return () => {
|
| document.removeEventListener("copy", onCopy, true);
|
| window.removeEventListener("blur", onWindowBlur);
|
| };
|
| }, []);
|
| const [gridSize, setGridSize] = useState({ width: 800, height: 600 });
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const storageKey = payload?.workspace?.storageKey ?? `local:${scope}`;
|
|
|
|
|
|
|
| useEffect(() => {
|
| if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return;
|
| const local = embedded ? null : readLocal(storageKey);
|
|
|
|
|
|
|
| const stamps = pruneStamps(local?.fieldStamps, Date.now());
|
| fieldStampsRef.current = {
|
| edited: { ...(stamps.edited ?? {}) },
|
| deleted: { ...(stamps.deleted ?? {}) },
|
| };
|
| const initialFields = reconcileFields(
|
| payloadFields,
|
| local?.fields ?? [],
|
| stamps,
|
| Date.now(),
|
| payload.workspace != null
|
| );
|
| const hostViews = embedded ? [] : payload.workspace?.views ?? [];
|
| const byId = new Map<string, SavedView>();
|
| byId.set(ALL_VIEW_ID, allRecordsView(initialFields, scope));
|
|
|
|
|
|
|
| viewWritesRef.current = pruneTombstones(local?.viewWrites, Date.now());
|
| for (const view of seedLocalViews(local?.views ?? [], hostViews, viewWritesRef.current,
|
| Date.now(), payload.workspace != null))
|
| byId.set(view.id, view);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const reemit = new Map<string, string>();
|
|
|
|
|
|
|
|
|
| const tombstones = pruneTombstones(local?.viewTombstones, Date.now());
|
| viewTombstonesRef.current = tombstones;
|
| for (const view of hostViews) {
|
|
|
|
|
| if (tombstones[view.id] !== undefined) {
|
| byId.delete(view.id);
|
| continue;
|
| }
|
| const merged = reconcileEchoView(
|
| view, byId.get(view.id), local?.savedAt, Date.now()
|
| );
|
| const key = echoReemit(merged, view, local?.reemitted?.[view.id]);
|
| if (key !== null) reemit.set(view.id, key);
|
| byId.set(view.id, merged);
|
| }
|
| const initialViews = [...byId.values()].map((view) => ({
|
| ...view,
|
| config: normalizeConfig(view.config, initialFields),
|
| }));
|
| const requested =
|
| local?.activeViewId ?? payload.workspace?.activeViewId ?? ALL_VIEW_ID;
|
| const active = initialViews.find((view) => view.id === requested) ?? initialViews[0];
|
| setFields(initialFields);
|
| setViews(initialViews);
|
| setActiveViewId(active.id);
|
| setConfig(active.config);
|
| setWorkspaceReady(true);
|
| initializedKey.current = storageKey;
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| if (!embedded && reemit.size > 0) {
|
| const stamped = { ...(local?.reemitted ?? {}) };
|
| for (const view of initialViews) {
|
| const key = reemit.get(view.id);
|
| if (key === undefined) continue;
|
| viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id,
|
| Date.now());
|
| viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id, Date.now());
|
| emitHostEvent({ id: eventId("view"), type: "view_upsert", view });
|
| stamped[view.id] = key;
|
| }
|
| reemittedRef.current = stamped;
|
| } else {
|
| reemittedRef.current = { ...(local?.reemitted ?? {}) };
|
| }
|
| }, [payload, payloadFields, storageKey, embedded, scope]);
|
|
|
| useEffect(() => {
|
| if (!workspaceReady || embedded) return;
|
| writeLocal(storageKey, {
|
| fields,
|
| views,
|
| activeViewId,
|
| savedAt: Date.now(),
|
| fieldStamps: pruneStamps(fieldStampsRef.current, Date.now()),
|
|
|
|
|
| reemitted: reemittedRef.current,
|
|
|
|
|
| viewTombstones: pruneTombstones(viewTombstonesRef.current, Date.now()),
|
|
|
|
|
| viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
|
| });
|
| }, [workspaceReady, storageKey, fields, views, activeViewId, embedded]);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const hostWorkspaceViews = payload?.workspace?.views;
|
| useEffect(() => {
|
| if (!workspaceReady || embedded) return;
|
| const now = Date.now();
|
| const nextFields = adoptNewFields(
|
| fields, payloadFields, fieldStampsRef.current.deleted, now
|
| );
|
| if (nextFields !== fields) setFields(nextFields);
|
| setViews((current) =>
|
| adoptNewViews(current, hostWorkspaceViews, viewTombstonesRef.current, now,
|
| (config) => normalizeConfig(config, nextFields))
|
| );
|
| }, [workspaceReady, hostWorkspaceViews, payloadFields, fields, embedded]);
|
|
|
| |
|
|
| const stampFieldEdit = useCallback((key: string) => {
|
| const s = pruneStamps(fieldStampsRef.current, Date.now());
|
| fieldStampsRef.current = {
|
| edited: { ...(s.edited ?? {}), [key]: Date.now() },
|
| deleted: { ...(s.deleted ?? {}) },
|
| };
|
| }, []);
|
| const stampFieldDelete = useCallback((key: string) => {
|
| const s = pruneStamps(fieldStampsRef.current, Date.now());
|
| const edited = { ...(s.edited ?? {}) };
|
| delete edited[key];
|
| fieldStampsRef.current = {
|
| edited,
|
| deleted: { ...(s.deleted ?? {}), [key]: Date.now() },
|
| };
|
| }, []);
|
|
|
| const updateConfig = useCallback(
|
| (next: ViewConfig) => {
|
| if (next.groupBy !== config.groupBy) setCollapsed(new Set());
|
| setConfig(next);
|
| },
|
| [config.groupBy]
|
| );
|
|
|
| const {
|
| visibleCols,
|
| fieldByKey,
|
| order,
|
| visible,
|
| lockedKey,
|
| onColumnResize,
|
| onColumnMoved,
|
| onColumnProposeMove,
|
| setColumnVisible,
|
| insertColumn,
|
| } = useGridColumns(fields, config, updateConfig);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const isUserTable = !embedded && scope.startsWith("ut_");
|
| const recordsMutable = payload?.recordsMutable !== false;
|
| const canMutateRecords = isUserTable && recordsMutable;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [linkTargets, setLinkTargets] = useState<LinkTarget[]>([]);
|
| useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if ((!columnMenu && !plusMenu) || linkTargets.length) return;
|
| let live = true;
|
| void fetchLinkTargets().then((t) => {
|
| if (live) setLinkTargets(t);
|
| });
|
| return () => {
|
| live = false;
|
| };
|
| }, [columnMenu, plusMenu, linkTargets.length]);
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [rollupSourceOffer, setRollupSourceOffer] =
|
| useState<RollupSourceOffer>({ topics: [], windows: [] });
|
| const rollupOfferAsked = useRef(false);
|
| useEffect(() => {
|
|
|
|
|
|
|
|
|
| if ((!columnMenu && !plusMenu) || rollupOfferAsked.current) return;
|
| rollupOfferAsked.current = true;
|
| let live = true;
|
| void fetchRollupSources().then((o) => {
|
| if (live) setRollupSourceOffer(o);
|
| });
|
| return () => {
|
| live = false;
|
| };
|
| }, [columnMenu, plusMenu]);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const seedValues = useCallback(
|
| () => filterSeedValues(config.filters, config.filterConj, fields, payload?.viewer),
|
| [config.filters, config.filterConj, fields, payload?.viewer]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const appendRow = useCallback(async (): Promise<undefined> => {
|
| if (!canMutateRecords) return undefined;
|
| const seeded = seedValues();
|
| const minted = mintRid();
|
| const optimistic: Row = { ...seeded, pid: minted };
|
| setPendingRows((cur) => [...cur, optimistic]);
|
| setNewRowPid(minted);
|
| const made = await addTableRow(scope, seeded, minted);
|
| if (!made) {
|
|
|
|
|
|
|
| setPendingRows((cur) => cur.filter((r) => r.pid !== minted));
|
| setNewRowPid(null);
|
| return undefined;
|
| }
|
| if (made.pid !== minted) {
|
| setPendingRows((cur) =>
|
| cur.map((r) => (r.pid === minted ? { ...r, pid: made.pid } : r))
|
| );
|
| setNewRowPid(made.pid);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
| kind: "rowAdd", table: scope, rows: [{ rid: made.rid, values: seeded }],
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| return undefined;
|
| }, [canMutateRecords, scope, seedValues, mintRid]);
|
|
|
| |
| |
| |
|
|
| const measureCols = useMemo(() => measureColumnIndex(fields), [fields]);
|
|
|
|
|
| useEffect(() => {
|
| if (!workspaceReady || embedded) return;
|
| const active = views.find((view) => view.id === activeViewId);
|
| if (!active || sameConfig(active.config, config)) {
|
| setSaveState("saved");
|
| return;
|
| }
|
| setSaveState("saving");
|
| if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
|
| saveTimer.current = window.setTimeout(() => {
|
| const updated = { ...active, config };
|
| setViews((current) =>
|
| current.map((view) => (view.id === updated.id ? updated : view))
|
| );
|
| viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id,
|
| Date.now());
|
| emitHostEvent({ id: eventId("view"), type: "view_upsert", view: updated });
|
| setSaveState("saved");
|
| saveTimer.current = null;
|
| }, 420);
|
| return () => {
|
| if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
|
| };
|
| }, [workspaceReady, activeViewId, config, views, embedded]);
|
|
|
|
|
|
|
| useLayoutEffect(() => {
|
| const element = gridBoxRef.current;
|
| if (!element) return;
|
| const measure = () => {
|
| const rect = element.getBoundingClientRect();
|
| if (rect.width > 0 && rect.height > 0) {
|
| setGridSize({
|
| width: Math.floor(rect.width),
|
| height: Math.floor(rect.height),
|
| });
|
| }
|
| };
|
| measure();
|
| const observer = new ResizeObserver(measure);
|
| observer.observe(element);
|
| window.addEventListener("resize", measure);
|
| return () => {
|
| observer.disconnect();
|
| window.removeEventListener("resize", measure);
|
| };
|
| }, [workspaceReady]);
|
|
|
|
|
|
|
| const mode = tableMode(payload?.counts);
|
| const serverWindowed = mode === "server-windowed";
|
|
|
| const hideViews = embedded || payload?.workspace?.hideViews === true;
|
|
|
|
|
|
|
| const displaySpec = cleanDisplay(config.display);
|
| const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const windowPredicate = useMemo(
|
| () => windowPredicateKey(config.filters, config.filterConj, config.sorts, search),
|
| [config.filters, config.filterConj, config.sorts, search]
|
| );
|
| const windowRequest = useCallback(
|
| (offset: number) => ({
|
| offset,
|
| limit: WINDOW_ROWS,
|
| filters: config.filters,
|
| filterConj: config.filterConj,
|
| sorts: config.sorts,
|
| search,
|
| }),
|
| [config.filters, config.filterConj, config.sorts, search]
|
| );
|
| const sentPredicate = useRef<string | null>(null);
|
| useEffect(() => {
|
| if (!serverWindowed) {
|
| sentPredicate.current = null;
|
| return;
|
| }
|
| if (sentPredicate.current === windowPredicate) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (sentPredicate.current === null && windowPredicate === EMPTY_WINDOW_PREDICATE) {
|
| sentPredicate.current = windowPredicate;
|
| return;
|
| }
|
| sentPredicate.current = windowPredicate;
|
| requestWindow(windowRequest(0));
|
| }, [serverWindowed, windowPredicate, windowRequest, requestWindow]);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const onVisibleRegionChanged = useCallback(
|
| (range: Rectangle) => {
|
| if (!serverWindowed) return;
|
| const offset = nextWindowOffset({
|
| lastVisibleRow: range.y + range.height,
|
| loaded: payload?.counts?.shown ?? 0,
|
| matched: payload?.counts?.matched ?? 0,
|
| limit: WINDOW_ROWS,
|
| });
|
| if (offset !== null) requestWindow(windowRequest(offset));
|
| },
|
| [serverWindowed, payload?.counts?.shown, payload?.counts?.matched, requestWindow,
|
| windowRequest]
|
| );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const measures = useMemo(() => payload?.measures ?? [], [payload?.measures]);
|
| const measureSetsRef = useRef<MeasureSets>({});
|
| const measureSets = useMemo(() => {
|
| const incoming = payload?.measureSets;
|
| if (incoming) {
|
| const next: MeasureSets = {};
|
| for (const [ruleId, pids] of Object.entries(incoming)) next[ruleId] = new Set(pids);
|
| measureSetsRef.current = next;
|
| }
|
| return measureSetsRef.current;
|
| }, [payload?.measureSets]);
|
| const pendingMeasureCount = useMemo(
|
| () => pendingMeasures(config.filters, measureSets),
|
| [config.filters, measureSets]
|
| );
|
|
|
|
|
| const measureRuleKeys = useMemo(
|
| () => activeMeasureRuleIds(config.filters).join(","),
|
| [config.filters]
|
| );
|
| const measureSetKeys = useMemo(() => Object.keys(measureSets).join(","), [measureSets]);
|
|
|
|
|
|
|
|
|
|
|
| const lists = useMemo(() => payload?.workspace?.lists ?? [], [payload?.workspace?.lists]);
|
| |
| |
| |
| |
|
|
| const cohortSets = useMemo(() => {
|
| const out: CohortSets = {};
|
| for (const l of lists) if (l.pids) out[l.id] = new Set(l.pids);
|
| return out;
|
| }, [lists]);
|
|
|
|
|
|
|
| const today = payload?.today;
|
|
|
|
|
|
|
| const viewer = payload?.viewer;
|
| const canEditField = useCallback(
|
| (f: Field): boolean => !embedded && recordsMutable && mayEditField(f, viewer),
|
| [embedded, recordsMutable, viewer]
|
| );
|
| const unresolvedCount = useMemo(
|
| () => unresolvedConditions(config.filters, { cohortSets, today }),
|
| [config.filters, cohortSets, today]
|
| );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const formulaAsts = useMemo(() => {
|
| const sources = new Map<string, string>();
|
| for (const f of fields) {
|
| if (f.type !== "formula") continue;
|
| const src = formulaOf(f);
|
| if (src) sources.set(f.key, src);
|
| }
|
| const { order, cyclic } = orderFormulas(sources);
|
| const out: { key: string; ast: FormulaAst }[] = [];
|
| for (const key of order) {
|
| if (cyclic.has(key)) continue;
|
| const p = parseFormula(sources.get(key)!);
|
| if (p.ok) out.push({ key, ast: p.ast });
|
| }
|
| return out;
|
| }, [fields]);
|
| const createdTimeKeys = useMemo(
|
| () => fields.filter((f) => f.type === "created_time").map((f) => f.key),
|
| [fields]
|
| );
|
| const computedRows = useMemo(() => {
|
| if (formulaAsts.length === 0 && createdTimeKeys.length === 0) return rawRows;
|
| const env = { today: payload?.today };
|
| return rawRows.map((r) => {
|
| const edits = overlayEdits[r.pid];
|
| const scope: Row = edits ? { ...r, ...edits } : { ...r };
|
| const out: Row = { ...r };
|
| for (const k of createdTimeKeys) {
|
| out[k] = (r._created as string | undefined) ?? null;
|
| scope[k] = out[k];
|
| }
|
| for (const { key, ast } of formulaAsts) {
|
| const v = evalFormula(ast, (k) => scope[k], env);
|
| out[key] = v;
|
| scope[key] = v;
|
| }
|
| return out;
|
| });
|
| }, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]);
|
|
|
|
|
|
|
| const cohortMode = payload?.workspace?.cohortMode === true;
|
| useEffect(() => {
|
| if (!cohortMode) return;
|
| if (activeCohortId && lists.some((l) => l.id === activeCohortId)) return;
|
| setActiveCohortId(lists[0]?.id ?? null);
|
| }, [cohortMode, lists, activeCohortId]);
|
| const activeCohort = cohortMode
|
| ? lists.find((l) => l.id === activeCohortId) ?? null
|
| : null;
|
| const cohortMemberSet = useMemo(
|
| () => new Set(activeCohort?.pids ?? []),
|
| [activeCohort]
|
| );
|
|
|
|
|
|
|
|
|
| const scopedRows = useMemo(
|
| () =>
|
| cohortMode
|
| ? computedRows.filter((r) => cohortMemberSet.has(r.pid))
|
| : computedRows,
|
| [cohortMode, computedRows, cohortMemberSet]
|
| );
|
|
|
| const { visibleRows, pidToIndex } = useVisibleRows(
|
| scopedRows,
|
| fields,
|
| config.filters,
|
| search,
|
| config.sorts,
|
|
|
| serverWindowed ? null : config.groupBy,
|
| collapsed,
|
| config.memberPids,
|
| config.filterConj,
|
| serverWindowed,
|
| measureSets,
|
| cohortSets,
|
| today,
|
|
|
|
|
|
|
|
|
|
|
|
|
| config.cohortLock
|
| );
|
|
|
|
|
|
|
|
|
|
|
| const [displayCap, setDisplayCap] = useState(DISPLAY_PAGE);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const capped = !serverWindowed && visibleRows.length > displayCap;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const totalsAggs = useMemo(
|
| () => computeAggs(
|
| visibleRows.flatMap((vr) => (vr.kind === "data" ? [vr.record] : [])),
|
| fields
|
| ),
|
| [visibleRows, fields]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const showTotals = useMemo(
|
| () => !config.groupBy && Object.keys(totalsAggs).length > 0,
|
| [config.groupBy, totalsAggs]
|
| );
|
| |
| |
| |
|
|
| const foldNote = useMemo(
|
| () =>
|
| serverWindowed && showTotals
|
| ? windowedFoldNote(payload?.counts?.shown ?? 0, payload?.counts?.matched ?? 0)
|
| : null,
|
| [serverWindowed, showTotals, payload?.counts?.shown, payload?.counts?.matched]
|
| );
|
| |
|
|
|
|
|
|
|
|
| const limitNote = useMemo(
|
| () => limitSummary(payload?.limits, payload?.counts),
|
| [payload?.limits, payload?.counts]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const capabilityNote = useMemo(
|
| () => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null),
|
| [serverWindowed, payload?.counts?.matched]
|
| );
|
| const displayRows = useMemo(() => {
|
| const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
|
|
|
|
|
| return showTotals
|
| ? [...shown,
|
| { kind: "group-footer" as const, groupKey: TOTAL_GROUP_KEY, aggs: totalsAggs }]
|
| : shown;
|
| }, [capped, visibleRows, displayCap, showTotals, totalsAggs]);
|
| const displayPidToIndex = useMemo(() => {
|
| if (!capped) return pidToIndex;
|
| const m = new Map<number, number>();
|
| displayRows.forEach((vr, i) => {
|
| if (vr.kind === "data" && !m.has(vr.record.pid)) m.set(vr.record.pid, i);
|
| });
|
| return m;
|
| }, [capped, displayRows, pidToIndex]);
|
|
|
|
|
|
|
|
|
| const [avatarTick, setAvatarTick] = useState(0);
|
| useEffect(() => {
|
| setAvatarRepaint(() => setAvatarTick((t) => t + 1));
|
| return () => setAvatarRepaint(undefined);
|
| }, []);
|
| const userAvatars = payload?.workspace?.userAvatars;
|
| |
| |
| |
| |
|
|
| const groupLabelSpace = useMemo(() => {
|
| if (!config.groupBy) return null;
|
| const n = Math.min(frozenCountOf(config), visibleCols.length);
|
| let px = 0;
|
|
|
|
|
|
|
| for (let i = 0; i < n; i += 1)
|
| px += (visibleCols[i] as { width?: number } | undefined)?.width ?? 0;
|
| return Math.max(0, px - GROUP_LABEL_PAD);
|
| }, [config, visibleCols]);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const pendingMeasureKeys = useMemo(() => {
|
| const out = new Set<string>();
|
| for (const f of fields)
|
| if (f.editRequestId && f.key.startsWith("measure_")) out.add(f.key);
|
| return out;
|
| }, [fields]);
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const [pulse, setPulse] = useState(0);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [pendingGaveUp, setPendingGaveUp] = useState(false);
|
| useEffect(() => {
|
| setPendingGaveUp(false);
|
| if (pendingMeasureKeys.size === 0) return;
|
| let n = 0;
|
| const id = window.setInterval(() => {
|
| n += 1;
|
| if (n > PULSE_MAX_TICKS) {
|
| window.clearInterval(id);
|
| setPendingGaveUp(true);
|
| return;
|
| }
|
| setPulse((p) => p + 1);
|
| }, PULSE_MS);
|
| return () => window.clearInterval(id);
|
| }, [pendingMeasureKeys]);
|
| const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys;
|
| const getCellContent = useGetCellContent(
|
| displayRows,
|
| visibleCols,
|
| fieldByKey,
|
| overlayEdits,
|
| canEditField,
|
| userAvatars,
|
| avatarTick,
|
| groupLabelSpace,
|
| measureGroupText,
|
| activePendingKeys,
|
| pulse
|
| );
|
| const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
|
| setActiveCell, clearSelection } =
|
| useGridSelection(
|
| displayRows,
|
| displayPidToIndex,
|
| visibleCols.length,
|
| embeddedSelectable ? embeddedSelectedIds : []
|
| );
|
| const embeddedSelectedKey = embeddedSelectedIds.join(",");
|
| const selectedPidKey = [...selectedPids].join(",");
|
| useEffect(() => {
|
| if (!embeddedSelectable) return;
|
| if (selectedPidKey !== embeddedSelectedKey)
|
| selectPids(embeddedSelectedIds as number[], "replace");
|
| }, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
|
| useEffect(() => {
|
| if (!embeddedSelectable) return;
|
| onEmbeddedSelectionChange?.([...selectedPids]);
|
| }, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const undoBook = useRef<UndoBook>({});
|
| const applyingUndo = useRef(false);
|
|
|
| |
|
|
| const rowByPid = useMemo(() => {
|
| const map = new Map<number, Row>();
|
| for (const r of rawRows) map.set(r.pid, r);
|
| return map;
|
| }, [rawRows]);
|
| const currentValue = useCallback(
|
| (pid: number, key: string): UndoValue => {
|
| const edited = overlayEdits[pid]?.[key];
|
| const raw = edited !== undefined ? edited : rowByPid.get(pid)?.[key];
|
| return raw == null ? null : (raw as UndoValue);
|
| },
|
| [overlayEdits, rowByPid]
|
| );
|
|
|
| const recordCells = useCallback(
|
| (changes: CellChange[], label: string) => {
|
| if (applyingUndo.current || changes.length === 0) return;
|
| undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
| kind: "cells", label, changes,
|
| });
|
| },
|
| [scope]
|
| );
|
|
|
|
|
| const patchAndRecord = useCallback(
|
| (pid: number, updates: Partial<Row>, label = "an edit") => {
|
| const changes: CellChange[] = Object.entries(updates).map(([key, value]) => ({
|
| pid, key,
|
| before: currentValue(pid, key),
|
| after: (value ?? null) as UndoValue,
|
| }));
|
| patchOverlay(pid, updates);
|
| recordCells(changes, label);
|
| },
|
| [currentValue, patchOverlay, recordCells]
|
| );
|
|
|
|
|
| const patchManyAndRecord = useCallback(
|
| (writes: { pid: number; updates: Partial<Row> }[], label: string) => {
|
| const changes: CellChange[] = [];
|
| for (const w of writes)
|
| for (const [key, value] of Object.entries(w.updates))
|
| changes.push({
|
| pid: w.pid, key,
|
| before: currentValue(w.pid, key),
|
| after: (value ?? null) as UndoValue,
|
| });
|
| for (const w of writes) patchOverlay(w.pid, w.updates);
|
| recordCells(changes, label);
|
| },
|
| [currentValue, patchOverlay, recordCells]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| const applyEntry = useCallback(
|
| async (entry: UndoEntry, dir: "back" | "forward") => {
|
| const op = directed(entry, dir);
|
| if (op.kind === "cells") {
|
| const byPid = new Map<number, Partial<Row>>();
|
| for (const c of op.changes) {
|
| const at = byPid.get(c.pid) ?? {};
|
| at[c.key] = (c.after ?? "") as Row[string];
|
| byPid.set(c.pid, at);
|
| }
|
| applyingUndo.current = true;
|
| try {
|
| for (const [pid, updates] of byPid) patchOverlay(pid, updates);
|
| } finally {
|
| applyingUndo.current = false;
|
| }
|
| } else if (op.kind === "rowAdd") {
|
|
|
|
|
|
|
|
|
|
|
|
|
| let any = false;
|
| let moved = 0;
|
| for (const r of op.rows) {
|
| const made = await addTableRow(op.table, r.values as Record<string, unknown>, r.rid);
|
| any = any || !!made;
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (made && String(made.rid) !== String(r.rid)) moved += 1;
|
| }
|
| if (!any) return;
|
| if (moved)
|
| signal(
|
| TOAST_EVENT,
|
| `${moved} restored record${moved === 1 ? "" : "s"} came back under a new id β ` +
|
| `something had taken the original while it was gone.`
|
| );
|
| signal(ROWS_STALE_EVENT);
|
| } else if (op.kind === "rowDelete") {
|
| let any = false;
|
| for (const r of op.rows) any = (await deleteTableRow(op.table, r.rid)) || any;
|
| if (!any) return;
|
|
|
|
|
|
|
| withdrawPending(op.rows.map((r) => r.rid));
|
| signal(ROWS_STALE_EVENT);
|
| } else if (op.kind === "choiceRename") {
|
|
|
|
|
|
|
| emitHostEvent({
|
| id: eventId("choicerename"),
|
| type: "choice_rename",
|
| key: op.fieldKey,
|
| renames: op.renames,
|
| });
|
| }
|
| signal(TOAST_EVENT, describe(entry, dir));
|
| },
|
| [patchOverlay, withdrawPending]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| useEffect(() => {
|
| const onKey = (event: KeyboardEvent) => {
|
| if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
|
| const key = event.key.toLowerCase();
|
| if (key !== "z" && key !== "y") return;
|
| const el = document.activeElement as HTMLElement | null;
|
| if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable))
|
| return;
|
| const forward = key === "y" || event.shiftKey;
|
| event.preventDefault();
|
| const book = undoBook.current;
|
| const state = stackFor(book, scope);
|
| const { state: next, entry } = forward ? popRedo(state) : popUndo(state);
|
| if (!entry) {
|
| signal(TOAST_EVENT, forward ? "Nothing to redo." : "Nothing to undo.");
|
| return;
|
| }
|
| book[scope] = next;
|
| void applyEntry(entry, forward ? "forward" : "back");
|
| };
|
| window.addEventListener("keydown", onKey, true);
|
| return () => window.removeEventListener("keydown", onKey, true);
|
| }, [scope, applyEntry]);
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const deleteRecords = useCallback(
|
| async (pids: number[]): Promise<boolean> => {
|
| if (!canMutateRecords || !pids.length) return false;
|
|
|
|
|
| const byPid = new Map<number, Row>();
|
| for (const vr of displayRows)
|
| if (vr.kind === "data") byPid.set(vr.record.pid, vr.record);
|
| const captured: UndoRow[] = [];
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const offscreen: number[] = [];
|
| for (const pid of pids) {
|
| const rec = byPid.get(pid);
|
| if (!rec) {
|
| offscreen.push(pid);
|
| continue;
|
| }
|
| const values: Record<string, UndoValue> = {};
|
| for (const f of fields) {
|
| const v = rec[f.key];
|
| if (v !== undefined && v !== null && v !== "")
|
| values[f.key] = v as UndoValue;
|
| }
|
| captured.push({ rid: pid, values });
|
| }
|
| if (offscreen.length) {
|
| signal(
|
| TOAST_EVENT,
|
| `Nothing was deleted β ${offscreen.length} of the ${pids.length} selected ` +
|
| `record${pids.length === 1 ? " is" : "s are"} not shown in this view. Clear the ` +
|
| `filter (or scroll them into view) and select again.`
|
| );
|
| return false;
|
| }
|
| if (!captured.length) return false;
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const riskyIds = new Set(machineFoundRows(fields, captured));
|
| const risky = captured.filter((r) => riskyIds.has(r.rid));
|
| const sig = captured.map((r) => r.rid).join(",");
|
| if (risky.length && delArmed !== sig) {
|
| setDelArmed(sig);
|
| const losses = reFindConsequences(fields);
|
| const one = risky.length === 1;
|
| signal(
|
| TOAST_EVENT,
|
| `${risky.length} of these ${one ? "records was" : "records were"} FOUND by an ` +
|
| `automation. Deleting ${one ? "it" : "them"} is not undone by a re-find` +
|
| (losses.length
|
| ? `: ${losses.join(", ")} β so a card somebody had already moved on comes back ` +
|
| `undecided`
|
| : ` β ${one ? "it" : "they"} would come back as a new record with no history`) +
|
| `. Press Delete again (or click again) to confirm.`
|
| );
|
| return false;
|
| }
|
| setDelArmed("");
|
|
|
|
|
| const gone: UndoRow[] = [];
|
| for (const r of captured)
|
| if (await deleteTableRow(scope, r.rid)) gone.push(r);
|
| if (!gone.length) {
|
| signal(TOAST_EVENT, "Nothing was deleted β the server refused.");
|
| return false;
|
| }
|
| undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
| kind: "rowDelete", table: scope, rows: gone,
|
| });
|
|
|
|
|
| withdrawPending(gone.map((r) => r.rid));
|
| clearSelection();
|
| signal(ROWS_STALE_EVENT);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const fedByAutomation = fields.some((f) => f.automation?.stageField);
|
| signal(
|
| TOAST_EVENT,
|
| `Deleted ${gone.length} record${gone.length === 1 ? "" : "s"}. Ctrl+Z to undo.` +
|
| (fedByAutomation
|
| ? " If this automation finds one again it comes back as a NEW candidate β first-found" +
|
| " and times-found reset, and its stage back to Review. Undo keeps all of that."
|
| : "")
|
| );
|
| return true;
|
| },
|
| [canMutateRecords, displayRows, fields, scope, clearSelection, delArmed,
|
| withdrawPending]
|
| );
|
|
|
| const onGridDelete = useCallback(
|
| (sel: GridSelection): boolean => {
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const dragged = !!sel.current
|
| && (sel.current.range.width > 1 || sel.current.range.height > 1);
|
| if (canMutateRecords && sel.rows.length > 0 && !dragged) {
|
| const pids: number[] = [];
|
| for (const rowIndex of sel.rows) {
|
| const vr = displayRows[rowIndex];
|
| if (vr && vr.kind === "data") pids.push(vr.record.pid);
|
| }
|
|
|
|
|
|
|
|
|
|
|
| if (!pids.length) return true;
|
| void deleteRecords(pids);
|
| return false;
|
| }
|
| const byPid = new Map<number, Partial<Row>>();
|
| const clear = (rowIndex: number, colIndex: number) => {
|
| const vr = displayRows[rowIndex];
|
| if (!vr || vr.kind !== "data") return;
|
| const column = visibleCols[colIndex];
|
| const field = column ? fieldByKey.get(column.id!) : undefined;
|
| if (!field || !canEditField(field)) return;
|
| const at = byPid.get(vr.record.pid) ?? {};
|
| at[field.key] = "";
|
| byPid.set(vr.record.pid, at);
|
| };
|
| for (const rowIndex of sel.rows)
|
| for (let c = 0; c < visibleCols.length; c++) clear(rowIndex, c);
|
| const range = sel.current?.range;
|
| if (range)
|
| for (let y = range.y; y < range.y + range.height; y++)
|
| for (let x = range.x; x < range.x + range.width; x++) clear(y, x);
|
| const writes = [...byPid.entries()].map(([pid, updates]) => ({ pid, updates }));
|
| if (!writes.length) return true;
|
| patchManyAndRecord(writes, "clearing cells");
|
|
|
|
|
|
|
|
|
|
|
|
|
| return false;
|
| },
|
| [canMutateRecords, deleteRecords, displayRows, visibleCols, fieldByKey, canEditField,
|
| patchManyAndRecord]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| useEffect(() => {
|
| if (newRowPid === null) return;
|
| const index = displayPidToIndex.get(newRowPid);
|
| if (index === undefined) return;
|
| setActiveCell(0, index);
|
| gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" });
|
| setNewRowPid(null);
|
| }, [newRowPid, displayPidToIndex, setActiveCell]);
|
|
|
| |
| |
|
|
| const orderedFields = useMemo(
|
| () => order.map((key) => fieldByKey.get(key)).filter((f): f is Field => !!f),
|
| [order, fieldByKey]
|
| );
|
| |
| |
|
|
| const activeRow = gridSelection.current?.cell[1];
|
|
|
| const statusValues = useMemo(() => {
|
| const map: Record<string, string[]> = {};
|
| for (const field of fields) {
|
|
|
|
|
| if (field.type === "user") {
|
| map[field.key] = payload?.userOptions ?? [];
|
| continue;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (field.type === "select" || field.type === "multiselect" || field.type === "status")
|
| map[field.key] = choiceVocabulary(field, rawRows);
|
| }
|
| return map;
|
| }, [fields, rawRows, payload?.userOptions]);
|
|
|
| const rowPx = ROW_PX[config.rowHeightMode];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const recordCount = pidToIndex.size;
|
| const shownRecords = displayPidToIndex.size;
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const getRowThemeOverride = useCallback(
|
| (row: number): Partial<Theme> | undefined => {
|
| const visibleRow = displayRows[row];
|
| if (!visibleRow || visibleRow.kind !== "data") return undefined;
|
|
|
|
|
|
|
| if (row === hoverRow) {
|
| if (!config.colorBy) return HOVER_NEUTRAL;
|
| return (
|
| HOVER_ROW_THEME[String(visibleRow.record[config.colorBy] ?? "").toLowerCase()] ??
|
| HOVER_NEUTRAL
|
| );
|
| }
|
|
|
|
|
|
|
| if (!config.colorBy) {
|
| if (row === activeRow) return ACTIVE_ROW_NEUTRAL;
|
| return undefined;
|
| }
|
| return STATUS_ROW_THEME[
|
| String(visibleRow.record[config.colorBy] ?? "").toLowerCase()
|
| ];
|
| },
|
| [displayRows, config.colorBy, hoverRow, activeRow]
|
| );
|
| const onItemHovered = useCallback(
|
| (args: GridMouseEventArgs) => {
|
| const row = args.kind === "cell" ? args.location[1] : undefined;
|
| setHoverRow((previous) => (previous === row ? previous : row));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const vrow = row !== undefined ? displayRows[row] : undefined;
|
| if (args.kind === "cell" && vrow?.kind === "data" && row !== undefined) {
|
| const pid = vrow.record.pid;
|
| const b = gridRef.current?.getBounds(0, row);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const at = b
|
| ? expandButtonRect(b, gridBoxRef.current?.getBoundingClientRect())
|
| : null;
|
|
|
|
|
|
|
|
|
| setExpandAt((prev) =>
|
| !at
|
| ? null
|
| : prev && prev.pid === pid && prev.x === at.x && prev.y === at.y
|
| ? prev
|
| : { pid, x: at.x, y: at.y, size: at.size }
|
| );
|
| }
|
|
|
|
|
|
|
|
|
| if (args.kind === "header") {
|
| const colDef = visibleCols[args.location[0]];
|
| const field = colDef ? fieldByKey.get(colDef.id!) : undefined;
|
| const note = field ? field.note || field.description : undefined;
|
|
|
|
|
|
|
|
|
| const cut = !!field && !!colDef && colDef.title !== field.label;
|
| const text = cut && field ? (note ? `${field.label} β ${note}` : field.label) : note;
|
| const bounds = args.bounds;
|
| if (text && bounds) {
|
| setHeaderTip((prev) =>
|
| prev && prev.text === text && prev.x === bounds.x
|
| ? prev
|
| : { text, x: bounds.x, y: bounds.y + bounds.height + 4 }
|
| );
|
| return;
|
| }
|
| }
|
| setHeaderTip((prev) => (prev === null ? prev : null));
|
|
|
|
|
|
|
|
|
|
|
| if (args.kind === "cell" && row !== undefined) {
|
| const vr = displayRows[row];
|
| const cellCol = visibleCols[args.location[0]];
|
|
|
|
|
| if (vr?.kind === "data" && cellCol) {
|
| const field = fieldByKey.get(cellCol.id!);
|
| const text = field ? formatDisplay(field, vr.record[field.key]) : "";
|
| const bounds = args.bounds;
|
| const tip = bounds ? cellTipText(text, measureCellText(text), bounds.width) : null;
|
| if (tip && bounds) {
|
| const x = tipLeft(bounds.x, window.innerWidth, measureCellText(tip) + 20);
|
| const y = bounds.y + bounds.height + 4;
|
| setCellTip((prev) =>
|
| prev && prev.text === tip && prev.x === x && prev.y === y
|
| ? prev
|
| : { text: tip, x, y }
|
| );
|
| return;
|
| }
|
| }
|
| }
|
| setCellTip((prev) => (prev === null ? prev : null));
|
| },
|
| [visibleCols, fieldByKey, displayRows]
|
| );
|
| const rowHeight = useMemo(
|
| () =>
|
| config.groupBy
|
| ? (row: number) => (displayRows[row]?.kind === "group-header" ? 32 : rowPx)
|
| : rowPx,
|
| [config.groupBy, rowPx, displayRows]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const gridVoid = useMemo(() => {
|
| const gutter = scrollbarGutter();
|
| let colsPx = rowMarkerPx(displayRows.length);
|
| for (const c of visibleCols) colsPx += (c as { width?: number }).width ?? 0;
|
| let rowsPx = HEADER_PX;
|
| if (typeof rowHeight === "number") rowsPx += displayRows.length * rowHeight;
|
| else for (let i = 0; i < displayRows.length; i++) rowsPx += rowHeight(i);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (canMutateRecords)
|
| rowsPx += typeof rowHeight === "number" ? rowHeight : rowHeight(displayRows.length);
|
|
|
| |
| |
| |
| |
|
|
| let vBar = rowsPx > gridSize.height;
|
| let hBar = colsPx > gridSize.width;
|
| if (vBar && !hBar) hBar = colsPx > gridSize.width - gutter;
|
| if (hBar && !vBar) vBar = rowsPx > gridSize.height - gutter;
|
| const clientW = gridSize.width - (vBar ? gutter : 0);
|
| const clientH = gridSize.height - (hBar ? gutter : 0);
|
|
|
| |
|
|
| return {
|
| clientW,
|
| clientH,
|
| below: rowsPx < clientH ? rowsPx : null,
|
| right: colsPx < clientW ? colsPx : null,
|
| };
|
| }, [visibleCols, displayRows, rowHeight, gridSize, canMutateRecords]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const fullSetMode =
|
| displayMode === "calendar" || displayMode === "kanban" || displayMode === "map" ||
|
| displayMode === "swipe";
|
| const detailRows = fullSetMode ? visibleRows : displayRows;
|
| const detailIdxMap = fullSetMode ? pidToIndex : displayPidToIndex;
|
| const detailIndex = detailPid !== null ? detailIdxMap.get(detailPid) : undefined;
|
| useEffect(() => {
|
| if (detailPid !== null && detailIndex === undefined) setDetailPid(null);
|
| }, [detailPid, detailIndex]);
|
| const detailRecord = useMemo<Row | null>(() => {
|
| if (detailIndex === undefined) return null;
|
| const row = detailRows[detailIndex];
|
| if (!row || row.kind !== "data") return null;
|
| return overlayEdits[row.record.pid]
|
| ? { ...row.record, ...overlayEdits[row.record.pid] }
|
| : row.record;
|
| }, [detailIndex, detailRows, overlayEdits]);
|
| const dataPosition = useMemo(() => {
|
| if (detailIndex === undefined) return 0;
|
| return detailRows
|
| .slice(0, detailIndex + 1)
|
| .filter((row) => row.kind === "data").length;
|
| }, [detailIndex, detailRows]);
|
| const neighborExists = useCallback(
|
| (delta: -1 | 1) => {
|
| if (detailIndex === undefined) return false;
|
| for (
|
| let index = detailIndex + delta;
|
| index >= 0 && index < detailRows.length;
|
| index += delta
|
| )
|
| if (detailRows[index]?.kind === "data") return true;
|
| return false;
|
| },
|
| [detailIndex, detailRows]
|
| );
|
| const go = useCallback(
|
| (delta: -1 | 1) => {
|
| if (detailIndex === undefined) return;
|
| for (
|
| let index = detailIndex + delta;
|
| index >= 0 && index < detailRows.length;
|
| index += delta
|
| ) {
|
| const row = detailRows[index];
|
| if (row?.kind !== "data") continue;
|
| setDetailPid(row.record.pid);
|
| if (displayMode === "grid")
|
| gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" });
|
| return;
|
| }
|
| },
|
| [detailIndex, detailRows, displayMode]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const drawGridHeader = useCallback<DrawHeaderCallback>(
|
| (args, drawContent) => {
|
| const field = args.column.id ? fieldByKey.get(args.column.id) : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| args.ctx.font = `${args.theme.headerFontStyle} ${args.theme.fontFamily}`;
|
| drawContent();
|
| if (!field) return;
|
| const hasInfo = !!(field.note || field.description);
|
|
|
|
|
|
|
|
|
|
|
|
|
| const isCustom = field.source === "overlay" && !isMachineOwned(field);
|
|
|
|
|
|
|
|
|
| const marks = headerMarkLayout(
|
| args.rect,
|
| args.menuBounds.width,
|
| headerMarkSizes(hasInfo, isCustom)
|
| );
|
| if (!marks) return;
|
| let slot = 0;
|
| if (hasInfo) {
|
| const at = marks[slot++];
|
| args.spriteManager.drawSprite(
|
| "aiosInfo",
|
| "normal",
|
| args.ctx,
|
| at.x,
|
| at.y,
|
| at.size,
|
| args.theme
|
| );
|
| }
|
| if (isCustom) {
|
|
|
|
|
|
|
|
|
| const at = marks[slot++];
|
| const ctx = args.ctx;
|
| ctx.save();
|
| ctx.beginPath();
|
| ctx.arc(at.x + at.size / 2, at.y + at.size / 2, at.size / 2, 0, Math.PI * 2);
|
| ctx.fillStyle = CUSTOM_FIELD_MARK;
|
| ctx.fill();
|
| ctx.restore();
|
| }
|
| },
|
| [fieldByKey]
|
| );
|
|
|
| const onCellClicked = useCallback(
|
| (cell: Item, event: CellClickedEventArgs) => {
|
| const row = displayRows[cell[1]];
|
| if (!row) return;
|
|
|
|
|
| signal(NAV_MINIMIZE_EVENT);
|
| if (row.kind === "group-header") {
|
| event.preventDefault();
|
| setCollapsed((current) => {
|
| const next = new Set(current);
|
| if (next.has(row.groupKey)) next.delete(row.groupKey);
|
| else next.add(row.groupKey);
|
| return next;
|
| });
|
| return;
|
| }
|
| const column = visibleCols[cell[0]];
|
| const field = column ? fieldByKey.get(column.id!) : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (row.kind === "data" && field && canEditField(field)
|
| && (isPickType(field.type) || field.type === "rating")) {
|
| event.preventDefault();
|
|
|
|
|
|
|
| setActiveCell(cell[0], cell[1]);
|
| const b = event.bounds;
|
| setPicker({
|
| pid: row.record.pid,
|
| fieldKey: field.key,
|
| anchor: {
|
| left: b.x, top: b.y, right: b.x + b.width, bottom: b.y + b.height,
|
| width: b.width, height: b.height,
|
| },
|
| });
|
| return;
|
| }
|
|
|
|
|
|
|
| if (row.kind === "data" && field?.type === "link" && field.link?.table) {
|
| if (field.link.table.startsWith("ut_")) {
|
| event.preventDefault();
|
| setActiveCell(cell[0], cell[1]);
|
| setLinkAt({ pid: row.record.pid, fieldKey: field.key });
|
| return;
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (row.kind === "data" && field?.type === "json") {
|
| event.preventDefault();
|
| setActiveCell(cell[0], cell[1]);
|
| setJsonAt({ pid: row.record.pid, fieldKey: field.key });
|
| return;
|
| }
|
|
|
|
|
| if (row.kind === "data" && field?.type === "url") {
|
| const raw = String(
|
| overlayEdits[row.record.pid]?.[field.key] ?? row.record[field.key] ?? ""
|
| ).trim();
|
| if (raw) {
|
| const href = /^https?:\/\//i.test(raw)
|
| ? raw
|
| : /^[\w-]+(\.[\w-]+)+/.test(raw)
|
| ? `https://${raw}`
|
| : null;
|
| if (href) {
|
| event.preventDefault();
|
| window.open(href, "_blank", "noopener");
|
| return;
|
| }
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
| if (row.kind === "data" && field && field.key === lockedKey) {
|
| togglePid(row.record.pid);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| },
|
| [displayRows, visibleCols, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid,
|
| setActiveCell]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const onCellActivated = useCallback(
|
| (cell: Item) => {
|
| const row = displayRows[cell[1]];
|
| const column = visibleCols[cell[0]];
|
| const field = column ? fieldByKey.get(column.id!) : undefined;
|
| if (row?.kind === "data" && field?.source !== "overlay")
|
| setDetailPid(row.record.pid);
|
| },
|
| [displayRows, visibleCols, fieldByKey]
|
| );
|
| const onCellEdited = useCallback(
|
| (cell: Item, value: EditableGridCell) => {
|
| const row = displayRows[cell[1]];
|
| const column = visibleCols[cell[0]];
|
| const field = column ? fieldByKey.get(column.id!) : undefined;
|
| if (!row || row.kind !== "data" || !field || !canEditField(field)) return;
|
|
|
|
|
| if (value.kind === GridCellKind.Boolean && field.type === "checkbox") {
|
| patchAndRecord(row.record.pid, { [field.key]: value.data ? "1" : "" }, "a tick");
|
| return;
|
| }
|
| if (value.kind === GridCellKind.Uri) {
|
| patchAndRecord(row.record.pid, { [field.key]: value.data ?? "" }, "an edit");
|
| return;
|
| }
|
| if (value.kind === GridCellKind.Text || value.kind === GridCellKind.Number) {
|
| patchAndRecord(row.record.pid, { [field.key]: value.data }, "an edit");
|
| }
|
| },
|
| [displayRows, visibleCols, fieldByKey, patchAndRecord, canEditField]
|
| );
|
| const validateCell = useCallback(
|
| (cell: Item): boolean => {
|
| const row = displayRows[cell[1]];
|
| const column = visibleCols[cell[0]];
|
| const field = column ? fieldByKey.get(column.id!) : undefined;
|
|
|
|
|
| return (
|
| !!row &&
|
| row.kind === "data" &&
|
| !!field &&
|
| canEditField(field) &&
|
| !isPickType(field.type) &&
|
| field.type !== "rating" &&
|
| field.type !== "status"
|
| );
|
| },
|
| [displayRows, visibleCols, fieldByKey, canEditField]
|
| );
|
| const onGridPaste = useCallback(
|
| (target: Item, values: readonly (readonly string[])[]): boolean => {
|
| const column = visibleCols[target[0]];
|
| const field = column ? fieldByKey.get(column.id!) : undefined;
|
| if (!field) return false;
|
|
|
|
|
|
|
|
|
|
|
| const rowCount = pasteRowCount(values.length, gridSelection.current?.range,
|
| { col: target[0], row: target[1] });
|
| const targetPids = Array.from({ length: rowCount }, (_, offset) => {
|
| const row = displayRows[target[1] + offset];
|
| return row?.kind === "data" ? row.record.pid : null;
|
| });
|
| const patches = planFieldPaste({
|
| field,
|
| sourceFieldKey: copyProvenanceRef.current.fieldKey,
|
| editable: canEditField(field),
|
| values,
|
| targetPids,
|
| allowedChoices: statusValues[field.key] ?? choiceOptions(field),
|
| });
|
| if (!patches) return false;
|
|
|
|
|
| patchManyAndRecord(
|
| patches.map((patch) => ({ pid: patch.pid, updates: { [field.key]: patch.value } })),
|
| "a paste"
|
| );
|
|
|
|
|
| return false;
|
| },
|
| [visibleCols, fieldByKey, canEditField, statusValues, displayRows, patchManyAndRecord,
|
| gridSelection.current]
|
| );
|
|
|
| const openHeaderMenu = useCallback(
|
| (column: number, bounds: Rectangle) => {
|
| if (embedded) return;
|
| const definition = visibleCols[column];
|
| if (!definition?.id) return;
|
| setColumnMenu({
|
| fieldKey: definition.id,
|
| anchor: {
|
| left: bounds.x,
|
| top: bounds.y,
|
| right: bounds.x + bounds.width,
|
| bottom: bounds.y + bounds.height,
|
| width: bounds.width,
|
| height: bounds.height,
|
| },
|
| });
|
| },
|
| [embedded, visibleCols]
|
| );
|
| const onHeaderClicked = useCallback(
|
| (column: number, event: HeaderClickedEventArgs) => {
|
| if (event.isEdge) return;
|
| event.preventDefault();
|
| openHeaderMenu(column, event.bounds);
|
| },
|
| [openHeaderMenu]
|
| );
|
| const onGridKeyDown = useCallback(
|
| (event: GridKeyEventArgs) => {
|
| if (
|
| event.key.toLowerCase() === "c" &&
|
| (event.ctrlKey || event.metaKey) &&
|
| !event.altKey
|
| ) {
|
| const range = gridSelection.current?.range;
|
| const source = range?.width === 1 ? visibleCols[range.x] : undefined;
|
| copyProvenanceRef.current = markGridCopy(source?.id ?? null, Date.now());
|
|
|
| return;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
| const cur = gridSelection.current?.cell;
|
| if (cur) {
|
| event.preventDefault();
|
| event.stopPropagation();
|
| event.cancel();
|
|
|
|
|
|
|
|
|
|
|
| const curRow = displayRows[cur[1]];
|
| const curColumn = visibleCols[cur[0]];
|
| const curField = curColumn ? fieldByKey.get(curColumn.id!) : undefined;
|
| if (
|
| !event.shiftKey &&
|
| curRow?.kind === "data" &&
|
| curField &&
|
| canEditField(curField) &&
|
| (isPickType(curField.type) || curField.type === "rating")
|
| ) {
|
| const b = gridRef.current?.getBounds(cur[0], cur[1]);
|
| if (b) {
|
| setPicker({
|
| pid: curRow.record.pid,
|
| fieldKey: curField.key,
|
| anchor: {
|
| left: b.x, top: b.y, right: b.x + b.width, bottom: b.y + b.height,
|
| width: b.width, height: b.height,
|
| },
|
| });
|
| return;
|
| }
|
| }
|
| const dir = event.shiftKey ? -1 : 1;
|
| let row = cur[1] + dir;
|
| while (row >= 0 && row < displayRows.length && displayRows[row]?.kind !== "data")
|
| row += dir;
|
| if (row >= 0 && row < displayRows.length) {
|
| setActiveCell(cur[0], row);
|
| gridRef.current?.scrollTo(cur[0], row, "vertical", 0, 0);
|
| }
|
| return;
|
| }
|
| }
|
| const contextMenu =
|
| event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
|
|
|
|
|
|
|
| const eventColumn =
|
| event.location && event.location[0] > 0
|
| ? event.location[0] - 1
|
| : undefined;
|
| const column = gridSelection.current?.cell[0] ?? eventColumn;
|
| if (!contextMenu || column === undefined || !event.bounds) return;
|
| const gridBounds = gridBoxRef.current?.getBoundingClientRect();
|
| const headerTop = gridBounds?.top ?? event.bounds.y;
|
| event.preventDefault();
|
| event.stopPropagation();
|
| event.cancel();
|
| openHeaderMenu(column, {
|
| x: event.bounds.x,
|
| y: headerTop,
|
| width: event.bounds.width,
|
| height: 36,
|
| });
|
| },
|
| [gridSelection.current, openHeaderMenu, displayRows, setActiveCell,
|
| visibleCols, fieldByKey, canEditField]
|
| );
|
|
|
| const persistView = useCallback((view: SavedView) => {
|
| setViews((current) => {
|
| const found = current.some((item) => item.id === view.id);
|
| return found
|
| ? current.map((item) => (item.id === view.id ? view : item))
|
| : [...current, view];
|
| });
|
| emitHostEvent({ id: eventId("view"), type: "view_upsert", view });
|
| }, []);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const onViewCohortLock = useCallback(
|
| (viewId: string, cohortId: string | null) => {
|
| const target = views.find((v) => v.id === viewId);
|
| if (!target) return;
|
| const nextConfig = { ...target.config };
|
| if (cohortId) nextConfig.cohortLock = cohortId;
|
| else delete (nextConfig as Record<string, unknown>).cohortLock;
|
| persistView({ ...target, config: nextConfig });
|
| if (viewId === activeViewId) {
|
| setConfig((live) => {
|
| const next = { ...live };
|
| if (cohortId) next.cohortLock = cohortId;
|
| else delete (next as Record<string, unknown>).cohortLock;
|
| return next;
|
| });
|
| }
|
| },
|
| [views, activeViewId, persistView]
|
| );
|
|
|
| const selectView = useCallback(
|
| (id: string) => {
|
| const current = views.find((view) => view.id === activeViewId);
|
| if (current && !sameConfig(current.config, config))
|
| persistView({ ...current, config });
|
| const next = views.find((view) => view.id === id);
|
| if (!next) return;
|
| setActiveViewId(id);
|
| setConfig(normalizeConfig(next.config, fields));
|
| setCollapsed(new Set());
|
| setSearch("");
|
| setDetailPid(null);
|
| setDisplayCap(DISPLAY_PAGE);
|
|
|
|
|
|
|
| emitHostEvent({ id: eventId("view"), type: "view_select", viewId: id });
|
|
|
| signal(NAV_MINIMIZE_EVENT);
|
| },
|
| [views, activeViewId, config, persistView, fields]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| useEffect(() => {
|
| const onOpen = (e: Event) => {
|
| const detail = (e as CustomEvent<ViewOpenDetail>).detail;
|
| if (!detail || detail.topic !== scope) return;
|
| if (!views.some((v) => v.id === detail.viewId)) return;
|
| selectView(detail.viewId);
|
| };
|
| window.addEventListener(VIEW_OPEN_EVENT, onOpen);
|
| return () => window.removeEventListener(VIEW_OPEN_EVENT, onOpen);
|
| }, [scope, views, selectView]);
|
| const createView = useCallback(
|
| (name: string, mode: DisplayMode, permissions: ViewPermissions) => {
|
| const acceptedName = uniqueDisplayName(name, views.map((view) => view.name));
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const nextConfig: ViewConfig = {
|
| ...defaultViewConfig(fields),
|
| display: cleanDisplay({ mode }),
|
| };
|
| const view: SavedView = {
|
| id: nextViewId(),
|
| name: acceptedName,
|
| kind: "custom",
|
| config: nextConfig,
|
|
|
|
|
|
|
| permissions,
|
| };
|
| persistView(view);
|
| setActiveViewId(view.id);
|
|
|
|
|
|
|
| setConfig(nextConfig);
|
| setSaveState("saved");
|
| },
|
|
|
|
|
| [fields, persistView, views]
|
| );
|
| const renameView = useCallback(
|
| (id: string, name: string) => {
|
| const view = views.find((item) => item.id === id);
|
| if (view) {
|
| const acceptedName = uniqueDisplayName(
|
| name,
|
| views.filter((item) => item.id !== id).map((item) => item.name)
|
| );
|
| persistView({ ...view, name: acceptedName });
|
| }
|
| },
|
| [views, persistView]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const toggleViewImportant = useCallback(
|
| (id: string, important: boolean) => {
|
| const view = views.find((item) => item.id === id);
|
| if (!view) return;
|
| persistView({ ...view, config: { ...view.config, important } });
|
| },
|
| [views, persistView]
|
| );
|
| const toggleViewLock = useCallback(
|
| (id: string, locked: boolean) => {
|
| const view = views.find((item) => item.id === id);
|
| if (!view || !mayToggleViewLock(view, viewer)) return;
|
| persistView({ ...view, locked });
|
| },
|
| [views, persistView, viewer]
|
| );
|
|
|
|
|
|
|
|
|
| const setViewNote = useCallback(
|
| (id: string, note: string) => {
|
| const view = views.find((item) => item.id === id);
|
| if (view) persistView({ ...view, note: note.slice(0, 2000) });
|
| },
|
| [views, persistView]
|
| );
|
| const duplicateView = useCallback(
|
| (id: string) => {
|
| const source = views.find((view) => view.id === id);
|
| if (!source) return;
|
| const copy: SavedView = {
|
| ...source,
|
| id: nextViewId(),
|
| name: uniqueDisplayName(
|
| `${source.name} copy`,
|
| views.map((view) => view.name)
|
| ),
|
| kind: "custom",
|
| locked: false,
|
|
|
|
|
|
|
|
|
|
|
| permissions: { edit: "personal" },
|
|
|
|
|
| createdBy: undefined,
|
| config: { ...source.config },
|
| };
|
| persistView(copy);
|
| setActiveViewId(copy.id);
|
| setConfig(copy.config);
|
| },
|
| [views, persistView]
|
| );
|
| const deleteView = useCallback(
|
| (id: string) => {
|
| const view = views.find((item) => item.id === id);
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (!view || isUndeletableView(view) || !mayEditView(view, viewer)) return;
|
|
|
|
|
|
|
|
|
|
|
| viewTombstonesRef.current = stampTombstone(viewTombstonesRef.current, id, Date.now());
|
| setViews((current) => current.filter((item) => item.id !== id));
|
| emitHostEvent({ id: eventId("view-delete"), type: "view_delete", viewId: id });
|
| if (activeViewId === id) {
|
| const all = views.find((item) => item.id === ALL_VIEW_ID) ?? allRecordsView(fields, scope);
|
| setActiveViewId(all.id);
|
| setConfig(all.config);
|
| }
|
| },
|
| [views, activeViewId, fields, viewer, scope]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| useEffect(() => {
|
| if (!cohortMode || !workspaceReady || !activeCohortId) return;
|
| const vid = `cohort:${activeCohortId}`;
|
| if (activeViewId === vid) return;
|
| const outgoing = views.find((view) => view.id === activeViewId);
|
| if (outgoing && !sameConfig(outgoing.config, config))
|
| persistView({ ...outgoing, config });
|
| const existing = views.find((view) => view.id === vid);
|
| const view: SavedView = existing ?? {
|
| id: vid,
|
| name: lists.find((l) => l.id === activeCohortId)?.name ?? "Cohort",
|
| kind: "custom",
|
| config: defaultViewConfig(fields),
|
| };
|
| if (!existing) setViews((current) => [...current, view]);
|
| setActiveViewId(vid);
|
| setConfig(normalizeConfig(view.config, fields));
|
| setCollapsed(new Set());
|
| setSearch("");
|
| }, [cohortMode, workspaceReady, activeCohortId, activeViewId, views, config,
|
| fields, lists, persistView]);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const importantIds = useMemo(
|
| () => views.filter((v) => v.config?.important === true).map((v) => v.id),
|
| [views]
|
| );
|
|
|
|
|
| const countedIds = useMemo(
|
| () => Array.from(new Set([...alerted, ...importantIds])),
|
| [alerted, importantIds]
|
| );
|
| const alertedKey = countedIds.join(",");
|
| const alertCounts = useMemo(() => {
|
| const out: Record<string, number> = {};
|
| if (serverWindowed || !countedIds.length) return out;
|
| for (const id of countedIds) {
|
| const view = views.find((v) => v.id === id);
|
| if (!view) continue;
|
| if (pendingMeasures(view.config.filters, measureSets) > 0) continue;
|
| const { pidToIndex } = runPipeline({
|
| rawRows: computedRows,
|
| fields,
|
| filters: view.config.filters,
|
| search: "",
|
| sorts: [],
|
| groupBy: null,
|
| collapsed: new Set<string>(),
|
| memberPids: view.config.memberPids,
|
| filterConj: view.config.filterConj ?? "and",
|
| measureSets,
|
| cohortSets,
|
| cohortLock: view.config.cohortLock,
|
| today,
|
| });
|
|
|
|
|
| out[id] = pidToIndex.size;
|
| }
|
| return out;
|
|
|
|
|
|
|
| }, [alertedKey, views, computedRows, fields, measureSets, cohortSets, today, serverWindowed]);
|
|
|
| const addToList = useCallback(
|
| (viewId: string, cohortId: string, name: string) => {
|
| if (serverWindowed) return;
|
| const view = views.find((v) => v.id === viewId);
|
| if (!view) return;
|
| if (pendingMeasures(view.config.filters, measureSets) > 0) return;
|
| const { visibleRows: matched } = runPipeline({
|
| rawRows: computedRows,
|
| fields,
|
| filters: view.config.filters,
|
| search: "",
|
| sorts: view.config.sorts,
|
| groupBy: null,
|
| collapsed: new Set<string>(),
|
| memberPids: view.config.memberPids,
|
| filterConj: view.config.filterConj ?? "and",
|
| measureSets,
|
| cohortSets,
|
| today,
|
| });
|
| const pids = matched
|
| .filter((r): r is { kind: "data"; record: Row } => r.kind === "data")
|
| .map((r) => r.record.pid);
|
| emitHostEvent({
|
| id: eventId("addlist"),
|
| type: "add_to_list",
|
| viewId,
|
| cohortId,
|
| name,
|
| pids,
|
| });
|
| },
|
| [views, computedRows, fields, serverWindowed, measureSets, cohortSets, today]
|
| );
|
|
|
|
|
|
|
|
|
|
|
| const [folderStamps, setFolderStamps] = useState<FolderStamps>({});
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const [localFolders, setLocalFolders] = useState<GridFolder[]>([]);
|
| const workspaceFolders = payload?.workspace?.folders;
|
| const folders = useMemo(
|
| () => reconcileFolders(workspaceFolders, localFolders, folderStamps, Date.now()),
|
| [workspaceFolders, localFolders, folderStamps]
|
| );
|
| const folderIdOf = useCallback(
|
| (viewId: string) =>
|
| resolveFolderId(
|
| viewId,
|
| views.find((v) => v.id === viewId)?.folderId ?? null,
|
| folders,
|
| folderStamps,
|
| Date.now()
|
| ),
|
| [views, folders, folderStamps]
|
| );
|
| const stampFolder = useCallback((patch: (prev: FolderStamps) => FolderStamps) => {
|
| setFolderStamps((prev) => pruneFolderStamps(patch(prev), Date.now()));
|
| }, []);
|
|
|
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| const folderAddPreview = useCallback(
|
| (folderId: string) => {
|
| const pids = new Set<number>();
|
| const skipped: { name: string; why: string }[] = [];
|
| let counted = 0;
|
| for (const view of views) {
|
| if (folderIdOf(view.id) !== folderId) continue;
|
| if (serverWindowed) {
|
| skipped.push({ name: view.name, why: "this table loads one page at a time" });
|
| continue;
|
| }
|
| if (pendingMeasures(view.config.filters, measureSets) > 0) {
|
|
|
| skipped.push({ name: view.name, why: "a metric condition has no answer yet" });
|
| continue;
|
| }
|
| counted += 1;
|
| const { visibleRows: matched } = runPipeline({
|
| rawRows: computedRows,
|
| fields,
|
| filters: view.config.filters,
|
| search: "",
|
| sorts: view.config.sorts,
|
| groupBy: null,
|
| collapsed: new Set<string>(),
|
| memberPids: view.config.memberPids,
|
| filterConj: view.config.filterConj ?? "and",
|
| measureSets,
|
| cohortSets,
|
| today,
|
| });
|
| for (const r of matched) if (r.kind === "data") pids.add(r.record.pid);
|
| }
|
| return { pids: [...pids], counted, skipped };
|
| },
|
| [views, folderIdOf, serverWindowed, computedRows, fields, measureSets, cohortSets, today]
|
| );
|
|
|
| const folderAddToList = useCallback(
|
| (folderId: string, cohortId: string, name: string) => {
|
| const { pids } = folderAddPreview(folderId);
|
| if (!pids.length) return;
|
|
|
|
|
| emitHostEvent({
|
| id: eventId("addlist"),
|
| type: "add_to_list",
|
| viewId: "",
|
| cohortId,
|
| name,
|
| pids,
|
| });
|
| },
|
| [folderAddPreview]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const exportRows = useCallback(
|
| (name: string, cfg: ViewConfig, poolRows: Row[], format: ExportFormat) => {
|
| if (serverWindowed) return;
|
| if (pendingMeasures(cfg.filters, measureSets) > 0) return;
|
| const { visibleRows: matched } = runPipeline({
|
| rawRows: poolRows,
|
| fields,
|
| filters: cfg.filters,
|
| search: "",
|
| sorts: cfg.sorts,
|
| groupBy: null,
|
| collapsed: new Set<string>(),
|
| memberPids: cfg.memberPids,
|
| filterConj: cfg.filterConj ?? "and",
|
| measureSets,
|
| cohortSets,
|
| today,
|
| });
|
| const records = matched
|
| .filter((r): r is { kind: "data"; record: Row } => r.kind === "data")
|
| .map((r) =>
|
| overlayEdits[r.record.pid]
|
| ? { ...r.record, ...overlayEdits[r.record.pid] }
|
| : r.record
|
| );
|
|
|
|
|
| const inOrder = new Set(cfg.order);
|
| const keys = [
|
| ...cfg.order,
|
| ...fields.map((f) => f.key).filter((k) => !inOrder.has(k)),
|
| ];
|
| const vis = new Set(cfg.visible);
|
| const cols = keys
|
| .filter((k) => vis.has(k))
|
| .map((k) => fieldByKey.get(k))
|
| .filter((f): f is Field => !!f);
|
| runExport(format, name, today, cols, records);
|
| },
|
| [serverWindowed, fields, fieldByKey, measureSets, cohortSets, today, overlayEdits]
|
| );
|
|
|
| |
| |
| |
| |
|
|
| const tsSheetRef = useRef<TsSheet | null>(null);
|
| const onTsSheet = useCallback((s: TsSheet) => { tsSheetRef.current = s; }, []);
|
| |
|
|
| const calSheetRef = useRef<{ fields: Field[]; rows: Row[] } | null>(null);
|
| const onCalSheet = useCallback(
|
| (s: { fields: Field[]; rows: Row[] } | null) => { calSheetRef.current = s; },
|
| []
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const exportViewData = useCallback(
|
| (viewId: string, format: ExportFormat) => {
|
| const view = views.find((v) => v.id === viewId);
|
| if (!view) return;
|
| const cfg = normalizeConfig(view.config, fields);
|
| const spec = cleanDisplay(cfg.display);
|
| const isActive = viewId === activeViewId;
|
| const refuse = (what: string) =>
|
| signal(
|
| TOAST_EVENT,
|
| `Open "${view.name}" first: ${what} is exported from what it draws on screen.`
|
| );
|
|
|
| if (spec?.mode === "timeseries") {
|
| const sheet = isActive ? tsSheetRef.current : null;
|
| if (!sheet) return refuse("a time series");
|
| if (sheet.empty) {
|
| signal(TOAST_EVENT,
|
| `"${view.name}" has no metrics on its sheet yet β add one, then export.`);
|
| return;
|
| }
|
| if (format === "csv") {
|
|
|
|
|
| triggerDownload(
|
| exportFilename(view.name, today, format),
|
| new Blob(["ο»Ώ" + buildTsCsv(sheet)], { type: "text/csv;charset=utf-8" })
|
| );
|
| } else {
|
| const { fields: tf, rows: tr } = tsSheetToTable(sheet);
|
| runExport(format, view.name, today, tf, tr);
|
| }
|
| return;
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| if (spec?.mode === "catalog") {
|
| signal(
|
| TOAST_EVENT,
|
| `"${view.name}" is a catalog β open it and use Print to save it as a PDF. ` +
|
| `A spreadsheet of its products is not what it draws.`
|
| );
|
| return;
|
| }
|
|
|
|
|
| if (spec?.mode === "calendar" && spec.calendarMode === "summary") {
|
| const sheet = isActive ? calSheetRef.current : null;
|
| if (!sheet) return refuse("a calendar summary");
|
| runExport(format, view.name, today, sheet.fields, sheet.rows);
|
| return;
|
| }
|
|
|
| exportRows(view.name, cfg, computedRows, format);
|
| },
|
| [views, fields, computedRows, exportRows, activeViewId, today]
|
| );
|
|
|
| |
| |
| |
| |
| |
|
|
| const addSelectionToList = useCallback(
|
| (cohortId: string, name: string) => {
|
| const pids = [...selectedPids];
|
| if (!pids.length || serverWindowed) return;
|
| emitHostEvent({
|
| id: eventId("addlist"),
|
| type: "add_to_list",
|
| viewId: activeViewId,
|
| cohortId,
|
| name,
|
| pids,
|
| });
|
| setSelAddOpen(false);
|
| setSelListName("");
|
| clearSelection();
|
| },
|
| [selectedPids, serverWindowed, activeViewId, clearSelection]
|
| );
|
|
|
|
|
|
|
|
|
|
|
| const scopeChoice = payload?.workspace?.scopeChoice === true;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const effectiveCohortLock = config.cohortLock;
|
| |
| |
| |
| |
| |
|
|
| const cohortLockChip = useMemo(() => {
|
| const id = effectiveCohortLock;
|
| if (!id) return undefined;
|
| const set = lists.find((l) => l.id === id);
|
| if (!set) return {};
|
| return { name: set.name, count: set.pids?.length ?? 0 };
|
| }, [effectiveCohortLock, lists]);
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const recordLayout = payload?.workspace?.recordLayout?.order;
|
| const onRecordLayout = useCallback((order: string[]) => {
|
| emitHostEvent({ id: eventId("rlayout"), type: "record_layout", order });
|
| }, []);
|
| const saveField = useCallback(
|
| (field: Field, scope?: FieldScope) => {
|
| const requestId = eventId("field");
|
| const accepted: Field = {
|
| ...field,
|
| label: uniqueDisplayName(
|
| field.label,
|
| fields.filter((item) => item.key !== field.key).map((item) => item.label)
|
| ),
|
| editRequestId: requestId,
|
| };
|
| delete accepted.labelCorrectedFrom;
|
| delete accepted.labelCorrectionId;
|
| stampFieldEdit(accepted.key);
|
| setFields((current) =>
|
| current.some((item) => item.key === accepted.key)
|
| ? current.map((item) => (item.key === accepted.key ? accepted : item))
|
| : [...current, accepted]
|
| );
|
| emitHostEvent({
|
| id: requestId,
|
| type: "field_upsert",
|
| field: accepted,
|
| ...(scope ? { scope } : {}),
|
| });
|
| },
|
| [stampFieldEdit, fields]
|
| );
|
| const createField = useCallback(
|
| (
|
| label: string,
|
| type: FieldType,
|
| anchorKey: string | null,
|
| side: "left" | "right" | "end",
|
| options?: string[],
|
| measureSpec?: { key: string; window: WindowSpec },
|
| extra?: FieldBuildExtra
|
| ) => {
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const fieldScope = scopeChoice ? extra?.scope : undefined;
|
| const scoped = fieldScope === "cohort" ? ({ scope: "cohort" } as const) : {};
|
|
|
|
|
|
|
|
|
|
|
| if (measureSpec) {
|
| const field: Field = {
|
| key: `measure_${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`,
|
| label,
|
| type,
|
| source: "odoo",
|
| default: true,
|
| custom: true,
|
| derived: true,
|
| filterable: false,
|
| agg: ["currency", "int"].includes(type) ? "sum" : undefined,
|
| measure: measureSpec,
|
| ...scoped,
|
| };
|
| saveField(field, fieldScope);
|
| insertColumn(field.key, anchorKey, side);
|
| return;
|
| }
|
| const field = { ...buildOverlayField(label, type, options, extra), ...scoped };
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| if (isUserTable && type !== "created_time") {
|
| void addTableField(scope, {
|
| key: field.key,
|
| label: field.label,
|
| type,
|
| ...(field.options?.length ? { options: field.options } : {}),
|
| ...(field.colorCodeOptions !== undefined
|
| ? { colorCodeOptions: field.colorCodeOptions }
|
| : {}),
|
| ...(field.optionColors ? { optionColors: field.optionColors } : {}),
|
| ...(extra?.link ? { link: extra.link } : {}),
|
| ...(extra?.rollup ? { rollup: extra.rollup } : {}),
|
| ...(type === "formula" && extra?.formula ? { formula: extra.formula } : {}),
|
| ...(field.agg ? { agg: field.agg } : {}),
|
| }).then((made) => {
|
| if (!made) return;
|
| setFields((current) =>
|
| current.some((f) => f.key === made.key) ? current : [...current, made]
|
| );
|
| insertColumn(made.key, anchorKey, side);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| });
|
| return;
|
| }
|
| saveField(field, fieldScope);
|
| insertColumn(field.key, anchorKey, side);
|
| },
|
| [insertColumn, saveField, scopeChoice, isUserTable, scope]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const setProfileFlag = useCallback(
|
| (key: string, on: boolean) => {
|
| if (!isUserTable) return;
|
| void patchTableField(scope, key, {
|
| profile: on ? { source: "instagram" } : null,
|
| }).then((made) => {
|
| if (!made) return;
|
| setFields((current) => current.map((f) => (f.key === made.key ? made : f)));
|
| signal(WORKSPACE_STALE_EVENT);
|
| });
|
| },
|
| [isUserTable, scope]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const setFieldConfig = useCallback(
|
| (key: string, patch: {
|
| label?: string; link?: unknown; rollup?: unknown; formula?: string; agg?: string;
|
| }) => {
|
| if (!isUserTable) return;
|
| void patchTableField(scope, key, patch as Record<string, unknown>).then((made) => {
|
| if (!made) return;
|
| setFields((current) => current.map((f) => (f.key === made.key ? made : f)));
|
|
|
|
|
|
|
| signal(WORKSPACE_STALE_EVENT);
|
| signal(ROWS_STALE_EVENT);
|
| });
|
| },
|
| [isUserTable, scope]
|
| );
|
|
|
| |
| |
| |
| |
| |
|
|
| const changeField = useCallback(
|
| (oldKey: string, newKey: string) => {
|
| if (oldKey === lockedKey || oldKey === newKey) return;
|
| if (!fieldByKey.has(newKey)) return;
|
|
|
|
|
| const withoutNew = order.filter((k) => k !== newKey);
|
| const at = withoutNew.indexOf(oldKey);
|
| if (at < 0) return;
|
| withoutNew.splice(at, 0, newKey);
|
| const nextVisible = new Set(visible);
|
| nextVisible.delete(oldKey);
|
| nextVisible.add(newKey);
|
| updateConfig({ ...config, order: withoutNew, visible: [...nextVisible] });
|
| },
|
| [config, updateConfig, order, visible, fieldByKey, lockedKey]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const deleteField = useCallback(
|
| (key: string) => {
|
| const f = fieldByKey.get(key);
|
| if (!f?.custom || key === lockedKey) return;
|
| setFields((current) => current.filter((item) => item.key !== key));
|
| const nextWidths = { ...(config.widths ?? {}) };
|
| delete nextWidths[key];
|
| updateConfig({
|
| ...config,
|
| order: order.filter((k) => k !== key),
|
| visible: [...visible].filter((k) => k !== key),
|
| widths: nextWidths,
|
| sorts: (config.sorts ?? []).filter((s) => s.colId !== key),
|
| groupBy: config.groupBy === key ? null : config.groupBy,
|
| colorBy: config.colorBy === key ? null : config.colorBy,
|
| });
|
| stampFieldDelete(key);
|
| emitHostEvent({ id: eventId("fielddel"), type: "field_delete", key });
|
| },
|
| [config, updateConfig, order, visible, fieldByKey, lockedKey, stampFieldDelete]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const deleteDefinitionField = useCallback(
|
| (key: string) => {
|
| if (!isUserTable || key === lockedKey) return;
|
| void deleteTableField(scope, key).then((ok) => {
|
| if (!ok) return;
|
| setFields((current) => current.filter((item) => item.key !== key));
|
| const nextWidths = { ...(config.widths ?? {}) };
|
| delete nextWidths[key];
|
| updateConfig({
|
| ...config,
|
| order: order.filter((k) => k !== key),
|
| visible: [...visible].filter((k) => k !== key),
|
| widths: nextWidths,
|
| sorts: (config.sorts ?? []).filter((s) => s.colId !== key),
|
| groupBy: config.groupBy === key ? null : config.groupBy,
|
| colorBy: config.colorBy === key ? null : config.colorBy,
|
| });
|
| signal(WORKSPACE_STALE_EVENT);
|
| signal(ROWS_STALE_EVENT);
|
| });
|
| },
|
| [isUserTable, scope, lockedKey, config, updateConfig, order, visible]
|
| );
|
|
|
| |
| |
| |
| |
|
|
| const createAndSwapField = useCallback(
|
| (
|
| oldKey: string,
|
| label: string,
|
| type: FieldType,
|
| options?: string[],
|
| extra?: FieldBuildExtra
|
| ) => {
|
| const scope = scopeChoice ? extra?.scope : undefined;
|
| const field = {
|
| ...buildOverlayField(label, type, options, extra),
|
| ...(scope === "cohort" ? ({ scope: "cohort" } as const) : {}),
|
| };
|
| saveField(field, scope);
|
| changeField(oldKey, field.key);
|
| },
|
| [saveField, changeField, scopeChoice]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const retypeField = useCallback(
|
| (
|
| key: string,
|
| type: FieldType,
|
| options?: string[],
|
|
|
|
|
| extra?: FieldBuildExtra
|
| ) => {
|
| const old = fieldByKey.get(key);
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| if (!old || !isUserSchemaField(old, isUserTable)) return;
|
| if (type === "formula" || type === "created_time") return;
|
| const numberFam = (t: FieldType) => t === "int" || t === "currency";
|
| const keepFormat =
|
| old.format && ((numberFam(old.type) && numberFam(type)) || old.type === type);
|
| const next: Field = {
|
| key,
|
| label: extra?.label?.trim() || old.label,
|
| type,
|
| source: "overlay",
|
| default: old.default,
|
| custom: true,
|
| agg: ["currency", "int"].includes(type) ? "sum" : undefined,
|
| ...(old.note ? { note: old.note } : {}),
|
| ...(old.description ? { description: old.description } : {}),
|
| ...(old.scope ? { scope: old.scope } : {}),
|
| ...(old.createdBy ? { createdBy: old.createdBy } : {}),
|
| ...(old.permissions ? { permissions: old.permissions } : {}),
|
| ...(keepFormat ? { format: old.format } : {}),
|
| ...((type === "select" || type === "multiselect") && options?.length
|
| ? { options }
|
| : {}),
|
| ...((type === "select" || type === "multiselect")
|
| ? {
|
| colorCodeOptions: extra?.colorCodeOptions !== false,
|
| ...(extra?.optionColors && Object.keys(extra.optionColors).length
|
| ? { optionColors: extra.optionColors }
|
| : {}),
|
| }
|
| : {}),
|
| ...(type === "multiselect" ? { multi: true } : {}),
|
| ...(type === "rating" ? { max: extra?.max ?? 5 } : {}),
|
| };
|
| if (isUserTable && !old.custom) {
|
| |
| |
| |
|
|
| void patchTableField(scope, key, {
|
| label: next.label,
|
| type,
|
| ...(next.options ? { options: next.options } : {}),
|
| ...(next.optionColors ? { optionColors: next.optionColors } : {}),
|
| ...(next.colorCodeOptions !== undefined
|
| ? { colorCodeOptions: next.colorCodeOptions }
|
| : {}),
|
| ...(next.max !== undefined ? { max: next.max } : {}),
|
| ...(next.agg ? { agg: next.agg } : {}),
|
| ...(next.format ? { format: next.format } : {}),
|
| }).then((made) => {
|
| if (!made) return;
|
| setFields((current) => current.map((f) => (f.key === made.key ? made : f)));
|
| signal(WORKSPACE_STALE_EVENT);
|
| signal(ROWS_STALE_EVENT);
|
| });
|
| } else {
|
| saveField(next);
|
| }
|
| |
| |
| |
| |
|
|
| const renames = extra?.renames?.filter((r) => r.from && r.to && r.from !== r.to) ?? [];
|
| if (renames.length) {
|
| emitHostEvent({ id: eventId("choicerename"), type: "choice_rename", key, renames });
|
|
|
|
|
|
|
| undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
| kind: "choiceRename", fieldKey: key, renames,
|
| });
|
| }
|
| },
|
|
|
|
|
|
|
|
|
| [fieldByKey, saveField, scope, isUserTable]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const duplicateField = useCallback(
|
| (source: Field) => {
|
| const prefix = source.key.startsWith("measure_") ? "measure_" : "custom_";
|
| const label = uniqueDisplayName(
|
| `${source.label} copy`,
|
| fields.map((field) => field.label)
|
| );
|
| const key = `${prefix}${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`;
|
| const requestId = eventId("fielddup");
|
| const clone: Field = { ...source, key, label, editRequestId: requestId };
|
| delete clone.createdBy;
|
| delete clone.permissions;
|
| delete clone.labelCorrectedFrom;
|
| delete clone.labelCorrectionId;
|
| stampFieldEdit(key);
|
| setFields((current) => [...current, clone]);
|
| emitHostEvent({
|
| id: requestId,
|
| type: "field_duplicate",
|
| sourceKey: source.key,
|
| key,
|
| label,
|
|
|
|
|
| ...(scopeChoice && source.scope === "cohort" ? { scope: "cohort" as const } : {}),
|
| });
|
| insertColumn(key, source.key, "right");
|
| },
|
| [insertColumn, scopeChoice, stampFieldEdit, fields]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| const changeMeasurePeriod = useCallback(
|
| (key: string, window: WindowSpec) => {
|
| const f = fieldByKey.get(key);
|
| if (!f?.measure) return;
|
| const m = measures.find((item) => item.key === f.measure?.key);
|
| const oldAuto = m ? `${m.label} Β· ${windowLabel(f.measure.window)}` : null;
|
| const label =
|
| m && oldAuto && f.label === oldAuto
|
| ? `${m.label} Β· ${windowLabel(window)}`
|
| : f.label;
|
| saveField({ ...f, label, measure: { ...f.measure, window } });
|
| },
|
| [fieldByKey, measures, saveField]
|
| );
|
|
|
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
| |
|
|
| const removeSelectionFromCohort = useCallback(() => {
|
| if (!activeCohortId) return;
|
| const pids = [...selectedPids].filter((p) => cohortMemberSet.has(p));
|
| if (!pids.length) return;
|
| emitHostEvent({
|
| id: eventId("cohortrm"),
|
| type: "cohort_remove",
|
| cohortId: activeCohortId,
|
| pids,
|
| });
|
| clearSelection();
|
| }, [activeCohortId, selectedPids, cohortMemberSet, clearSelection]);
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const removeSelectionFromList = useCallback(
|
| (cohortId: string) => {
|
| const members = cohortSets[cohortId];
|
| if (!members) return;
|
| const pids = [...selectedPids].filter((p) => members.has(p));
|
| if (!pids.length) return;
|
| emitHostEvent({ id: eventId("cohortrm"), type: "cohort_remove", cohortId, pids });
|
| setSelRemoveOpen(false);
|
| clearSelection();
|
| },
|
| [cohortSets, selectedPids, clearSelection]
|
| );
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const removableLists = useMemo(() => {
|
| if (cohortMode || selectedPids.size === 0) return [];
|
| const out: { id: string; name: string; hits: number }[] = [];
|
| for (const l of lists) {
|
| const members = cohortSets[l.id];
|
| if (!members) continue;
|
| let hits = 0;
|
| for (const pid of selectedPids) if (members.has(pid)) hits += 1;
|
| if (!hits) continue;
|
| const projected = views.find((v) => v.id === l.id);
|
| if (projected && !mayEditView(projected, viewer)) continue;
|
| out.push({ id: l.id, name: l.name, hits });
|
| }
|
| return out;
|
| }, [cohortMode, selectedPids, lists, cohortSets, views, viewer]);
|
|
|
|
|
| const addCustomersToCohort = useCallback(() => {
|
| if (!activeCohortId || addCustPicked.size === 0) return;
|
| emitHostEvent({
|
| id: eventId("cohortadd"),
|
| type: "cohort_add",
|
| cohortId: activeCohortId,
|
| pids: [...addCustPicked],
|
| });
|
| setAddCustPicked(new Set());
|
| setAddCustQuery("");
|
| setAddCustOpen(false);
|
| }, [activeCohortId, addCustPicked]);
|
|
|
| |
| |
| |
| |
|
|
| const addCandidates = useMemo(() => {
|
| if (!addCustOpen) return [];
|
| const q = addCustQuery.trim().toLowerCase();
|
| const out: { pid: number; name: string }[] = [];
|
| for (const r of rawRows) {
|
| if (cohortMemberSet.has(r.pid)) continue;
|
| const name = String(r[lockedKey] ?? "");
|
| if (q && !name.toLowerCase().includes(q)) continue;
|
| out.push({ pid: r.pid, name });
|
| }
|
| return out;
|
| }, [addCustOpen, addCustQuery, rawRows, cohortMemberSet, lockedKey]);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const visibleKeys = useMemo(
|
| () => order.filter((key) => visible.has(key)),
|
| [order, visible]
|
| );
|
| const frozenN = frozenCountOf(config);
|
| const pinFieldTo = useCallback(
|
| (key: string) => {
|
| const index = visibleKeys.indexOf(key);
|
| if (index < 0) return;
|
| if (index < MAX_FROZEN) {
|
| updateConfig({ ...config, frozenCount: clampFrozenCount(index + 1) });
|
| return;
|
| }
|
| const moved = visibleKeys.filter((k) => k !== key);
|
| moved.splice(MAX_FROZEN - 1, 0, key);
|
| let cursor = 0;
|
| const nextOrder = order.map((k) => (visible.has(k) ? moved[cursor++] : k));
|
| updateConfig({ ...config, order: nextOrder, frozenCount: MAX_FROZEN });
|
| },
|
| [visibleKeys, order, visible, config, updateConfig]
|
| );
|
| const unpinFields = useCallback(
|
| () => updateConfig({ ...config, frozenCount: undefined }),
|
| [config, updateConfig]
|
| );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const setDisplayMode = useCallback(
|
| (m: DisplayMode) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| const av = views.find((v) => v.id === activeViewId);
|
| if (av && isModeFrozen(av)) return;
|
| const spec = cleanDisplay(config.display);
|
| if (m === "grid") {
|
| updateConfig({
|
| ...config,
|
| display: spec ? cleanDisplay({ ...spec, mode: "grid" }) : undefined,
|
| });
|
| return;
|
| }
|
| updateConfig({ ...config, display: { ...(spec ?? {}), mode: m } });
|
| },
|
| [config, updateConfig, views, activeViewId]
|
| );
|
|
|
|
|
| const setDisplayField = useCallback(
|
| (k: "dateField" | "stackField" | "colorField" | "sizeField", key: string) => {
|
| const spec = cleanDisplay(config.display);
|
| if (!spec) return;
|
|
|
|
|
|
|
| const next = { ...spec, [k]: key };
|
| if (!key) delete (next as Record<string, unknown>)[k];
|
| updateConfig({ ...config, display: next });
|
| },
|
| [config, updateConfig]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const setKanbanClamp = useCallback(
|
| (next: false | undefined) => {
|
| const spec = cleanDisplay(config.display);
|
| if (!spec) return;
|
| const display = { ...spec };
|
| if (next === false) display.kanbanClamp = false;
|
| else delete (display as Record<string, unknown>).kanbanClamp;
|
| updateConfig({ ...config, display });
|
| },
|
| [config, updateConfig]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const setSwipeSpec = useCallback(
|
| (next: SwipeSpec | undefined) => {
|
| const spec = cleanDisplay(config.display);
|
| if (!spec) return;
|
| const display = { ...spec };
|
| if (next) display.swipe = next;
|
| else delete (display as Record<string, unknown>).swipe;
|
| updateConfig({ ...config, display });
|
| },
|
| [config, updateConfig]
|
| );
|
| |
| |
| |
| |
| |
| |
|
|
| const setFormSpec = useCallback(
|
| (next: FormSpec | null) => {
|
| const spec = cleanDisplay(config.display);
|
| if (!spec) return;
|
| const display = { ...spec };
|
| if (next) display.form = next;
|
| else delete (display as Record<string, unknown>).form;
|
| updateConfig({ ...config, display });
|
| },
|
| [config, updateConfig]
|
| );
|
| |
| |
| |
| |
| |
|
|
| const setCalendarMetrics = useCallback(
|
| (next: { id: string; field: string; agg: string }[]) => {
|
| const spec = cleanDisplay(config.display);
|
| if (!spec) return;
|
| const display = { ...spec };
|
| const capped = next.slice(0, MAX_CALENDAR_METRICS);
|
| if (capped.length) display.calendarMetrics = capped;
|
| else delete (display as Record<string, unknown>).calendarMetrics;
|
| updateConfig({ ...config, display });
|
| },
|
| [config, updateConfig]
|
| );
|
|
|
| const setCalendarMode = useCallback(
|
| (next: "records" | "summary") => {
|
| const spec = cleanDisplay(config.display);
|
| if (!spec) return;
|
| const display = { ...spec };
|
| if (next === "summary") display.calendarMode = "summary";
|
| else delete (display as Record<string, unknown>).calendarMode;
|
| updateConfig({ ...config, display });
|
| },
|
| [config, updateConfig]
|
| );
|
|
|
|
|
|
|
| const dateFieldChoices = useMemo(
|
| () => fields.filter((f) => isDateFamilyType(f.type)),
|
| [fields]
|
| );
|
| const stackFieldChoices = useMemo(
|
| () => fields.filter((f) => f.type === "status" || f.type === "select"),
|
| [fields]
|
| );
|
| const pickBy = (want: string | undefined, choices: Field[], preferred?: string): Field | undefined => {
|
| const wanted = want ? choices.find((f) => f.key === want) : undefined;
|
| if (wanted) return wanted;
|
| const pref = preferred ? choices.find((f) => f.key === preferred) : undefined;
|
| return pref ?? choices[0];
|
| };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const charts = useMemo<ChartSpec[]>(
|
| () => cleanCharts(displaySpec?.charts) ?? [],
|
| [displaySpec?.charts]
|
| );
|
| const setCharts = useCallback(
|
| (next: ChartSpec[]) => {
|
| const spec = cleanDisplay(config.display);
|
| if (!spec) return;
|
| const display = { ...spec, charts: next };
|
| if (!next.length) delete (display as Record<string, unknown>).charts;
|
| updateConfig({ ...config, display });
|
| },
|
| [config, updateConfig]
|
| );
|
| const colorFieldChoices = useMemo(
|
| () => fields.filter((f) => f.type === "status" || f.type === "select"),
|
| [fields]
|
| );
|
| const sizeFieldChoices = useMemo(
|
| () => fields.filter((f) => isNumericFieldType(f.type)),
|
| [fields]
|
| );
|
| const mapColorField = displaySpec?.colorField
|
| ? colorFieldChoices.find((f) => f.key === displaySpec.colorField)
|
| : undefined;
|
| const mapSizeField = displaySpec?.sizeField
|
| ? sizeFieldChoices.find((f) => f.key === displaySpec.sizeField)
|
| : undefined;
|
| const calendarField = pickBy(displaySpec?.dateField, dateFieldChoices, "last_order");
|
| const kanbanField = pickBy(displaySpec?.stackField, stackFieldChoices);
|
| |
| |
| |
| |
|
|
| const swipeField = displaySpec?.swipe
|
| ? fieldByKey.get(displaySpec.swipe.fieldKey)
|
| : undefined;
|
|
|
|
|
|
|
| const modeDataRows = useMemo(() => {
|
| if (
|
| displayMode !== "calendar" &&
|
| displayMode !== "kanban" &&
|
| displayMode !== "map" &&
|
| displayMode !== "chart" &&
|
|
|
|
|
|
|
| displayMode !== "timeseries" &&
|
| |
| |
| |
| |
|
|
| displayMode !== "catalog" &&
|
| |
| |
| |
|
|
| displayMode !== "swipe"
|
| )
|
| return [];
|
| const seen = new Set<number>();
|
| const out: Row[] = [];
|
| for (const vr of visibleRows) {
|
| if (vr.kind !== "data" || seen.has(vr.record.pid)) continue;
|
| seen.add(vr.record.pid);
|
| const edits = overlayEdits[vr.record.pid];
|
| out.push(edits ? { ...vr.record, ...edits } : vr.record);
|
| }
|
| return out;
|
| }, [displayMode, visibleRows, overlayEdits]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const kanbanCardKeys = useMemo(
|
| () =>
|
| kanbanField
|
| ? visibleKeys.filter((k) => k !== lockedKey && k !== kanbanField.key).slice(0, 3)
|
| : [],
|
| [visibleKeys, lockedKey, kanbanField]
|
| );
|
| const onKanbanMove = useCallback(
|
| (pid: number, value: string) => {
|
| if (!kanbanField) return;
|
| patchAndRecord(pid, { [kanbanField.key]: value }, "a card move");
|
| },
|
| [kanbanField, patchAndRecord]
|
| );
|
| |
| |
|
|
| const onSwipeDecide = useCallback(
|
| (pid: number, value: string) => {
|
| if (!swipeField) return;
|
| patchAndRecord(pid, { [swipeField.key]: value }, "a swipe");
|
| },
|
| [swipeField, patchAndRecord]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| const kanbanCanAddOption =
|
| !!kanbanField && kanbanField.type === "select" &&
|
| isUserSchemaField(kanbanField, isUserTable);
|
| const onKanbanAddOption = useCallback(
|
| (anchor: AnchorRect) => {
|
| if (!kanbanField) return;
|
| setColumnMenu({ fieldKey: kanbanField.key, anchor, pane: "edit" });
|
| },
|
| [kanbanField]
|
| );
|
| const onListToggleGroup = useCallback((groupKey: string) => {
|
| setCollapsed((current) => {
|
| const next = new Set(current);
|
| if (next.has(groupKey)) next.delete(groupKey);
|
| else next.add(groupKey);
|
| return next;
|
| });
|
| }, []);
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const timeseriesPids = useMemo(
|
| () => (displayMode === "timeseries" ? modeDataRows.map((r) => r.pid) : []),
|
| [displayMode, modeDataRows]
|
| );
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const catalogs = useMemo<CatalogSpec[]>(
|
| () => (displayMode === "catalog" ? (displaySpec?.catalogs ?? []) : []),
|
| [displayMode, displaySpec]
|
| );
|
| |
|
|
| const catalogFilteredCodes = useMemo(
|
| () =>
|
| displayMode === "catalog"
|
| ? modeDataRows
|
| .map((r) => r[CATALOG_CODE_FIELD])
|
| .filter((c): c is string => typeof c === "string" && !!c)
|
| : [],
|
| [displayMode, modeDataRows]
|
| );
|
| const onCatalogs = useCallback(
|
| (next: CatalogSpec[]) => {
|
| const spec = cleanDisplay(config.display) ?? { mode: "catalog" as const };
|
| updateConfig({ ...config, display: { ...spec, catalogs: next } });
|
| },
|
| [config, updateConfig]
|
| );
|
|
|
|
|
| const onTimeseriesDisplay = useCallback(
|
| (next: Partial<DisplaySpec>) => {
|
| const spec = cleanDisplay(config.display) ?? { mode: "timeseries" as const };
|
| updateConfig({ ...config, display: { ...spec, ...next } });
|
| },
|
| [config, updateConfig]
|
| );
|
| const kanbanCanMove =
|
| !!kanbanField && kanbanField.type === "select" && canEditField(kanbanField);
|
| const kanbanReason = !kanbanField
|
| ? null
|
| : kanbanField.type === "status"
|
| ? `Stacked by ${kanbanField.label} β read-only, computed from the source system.`
|
| : !canEditField(kanbanField)
|
| ? "You do not have permission to edit this field, so cards cannot be moved."
|
| : null;
|
| |
|
|
| const swipeCanWrite = !!swipeField && swipeField.type === "select" && canEditField(swipeField);
|
| const swipeReason = !swipeField
|
| ? null
|
| : swipeField.type === "status"
|
| ? `Bound to ${swipeField.label} β read-only, computed from the source system.`
|
| : !canEditField(swipeField)
|
| ? "You do not have permission to edit this field, so records cannot be decided."
|
| : null;
|
|
|
| if (loading || !workspaceReady) {
|
| |
| |
| |
| |
|
|
| return (
|
| <div className="cg-loading">
|
| <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| </div>
|
| );
|
| }
|
|
|
| const activeView = views.find((view) => view.id === activeViewId);
|
|
|
|
|
|
|
| const cohortAction = cohortMode ? (
|
| <button
|
| type="button"
|
| className="cg-tb-btn cg-tb-addcust"
|
| ref={addCustRef}
|
| disabled={!activeCohortId}
|
| onClick={() => setAddCustOpen((o) => !o)}
|
| aria-expanded={addCustOpen}
|
| aria-haspopup="dialog"
|
| >
|
| + Add customers
|
| </button>
|
| ) : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const deletableKeys = new Set(
|
| fields.filter((f) => f.custom && f.key !== lockedKey).map((f) => f.key)
|
| );
|
| const menuField = columnMenu ? fieldByKey.get(columnMenu.fieldKey) : undefined;
|
|
|
|
|
| const menuSortedDir = menuField
|
| ? config.sorts.find((s) => s.colId === menuField.key)?.dir ?? null
|
| : null;
|
| const menuIsFiltered = menuField
|
| ? treeNamesField(config.filters, menuField.key, measureCols)
|
| : false;
|
|
|
| const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
|
| const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
|
|
|
| const modeControl = serverWindowed || embedded ? undefined : (
|
| <ModeSwitch
|
| mode={displayMode}
|
| onMode={setDisplayMode}
|
| // I12 (C3) β a locked view's mode is frozen, so the switcher stops being a switcher.
|
| // The field pickers beside it stay: C3 freezes the MODE, and a kanban's stack field is
|
| // config, not mode ("filters/sorts/columns stay editable").
|
| locked={!!activeView && isModeFrozen(activeView)}
|
| dateField={calendarField}
|
| dateChoices={dateFieldChoices}
|
| stackField={kanbanField}
|
| stackChoices={stackFieldChoices}
|
| colorField={mapColorField}
|
| colorChoices={colorFieldChoices}
|
| sizeField={mapSizeField}
|
| sizeChoices={sizeFieldChoices}
|
| onPickField={setDisplayField}
|
| // Items 3 + 4 (C-DISP). `kanbanClamp` is absent-means-clamped, so the boolean handed
|
| // down is `!== false` rather than a truthiness test β the one place that distinction
|
| // decides whether the user's opt-out survives a reload.
|
| clamped={displaySpec?.kanbanClamp !== false}
|
| onClamp={setKanbanClamp}
|
| calendarMode={displaySpec?.calendarMode ?? "records"}
|
| onCalendarMode={setCalendarMode}
|
| calendarMetrics={displaySpec?.calendarMetrics}
|
| // The numeric family, exactly what C-DISP makes eligible β and the same memo the map's
|
| // bubble-size picker feeds from, so "what can be totalled" has one definition.
|
| metricChoices={sizeFieldChoices}
|
| onCalendarMetrics={setCalendarMetrics}
|
| />
|
| );
|
|
|
|
|
|
|
|
|
| const jsonField = jsonAt ? fieldByKey.get(jsonAt.fieldKey) : undefined;
|
| const jsonRow = jsonAt ? rawRows.find((r) => r.pid === jsonAt.pid) : undefined;
|
| const jsonValue = String(
|
| (jsonAt && overlayEdits[jsonAt.pid]?.[jsonAt.fieldKey])
|
| ?? (jsonField && jsonRow ? jsonRow[jsonField.key] : "")
|
| ?? ""
|
| );
|
| const linkField = linkAt ? fieldByKey.get(linkAt.fieldKey) : undefined;
|
| const linkRow = linkAt ? rawRows.find((row) => row.pid === linkAt.pid) : undefined;
|
| const linkValue = String(
|
| (linkAt && overlayEdits[linkAt.pid]?.[linkAt.fieldKey])
|
| ?? (linkField && linkRow ? linkRow[linkField.key] : "")
|
| ?? ""
|
| );
|
| const linkedRecordIds = [...new Set(
|
| linkValue.split(",").map((part) => Number(part.trim())).filter(
|
| (pid) => Number.isInteger(pid) && pid > 0
|
| )
|
| )];
|
| const pickerField = picker ? fieldByKey.get(picker.fieldKey) : undefined;
|
|
|
|
|
|
|
| const pickerChoices = !pickerField
|
| ? []
|
| : pickerField.type === "user"
|
| ? payload?.userOptions ?? []
|
| : choiceOptions(pickerField);
|
| const pickerRow = picker ? rawRows.find((r) => r.pid === picker.pid) : undefined;
|
| const pickerValue = String(
|
| (picker && overlayEdits[picker.pid]?.[picker.fieldKey])
|
| ?? (pickerField && pickerRow ? pickerRow[pickerField.key] : "")
|
| ?? ""
|
| );
|
|
|
|
|
| const pickerMulti = pickerField?.type === "multiselect";
|
| const pickerParts = pickerMulti ? splitMulti(pickerValue) : [];
|
| |
|
|
| const pickerFocusChoice = !pickerChoices.length
|
| ? null
|
| : pickerMulti
|
| ? pickerParts.find((p) => pickerChoices.includes(p)) ?? pickerChoices[0]
|
| : pickerChoices.includes(pickerValue)
|
| ? pickerValue
|
| : pickerChoices[0];
|
|
|
| return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| <div
|
| className="cg-shell"
|
| data-today={today ?? ""}
|
| data-measure-rules={measureRuleKeys}
|
| data-measure-sets={measureSetKeys}
|
| >
|
| {/* β wave17 R1 / C-LOCKV β the COHORT SIDEBAR is gone, and with it the last surface that
|
| treated a cohort as its own kind of object. `CohortSidebar` was the retired `#/cohort`
|
| page's left panel: a second rail, with its own rename, delete, export and folder
|
| machinery, over `workspace.lists`. Under R1 a cohort IS a saved view, so the Views rail
|
| below renders every one of them and the second rail has nothing to switch between.
|
| β `lists` is NOT gone with it β it stays the membership channel that feeds `cohortSets`
|
| (C-LOCKV point 2), which is what the lock resolves against. */}
|
| {!hideViews && (
|
| <ViewSidebar
|
| // Item 12 (C-LOCK) β the rail's menu is the only emitter of a SAVED `cohortLock`.
|
| onCohortLock={onViewCohortLock}
|
| /**
|
| * β ITEM 5 (C7) β the rail's order, corrected by the drag this browser just made.
|
| *
|
| * β APPLIED AT THE RAIL, NOT TO `views` ITSELF, and the distinction is load-bearing.
|
| * `views` is the workspace's list and half this component keys off it (selection,
|
| * autosave, the echo reconcile, `uniqueDisplayName`). A view ORDER is a rendering
|
| * fact about ONE surface; folding it into the state would make every consumer's
|
| * behaviour depend on a drag, and the drag's whole scope is which row sits where.
|
| */
|
| views={applyViewOrder(views, folderStamps, Date.now())}
|
| alertCounts={alertCounts}
|
| activeViewId={activeViewId}
|
| saveState={saveState}
|
| onSelect={selectView}
|
| onCreate={createView}
|
| onRename={renameView}
|
| onNote={setViewNote}
|
| onDuplicate={duplicateView}
|
| onDelete={deleteView}
|
| lists={lists}
|
| // β WAVE 21 item 11 (R10) β offered on a CLIENT-MODE table only, the same
|
| // condition "Add to locked view" and Export already carry. A windowed table
|
| // holds ONE PAGE of the matched set (CG-3), so "not found" would mean "not on
|
| // this page" for most of a list β precisely the silent cap R10 rules out.
|
| onSelectFromFile={serverWindowed ? undefined : () => setSelectFromFile(true)}
|
| /* β WAVE-29 T25 (owner item 6) β the Import door, offered ONLY where records can be
|
| added. `canMutateRecords` is `isUserTable && recordsMutable`: the same predicate the
|
| "+" row and the delete path read, so a locked database (an Odoo mirror, an
|
| automation-owned child table) has no Import row at all rather than an Import that
|
| fails at the server. */
|
| onImport={canMutateRecords ? () => setImportOpen(true) : undefined}
|
| onAddToList={serverWindowed ? undefined : addToList}
|
| today={today}
|
| onExport={serverWindowed ? undefined : exportViewData}
|
| folders={folders}
|
| folderIdOf={folderIdOf}
|
| onFolderCreate={(name, icon) => {
|
| const id = newFolderId();
|
| // Bounded: reconcileFolders drops a local entry once the echo
|
| // carries it or its create stamp ages out, so this only has to not
|
| // grow without limit.
|
| // C5 (I15): the icon rides the LOCAL entry too, or the folder the user just
|
| // styled renders grey for one round trip and then changes colour under them.
|
| setLocalFolders((prev) => [...prev.slice(-31), { id, name, order: 9_999, icon }]);
|
| stampFolder((p) => ({ ...p, created: { ...p.created, [id]: Date.now() } }));
|
| emitHostEvent({
|
| id: eventId("foldnew"),
|
| type: "folder_create",
|
| surface: "views",
|
| folderId: id,
|
| name,
|
| icon,
|
| });
|
| }}
|
| onFolderRename={(folderId, name) => {
|
| stampFolder((p) => ({
|
| ...p,
|
| renamed: { ...p.renamed, [folderId]: { at: Date.now(), name } },
|
| }));
|
| emitHostEvent({
|
| id: eventId("foldren"),
|
| type: "folder_rename",
|
| surface: "views",
|
| folderId,
|
| name,
|
| });
|
| }}
|
| onFolderDelete={(folderId) => {
|
| // The tombstone is stamped BEFORE the emit: between the click and the
|
| // echo the payload still lists the folder, and rendering it again for
|
| // one round trip is the delete blip (folders.ts).
|
| stampFolder((p) => ({ ...p, deleted: { ...p.deleted, [folderId]: Date.now() } }));
|
| emitHostEvent({
|
| id: eventId("folddel"),
|
| type: "folder_delete",
|
| surface: "views",
|
| folderId,
|
| });
|
| }}
|
| onFolderDuplicate={(folderId) => {
|
| const newId = newFolderId();
|
| const src = folders.find((f) => f.id === folderId);
|
| setLocalFolders((prev) => [
|
| ...prev.slice(-31),
|
| { id: newId, name: `${src?.name ?? "Folder"} copy`, order: 9_999 },
|
| ]);
|
| stampFolder((p) => ({ ...p, created: { ...p.created, [newId]: Date.now() } }));
|
| emitHostEvent({
|
| id: eventId("folddup"),
|
| type: "folder_duplicate",
|
| surface: "views",
|
| folderId,
|
| newId,
|
| });
|
| }}
|
| onFolderReorder={(order) => {
|
| // A-S4-1 (item 19). S4 built the drag and the reconcile behind an OPTIONAL prop, so
|
| // its absence silently disabled the whole feature rather than breaking anything β
|
| // which is why every gate stayed green while folders would not drag at all. The
|
| // stamp is what stops the rail snapping back for one round trip (the echo window).
|
| stampFolder((p) => ({ ...p, ordered: { at: Date.now(), order } }));
|
| emitHostEvent({
|
| id: eventId("foldord"),
|
| type: "folder_reorder",
|
| surface: "views",
|
| order,
|
| });
|
| }}
|
| /**
|
| * β WAVE 27 Β· OWNER ITEM 5 (contract C7) β the rail's new VIEW order.
|
| *
|
| * Three things happen and each one is needed:
|
| * Β· the LOCAL list is reordered, so the row lands where it was dropped and stays
|
| * there for this render;
|
| * Β· a STAMP is written, so the next workspace echo β which is one round trip
|
| * behind and still carries the server's order β does not snap it back
|
| * (`applyViewOrder` in folders.ts holds it for the echo window and no longer);
|
| * Β· ONE event carries the WHOLE order, never a delta. A partial list cannot say
|
| * where an UNNAMED view went, so "moved B after C" leaves every other position
|
| * to be re-derived by two sides that can disagree. The server's own
|
| * `folder_reorder` note states this rule; the same rule, same door.
|
| *
|
| * β AMENDMENT C7-A1 (2026-08-08, SESSION C): the event is
|
| * `{type: "view_reorder", surface: "views", order}` β `surface`, not `workspace`.
|
| * The contract said `{workspace, order}`; the door `grid_events.py:1299` actually
|
| * opens on `surface β FOLDER_SURFACES`, and the TABLE is resolved from the request
|
| * context rather than the payload (`_store_of(ctx)`). Emitting a key the handler
|
| * does not read would have shipped a drag that persists nothing, behind a green
|
| * gate on each side.
|
| */
|
| onViewReorder={(order) => {
|
| setViews((cur) => {
|
| const byId = new Map(cur.map((v) => [v.id, v]));
|
| const out: SavedView[] = [];
|
| const placed = new Set<string>();
|
| for (const id of order) {
|
| const v = byId.get(id);
|
| if (!v || placed.has(id)) continue;
|
| placed.add(id);
|
| out.push(v);
|
| }
|
| // Anything the order does not name keeps its current place, after the named
|
| // ones β never dropped. A view created in another tab is not evidence the
|
| // drop was wrong.
|
| for (const v of cur) if (!placed.has(v.id)) out.push(v);
|
| return out;
|
| });
|
| stampFolder((p) => ({ ...p, orderedViews: { at: Date.now(), order } }));
|
| // β ONE CAST, AT ONE SITE, WITH A RATCHET BEHIND IT (ASK ->D, wave-27 mailbox
|
| // C-6). `HostEvent`'s union lives in `types.ts`, which is SESSION D's file, and
|
| // D owns C7's persistence half β so the member `{ id; type: "view_reorder";
|
| // surface: FolderSurface; order: string[] }` is theirs to add. Until it lands
|
| // this emit cannot be typed from here.
|
| // β The gap is DECLARED, not hidden: `verify_wiring.py`'s
|
| // `PENDING_UNION_MEMBERS` carries `view_reorder`, prints the ask, and goes RED
|
| // the moment `types.ts` gains the member β which forces this cast to be deleted
|
| // in the same change. That is the D-70 ratchet's shape, and it fired on its own
|
| // this wave, which is the argument for reusing it rather than a comment.
|
| emitHostEvent({
|
| id: eventId("vieword"),
|
| type: "view_reorder",
|
| surface: "views",
|
| order,
|
| } as unknown as HostEvent);
|
| }}
|
| onItemMove={(viewId, folderId) => {
|
| stampFolder((p) => ({
|
| ...p,
|
| moved: { ...p.moved, [viewId]: { at: Date.now(), folderId } },
|
| }));
|
| emitHostEvent({
|
| id: eventId("itemmove"),
|
| type: "item_move",
|
| surface: "views",
|
| itemId: viewId,
|
| folderId,
|
| });
|
| }}
|
| folderAddPreview={serverWindowed ? undefined : folderAddPreview}
|
| onFolderAddToList={serverWindowed ? undefined : folderAddToList}
|
| viewer={viewer}
|
| onToggleLock={toggleViewLock}
|
| onToggleImportant={toggleViewImportant}
|
| userOptions={payload?.userOptions}
|
| />
|
| )}
|
| <main className="cg-main">
|
| <Toolbar
|
| nouns={topic.nouns}
|
| // Item 12 (C-LOCK) β the banner's copy. β BOTH fields are omitted when this reader
|
| // cannot see the locked set, and that omission is the SIGNAL, not an oversight: it
|
| // is what selects RECORD's second sentence ("locked to a set you cannot see").
|
| // Inventing a name or a count for a set we were given no membership for would be
|
| // disclosing the very thing the permission gate withheld.
|
| cohortLock={cohortLockChip}
|
| // Wave-14 close-out stitch (HOST): PANEL's item-13 door β absent, the
|
| // copy-configuration modal renders nowhere (absent beats present-and-inert).
|
| // `updateConfig` takes a FULL config, so the partial patch spreads over the
|
| // live one; `copyConfig.ts` computed it against this same view's config.
|
| copyViews={views.filter((v) => v.id !== activeViewId)}
|
| onCopyConfig={(patch) => updateConfig({ ...config, ...patch })}
|
| fields={fields}
|
| visible={visible}
|
| lockedKey={lockedKey}
|
| onColumnVisible={setColumnVisible}
|
| onColumnsVisible={(keys) =>
|
| updateConfig({
|
| ...config,
|
| visible: [...new Set([lockedKey, ...keys])],
|
| })
|
| }
|
| // Owner item 23 β the Hide-fields panel lists and reorders COLUMN order, so it is
|
| // handed the ordered projection, not the definition-ordered `fields` above.
|
| // `order` is `useGridColumns`' reconciled copy (locked key first, unknown keys
|
| // dropped, new keys appended), so the list can never show a field the grid does not
|
| // have or omit one it does.
|
| orderedFields={orderedFields}
|
| onFieldOrder={(keys) => updateConfig({ ...config, order: keys })}
|
| deletableKeys={deletableKeys}
|
| onDeleteField={deleteField}
|
| colorOn={config.colorBy !== null}
|
| onToggleColor={(on) =>
|
| updateConfig({
|
| ...config,
|
| colorBy: on
|
| ? fields.find((field) => field.type === "status")?.key ?? null
|
| : null,
|
| })
|
| }
|
| rowHeightMode={config.rowHeightMode}
|
| onRowHeightMode={(rowHeightMode) => updateConfig({ ...config, rowHeightMode })}
|
| filters={config.filters}
|
| onFilters={(filters) => updateConfig({ ...config, filters })}
|
| filterConj={config.filterConj ?? "and"}
|
| onFilterConj={(filterConj) => updateConfig({ ...config, filterConj })}
|
| sorts={config.sorts}
|
| onSorts={(sorts) => updateConfig({ ...config, sorts })}
|
| // the toolbar must not advertise a grouping the pipeline is not applying
|
| groupBy={serverWindowed ? null : config.groupBy}
|
| onGroupBy={(groupBy) => updateConfig({ ...config, groupBy })}
|
| statusValues={statusValues}
|
| measures={measures}
|
| unresolvedCount={unresolvedCount}
|
| lists={lists}
|
| pendingMeasureCount={pendingMeasureCount}
|
| search={search}
|
| onSearch={setSearch}
|
| recordCount={recordCount}
|
| scopeCounts={payload?.counts}
|
| pool={payload?.workspace?.pool}
|
| serverWindowed={serverWindowed}
|
| cohortAction={cohortAction}
|
| filterSeed={filterSeed}
|
| modeControl={modeControl}
|
| />
|
| {/* Wave-10 item 9: the view's description used to render HERE, as a full-width band
|
| between the toolbar and the grid β it cost every row a strip of vertical space and
|
| read as an alert rather than as a label. It now sits under the view's name in the
|
| rail (ViewSidebar), where it annotates the thing it describes. */}
|
| <div
|
| className="cg-grid-box"
|
| ref={gridBoxRef}
|
| // Item 19 β THE dismissal. Not glide's out-of-bounds hover: the button sits over the
|
| // canvas, so reaching for it IS an out-of-bounds event and dismissing there would
|
| // unmount the control under the arriving pointer. Leaving the whole grid box is the
|
| // honest "you are done with this row".
|
| onMouseLeave={() => setExpandAt((prev) => (prev === null ? prev : null))}
|
| >
|
| {displayMode === "grid" && (
|
| <>
|
| <DataEditor
|
| ref={gridRef}
|
| columns={visibleCols}
|
| getCellContent={getCellContent}
|
| rows={displayRows.length}
|
| theme={lightTheme}
|
| // β Wave-14 item 15 (R8) β was `config.groupBy ? 0 : frozenN`: a grouping UNPINNED
|
| // EVERY column, which is the "the first field does not stay pinned" the owner
|
| // reported. The clamp existed on the assumption that glide cannot draw a group
|
| // bar's `span` across the freeze boundary. **It can, and it is built for it.**
|
| // `getSpanBounds` (`data-grid-render.walk.js`) returns `[frozenRect, contentRect]`
|
| // β it SPLITS a straddling span in two β and the cell renderer keys its
|
| // already-drawn set on `${row},${startCol},${endCol},${c.sticky}` so the same span
|
| // is drawn once per pass, taking `areas[0]` when sticky and `areas[1]` when not.
|
| // The scrollable half then sets `skipContents = true`, so the bar's TEXT is painted
|
| // by the frozen half and its background continues across the scroll region.
|
| // Net effect, which is exactly what was wanted: with a grouping active the group
|
| // label rides the pinned first column and stays readable while you scroll right.
|
| freezeColumns={frozenN}
|
| /**
|
| * ββ WAVE-29 T33 (owner item 17) β the totals row is glide's OWN frozen trailing
|
| * row, which is why it is aligned to every column width for free.
|
| *
|
| * β `showTotals ? 1 : 0`, never a bare `1`: with no totals row appended, freezing
|
| * one trailing row would pin the LAST DATA RECORD to the bottom of the viewport β
|
| * a record that then appears twice while scrolling, which reads as duplicated data
|
| * rather than as a layout bug.
|
| */
|
| freezeTrailingRows={showTotals ? 1 : 0}
|
| rowHeight={rowHeight}
|
| headerHeight={36}
|
| rowMarkers="both"
|
| // βββ W18-B VOID βββ PINNED, not left to default. The marker column is part of
|
| // the content width the void's right-hand edge is measured from, and a width we
|
| // merely predicted could disagree with the one glide drew β four pixels of white
|
| // against the tint, visible on screen and invisible to every gate. `rowMarkerPx`
|
| // reproduces glide's own ladder verbatim (data-editor.js:103), so this changes no
|
| // pixel of the marker column; it only makes its width a number we own.
|
| // βββ end W18-B VOID βββ
|
| rowMarkerWidth={rowMarkerPx(displayRows.length)}
|
| /**
|
| * ββ W30-T42 (contract C2) β THE SCROLL THAT ASKS FOR THE NEXT WINDOW.
|
| *
|
| * β The W18-B VOID note above says there is deliberately no such handler "anywhere
|
| * near it", and that stays true of the void, which is arithmetic over the box and
|
| * must not become scroll-tracking. This one is a different subject: how far down
|
| * the LOADED rows the viewport has reached, which is the only question a windowed
|
| * grid can answer paging with. It is a no-op on every whole-book table (the
|
| * callback returns on `!serverWindowed` before reading anything).
|
| */
|
| onVisibleRegionChanged={onVisibleRegionChanged}
|
| gridSelection={gridSelection}
|
| onGridSelectionChange={onGridSelectionChange}
|
| getRowThemeOverride={getRowThemeOverride}
|
| onItemHovered={onItemHovered}
|
| onCellClicked={onCellClicked}
|
| onCellActivated={onCellActivated}
|
| onCellEdited={onCellEdited}
|
| onPaste={onGridPaste}
|
| // R4 β Backspace/Delete over a selection is ONE undoable action. Returning
|
| // `false` from here takes the write over from glide's per-cell path.
|
| onDelete={onGridDelete}
|
| validateCell={validateCell}
|
| onColumnResize={onColumnResize}
|
| onColumnMoved={onColumnMoved}
|
| onColumnProposeMove={onColumnProposeMove}
|
| drawHeader={drawGridHeader}
|
| onHeaderMenuClick={(column, bounds) => openHeaderMenu(column, bounds)}
|
| onHeaderClicked={onHeaderClicked}
|
| onHeaderContextMenu={onHeaderClicked}
|
| onKeyDown={onGridKeyDown}
|
| maxColumnWidth={600}
|
| getCellsForSelection={true}
|
| rowSelectionMode="multi"
|
| smoothScrollX
|
| smoothScrollY
|
| width={gridSize.width}
|
| height={gridSize.height}
|
| customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
|
| headerIcons={HEADER_ICONS}
|
| rightElement={embedded ? undefined : (
|
| // Wave-6 item 4 β the "+" of the header row: the create-field form,
|
| // insert-at-end.
|
| //
|
| // β Wave-20 owner item 5 UNPINNED it. It used to be `sticky: true` ("so it
|
| // stays visible however far the columns scroll"), which parked it against the
|
| // right edge of the WINDOW β on a four-column table that is half a screen of
|
| // white between the last field and the control that adds the next one, and it
|
| // reads as page chrome rather than as part of the table. It now sits
|
| // immediately after the last column, where Airtable puts it.
|
| //
|
| // TWO changes are needed and neither works alone: `sticky:false` stops glide
|
| // pinning the wrapper (`infinite-scroller.js:204` sets `right` only when
|
| // sticky), and the CSS region kills `.dvn-spacer`'s `flex-grow: 1`, which
|
| // would otherwise expand to fill the scroller and push the button right back
|
| // to the edge. Consequence, accepted: on a table wider than the viewport the
|
| // "+" is off-screen until you scroll to the end of the columns β which is
|
| // where the new column is going to land anyway.
|
| <button
|
| type="button"
|
| className="cg-add-field-btn"
|
| aria-label="Add field"
|
| title="Add a field"
|
| onClick={(event) => {
|
| const r = event.currentTarget.getBoundingClientRect();
|
| setPlusMenu({
|
| left: r.left,
|
| top: r.top,
|
| right: r.right,
|
| bottom: r.bottom,
|
| width: r.width,
|
| height: r.height,
|
| });
|
| }}
|
| >
|
| +
|
| </button>
|
| )}
|
| rightElementProps={{ sticky: false, fill: false }}
|
| // Owner item 4 (R8) β the trailing "+" row, USER DATABASES ONLY. Passing
|
| // `onRowAppended` is what makes glide paint the ghost row at all, so the
|
| // affordance exists exactly where a POST can succeed. See `appendRow`.
|
| onRowAppended={canMutateRecords ? appendRow : undefined}
|
| trailingRowOptions={
|
| canMutateRecords
|
| ? {
|
| // The hint sits in the identity column (targetColumn 0), which is the
|
| // one a new record is named in β the same cell the cursor lands on.
|
| hint: "New record",
|
| sticky: false,
|
| targetColumn: 0,
|
| }
|
| : undefined
|
| }
|
| />
|
| {/* βββ W18-B VOID βββ (wave 18, owner item 1b / ruling R12) β the two rectangles.
|
|
|
| They are SIBLINGS of the canvas, immediately after it and before everything else
|
| `.cg-grid-box` holds, which is the whole of their stacking story: `z-index: auto`
|
| plus tree order puts them over the canvas, and `.cg-selbar` / the popovers carry
|
| explicit `--cg-z-*` and stay over them. `pointer-events: none` keeps glide's
|
| scroller β which is UNDER this and owns every mouse event the grid reacts to β
|
| hit-testable straight through them.
|
|
|
| The right-hand one starts BELOW the header rather than at 0: in `Airtable 7.png`
|
| the header band continues past the last column in its own near-white (#FBFCFE at
|
| (1250,141)), and it is where the add-field "+" lives. The two rectangles overlap
|
| in the bottom-right corner; they are the same flat colour, so the overlap is not
|
| visible and neither one needs to know about the other. */}
|
| {gridVoid.below !== null && (
|
| <div
|
| className="cg-grid-void"
|
| aria-hidden
|
| style={{
|
| top: gridVoid.below,
|
| left: 0,
|
| width: gridVoid.clientW,
|
| height: gridVoid.clientH - gridVoid.below,
|
| }}
|
| />
|
| )}
|
| {gridVoid.right !== null && (
|
| <div
|
| className="cg-grid-void"
|
| aria-hidden
|
| style={{
|
| top: HEADER_PX,
|
| left: gridVoid.right,
|
| width: gridVoid.clientW - gridVoid.right,
|
| height: gridVoid.clientH - HEADER_PX,
|
| }}
|
| />
|
| )}
|
| {/* βββ end W18-B VOID βββ */}
|
| </>
|
| )}
|
| {displayMode === "list" && (
|
| <ListView
|
| rows={displayRows}
|
| fieldByKey={fieldByKey}
|
| visibleKeys={visibleKeys}
|
| titleKey={lockedKey}
|
| onOpen={setDetailPid}
|
| onToggleGroup={onListToggleGroup}
|
| />
|
| )}
|
| {displayMode === "calendar" &&
|
| (calendarField ? (
|
| <CalendarView
|
| rows={modeDataRows}
|
| field={calendarField}
|
| today={today}
|
| titleKey={lockedKey}
|
| // Item 4 (C-DISP) β records vs summaries, and the metrics a summary day shows.
|
| mode={displaySpec?.calendarMode ?? "records"}
|
| metrics={displaySpec?.calendarMetrics}
|
| fieldByKey={fieldByKey}
|
| /* wave17 item 9 / R4 β the surface the day-grain measure channel is asked
|
| about (C-CAL). The server refuses `product` in words. */
|
| scope={scope}
|
| onOpen={setDetailPid}
|
| /* owner item 3 β the summary month, so the view menu's Export can carry it. */
|
| onSheet={onCalSheet}
|
| />
|
| ) : (
|
| <div className="cg-mode-empty">
|
| This table has no date field to place records by.
|
| </div>
|
| ))}
|
| {displayMode === "kanban" &&
|
| (kanbanField ? (
|
| <KanbanView
|
| rows={modeDataRows}
|
| field={kanbanField}
|
| fieldByKey={fieldByKey}
|
| cardKeys={kanbanCardKeys}
|
| titleKey={lockedKey}
|
| canMove={kanbanCanMove}
|
| readOnlyReason={kanbanReason}
|
| clamped={displaySpec?.kanbanClamp !== false}
|
| onMove={onKanbanMove}
|
| onOpen={setDetailPid}
|
| /* Item 12 (R8). β A `useCallback` or the literal `null` β NEVER an inline arrow.
|
| KanbanView is memo'd and every prop here is referentially stable on purpose
|
| (see the note above `kanbanCardKeys`); a fresh closure each render undoes that
|
| fix and nothing goes red. */
|
| onAddOption={kanbanCanAddOption ? onKanbanAddOption : null}
|
| />
|
| ) : (
|
| <div className="cg-mode-empty">
|
| This table has no status or single-select field to stack by.
|
| </div>
|
| ))}
|
| {/* β WAVE-27 item 8 (owner ruling R2, contract C3) β the SWIPE deck.
|
| β No `field ? ... : empty-state` fork, and the asymmetry with kanban above is
|
| deliberate. A kanban with no select field cannot exist, so the host answers for it.
|
| A swipe deck with no binding is the ORDINARY state of a view somebody just created,
|
| and SwipeView owns that state along with the three ways a stored binding rots β
|
| each one re-opening the picker rather than being repaired by a guess. Forking here
|
| would put a second, dumber explanation in front of the good one. */}
|
| {displayMode === "swipe" && (
|
| <SwipeView
|
| rows={modeDataRows}
|
| fields={fields}
|
| fieldByKey={fieldByKey}
|
| spec={displaySpec?.swipe}
|
| cardKeys={kanbanCardKeys}
|
| titleKey={lockedKey}
|
| canWrite={swipeCanWrite}
|
| readOnlyReason={swipeReason}
|
| /* β NOT `swipeCanWrite`. Binding the deck changes the VIEW's config; writing a
|
| cell changes a VALUE. Conflating them is [[schema-role-is-not-a-value-wall]],
|
| and the two genuinely differ here β a viewer with a read-only grant on a shared
|
| view may still decide records through it. */
|
| canBind={!!activeView && mayEditView(activeView, viewer)}
|
| onSpec={setSwipeSpec}
|
| onSwipe={onSwipeDecide}
|
| onOpen={setDetailPid}
|
| /* ββ WAVE-29 T26 (owner R9) β the card renders the RECORD, so the deck now gets
|
| what the modal gets: the view's visible fields (not the three-key kanban
|
| summary), this surface's scope so comments can key on it, and the document
|
| plumbing.
|
| β `scope` is OPTIONAL on this grid and stays that way β the standalone embed
|
| supplies none, and a card must render WITHOUT comments rather than throw. That
|
| precondition is pre-existing (`RecordDetail` carries the same one); T26 keeps it
|
| honest instead of forcing it.
|
| β The doc handlers take a PID here, unlike the modal's, which close over the one
|
| open record β a deck paints many at once. */
|
| detailKeys={[...visible].filter((k) => k !== lockedKey)}
|
| scope={scope}
|
| viewer={viewer}
|
| docs={payload?.docs}
|
| docPayload={payload?.docPayload}
|
| onDocAdd={
|
| payload?.docs
|
| ? (pid, file) =>
|
| emitHostEvent({
|
| id: eventId("docadd"),
|
| type: "doc_add",
|
| pid,
|
| docId: `doc_${eventId("d").slice(2, 14).replace(/[^a-z0-9]/gi, "")}`,
|
| ...file,
|
| })
|
| : undefined
|
| }
|
| onDocFetch={
|
| payload?.docs
|
| ? (pid, docId) =>
|
| emitHostEvent({ id: eventId("docget"), type: "doc_fetch", pid, docId })
|
| : undefined
|
| }
|
| onDocDelete={
|
| payload?.docs
|
| ? (pid, docId) =>
|
| emitHostEvent({ id: eventId("docdel"), type: "doc_delete", pid, docId })
|
| : undefined
|
| }
|
| />
|
| )}
|
| {/**
|
| * ββ WAVE-29 T29 / CONTRACT C4 (owner R7) β THE FORM INTERFACE, mounted.
|
| *
|
| * β THE WAVE'S ONLY CROSS-FENCE WIRING, and the single most reliably-dropped artifact
|
| * in this protocol: four consecutive waves lost one, and one wave shipped four dead
|
| * features behind 43 green gates. C declared it, A carries the `verify_wiring` row,
|
| * and this is the mount β three parties, so the session that REQUESTS a wiring cannot
|
| * be the one that certifies it.
|
| *
|
| * β EIGHT PROPS, ALL REQUIRED, and none of them guessed β C published the exact names
|
| * and the exact expressions in its mailbox before this ticket started. `spec` is
|
| * required-but-NULLABLE and the `?? null` is the contract: `displaySpec?.form` is
|
| * `undefined`, and widening the prop to accept it would be the optional-prop
|
| * degradation wearing another hat.
|
| * β `readOnlyReason` carries the `canEdit` reason ONLY. A locked database is a
|
| * different sentence and `FormInterface` owns it, because it is a property of the
|
| * database rather than of this viewer's grant.
|
| */}
|
| {displayMode === "form" && (
|
| <FormInterface
|
| /* β THE TABLE KEY, not the `TopicConfig` object. C's hand-off wrote
|
| `topicForScope(scope)` and gave `ut_leads` as the example; that helper returns a
|
| CONFIG (`{key, scope, rowsPath, β¦}`), and `routes_forms._may_administer_view`
|
| takes a `table_key`. The example is the contract β a config object would have
|
| stringified into a query parameter nothing can resolve. Caught by `tsc`, and
|
| posted back to C rather than fixed silently. */
|
| topic={scope ?? ""}
|
| viewId={activeView?.id ?? null}
|
| fields={fields}
|
| spec={displaySpec?.form ?? null}
|
| onSpec={setFormSpec}
|
| canEdit={!!activeView && mayEditView(activeView, viewer)}
|
| readOnlyReason={
|
| !activeView
|
| ? "A form lives on a saved view β save this one first."
|
| : mayEditView(activeView, viewer)
|
| ? null
|
| : "You have view-only access to this view, so its form cannot be changed."
|
| }
|
| canCollect={canMutateRecords}
|
| />
|
| )}
|
| {/* Wave-8 I19c β wave-9 I10 (C2) β CHART is a VIEW like the rest: the charts
|
| are computed from `modeDataRows`, the very rows the grid counts, so a filter
|
| change moves the charts and the toolbar count together.
|
| β Only 'chart' is tested, never 'dashboard': `cleanDisplay` normalises the
|
| legacy value on READ, so a stored 'dashboard' has already become 'chart' by
|
| the time it reaches here. Testing both would hide a broken normalisation. */}
|
| {displayMode === "chart" && (
|
| <DashboardView
|
| rows={modeDataRows}
|
| fields={fields}
|
| fieldByKey={fieldByKey}
|
| charts={charts}
|
| onCharts={setCharts}
|
| />
|
| )}
|
| {/* Owner item 7 (C-TS) β Time series is a VIEW: metric rows x bucket columns over
|
| the pids the current filter kept, so the table and the series answer the same
|
| question. The panel is HOST-AGNOSTIC by contract (RECORD re-mounts it inside the
|
| record modal for item 13), which is why it takes pids and fields rather than
|
| reaching into anything here. */}
|
| {displayMode === "timeseries" && (
|
| <TimeSeriesPanel
|
| scope={scope}
|
| pids={timeseriesPids}
|
| fields={fields}
|
| /* wave17 item 5 / R9 β the SAME rows `timeseriesPids` is derived from (see its
|
| memo), so a snapshot metric and a server metric on one sheet cannot be
|
| answering about different populations. */
|
| rows={modeDataRows}
|
| display={displaySpec}
|
| onDisplay={onTimeseriesDisplay}
|
| /* owner item 3 β the built sheet, so the view menu's Export can carry it. */
|
| onSheet={onTsSheet}
|
| />
|
| )}
|
| {/* βββ W18-C CATALOG βββ Owner item 4 (C6) β Catalog is a VIEW, and the only one
|
| whose payload is authored CONTENT rather than an arrangement of the rows. It
|
| joins its stored product codes against the WHOLE pool (`rawRows`), not the
|
| filtered rows: a finished page must keep printing the products it names after
|
| somebody narrows the view. The filter still has a job β `catalogFilteredCodes`
|
| is the "add what this view shows" door β but it narrows what you can ADD, never
|
| what a page prints.
|
|
|
| β Gated on the product surface. A catalog joins products to their images by
|
| `code`, which is `product_data`'s business key AND the C2-ASSET key; on the
|
| customer table the primary field is a customer NAME, and joining on it would ask
|
| the asset route for a picture of a person. The refusal says so in words rather
|
| than rendering an empty designer. */}
|
| {displayMode === "catalog" &&
|
| (scope === "product" ? (
|
| <CatalogView
|
| catalogs={catalogs}
|
| onCatalogs={onCatalogs}
|
| fields={fields}
|
| fieldByKey={fieldByKey}
|
| poolRows={rawRows}
|
| filteredCodes={catalogFilteredCodes}
|
| />
|
| ) : (
|
| <div className="cg-mode-empty">
|
| A catalog prints products. Open the Products table to build one.
|
| </div>
|
| ))}
|
| {/* βββ end W18-C CATALOG βββ */}
|
| {/* Wave-7 item W11c (C3) β Map is a VIEW: same pipeline rows, pins for rows
|
| with host-supplied coords, click-through to the same record detail, and
|
| an honest no-location chip. */}
|
| {displayMode === "map" && fieldByKey.get(lockedKey) && (
|
| <MapView
|
| rows={modeDataRows}
|
| field={fieldByKey.get(lockedKey)!}
|
| colorField={mapColorField}
|
| sizeField={mapSizeField}
|
| selectedPids={selectedPids}
|
| onSelectPids={selectPids}
|
| onOpen={setDetailPid}
|
| />
|
| )}
|
| {/* Owner item 19 β the hover-only Expand. The COUNTERPART to item 17: the single
|
| click that used to open a record now highlights it, and this is where opening
|
| moved to. `position: fixed` because glide reports viewport coordinates (the same
|
| space `.cg-header-tip` is placed in).
|
|
|
| β `onMouseDown` preventDefault, not just onClick: mousedown would otherwise reach
|
| the canvas underneath, move the selection to the primary cell, and repaint the
|
| row a frame before the panel opens β a visible flicker on every expand.
|
|
|
| Rendered only in `grid` mode: list / calendar / kanban / map have their own
|
| open affordances and glide is not mounted to report bounds for them. */}
|
| {!embedded && displayMode === "grid" && expandAt && (
|
| <button
|
| type="button"
|
| className="cg-row-expand"
|
| style={{
|
| left: expandAt.x,
|
| top: expandAt.y,
|
| width: expandAt.size,
|
| height: expandAt.size,
|
| }}
|
| title="Expand record"
|
| aria-label="Expand record"
|
| onMouseDown={(event) => event.preventDefault()}
|
| onClick={() => setDetailPid(expandAt.pid)}
|
| >
|
| {/* Airtable's expand mark: two corner brackets pushing apart. SVG, never a
|
| glyph β no emojis in this UI, and a unicode arrow renders differently on
|
| every platform. Sized in the parent's em so it tracks the button. */}
|
| <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden focusable="false">
|
| <path
|
| d="M9.5 2.5H13.5V6.5M6.5 13.5H2.5V9.5M13.5 2.5L9 7M2.5 13.5L7 9"
|
| fill="none"
|
| stroke="currentColor"
|
| strokeWidth="1.5"
|
| strokeLinecap="round"
|
| strokeLinejoin="round"
|
| />
|
| </svg>
|
| </button>
|
| )}
|
| {/* Wave-5 item 6 β the header description tip. pointer-events:none + aria-hidden:
|
| it can never become a click target, so the control under it stays clickable
|
| (the tooltip-swallows-the-next-click trap, paid for once already). */}
|
| {headerTip && (
|
| <div
|
| className="cg-header-tip"
|
| style={{ left: headerTip.x, top: headerTip.y }}
|
| aria-hidden
|
| >
|
| {headerTip.text}
|
| </div>
|
| )}
|
| {/* I2 β the cell tip. `pointer-events: none` + `aria-hidden` are not styling here,
|
| they are the contract: the control under the tip must stay clickable, and a
|
| screen reader already has the cell's own value. */}
|
| {cellTip && (
|
| <div
|
| className="cg-header-tip cg-cell-tip"
|
| style={{ left: cellTip.x, top: cellTip.y }}
|
| aria-hidden
|
| >
|
| {cellTip.text}
|
| </div>
|
| )}
|
| {/* Owner item 2's point: checked customers accumulate, and the selection DOES
|
| something. FLOATED over the grid, never in the flow: a bar that reflows the grid
|
| on first selection shifts every row under the user's cursor mid-click-run (and
|
| broke this gate's own coordinate math before it broke a user). It states the
|
| count and offers "Add to cohort" over exactly the checked pids β the same guarded
|
| add_to_list event the view menu uses, so the host treats both alike. */}
|
| {!embedded && selectedPids.size > 0 && !serverWindowed &&
|
| (displayMode === "grid" || displayMode === "map") && (
|
| <div className="cg-selbar">
|
| <span className="cg-selbar-count">
|
| {selectedPids.size.toLocaleString()} selected
|
| </span>
|
| {/* β 2026-08-06 (owner) β *"right now it only shows 'Add to cohort'"*. On a user
|
| database the checked rows are RECORDS the reader owns, and there was no door to
|
| remove one; the whole delete path existed server-side with nothing calling it.
|
| β ONLY on a `ut_` database: the customer and product grids are projections of
|
| Odoo, where a "delete" would be a write to somebody else's system of record β
|
| the server refuses it, and a button that must be refused is worse than none. */}
|
| {canMutateRecords && (
|
| <button
|
| type="button"
|
| /* ARMED is a VISIBLE state, not just a changed handler: the button that is
|
| about to do something irreversible-feeling has to look different from the
|
| one that merely offered. Same `is-armed` idiom the column menu, the docs
|
| list and the views rail already use for their deletes. */
|
| className={
|
| "cg-btn cg-btn--danger cg-selbar-del" +
|
| (delArmed && delArmed === [...selectedPids].join(",") ? " is-armed" : "")
|
| }
|
| style={ONE_LINE}
|
| onClick={() => void deleteRecords([...selectedPids])}
|
| >
|
| {delArmed && delArmed === [...selectedPids].join(",")
|
| ? "Delete anyway?"
|
| : `Delete ${selectedPids.size === 1 ? "record" : "records"}`}
|
| </button>
|
| )}
|
| {cohortMode ? (
|
| // Item 2c: on the Cohort page the selection EDITS MEMBERSHIP of the fixed set β
|
| // "Remove from cohort" replaces "Add to cohort". Direct (no popover): the
|
| // checked set is explicit, the button says what it does, and re-adding is one
|
| // picker away.
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--danger cg-selbar-remove"
|
| style={ONE_LINE}
|
| onClick={removeSelectionFromCohort}
|
| >
|
| Remove from cohort
|
| </button>
|
| ) : (
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary cg-selbar-add"
|
| style={ONE_LINE}
|
| ref={selAddRef}
|
| onClick={() => {
|
| // Item 4: seed the new-cohort name as `<view name> Β· <today>` β today from
|
| // the PAYLOAD verbatim, never the browser clock; no date when absent.
|
| if (!selAddOpen) {
|
| const vn = activeView?.name ?? "Customers";
|
| setSelListName(today ? `${vn} Β· ${today}` : vn);
|
| }
|
| setSelAddOpen(!selAddOpen);
|
| }}
|
| aria-expanded={selAddOpen}
|
| aria-haspopup="dialog"
|
| >
|
| Add to cohort
|
| </button>
|
| )}
|
| {/* β Owner item 9 β the counterpart, and it renders ONLY when it can do
|
| something: the checked rows have to be in a cohort this viewer may edit for
|
| "Remove from cohortβ¦" to mean anything. Same bar, same selection, opposite
|
| direction β the ellipsis says a picker follows, because unlike cohort mode
|
| the target is not implied by where you are standing. */}
|
| {!cohortMode && removableLists.length > 0 && (
|
| <button
|
| type="button"
|
| className="cg-btn cg-selbar-remove-pick"
|
| style={ONE_LINE}
|
| ref={selRemoveRef}
|
| onClick={() => setSelRemoveOpen((open) => !open)}
|
| aria-expanded={selRemoveOpen}
|
| aria-haspopup="dialog"
|
| >
|
| Remove from cohortβ¦
|
| </button>
|
| )}
|
| <button type="button" className="cg-btn" style={ONE_LINE}
|
| onClick={clearSelection}>
|
| Clear
|
| </button>
|
| </div>
|
| )}
|
| </div>
|
| {/* Owner item 8 β the cap is VISIBLE, beside the control that lifts it. The toolbar
|
| count above still states the full matched count; this bar says what is painted.
|
| Grid + list only: calendar/kanban project the FULL set with their own stated
|
| bounds (a month, a stack's Show-all). */}
|
| {/* β W30-T42 β the WINDOWED grid's honest footnotes, in the strip that already exists
|
| for exactly this job. Reuses `.cg-more` / `.cg-more-note` rather than adding a
|
| class: `index.css` is another lane's fence this wave, and this needs no new pixel.
|
| `foldNote` is null unless a totals row is folded over a partial window; `limitNote`
|
| is null unless the SERVER declared a limit on this response (R6's second sentence β
|
| short on screen, the full cause and recommendation one hover away, the same
|
| short-form/`title` split `countLabelCompact` uses for the same strip). */}
|
| {/* ββ WAVE 31 Β· T22 (D-173) β THE LIMIT SENTENCE IS NO LONGER GATED ON `!capped`.
|
| β THE DEFECT IN ONE LINE: `capped` means "this grid has more rows than the display
|
| cap", i.e. it becomes TRUE at exactly the size that makes a server-declared limit
|
| worth reading β and the note was hidden precisely then. A limit reported only on small
|
| tables is a limit not reported at all, which is the violation R6's second sentence
|
| names ("if it can't be done, tell me why and recommend a fix"), not the limit itself.
|
| β The display cap and the server's limits are INDEPENDENT FACTS about one grid: one is
|
| how much we are painting, the other is what the server could not do. They belong in
|
| the same strip and neither may suppress the other. */}
|
| {(foldNote || limitNote || capabilityNote) && (
|
| <div className="cg-more">
|
| {foldNote && <span className="cg-more-note">{foldNote}</span>}
|
| {limitNote && (
|
| <span className="cg-more-note" title={limitNote.full}>{limitNote.short}</span>
|
| )}
|
| {capabilityNote && (
|
| <span className="cg-more-note" title={capabilityNote.full}>
|
| {capabilityNote.short}
|
| </span>
|
| )}
|
| </div>
|
| )}
|
| {capped && (displayMode === "grid" || displayMode === "list") && (
|
| <div className="cg-more">
|
| <span className="cg-more-note">
|
| Showing first {shownRecords.toLocaleString()} of{" "}
|
| {recordCount.toLocaleString()}
|
| </span>
|
| <button
|
| type="button"
|
| className="cg-link-btn"
|
| onClick={() => setDisplayCap((c) => c + DISPLAY_STEP)}
|
| >
|
| See more
|
| </button>
|
| <button
|
| type="button"
|
| className="cg-link-btn"
|
| onClick={() => setDisplayCap(visibleRows.length)}
|
| >
|
| Show all
|
| </button>
|
| </div>
|
| )}
|
| </main>
|
|
|
| {menuField && columnMenu && (
|
| <ColumnMenu
|
| key={menuField.key}
|
| state={columnMenu}
|
| field={menuField}
|
| fields={fields}
|
| /**
|
| * β 2026-08-10 β SELF-LINKING, and somebody asked for it (owner, D-113).
|
| *
|
| * The note this replaces excluded the current database with `linkTargets.filter(t =>
|
| * t.key !== scope)` and said so honestly: "a legal relation in Airtable and a confusing
|
| * one to explain in a one-line picker β¦ left out until somebody asks". It was a taste
|
| * call, not a constraint, and nothing below the picker ever needed it β `_clean_link`
|
| * only requires a `ut_*` key, and `sync_reciprocal_link` puts the backlink on the target
|
| * whichever table that is. "This lead is a duplicate of that lead" and "this record's
|
| * parent" are the ordinary cases, and both were unbuildable.
|
| *
|
| * β THE UNFILTERED LIST ALSO REPAIRS THE ROLLUP EDITOR, which is the half a one-line
|
| * change hides: it resolves the fold's source columns with
|
| * `linkTargets.find(t => t.key === chosenLink.link.table)`, so a rollup over a self-link
|
| * would have offered an EMPTY column picker and looked like the rollup was broken.
|
| * β Both `<ColumnMenu>` renders take the same list β the full menu and the "+" create
|
| * menu. One of the two is the half-landing this repo keeps paying for.
|
| */
|
| linkTargets={linkTargets}
|
| rollupSourceOffer={rollupSourceOffer}
|
| locked={menuField.key === lockedKey}
|
| schemaLocked={isSchemaLocked(menuField)}
|
| viewer={viewer}
|
| sortedDir={menuSortedDir}
|
| isFiltered={menuIsFiltered}
|
| isGrouped={config.groupBy === menuField.key}
|
| groupable={isGroupableField(menuField, lockedKey)}
|
| // Item 12 (R8) β the kanban's "Add option" opens THIS menu straight into its Edit-field
|
| // pane. Absent for every other opening, which is the action list as before.
|
| initialPane={columnMenu.pane}
|
| onClose={() => setColumnMenu(null)}
|
| onNote={(note) => saveField({ ...menuField, note })}
|
| onHide={() => {
|
| setColumnVisible(menuField.key, false);
|
| setColumnMenu(null);
|
| }}
|
| onCreate={createField}
|
| onChangeField={(newKey) => changeField(menuField.key, newKey)}
|
| onCreateAndSwap={(label, type, options, extra) =>
|
| createAndSwapField(menuField.key, label, type, options, extra)
|
| }
|
| onProfileFlag={isUserTable ? setProfileFlag : undefined}
|
| onFieldConfig={isUserTable ? (patch) => setFieldConfig(menuField.key, patch) : undefined}
|
| onRetype={
|
| // Item 2 β a CREATED field retypes IN PLACE from the pane.
|
| //
|
| // ββ WAVE-29 T27 (owner item 14): the predicate is `isUserSchemaField`, not the raw
|
| // `custom` flag. `user_tables._clean_field` never stamps `custom`, so EVERY column on
|
| // a `ut_*` database failed the old test β the Field-type picker vanished, the pane was
|
| // left showing only the "Change field" swap picker (a different feature entirely,
|
| // holding the autofocus), and the owner reported the innocent control as broken.
|
| isUserSchemaField(menuField, isUserTable)
|
| ? (type, options, extra) =>
|
| retypeField(menuField.key, type, options, extra)
|
| : undefined
|
| }
|
| onRename={
|
| // Item 5 β rename is a def edit on created strata.
|
| //
|
| // β T27: withheld by the SAME flag, which is why the Name box went disabled saying
|
| // *"A source field keeps its name and type from the data source"* about a column the
|
| // user created themselves. A `ut_*` definition field renames through the DEFINITION
|
| // door; `saveField` would fork it into a private overlay copy of itself (the reason
|
| // `onFormula` below already branches this way).
|
| !isUserSchemaField(menuField, isUserTable)
|
| ? undefined
|
| : menuField.custom
|
| ? (label) => saveField({ ...menuField, label })
|
| : (label) => setFieldConfig(menuField.key, { label })
|
| }
|
| onFormula={
|
| // Owner item 8 β edit a created FORMULA field's source in place; the optional
|
| // label rides the SAME upsert (see ColumnMenu's one-upsert rule).
|
| //
|
| // ββ 2026-08-10 β TWO STRATA, TWO DOORS, and the gate is `custom` rather than the
|
| // type. A formula column on a `ut_*` database now lives in the SHARED definition, and
|
| // definition fields carry no `custom` flag (`_clean_field` never stamps one) β so the
|
| // old single condition made the expression editor DISAPPEAR for exactly the columns
|
| // this change creates: creatable and then permanently frozen, which is a worse state
|
| // than the one it replaces. `setFieldConfig` PATCHes the definition; `saveField`
|
| // writes this user's overlay, and sending a definition field down that path would
|
| // fork the column into a private second copy of itself.
|
| menuField.type !== "formula"
|
| ? undefined
|
| : isUserTable && !menuField.custom
|
| ? (formula, label) =>
|
| setFieldConfig(menuField.key, {
|
| formula,
|
| ...(label ? { label } : {}),
|
| })
|
| : menuField.custom
|
| ? (formula, label) =>
|
| saveField({
|
| ...menuField,
|
| formula,
|
| ...(label ? { label } : {}),
|
| })
|
| : undefined
|
| }
|
| onPeriod={
|
| menuField.measure
|
| ? (w) => changeMeasurePeriod(menuField.key, w)
|
| : undefined
|
| }
|
| onDelete={
|
| menuField.custom && menuField.key !== lockedKey
|
| ? () => {
|
| deleteField(menuField.key);
|
| setColumnMenu(null);
|
| }
|
| : /**
|
| * ββ 2026-08-10 β the DEFINITION stratum's own delete.
|
| *
|
| * β NARROWED TO THE THREE KINDS THAT HOLD NO DATA OF THEIR OWN, which is
|
| * `preset_editable`'s argument reused rather than a new one: a link, a rollup and
|
| * a formula are all QUESTIONS asked of other cells, so removing one destroys no
|
| * measurement and can be undone by asking it again. Every other definition column
|
| * on a `ut_*` database holds real values, and offering a type-blind Delete over
|
| * those is a destructive widening this change has no mandate for (booked as
|
| * D-114). It is also exactly the three kinds `createField` now routes to the
|
| * definition β so what a user can make here, a user can unmake.
|
| * β `schemaLocked` still gates the button inside ColumnMenu, so a pre-set link
|
| * never shows it; a pre-set ROLLUP does, by the same owner ruling that made it
|
| * editable, and the confirm says it will come back.
|
| */
|
| isUserTable &&
|
| !menuField.custom &&
|
| menuField.key !== lockedKey &&
|
| (menuField.type === "link" || menuField.type === "rollup" ||
|
| menuField.type === "formula")
|
| ? () => {
|
| deleteDefinitionField(menuField.key);
|
| setColumnMenu(null);
|
| }
|
| : undefined
|
| }
|
| onDuplicate={
|
| // Creatable strata only β a base Odoo field offers no Duplicate (contract).
|
| menuField.custom ? () => duplicateField(menuField) : undefined
|
| }
|
| onPermissions={
|
| // Creatable strata only; ColumnMenu further gates on the viewer (creator/admin).
|
| menuField.custom
|
| ? (edit) => saveField({ ...menuField, permissions: { edit } })
|
| : undefined
|
| }
|
| onFormat={(format) => saveField({ ...menuField, format })}
|
| /**
|
| * β WAVE-29 T33 (owner item 17) β the column summary, through whichever door owns this
|
| * column's definition. Same two-strata split as `onRename` above: a `ut_*` definition
|
| * field PATCHes the definition (`_clean_field` stores `agg`), everything else rides the
|
| * ordinary overlay upsert. β `undefined` CLEARS it, and the clear has to travel β a
|
| * PATCH that merely omits `agg` keeps the stored one, which is the stickiness the money
|
| * columns depend on.
|
| */
|
| onAggregate={(agg) =>
|
| isUserTable && !menuField.custom
|
| ? setFieldConfig(menuField.key, { agg: agg ?? "" })
|
| : saveField({ ...menuField, agg })
|
| }
|
| onSort={(dir) =>
|
| updateConfig({ ...config, sorts: [{ colId: menuField.key, dir }] })
|
| }
|
| onClearSort={() =>
|
| updateConfig({
|
| ...config,
|
| sorts: config.sorts.filter((s) => s.colId !== menuField.key),
|
| })
|
| }
|
| onFilterBy={() =>
|
| // Item 6c β a MEASURE-carrying column seeds the equivalent MEASURE condition
|
| // (same measure key + window, value empty); every other filterable column seeds
|
| // an ordinary column condition. Same signal, one consumer (the Toolbar).
|
| setFilterSeed((prev) => ({
|
| n: (prev?.n ?? 0) + 1,
|
| ...(menuField.measure
|
| ? { measure: { key: menuField.measure.key, window: menuField.measure.window } }
|
| : { key: menuField.key }),
|
| }))
|
| }
|
| pinnedTo={menuPinnedTo}
|
| onPinTo={
|
| // Item 11c β pinning the identity column is the same as unpinning; skip it.
|
| menuVisIndex > 0 && !menuPinnedTo
|
| ? () => pinFieldTo(menuField.key)
|
| : undefined
|
| }
|
| onUnpin={frozenN > 1 ? unpinFields : undefined}
|
| scopeChoice={scopeChoice}
|
| onClearFilter={() =>
|
| updateConfig({
|
| ...config,
|
| filters: dropFieldFromTree(config.filters, menuField.key, measureCols),
|
| })
|
| }
|
| onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
|
| onClearGroup={() => updateConfig({ ...config, groupBy: null })}
|
| userOptions={payload?.userOptions ?? []}
|
| measures={measures}
|
| />
|
| )}
|
|
|
| {/* Wave-6 item 4 β the header "+": the SAME ColumnMenu, opened straight into its
|
| create form in insert-at-end mode. Anchored on the button; the nominal field is the
|
| locked identity column (only its position matters β the create form never shows it).
|
| The action/pane handlers below are unreachable in this mode (create-only; Cancel
|
| closes), supplied inert to satisfy the surface. */}
|
| {plusMenu && fieldByKey.get(lockedKey) && (
|
| <ColumnMenu
|
| key="__add-at-end__"
|
| state={{ fieldKey: lockedKey, anchor: plusMenu }}
|
| field={fieldByKey.get(lockedKey)!}
|
| fields={fields}
|
| locked
|
| viewer={viewer}
|
| sortedDir={null}
|
| isFiltered={false}
|
| isGrouped={false}
|
| groupable={false}
|
| initialPosition="end"
|
| onClose={() => setPlusMenu(null)}
|
| onNote={() => undefined}
|
| onHide={() => undefined}
|
| onCreate={createField}
|
| onChangeField={() => undefined}
|
| onCreateAndSwap={() => undefined}
|
| onSort={() => undefined}
|
| onClearSort={() => undefined}
|
| onFilterBy={() => undefined}
|
| onClearFilter={() => undefined}
|
| onGroupByField={() => undefined}
|
| onClearGroup={() => undefined}
|
| scopeChoice={scopeChoice}
|
| userOptions={payload?.userOptions ?? []}
|
| measures={measures}
|
| // ββ 2026-08-09 β THE SECOND HALF OF THE SAME DEFECT. The sibling instance above has
|
| // passed these since the relational types shipped; this one never did, so a rollup
|
| // created through "+" had no databases to resolve its link against and no source offer
|
| // to switch modes with β the Column picker held its placeholder and nothing else.
|
| // β TWO THINGS WERE BOTH WRONG (the fetch was gated on `columnMenu` and these props
|
| // were absent), so fixing either alone would still have shipped an empty picker and
|
| // read as "the fix did not work" β [[defects-that-mask-each-other]].
|
| // β 2026-08-10 β THE SELF-EXCLUSION IS GONE FROM BOTH SITES (D-113). The line this
|
| // replaces said the two call sites "must not disagree about that", and it was right for
|
| // a reason it did not anticipate: the exclusion was removed from the sibling first and
|
| // this copy kept it, so for one edit the "+" menu and the column menu offered DIFFERENT
|
| // databases β caught by grepping the prop rather than by the build, which is happy
|
| // either way. See the sibling's note for why self-links are offered now.
|
| linkTargets={linkTargets}
|
| rollupSourceOffer={rollupSourceOffer}
|
| />
|
| )}
|
|
|
| {/* β WAVE 21 item 11 (R10 / contract C5) β "Select records from a list".
|
| β `inView` is `visibleRows`, NOT `displayRows`. `displayRows` is a capped slice
|
| (`DISPLAY_PAGE`) of what the view matched; matching over it would report every
|
| record past the cap as "not found" β a lie that grows with the table. The whole
|
| pool goes in beside it so a value the FILTER hides is reported as filtered, not
|
| as missing. */}
|
| {/* β WAVE-29 T25 β the import dialog, mounted as a conditional sibling exactly like
|
| `SelectFromFile` above it. `scope` is the `ut_*` table key the import door is per. */}
|
| {importOpen && (
|
| <ImportDialog
|
| fields={fields}
|
| scope={scope ?? ""}
|
| tableName={activeView?.name ?? "this database"}
|
| onImported={(count) => {
|
| signal(TOAST_EVENT,
|
| `${count.toLocaleString()} record${count === 1 ? "" : "s"} imported.`);
|
| signal(ROWS_STALE_EVENT);
|
| }}
|
| onClose={() => setImportOpen(false)}
|
| />
|
| )}
|
|
|
| {selectFromFile && (
|
| <SelectFromFile
|
| fields={fields}
|
| primaryKey={lockedKey}
|
| inView={visibleRows.flatMap((vr) => (vr.kind === "data" ? [vr.record] : []))}
|
| wholeTable={computedRows}
|
| viewName={activeView?.name ?? "this view"}
|
| onSelect={(pids) => selectPids(pids, "replace")}
|
| onClose={() => setSelectFromFile(false)}
|
| />
|
| )}
|
|
|
| {/* The selection bar's cohort picker: existing cohorts, or a new one by name. Mirrors the
|
| view menu's Add-to-list surface so the two ways of building a cohort read identically. */}
|
| {selAddOpen && selAddRef.current && selectedPids.size > 0 && (
|
| <AnchoredOverlay
|
| anchor={selAddRef.current}
|
| className="cg-pop cg-add-list-pop"
|
| placement="bottom-start"
|
| role="dialog"
|
| ariaLabel="Add selected customers to a locked view"
|
| onDismiss={() => setSelAddOpen(false)}
|
| dataKind="selection-add-to-list"
|
| >
|
| <div className="cg-pop-title">Add to locked view</div>
|
| <div className="cg-pop-note">
|
| Adds the {selectedPids.size.toLocaleString()} checked customer
|
| {selectedPids.size === 1 ? "" : "s"}. The cohort stays fixed as the data changes.
|
| </div>
|
| {lists.map((l) => (
|
| <button
|
| type="button"
|
| key={l.id}
|
| className="cg-pick-row"
|
| onClick={() => addSelectionToList(l.id, l.name)}
|
| >
|
| {l.name}
|
| </button>
|
| ))}
|
| <div className="cg-view-create">
|
| <label htmlFor="cg-sel-new-list">Lock records into a new view</label>
|
| <input
|
| id="cg-sel-new-list"
|
| className="cg-input"
|
| data-overlay-autofocus
|
| value={selListName}
|
| placeholder="e.g. Q3 call plan"
|
| onChange={(event) => setSelListName(event.target.value)}
|
| onKeyDown={(event) => {
|
| if (event.key === "Enter" && selListName.trim())
|
| addSelectionToList("", selListName.trim());
|
| }}
|
| />
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| style={ONE_LINE}
|
| disabled={!selListName.trim()}
|
| onClick={() => addSelectionToList("", selListName.trim())}
|
| >
|
| Create and add
|
| </button>
|
| </div>
|
| </div>
|
| </AnchoredOverlay>
|
| )}
|
|
|
| {/* β Owner item 9 β the remove picker. Every row states how many of the CHECKED rows it
|
| would take out ("3 of 4"), because the selection and the cohort are two different sets
|
| and the difference is the whole reason this needs a picker rather than a button. */}
|
| {selRemoveOpen && selRemoveRef.current && removableLists.length > 0 && (
|
| <AnchoredOverlay
|
| anchor={selRemoveRef.current}
|
| className="cg-pop cg-add-list-pop"
|
| placement="bottom-start"
|
| role="dialog"
|
| ariaLabel="Remove selected customers from a locked view"
|
| onDismiss={() => setSelRemoveOpen(false)}
|
| dataKind="selection-remove-from-list"
|
| >
|
| <div className="cg-pop-title">Remove from locked view</div>
|
| <div className="cg-pop-note">
|
| Only the locked views holding one of the {selectedPids.size.toLocaleString()} checked
|
| customer{selectedPids.size === 1 ? "" : "s"} are listed. The records themselves are
|
| not touched.
|
| </div>
|
| {removableLists.map((l) => (
|
| <button
|
| type="button"
|
| key={l.id}
|
| className="cg-pick-row"
|
| onClick={() => removeSelectionFromList(l.id)}
|
| >
|
| {l.name}
|
| <span className="cg-pick-hint">
|
| {l.hits.toLocaleString()} of {selectedPids.size.toLocaleString()}
|
| </span>
|
| </button>
|
| ))}
|
| </AnchoredOverlay>
|
| )}
|
|
|
| {/* Cohort mode's "+ Add customers" picker (item 2c): search the POOL, tick many, confirm
|
| once. Candidates exclude current members; picks ACCUMULATE across searches, so the
|
| confirm button carries the running total. The 50-row render cap is stated beside the
|
| list β nothing silently truncated. */}
|
| {cohortMode && addCustOpen && addCustRef.current && activeCohortId && (
|
| <AnchoredOverlay
|
| anchor={addCustRef.current}
|
| className="cg-pop cg-addcust-pop"
|
| placement="bottom-start"
|
| role="dialog"
|
| ariaLabel="Add customers to this locked view"
|
| onDismiss={() => setAddCustOpen(false)}
|
| dataKind="cohort-add-customers"
|
| >
|
| <div className="cg-pop-title">Add customers</div>
|
| <div className="cg-pop-note">
|
| Search your customer pool and tick who to add to{" "}
|
| <strong>{activeCohort?.name ?? "this locked view"}</strong>.
|
| </div>
|
| <input
|
| type="text"
|
| className="cg-input cg-addcust-search"
|
| data-overlay-autofocus
|
| placeholder="Search customersβ¦"
|
| aria-label="Search customers"
|
| value={addCustQuery}
|
| onChange={(e) => setAddCustQuery(e.target.value)}
|
| />
|
| <div className="cg-pick-list cg-addcust-list">
|
| {addCandidates.slice(0, 50).map((c) => (
|
| <label key={c.pid} className="cg-check-row">
|
| <input
|
| type="checkbox"
|
| checked={addCustPicked.has(c.pid)}
|
| onChange={() =>
|
| setAddCustPicked((prev) => {
|
| const next = new Set(prev);
|
| if (next.has(c.pid)) next.delete(c.pid);
|
| else next.add(c.pid);
|
| return next;
|
| })
|
| }
|
| />
|
| <span className="cg-check-label">{c.name}</span>
|
| </label>
|
| ))}
|
| {addCandidates.length === 0 && (
|
| <div className="cg-pop-note">
|
| {addCustQuery.trim()
|
| ? "No customer outside this locked view matches your search."
|
| : "Every customer in your pool is already in this locked view."}
|
| </div>
|
| )}
|
| </div>
|
| {addCandidates.length > 50 && (
|
| <div className="cg-field-hint">
|
| Showing the first 50 of {addCandidates.length.toLocaleString()} matches β keep
|
| typing to narrow.
|
| </div>
|
| )}
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| style={ONE_LINE}
|
| disabled={addCustPicked.size === 0}
|
| onClick={addCustomersToCohort}
|
| >
|
| {addCustPicked.size === 0
|
| ? "Add customers"
|
| : `Add ${addCustPicked.size.toLocaleString()} customer${
|
| addCustPicked.size === 1 ? "" : "s"
|
| }`}
|
| </button>
|
| <button
|
| type="button"
|
| className="cg-btn"
|
| style={ONE_LINE}
|
| onClick={() => setAddCustOpen(false)}
|
| >
|
| Cancel
|
| </button>
|
| </div>
|
| </AnchoredOverlay>
|
| )}
|
|
|
| {/* β Wave-23 C7 β the document viewer. `onSave` is OMITTED, not disabled, when the reader
|
| may not edit: an absent handler is what makes the raw tab a `<pre>` instead of a
|
| textarea, so there is no editor to be refused by. */}
|
| {jsonField && jsonAt && (
|
| <JsonViewer
|
| label={jsonField.label}
|
| value={jsonValue}
|
| onSave={
|
| canEditField(jsonField)
|
| ? (next) => patchAndRecord(jsonAt.pid, { [jsonField.key]: next }, "a document")
|
| : undefined
|
| }
|
| onClose={closeJson}
|
| />
|
| )}
|
| {linkField?.type === "link" && linkField.link?.table.startsWith("ut_") && linkAt && (
|
| <LinkGridModal
|
| label={linkField.label}
|
| table={linkField.link.table as SurfaceScope}
|
| recordIds={linkedRecordIds}
|
| editable={canEditField(linkField) && !isDerivedLink(linkField)}
|
| single={linkField.link.single === true}
|
| onSave={(ids) => patchAndRecord(
|
| linkAt.pid,
|
| { [linkField.key]: ids.join(",") },
|
| "linked records"
|
| )}
|
| onClose={closeLink}
|
| />
|
| )}
|
| {pickerField && picker && (
|
| <AnchoredOverlay
|
| anchor={picker.anchor}
|
| className="cg-pop cg-pick-pop"
|
| placement="bottom-start"
|
| role="listbox"
|
| ariaLabel={`Choose ${pickerField.label}`}
|
| onDismiss={closePicker}
|
| dataKind="cell-picker"
|
| // Owner item 6 (2026-07-31) β the picker is a KEYBOARD surface: it opens focused on
|
| // the current choice (fallback: the first), arrows move between choices, Enter picks
|
| // (native button activation), Escape closes, and closePicker hands focus back to the
|
| // grid so the next Enter keeps walking the column.
|
| initialFocus="[data-overlay-autofocus]"
|
| onKeyDown={(e) => {
|
| const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
|
| const back = e.key === "ArrowUp" || e.key === "ArrowLeft";
|
| if (!fwd && !back) return;
|
| const items = Array.from(
|
| e.currentTarget.querySelectorAll<HTMLButtonElement>(
|
| ".cg-pick-row, .cg-star-btn"
|
| )
|
| );
|
| if (!items.length) return;
|
| e.preventDefault();
|
| const at = items.indexOf(document.activeElement as HTMLButtonElement);
|
| const next = fwd
|
| ? (at + 1) % items.length
|
| : (at - 1 + items.length) % items.length;
|
| items[next]?.focus();
|
| }}
|
| >
|
| {/* Wave-5 item 11 β a RATING cell's picker is the star row itself. */}
|
| {pickerField.type === "rating" && (
|
| <div
|
| className="cg-stars"
|
| role="radiogroup"
|
| aria-label={`Set ${pickerField.label}`}
|
| >
|
| {Array.from({ length: ratingMax(pickerField) }, (_, i) => i + 1).map(
|
| (n) => (
|
| <button
|
| key={n}
|
| type="button"
|
| className="cg-star-btn"
|
| role="radio"
|
| aria-checked={Number(pickerValue) === n}
|
| data-overlay-autofocus={
|
| Number(pickerValue) === n || (!pickerValue && n === 1)
|
| ? true
|
| : undefined
|
| }
|
| aria-label={`${n} star${n === 1 ? "" : "s"}`}
|
| onClick={() => {
|
| patchAndRecord(picker.pid, { [pickerField.key]: String(n) }, "a rating");
|
| closePicker();
|
| }}
|
| >
|
| <StarIcon on={Number(pickerValue) >= n} />
|
| </button>
|
| )
|
| )}
|
| </div>
|
| )}
|
| {pickerField.type !== "rating" && pickerChoices.length === 0 && (
|
| <div className="cg-pop-note">
|
| {pickerField.type === "user"
|
| ? "No assignable people were supplied for this workspace."
|
| : "This field has no choices yet. Add them from the column menu."}
|
| </div>
|
| )}
|
| <div className="cg-pick-list" aria-multiselectable={pickerMulti || undefined}>
|
| {pickerChoices.map((choice) => {
|
| const tint = optionTint(pickerField, choice);
|
| const on = pickerMulti
|
| ? pickerParts.includes(choice)
|
| : pickerValue === choice;
|
| return (
|
| <button
|
| type="button"
|
| role="option"
|
| aria-selected={on}
|
| key={choice}
|
| data-overlay-autofocus={choice === pickerFocusChoice ? true : undefined}
|
| className={"cg-pick-row" + (on ? " is-on" : "")}
|
| onClick={() => {
|
| if (pickerMulti) {
|
| // Toggle membership; each toggle persists (the event log absorbs the
|
| // burst), and the picker STAYS OPEN so a set is built in one visit.
|
| const next = on
|
| ? pickerParts.filter((p) => p !== choice)
|
| : [...pickerParts, choice];
|
| patchAndRecord(picker.pid, {
|
| [pickerField.key]: next.join(","),
|
| }, "a choice");
|
| return;
|
| }
|
| patchAndRecord(picker.pid, { [pickerField.key]: choice }, "a choice");
|
| closePicker();
|
| }}
|
| >
|
| {/* Wave-14 item 11 / R6 β a PICKER shows the avatar AND the name. The grid
|
| cell shows the face alone (you already know who your people are); this is
|
| where you find out which face is whose, so dropping the name here would
|
| make the feature unusable the first time two colleagues share initials.
|
| β Inline styles, no new class rules: `index.css` is PANEL's fence this
|
| wave. The class name is a QA hook only and styles nothing. */}
|
| {pickerField.type === "user" && (
|
| <span
|
| className="cg-pick-avatar"
|
| aria-hidden="true"
|
| style={{
|
| display: "inline-flex",
|
| alignItems: "center",
|
| justifyContent: "center",
|
| flex: "0 0 auto",
|
| width: 20,
|
| height: 20,
|
| borderRadius: "50%",
|
| overflow: "hidden",
|
| marginRight: 8,
|
| fontSize: 9,
|
| fontWeight: 600,
|
| lineHeight: 1,
|
| position: "relative",
|
| background: pickTint(choice).bg,
|
| color: pickTint(choice).fg,
|
| }}
|
| >
|
| {/* β The initials are ALWAYS rendered and the photo is laid OVER them, so
|
| a corrupt data URL hides itself and the fallback is already underneath.
|
| C-AVATAR's rule is "never a broken-image glyph, anywhere" β the canvas
|
| path honours it via `img.onerror`, and this is the DOM path's version
|
| of the same promise. It cannot fire until HOST serves a photo, which is
|
| exactly why it is easy to ship without. */}
|
| {avatarInitials(choice)}
|
| {userAvatars?.[choice] && (
|
| <img
|
| src={userAvatars[choice]}
|
| alt=""
|
| onError={(e) => {
|
| e.currentTarget.style.visibility = "hidden";
|
| }}
|
| style={{
|
| position: "absolute",
|
| inset: 0,
|
| width: "100%",
|
| height: "100%",
|
| objectFit: "cover",
|
| }}
|
| />
|
| )}
|
| </span>
|
| )}
|
| <span
|
| className="cg-pick-pill"
|
| style={tint ? { background: tint.bg, color: tint.fg } : undefined}
|
| >
|
| {choice}
|
| </span>
|
| </button>
|
| );
|
| })}
|
| </div>
|
| {pickerMulti && (
|
| <div className="cg-form-actions">
|
| <button
|
| type="button"
|
| className="cg-btn"
|
| style={ONE_LINE}
|
| onClick={closePicker}
|
| >
|
| Done
|
| </button>
|
| </div>
|
| )}
|
| {/* Clearing must be possible, and it must write "" rather than delete the key β
|
| the overlay patch contract is a value per field, and a missing key would leave
|
| the previous value standing on the next render. */}
|
| {pickerValue !== "" && (
|
| <button
|
| type="button"
|
| className="cg-pick-row cg-pick-clear"
|
| // Fallback focus target for a picker with a value but no choices left
|
| // (the querySelector takes the FIRST match, so a real choice row wins).
|
| data-overlay-autofocus={pickerChoices.length === 0 ? true : undefined}
|
| onClick={() => {
|
| patchAndRecord(picker.pid, { [pickerField.key]: "" }, "a choice");
|
| closePicker();
|
| }}
|
| >
|
| Clear
|
| </button>
|
| )}
|
| </AnchoredOverlay>
|
| )}
|
|
|
| {!embedded && detailRecord && detailPid !== null && (
|
| <RecordDetail
|
| fields={fields}
|
| record={detailRecord}
|
| titleKey={lockedKey}
|
| positionLabel={`Record ${dataPosition.toLocaleString()} of ${recordCount.toLocaleString()}`}
|
| canPrev={neighborExists(-1)}
|
| canNext={neighborExists(1)}
|
| onPrev={() => go(-1)}
|
| onNext={() => go(1)}
|
| onClose={() => setDetailPid(null)}
|
| // Item 5 / C-LAYOUT β see `recordLayout` above. Both optional on RECORD's side, so
|
| // this compiles whether or not their reorder UI has landed yet.
|
| recordLayout={recordLayout}
|
| onRecordLayout={onRecordLayout}
|
| // Item 13 / C-EMBED β the host names WHICH question Insights asks. Without this the
|
| // tab deliberately does not render (RECORD's no-safe-default rule: a wrong scope
|
| // succeeds with plausible wrong numbers, so absence must mean absent, not guessed).
|
| scope={scope}
|
| docs={payload?.docs?.[String(detailPid)] ?? []}
|
| docPayload={payload?.docPayload}
|
| // C5 β documents ride the ordinary event log. The host answers a fetch
|
| // on the NEXT render via `payload.docPayload`; Documents.tsx matches it
|
| // to its own pending request before touching it.
|
| onDocAdd={
|
| payload?.docs
|
| ? (file) =>
|
| emitHostEvent({
|
| id: eventId("docadd"),
|
| type: "doc_add",
|
| pid: detailPid,
|
| docId: `doc_${eventId("d").slice(2, 14).replace(/[^a-z0-9]/gi, "")}`,
|
| ...file,
|
| })
|
| : undefined
|
| }
|
| onDocFetch={
|
| payload?.docs
|
| ? (docId) =>
|
| emitHostEvent({
|
| id: eventId("docget"),
|
| type: "doc_fetch",
|
| pid: detailPid,
|
| docId,
|
| })
|
| : undefined
|
| }
|
| onDocDelete={
|
| payload?.docs
|
| ? (docId) =>
|
| emitHostEvent({
|
| id: eventId("docdel"),
|
| type: "doc_delete",
|
| pid: detailPid,
|
| docId,
|
| })
|
| : undefined
|
| }
|
| onNotesChange={(key, value) =>
|
| setOverlayEdits((current) => ({
|
| ...current,
|
| [detailPid]: { ...current[detailPid], [key]: value },
|
| }))
|
| }
|
| onNotesCommit={(key, value) => patchAndRecord(detailPid, { [key]: value })}
|
| viewer={viewer}
|
| // Wave-14 close-out stitch (HOST): TSREC's two light-switch props, unwired when
|
| // the GRID session ended β item 9's picker vocabulary + item 11's photos. Same
|
| // values the grid's own surfaces already read (the wave-13 sixth-prop precedent).
|
| userOptions={payload?.userOptions ?? []}
|
| userAvatars={userAvatars}
|
| />
|
| )}
|
| </div>
|
| );
|
| }
|
|
|
| export default memo(CustomerGrid);
|
|
|