loopable / web /src /customer-grid /ViewSidebar.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
105 kB
import { useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnchoredOverlay } from "./OverlaySurface";
import { EXPORT_FORMATS, EXPORT_LABELS } from "./export";
import type { ExportFormat } from "./export";
import type {
DisplayMode,
FolderIcon,
GridFolder,
SavedView,
ViewEditMode,
ViewPermissions,
Viewer,
} from "./types";
import {
DEFAULT_FOLDER_SHAPE,
DEFAULT_FOLDER_TONE,
FOLDER_SHAPES,
FOLDER_TONES,
MAX_VIEW_USERS,
VIEW_EDIT_BLURBS,
VIEW_EDIT_LABELS,
VIEW_EDIT_MODES,
cleanFolderIcon,
cleanViewPermissions,
isModeFrozen,
isUndeletableView,
mayEditView,
mayToggleViewLock,
viewDisplayMode,
} from "./types";
import {
CREATABLE_MODES,
FOLDER_SHAPE_LABELS,
FOLDER_TONE_LABELS,
MODE_LABELS,
MODE_TONE,
} from "./iconShapes";
import { FOLDER_TONE_PAINT } from "./iconShapes";
import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
import { ROOT_FOLDER_ID } from "./folders";
// ⭐ W32-T23 (owner item 17) — THE BELL, from the shared layer rather than redrawn here.
// The alert row wore `cohortAdd` (a LIST with a plus), which names "add to a cohort" on the
// one row that creates an ALERT — a borrowed mark is a small lie a menu repeats every time
// it opens, and `icons.tsx`'s own `upload` header records the last time this bit.
// ⚠ `MenuLabel` takes a NODE for exactly this case: a mark that is not part of the grid's
// menu vocabulary. The Inbox module's header wears the SAME component (`ui/icons.BellIcon`),
// so the bell that opens the Inbox and the bell that fills it cannot drift apart.
import { BellIcon } from "../ui/icons";
import { SHARED_FOLDER_ID, groupByFolder, reorderFolderIds } from "./folders";
/* ─────────────────────────────────────────────────────────────────────────────────────────
⭐ WAVE 27 · OWNER ITEM 1 — a MARK on each "Who can edit" row.
The three choices used to be three radio buttons and three sentences, which is a wall of
prose at the exact moment a user is deciding who else can change their work. A mark is the
part you recognise on the second visit, and Airtable's own share step leads every row with
one.
⛔ WHY THE PATHS LIVE HERE AND NOT IN `icons.tsx`, stated so a later wave does not "tidy" it
back: `icons.tsx` renders `MODE_SHAPES` from `iconShapes.ts`, which the glide CANVAS also
paints — geometry with two painters belongs there. These three have exactly ONE call site,
thirty lines below, and no canvas draws them. `LockMark` and `EyeOffIcon` already carry the
same argument at their definitions ("paths are inline… no header ever draws this one").
⚠ The wave-27 ownership map puts `iconShapes.ts` in session D's fence and leaves `icons.tsx`
in NOBODY's; a one-call-site component in a file this session owns has zero collision risk,
and the ask to settle `icons.tsx`'s owner is in the mailbox (C-3).
The tone follows the STRENGTH of the grant — neutral for the private default, blue for the
open one, green for the deliberate pick — and the paint comes from the shared pastel table,
never a literal, so these can never be the one place in the product with its own colours.
Outline-only, the register the owner set in 2026-07-29: "it doesn't have a full color, only
the linings". */
const PERM_TONE: Record<ViewEditMode, "neutral" | "blue" | "green"> = {
personal: "neutral",
collaborative: "blue",
users: "green",
};
/** head + shoulders, at `x`. One drawing, three uses — a second hand-drawn person is how two
* marks in one panel end up different heights. */
const person = (x: number) =>
`M${x} 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z` +
`M${x - 3.2} 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1`;
const PERM_MARK: Record<ViewEditMode, string[]> = {
// One person: only you.
personal: [person(8)],
// Two, side by side and overlapping: anyone who can see the table.
collaborative: [person(5.9), person(10.6)],
// One person and a tick: the people you picked.
users: [person(6), "M10.6 9.4l1.5 1.6 2.6-3"],
};
function PermMark({ mode }: { mode: ViewEditMode }) {
const paint = FOLDER_TONE_PAINT[PERM_TONE[mode]];
return (
<svg className="cg-perm-mark" width={16} height={16} viewBox="0 0 16 16" aria-hidden>
{PERM_MARK[mode].map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke={paint.stroke}
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
);
}
/* WAVE 20 item 18 (C-SHARE) — the system folder every view shared WITH you lands in until
you file it somewhere of your own. It is the only folder id this component recognises by
name, and as of wave 21 (item 9) it is DECLARED IN `folders.ts` beside the synthesis that
produces it — a component-local copy of a contract id is a copy that can drift from the
rule, and the rule is the half a gate can run. */
interface ViewSidebarProps {
views: SavedView[];
activeViewId: string;
saveState: "saved" | "saving";
onSelect: (id: string) => void;
/** I14 + I17 — the flyout names the display mode and the prompt names who may edit, so
* creation carries both. `permissions` is always explicit (C4: absent on create means
* personal, so an omission would silently contradict the form). */
onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
onRename: (id: string, name: string) => void;
/** Persist a view's description. "" is a real value — it clears it. */
onNote: (id: string, note: string) => void;
onDuplicate: (id: string) => void;
onDelete: (id: string) => void;
/** Cohorts this user can add to (owner item 6). Empty is fine — the menu then only offers
* "New cohort…", which is how the first cohort gets created. */
lists?: { id: string; name: string }[];
/** Add the customers THIS view currently matches to a cohort. `cohortId` empty = create one
* called `name`. The view need not be the active one — the caller re-runs the engine for it. */
onAddToList?: (viewId: string, cohortId: string, name: string) => void;
/**
* ⭐ WAVE 21 item 11 (ruling R10) — open "Select from file…" for the ACTIVE view.
*
* ⛔ NO ARGUMENTS, and that is the contract rather than an omission. The dialog matches
* over the rows the GRID holds, which are the active view's; passing a view id here
* would invite a caller to match one view's list against another's rows and report the
* difference as missing records. The menu switches views first (see its own note) so
* this can stay a verb with one subject.
*/
onSelectFromFile?: () => void;
/** ⭐ WAVE-29 T25 (owner item 6) — open the Import dialog. Supplied only where records may be
* added, so a locked database simply has no such row in its menu. */
onImport?: () => void;
/**
* Item 12 (contract C-LOCK) — lock a view to a cohort, or `null` to unlock.
*
* ⚠ This menu is the ONLY emitter of `cohortLock` in the client. Optional so the rail still
* compiles and renders wherever the host has not accepted the key — the same posture every
* other optional action here takes, and the reason a half-deployed wave degrades to "the
* action is absent" rather than to "the action silently does nothing".
*/
onCohortLock?: (viewId: string, cohortId: string | null) => void;
/**
* The TENANT'S today (wave-2 item 4) — the payload's `today` string VERBATIM, seeding the
* default cohort name `<view name> · <YYYY-MM-DD>`. Never the browser clock: a viewer a day
* ahead of the tenant would stamp a cohort with a date the host's own data denies
* ([[date-window-vocabulary]]). Absent = the name seeds without a date.
*/
today?: string;
/**
* Wave-7 item W2 (contract C2) — export THIS view's current matches (filters + sorts,
* visible fields in display order), generated client-side. Absent on tables where the
* client does not hold the rows (server-windowed), so the entry never lies.
*/
onExport?: (viewId: string, format: ExportFormat) => void;
/**
* Wave-8 I11c (contract C4) — folders. All of it is OPTIONAL: a caller that
* passes no `folders` and no handlers renders exactly the pre-wave-8 flat rail,
* which is what keeps the cohort page (and any future rail) from having to opt
* into a feature it does not want.
*/
folders?: GridFolder[];
/** The folder a view belongs to RIGHT NOW — resolved by the caller through
* `resolveFolderId`, so a pending drag and a dangling ref are already handled
* before the rail sees them. */
folderIdOf?: (viewId: string) => string | null;
/** C5 (I15) — `icon` absent means the caller should send no icon at all (the folder wears
* the default), NOT that it should clear one. */
onFolderCreate?: (name: string, icon?: FolderIcon) => void;
onFolderRename?: (folderId: string, name: string) => void;
onFolderDelete?: (folderId: string) => void;
onFolderDuplicate?: (folderId: string) => void;
onItemMove?: (viewId: string, folderId: string | null) => void;
/**
* WAVE 20 item 19 (C-FOLDER-REORDER) — the folders, in the order the user just dragged
* them into. The FULL list every time, never a delta: a partial order cannot say where an
* unnamed folder went, and the host has to be able to stamp `order` on all of them.
*
* Optional like every other folder handler here: a caller that does not pass it gets a
* rail with no folder drag at all, rather than a grip that lifts a folder and drops it
* back — the posture this whole component takes toward an action nobody can honour.
*/
onFolderReorder?: (order: string[]) => void;
/**
* ⭐ WAVE 27 · OWNER ITEM 5 (contract C7) — the rail's new VIEW order, whole.
*
* ⛔ REQUIRED, AND THE OPTIONAL VERSION OF THIS EXACT PROP IS WHY. `onFolderReorder` above
* shipped optional in wave 20: its absence disabled the entire folder drag SILENTLY, every
* gate stayed green, and folders simply would not move (the note at its call site in
* `CustomerGrid` records it). A required prop fails `tsc` at the unmounted call site, which
* is the only version of this rule a person cannot forget.
*/
onViewReorder: (order: string[]) => void;
/**
* ⭐ WAVE 27 · OWNER ITEM 21 / RULING R14 — `viewId -> how many records match it RIGHT NOW`,
* for the views that carry an alert. A view with no alert is ABSENT from this map, and that
* absence is the whole display rule ("alert-on = badge-on").
*
* ⛔ REQUIRED, and `{}` is the honest empty. An optional prop would make "no alerts on this
* table" and "the caller forgot to compute them" the same state on screen — which is the
* shape wave 20's optional `onFolderReorder` shipped in, and it went unnoticed for a wave.
* ⚠ ABSENT vs ZERO are DIFFERENT and both are real: absent = no alert, so no badge; `0` = an
* alert on a view that currently matches nothing, which is exactly the case a user wants to
* see. `!== undefined` rather than a truthiness test, for that reason.
*/
alertCounts: Record<string, number>;
/**
* C4 as AMENDED 2026-07-28 — the folder-level bulk "Add to cohort". The caller
* owns the arithmetic (it holds the engine); the rail owns the confirm.
* `preview` must return the DEDUPED union plus anything it could not compute,
* because the amendment is explicit that a partial union is never silent.
*/
folderAddPreview?: (folderId: string) => {
pids: number[];
counted: number;
skipped: { name: string; why: string }[];
};
onFolderAddToList?: (folderId: string, cohortId: string, name: string) => void;
/**
* Wave-9 contract C3 (I12) — who is looking, so the lock entry appears only for the view's
* creator or an admin. Absent = the entry never appears (fail-closed), which is also what
* every caller that has not wired `onToggleLock` gets.
*/
viewer?: Viewer;
/** C3 — freeze/unfreeze this view's DISPLAY MODE. The host re-checks the actor. */
onToggleLock?: (viewId: string, locked: boolean) => void;
/**
* ⭐⭐ WAVE 32 · T24 (owner item 17, ruling R5, contract C4) — mark / unmark a view IMPORTANT.
*
* ⚠ OPTIONAL, matching every other host callback on this rail: the sidebar is host-neutral and
* a host that has not wired it simply does not offer the row (rather than offering one that
* throws). The behaviour is not silently degraded — the row is ABSENT, which a reader can see.
*/
onToggleImportant?: (viewId: string, important: boolean) => void;
/* wave17 R1 / C-LOCKV — `cohorts` and `onSelectCohort` are GONE from this component's
surface. They carried the transient-lock selection for the retired Cohorts section; a
locked view is now an ordinary member of `views` and is selected by `onSelect` like any
other, which is the whole point of R1. */
/** I17 (C4) — the tenant's real account list, for the "Specific users" picker. The HOST
* re-validates every name against core/users.py and drops what it cannot resolve, so this
* list is a convenience, never the authority. */
userOptions?: string[];
}
export default function ViewSidebar({
views,
activeViewId,
saveState,
onSelect,
onCreate,
onRename,
onNote,
onDuplicate,
onDelete,
lists = [],
onAddToList,
onSelectFromFile,
onImport,
onCohortLock,
today,
onExport,
folders,
folderIdOf,
onFolderCreate,
onFolderRename,
onFolderDelete,
onFolderDuplicate,
onItemMove,
onFolderReorder,
onViewReorder,
alertCounts,
folderAddPreview,
onFolderAddToList,
viewer,
onToggleLock,
onToggleImportant,
userOptions = [],
}: ViewSidebarProps) {
/**
* I14 — creating a view now always knows WHICH KIND of view is being created, because the
* flyout is the only door in. `null` = not creating; a mode = the name step for that mode.
*/
const [creating, setCreating] = useState<DisplayMode | null>(null);
/** I14 — the "+ Create new…" flyout's anchor. */
const [createMenu, setCreateMenu] = useState<HTMLButtonElement | null>(null);
const [name, setName] = useState("");
/** I17 (C4) — the create prompt's who-can-edit draft. Seeded PERSONAL, matching the host's
* create-side default, so the form and the store agree before the user touches anything. */
const [permEdit, setPermEdit] = useState<ViewEditMode>("personal");
const [permUsers, setPermUsers] = useState<string[]>([]);
const [menu, setMenu] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [noteFor, setNoteFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const [noteDraft, setNoteDraft] = useState("");
const [addFor, setAddFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
/** Item 12 (C-LOCK) — which view's cohort lock the picker is open for. */
const [lockFor, setLockFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const [newListName, setNewListName] = useState("");
/** W2 — the Export format pane, anchored where the "…" menu was. */
const [exportFor, setExportFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const exportView = exportFor
? views.find((view) => view.id === exportFor.viewId)
: undefined;
const addView = addFor ? views.find((view) => view.id === addFor.viewId) : undefined;
const menuView = menu ? views.find((view) => view.id === menu.viewId) : undefined;
/** C4 — may this viewer change the menu view's IDENTITY? Courtesy only; the host is the
* wall. C3's lock toggle ANDs with this rather than replacing it: the two contracts
* compose (C4 says who may edit, C3 says who may freeze the mode). */
const canEditMenuView = !!menuView && mayEditView(menuView, viewer);
// I11c local UI state. Collapse is deliberately LOCAL (C4: "minimize state
// local") — which folders you have open is a per-person, per-session habit,
// not a property of the shared workspace.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [folderMenu, setFolderMenu] = useState<{ id: string; anchor: HTMLElement } | null>(null);
const [renamingFolder, setRenamingFolder] = useState<string | null>(null);
const [creatingFolder, setCreatingFolder] = useState(false);
/**
* I15 — the folder-create draft. The name became CONTROLLED with an explicit Create button
* for a reason that is not cosmetic: the old input committed on BLUR, and clicking an icon
* swatch blurs the input — so an icon picker beside a commit-on-blur field would create the
* folder the moment you tried to colour it.
*/
const [folderName, setFolderName] = useState("");
const [folderIcon, setFolderIcon] = useState<FolderIcon>({
shape: DEFAULT_FOLDER_SHAPE,
tone: DEFAULT_FOLDER_TONE,
});
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [addTarget, setAddTarget] = useState<string | null>(null);
/** The element the bulk-add pane hangs off. Anchoring to document.body
* put it at the bottom of the DOCUMENT rather than beside the folder —
* painted, and effectively unreachable ([[ui-invisible-to-assertions]]).
* Captured from the folder menu before that menu is dismissed. */
const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
const [addName, setAddName] = useState("");
const [dropTarget, setDropTarget] = useState<string | null>(null);
// Owner item 9 (2026-07-31) — "Find a view". Local and never persisted: a search is a
// moment, not a setting. Filtering runs BEFORE grouping so a folder with no matches
// simply drops out of the rail while the query is live.
const [viewQuery, setViewQuery] = useState("");
// Owner item 11 — this rail is the SECOND navigation bar: it folds to the same slim strip
// the shell nav does, remembered per browser under its own key.
const [railShut, setRailShut] = useState<boolean>(() => {
try {
return localStorage.getItem("aios-views-rail") === "1";
} catch {
return false;
}
});
const toggleRail = () =>
setRailShut((v) => {
const next = !v;
try {
localStorage.setItem("aios-views-rail", next ? "1" : "0");
} catch {
// storage can be blocked; the fold still works for the session
}
return next;
});
const viewNeedle = viewQuery.trim().toLowerCase();
const shownViews = viewNeedle
? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
: views;
// ── WAVE 20 item 19 (C-FOLDER-REORDER) — dragging a FOLDER to reorder the rail ──────
//
// ⚠ A PRIVATE dataTransfer MIME, and it is not decoration. The VIEW drag two blocks down
// carries `text/plain`, and the folder groups already accept that drop ("file this view
// into me"). Sharing one channel would make every folder drag look like a view drop to
// the handler that fires first — so the two drags are told apart by TYPE, the same
// discipline the shell nav's own drag uses, and a drop of this type anywhere
// text-editable types nothing.
const FOLD_DRAG_TYPE = "application/x-loopable-fold";
/** The folder being dragged, and the group its insertion line is currently drawn above. */
const [foldDrag, setFoldDrag] = useState<string | null>(null);
const [foldOver, setFoldOver] = useState<string | null>(null);
const foldsReorderable = !!folders && !!onFolderReorder;
/**
* Land `draggedId` before `beforeId` (null = last) and emit the new order. The arithmetic
* lives in `folders.ts` so a gate can run it under node — the handler around it is DOM.
*
* ⚠ The order is computed from the `folders` PROP, never from the rendered groups: a live
* "Find a view" query hides folders with no matches, and an order derived from what is on
* screen would silently drop the hidden ones out of the sequence.
*/
const reorderFolders = (draggedId: string, beforeId: string | null) => {
const next = reorderFolderIds((folders ?? []).map((f) => f.id), draggedId, beforeId);
// `null` is "nothing to say" — an unknown id, or a drop that changed nothing.
if (next) onFolderReorder?.(next);
};
// ── ⭐ WAVE 27 · OWNER ITEM 5 (contract C7) — dragging a VIEW to reorder the rail ────────
//
// The owner asked for both lists to be draggable top-to-bottom; folders already were
// (wave 20 item 19) and views could only be FILED INTO a folder, never moved past each
// other. This is that drag, built on the same three pieces so the two gestures cannot drift:
// a private MIME, the shared `reorderFolderIds` arithmetic, and a whole-order emit.
//
// ⛔ THE ARITHMETIC IS REUSED, NOT COPIED. `reorderFolderIds` is id-generic — it takes a list
// of ids, a dragged id and a "before" id, and every clause in it (dropped-on-itself, the
// not-found fallback, the no-op return) is about SEQUENCES rather than about folders. It is
// already exercised by `verify_folders.py`, and a second copy here would be a second place
// for the dropped-on-itself bug that function's own comment records ([[one-evaluator-per-question]]).
const VIEW_DRAG_TYPE = "application/x-loopable-view";
const [viewDrag, setViewDrag] = useState<string | null>(null);
const [viewOver, setViewOver] = useState<string | null>(null);
/**
* Land `draggedId` before `beforeId` and emit the FULL order.
*
* ⚠ Computed from the `views` PROP, never from the rendered rows — a live "Find a view"
* query hides non-matching rows, and an order derived from what is on screen would silently
* drop every hidden view out of the sequence. (The folder half carries the identical note;
* it is the same trap and this rail has both filters on it.)
*/
const reorderViews = (draggedId: string, beforeId: string | null) => {
const next = reorderFolderIds(views.map((v) => v.id), draggedId, beforeId);
if (next) onViewReorder(next); // `null` = an unknown id, or a drop that changed nothing
};
/**
* One pair of handlers per VIEW ROW, listening for the view MIME only.
*
* ⛔ `stopPropagation` IS LOAD-BEARING. A view row sits inside its folder group, whose own
* `onDrop` reads `text/plain` and files the view into that folder. Both types are on this
* drag, so without this a reorder inside a folder would ALSO re-file the view — usually into
* the folder it is already in, i.e. invisible, until the day it is not.
* ⚠ WHAT THIS DELIBERATELY DOES NOT DO: change the view's folder. Dropping a view onto a row
* in another folder reorders it and leaves it where it lives; filing stays a drop on the
* folder itself. Two gestures, two meanings, rather than one gesture guessing.
*/
const viewDropHandlers = (viewId: string) => ({
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(VIEW_DRAG_TYPE)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setViewOver(viewId);
},
onDragLeave: () => setViewOver(null),
onDrop: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(VIEW_DRAG_TYPE)) return;
const dragged = e.dataTransfer.getData(VIEW_DRAG_TYPE);
setViewOver(null);
setViewDrag(null);
if (!dragged) return;
e.preventDefault();
e.stopPropagation();
reorderViews(dragged, viewId);
},
});
const viewsReorderable = views.length > 1;
const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
const groups = groupByFolder(
shownViews,
foldersOn ? (folders as GridFolder[]) : [],
(v) => (folderIdOf ? folderIdOf(v.id) : null),
/**
* ⭐ WAVE 21 item 9 (R12 / contract C1) — THE CONSUMER of `shared`.
*
* The wire marks each granted view `shared: true` (C1); this is where that mark becomes
* a place on screen. Passed unconditionally — NOT gated on `foldersOn` — because the
* folder machinery is an opt-in feature of the views rail and "who shared this with me"
* is not: a surface with folders switched off must still be able to show you somebody
* else's view under a heading that says whose it is.
*/
(v) => v.shared === true
);
const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
/**
* ONE pair of drop handlers per group, serving TWO drags — a view being filed into this
* folder (`text/plain`) and, since wave 20 item 19, a folder being dropped in front of
* this one (`FOLD_DRAG_TYPE`). They must be one pair rather than two spreads: a second
* `onDragOver`/`onDrop` on the same element replaces the first, silently, and the drag
* that lost would simply stop working.
*
* The type is read BEFORE anything else, so a folder drag never paints the "file a view
* in here" fill and a view drag never draws the insertion rule.
*/
const dropHandlers = (folderId: string | null) =>
foldersOn || foldsReorderable
? {
onDragOver: (e: React.DragEvent) => {
if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
if (!foldsReorderable) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setFoldOver(folderId ?? "__root__");
return;
}
if (!foldersOn) return;
e.preventDefault();
setDropTarget(folderId ?? "__root__");
},
onDragLeave: () => {
setDropTarget(null);
setFoldOver(null);
},
onDrop: (e: React.DragEvent) => {
if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
const dragged = e.dataTransfer.getData(FOLD_DRAG_TYPE);
setFoldOver(null);
setFoldDrag(null);
if (!foldsReorderable || !dragged) return;
e.preventDefault();
e.stopPropagation();
reorderFolders(dragged, folderId);
return;
}
if (!foldersOn) return;
e.preventDefault();
setDropTarget(null);
const id = e.dataTransfer.getData("text/plain");
// ⭐⭐ W32-T27 (owner item 20) — DROPPING ON ROOT SENDS A VALUE, NOT `null`.
//
// `null` means "no placement", and `groupByFolder` reads a shared view with no
// placement as one that arrived by grant — so dragging a shared view to the top
// level put it straight back under "Shared with me". `ROOT_FOLDER_ID` is the
// receiver saying they filed it here, which is a different fact and now has a
// different value.
//
// ⚠ SENT FOR EVERY ITEM, not only shared ones, and that is deliberate: a rail that
// emits one shape for a shared view and another for your own has two move paths to
// keep in step, and the bug being fixed here is exactly what happens when two
// layers disagree about what absence means. For an unshared view the two are
// equivalent — `groupByFolder` maps the sentinel onto the same root bucket.
if (id) onItemMove?.(id, folderId ?? ROOT_FOLDER_ID);
},
}
: {};
const noteView = noteFor
? views.find((view) => view.id === noteFor.viewId)
: undefined;
const create = () => {
const value = name.trim();
if (!value || !creating) return;
// C4: permissions are sent EXPLICITLY on create. Omitting them means 'personal'
// host-side, so a user who picked "Collaborative" and got a personal view would have no
// way to tell — the form said one thing and the store did the other.
// Cleaned through the same rules the host applies, so an empty "Specific users" grant
// collapses to 'personal' HERE too and the confirmation the user sees is the truth.
onCreate(value, creating, cleanViewPermissions(
{ edit: permEdit, users: permUsers }, "personal", userOptions));
setName("");
setPermEdit("personal");
setPermUsers([]);
setCreating(null);
// WAVE 20 item 20 — the two steps share ONE panel, so the anchor outlives step 1 and
// has to be released here. Left set, the flyout would spring back to the type chooser
// the instant the view was created.
setCreateMenu(null);
};
/** Item 20 — abandon the whole flyout, from either step. Escape, an outside click and
* Cancel all mean the same thing ("I am not creating anything"), so they call one
* function rather than three combinations of setters — and abandoning drops the DRAFT
* too, or the next "+ Create new…" would hand back a half-filled form from a decision
* the user already walked away from. */
const closeCreate = () => {
setCreating(null);
setCreateMenu(null);
setName("");
setPermEdit("personal");
setPermUsers([]);
};
const createFolder = () => {
const value = folderName.trim();
if (!value) return;
// cleanFolderIcon returns undefined for the all-defaults pair, so a folder the user
// never restyled stays byte-identical to a pre-wave-9 one (the no-churn rule).
onFolderCreate?.(value, cleanFolderIcon(folderIcon));
setFolderName("");
setFolderIcon({ shape: DEFAULT_FOLDER_SHAPE, tone: DEFAULT_FOLDER_TONE });
setCreatingFolder(false);
};
/**
* WAVE 20 item 20 — the view door KEEPS the anchor, because the name/permissions step
* now renders in the SAME anchored box the type chooser used. The old comment ("close
* the flyout first, or the menu sits on top of the input") described the bug the owner
* reported from the other side: the two steps of one action were drawn in two different
* places, one beside the rail and one inside it, so step 2 read as an unrelated form.
* One panel, two contents, one geometry.
*
* The FOLDER door still closes it: that path is a single step and has no second panel
* to disagree with. Moving it too would be a change the ruling does not ask for.
*/
const startView = (mode: DisplayMode) => {
setCreatingFolder(false);
setCreating(mode);
};
const startFolder = () => {
setCreateMenu(null);
setCreating(null);
setCreatingFolder(true);
};
/**
* WAVE 20 items 18/23/26 (R10, C-SHARE) — open the access editor for a view or a
* folder.
*
* ⚠ A WINDOW EVENT, NOT A PROP, and not for convenience. The dialog is the SHELL's
* (one editor for views, folders and databases — R10 asks for one vocabulary), and
* this tree is host-neutral: `customer-grid/**` must not import a shell. The frame
* already listens on this channel for toasts and staleness; this is one more note on
* it. The literal is spelled once here and once in `shell/shareModel.ts`, which is
* where it becomes a shared constant when S3 publishes it in `apiContract.ts`
* (amendment A-S4-4).
*
* ⛔ It degrades to NOTHING when no shell is listening (the Streamlit-era bare grid):
* a dispatched event nobody hears opens no dialog and throws no error — which is the
* right failure for a frame-level surface reached from a rail.
*/
const openShare = (kind: "view" | "folder", id: string, label: string) => {
window.dispatchEvent(
new CustomEvent("aios:share-open", { detail: { kind, id, label } })
);
};
const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const items = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
];
if (!items.length) return;
event.preventDefault();
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const next =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: event.key === "ArrowDown"
? (current + 1) % items.length
: (current - 1 + items.length) % items.length;
items[next]?.focus();
};
return (
<aside
className={"cg-views" + (railShut ? " is-collapsed" : "")}
aria-label="Customer views"
>
{/* Owner item 11 — the rail's own minimize control: the same three-bars glyph as the
shell nav's, because the two rails are one idea ("a navigation bar folds"). */}
<div className="cg-views-top">
<button
type="button"
className="cg-rail-toggle"
aria-label={railShut ? "Expand views" : "Minimize views"}
aria-expanded={!railShut}
title={railShut ? "Expand views" : "Minimize views"}
onClick={toggleRail}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/*
I13 — the "Views / All changes saved" header block is GONE and everything moved up.
The save state survives as a ZERO-PIXEL live region. That header was the only place
this surface ever announced "Saving changes…", and `saveState` has exactly one
consumer (CustomerGrid:2074 → here), so deleting the markup outright would take a
real signal away from a screen-reader user in order to satisfy a layout request. The
item asked for the pixels back; this gives back every pixel and keeps the
announcement. It is also what keeps `saveState` a used prop under `noUnusedLocals`.
*/}
<div className="cg-sr-only" aria-live="polite">
{saveState === "saving" ? "Saving changes…" : "All changes saved"}
</div>
{/* I14 — "+ Create new…", left-aligned, opening a flyout to the RIGHT of the rail that
lists every creatable view type AND "Folder".
Wave-10 item 12: the second door — a separate "+ New folder" link that used to sit
directly under this one — is DELETED. It was a duplicate of the flyout's own Folder
row, so the rail offered two controls that did the same thing. `startFolder()` still
has exactly one caller (the flyout row); only the redundant entry point is gone.
⚠ wave17 R1 — the reason `.cg-fold-new` was kept has EXPIRED. It survived wave 10
because `CohortSidebar.tsx` rendered its own "+ New folder" under that class and had
no create-flyout to fold it into; that file is deleted this wave, so the class now has
no consumer in this tree. Left in index.css rather than swept blind: a CSS rule with
no consumer is inert, and hunting one down mid-wave is how a rule something else still
reads gets removed. Named here so the sweep is a decision, not a discovery. */}
<div className="cg-create-new">
<button
type="button"
className="cg-link-btn cg-create-btn"
aria-haspopup="menu"
aria-expanded={!!createMenu}
onClick={(event) => {
// ⛔ THE ANCHOR IS HOISTED OUT OF THE UPDATER — the same line that took the whole
// app white from the view row's "…" (see the long note at that button). React
// nulls `event.currentTarget` when the handler returns and may re-invoke a
// functional updater afterwards, so `setCreateMenu(cur => … event.currentTarget)`
// can store null. Item 20 made this panel outlive a single click — it now carries
// the name step too — so a randomly-nulled anchor would close a form mid-typing
// rather than merely mis-place a menu.
const anchor = event.currentTarget;
if (createMenu) {
closeCreate();
return;
}
// Every open starts at the type chooser: `creating` left set by an abandoned
// flyout would re-enter at step 2 for a kind nobody just picked.
setCreating(null);
setCreateMenu(anchor);
}}
>
<span className="cg-create-icon" aria-hidden="true">+</span>
<span className="cg-create-label">Create new…</span>
</button>
</div>
{/* Owner item 9 — "Find a view". Borderless on the white rail (no box until it is
being used); focus paints the brand-purple outline. Clearing the query restores
the full rail — filtering never persists. */}
<div className="cg-find-view">
<svg
className="cg-find-view-icon"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" />
<path
d="m10.4 10.4 3.1 3.1"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
</svg>
<input
type="text"
value={viewQuery}
placeholder="Find a view"
aria-label="Find a view"
onChange={(event) => setViewQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setViewQuery("");
}}
/>
</div>
{/* ⛔ WAVE 20 item 20 — THE CREATE FORM NO LONGER RENDERS HERE. It is the flyout's
second step, inside the same `AnchoredOverlay` (below, next to the type list it
follows). This comment stands in for the moved markup because the move IS the
item: two steps of one action that used to appear in two different places. */}
{/* I15 — the folder form: name + the icon the folder will wear. */}
{foldersOn && onFolderCreate && creatingFolder && (
<div className="cg-view-create cg-create-form cg-fold-form">
<label htmlFor="cg-new-folder">New folder</label>
<input
id="cg-new-folder"
className="cg-input"
autoFocus
value={folderName}
placeholder="Folder name"
onChange={(event) => setFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") createFolder();
if (event.key === "Escape") setCreatingFolder(false);
}}
/>
<div className="cg-fold-pick" role="group" aria-label="Folder icon">
<div className="cg-fold-pick-row">
{FOLDER_SHAPES.map((shape) => (
<button
key={shape}
type="button"
className={
"cg-fold-swatch" + (folderIcon.shape === shape ? " is-on" : "")
}
aria-pressed={folderIcon.shape === shape}
aria-label={FOLDER_SHAPE_LABELS[shape]}
title={FOLDER_SHAPE_LABELS[shape]}
onClick={() => setFolderIcon((cur) => ({ ...cur, shape }))}
>
<FolderMark icon={{ shape, tone: folderIcon.tone }} size={15} />
</button>
))}
</div>
<div className="cg-fold-pick-row">
{FOLDER_TONES.map((tone) => (
<button
key={tone}
type="button"
className={"cg-fold-swatch" + (folderIcon.tone === tone ? " is-on" : "")}
aria-pressed={folderIcon.tone === tone}
aria-label={FOLDER_TONE_LABELS[tone]}
title={FOLDER_TONE_LABELS[tone]}
onClick={() => setFolderIcon((cur) => ({ ...cur, tone }))}
>
<FolderMark icon={{ shape: folderIcon.shape, tone }} size={15} />
</button>
))}
</div>
</div>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
onClick={createFolder}
disabled={!folderName.trim()}
>
Create
</button>
<button
type="button"
className="cg-btn"
onClick={() => setCreatingFolder(false)}
>
Cancel
</button>
</div>
</div>
)}
{/* I14 — the flyout. To the RIGHT (placement right-start), every creatable view type
with its own pastel mark, and Folder LAST behind a separator: it is not a view, and
the owner put it at the bottom of the list rather than among them. */}
{createMenu && (
<AnchoredOverlay
anchor={createMenu}
className="cg-view-menu cg-create-flyout"
placement="right-start"
// WAVE 20 item 20ONE panel, two contents. A menu of choices and a form are
// different KINDS of thing to a screen reader even when they are one box to the
// eye, so the role and the label follow the step rather than being frozen at the
// panel's first purpose.
role={creating ? "dialog" : "menu"}
ariaLabel={
creating ? `New ${MODE_LABELS[creating].toLowerCase()} view` : "Create new"
}
onDismiss={closeCreate}
// ⛔ THE ARROW-KEY HANDLER IS STEP 1's ONLY. `onMenuKeyDown` swallows
// ArrowUp/ArrowDown to walk `[role=menuitem]`; over a text input that is the
// cursor keys refusing to move through what you just typed.
{...(creating ? {} : { onKeyDown: onMenuKeyDown })}
dataKind={creating ? "create-new-name" : "create-new"}
>
{creating ? (
<div className="cg-view-create cg-create-form">
<label htmlFor="cg-new-view">
{/* The prompt NAMES the kind picked one step ago — the panel replaced its
own contents, so without this the box gives no sign that "Calendar" was
ever chosen. */}
New {MODE_LABELS[creating].toLowerCase()} view
</label>
<input
id="cg-new-view"
className="cg-input"
autoFocus
// The overlay's own initial-focus effect fires on MOUNT, and this panel does
// not re-mount between the two stepsso the input carries `autoFocus` (React
// focuses it when it mounts) and the marker the other in-panel forms use.
data-overlay-autofocus
value={name}
placeholder="e.g. Florida at risk"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") create();
// Escape is handled by the overlay layer too; both mean "abandon", and
// calling the same closer twice is idempotent.
if (event.key === "Escape") closeCreate();
}}
/>
{/* I17 (C4) — the owner's prompt ORDER: type (picked in step 1, named above)
→ who can edit → the user picker, and the picker only when it applies. */}
<div className="cg-perm" role="radiogroup" aria-label="Who can edit this view">
<span className="cg-perm-title">Who can edit</span>
{VIEW_EDIT_MODES.map((m) => (
<label key={m} className="cg-radio-row cg-perm-row">
<input
type="radio"
name="cg-view-perm"
checked={permEdit === m}
onChange={() => setPermEdit(m)}
/>
{/* ⭐ ITEM 1 — the mark is its OWN COLUMN, between the radio and the words.
⚠ Not inside `.cg-perm-label`: that element is a flex COLUMN (title over
blurb), so a mark placed in it becomes a THIRD ROW under the sentence
rather than a leading glyph — the wrong-parent trap
([[wrong-parent-not-broken-control]]) in its cheapest form. */}
<PermMark mode={m} />
<span className="cg-perm-label">
{VIEW_EDIT_LABELS[m]}
<span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
</span>
</label>
))}
{permEdit === "users" && (
<div className="cg-perm-users">
{userOptions.length === 0 ? (
// Never a silent empty box: an empty grant collapses to Personal
// host-side, so say that rather than letting the user think they
// shared it.
<span className="cg-perm-empty">
No other accounts to pick — this will save as Personal.
</span>
) : (
userOptions.map((u) => (
<label key={u} className="cg-perm-user">
<input
type="checkbox"
checked={permUsers.includes(u)}
onChange={(e) =>
setPermUsers((cur) =>
e.target.checked
? [...cur, u].slice(0, MAX_VIEW_USERS)
: cur.filter((x) => x !== u)
)
}
/>
<span>{u}</span>
</label>
))
)}
{userOptions.length > 0 && permUsers.length === 0 && (
<span className="cg-perm-empty">
Pick at least one person, or this saves as Personal.
</span>
)}
</div>
)}
</div>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
onClick={create}
disabled={!name.trim()}
>
Create
</button>
<button type="button" className="cg-btn" onClick={closeCreate}>
Cancel
</button>
</div>
</div>
) : (
<>
{CREATABLE_MODES.map((mode) => (
<button
key={mode}
type="button"
role="menuitem"
className="cg-create-row"
onClick={() => startView(mode)}
>
{/* size 16 — item 13's "bigger icons", and the SAME 16 the view rows use,
so a Calendar is one mark at one size on this whole surface. */}
<ToneModeIcon mode={mode} tone={MODE_TONE[mode]} size={16} />
<span>{MODE_LABELS[mode]}</span>
</button>
))}
{foldersOn && onFolderCreate && (
<>
<div className="cg-menu-sep" role="separator" aria-hidden />
<button
type="button"
role="menuitem"
className="cg-create-row"
onClick={() => startFolder()}
>
<FolderMark size={16} />
<span>Folder</span>
</button>
</>
)}
</>
)}
</AnchoredOverlay>
)}
<div className="cg-view-list cg-views-scroll">
{groups.map((group) => {
const gid = group.folder?.id ?? null;
// Item 9 — while a search is live, a folder with no matches is noise, not a target.
if (viewNeedle && group.folder && group.items.length === 0) return null;
const isRoot = gid === null;
/**
* ⭐ WAVE 21 item 9 (R12/C1) — "Shared with me" IS NOT A STORED FOLDER, and every
* folder affordance below has to know it.
*
* The group is synthesised from `shared: true` views (`folders.ts`), so there is no
* record behind it: a drop would emit `item_move(viewId, "__shared__")` and persist
* a folderId naming a folder that does not exist, and a folder-drag would send an
* order containing an id `reorderFolderIds` was never given. Both write nonsense the
* server would store. The row menu was already suppressed for this id in wave 20;
* the DRAG surfaces were not, because nothing ever constructed the group.
*/
const isSynthetic = gid === SHARED_FOLDER_ID;
const shut = gid != null && collapsed.has(gid);
return (
<div
key={gid ?? "__root__"}
className={
(isRoot ? "cg-fold-root" : "cg-fold") +
(dropTarget === (gid ?? "__root__") ? " is-drop" : "") +
// Item 19the insertion rule reads "the folder you are dragging lands HERE":
// above this folder, or above the ungrouped section, which is after them all.
(foldOver === (gid ?? "__root__") && foldDrag && foldDrag !== gid
? " is-drop-above"
: "") +
// ⛔ `foldDrag !== null` FIRST. `gid` is null for the UNGROUPED section and
// `foldDrag` idles at null, so a bare `foldDrag === gid` is `null === null` — TRUE
// from first paint, with no drag anywhere. The ungrouped section (which is where
// most people's views live) rendered at `opacity: .45` permanently, and the symptom
// is not "a folder looks dragged" but "my view NAMES are faded, as if hidden" —
// which is why it read as a colour bug rather than a drag-state bug.
//
// The `is-drop-above` line above got this right (`&& foldDrag &&`), which is the
// tell: two conditions written minutes apart, one guarded and one not.
(foldDrag !== null && foldDrag === gid ? " is-folddrag" : "")
}
{/* No drop handlers on the synthetic groupsee `isSynthetic`. Spreading `{}`
rather than branching the element keeps ONE render path for every group. */
...(isSynthetic ? {} : dropHandlers(gid))}
>
{group.folder && (
<div
className="cg-fold-head"
// ── Item 19the folder itself is the drag handle ─────────────────────
// Draggable only while it is renameable-idle: a `draggable` ancestor eats
// the text selection inside an input, so dragging would win over editing
// the name you just opened.
draggable={foldsReorderable && !isSynthetic && renamingFolder !== group.folder.id}
onDragStart={(e) => {
e.dataTransfer.setData(FOLD_DRAG_TYPE, group.folder!.id);
e.dataTransfer.effectAllowed = "move";
setFoldDrag(group.folder!.id);
}}
onDragEnd={() => {
setFoldDrag(null);
setFoldOver(null);
}}
>
{/* ⛔ NO DRAG GRIP, and that is a decision rather than an omission. A grip in
flow shifts the folder's name to the right, and this rail's indent is
MEASURED to the pixel (index.css: "root view name 41.00px / folder header
48.36px" — the whole point of the wave-10 item-2 fix); the gutter it would
otherwise hide in is already occupied by the disclosure chevron, whose
open/shut state is the only one this row has. The view rows above have been
draggable with no handle since wave 8, so a folder that drags the same way
is the vocabulary this list already teaches, not a hidden feature. */}
<button
type="button"
className="cg-fold-toggle"
aria-expanded={!shut}
onClick={() =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(group.folder!.id)) next.delete(group.folder!.id);
else next.add(group.folder!.id);
return next;
})
}
>
<span className={"cg-fold-chev" + (shut ? " is-shut" : "")} aria-hidden>
</span>
{/* I14/I15 — the folder wears its chosen mark; a folder that never chose one
(every pre-wave-9 folder) renders the default grey folder shape, which is
precisely "existing folders get the folder icon". */}
{/* size 16 — one mark box across the whole rail (view rows, flyout rows and
now folder headers), which is what lets item 2's text alignment be exact
rather than approximately right. */}
<FolderMark icon={group.folder.icon} size={16} />
{renamingFolder === group.folder.id ? (
<input
className="cg-input cg-fold-rename"
autoFocus
defaultValue={group.folder.name}
onClick={(e) => e.stopPropagation()}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== group.folder!.name) onFolderRename?.(group.folder!.id, v);
setRenamingFolder(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") setRenamingFolder(null);
}}
/>
) : (
<span className="cg-fold-name">{group.folder.name}</span>
)}
{/* ⛔ WAVE 20 item 21 — THE VIEW COUNT IS DELETED, and the item is not
really about the number. `.cg-view-more` (the "…" beside this button)
ships `opacity: 0` and is revealed by `.cg-view-row:hover`; a folder
head is not a view row, so NO rule ever revealed it — the folder's
actions button was painted at zero opacity in every state but keyboard
focus. The badge is what the owner saw sitting where the control should
have been. Deleting it and making the "…" unconditionally visible (one
rule in this wave's S4 region) are the two halves of one fix.
⚠ `.cg-fold-count` in index.css now has no consumer in this tree. Left
in place rather than swept blind — same posture as `.cg-fold-new` above
— and booked in the S4 mailbox so the sweep is a decision, not a
discovery. The NAV rail's own count stays: that rail reveals its "…" on
hover already, so it never had this defect and item 21 never named it. */}
</button>
{/* ⛔ THE SYSTEM FOLDER HAS NO ACTIONS (item 18 / C-SHARE). "Shared with
me" is not the reader's folder: it cannot be renamed into something
else, duplicated into a second copy of other people's work, or
deleted — the contract calls it undeletable, and a menu whose every
row the server would refuse is three fake affordances rather than
one. Views MOVE OUT of it normally (that is per-receiver placement),
which is the only thing anyone needs to do to it. */}
{group.folder.id === SHARED_FOLDER_ID ? null : (
<button
type="button"
className="cg-view-more"
aria-label={`Actions for folder ${group.folder.name}`}
aria-haspopup="menu"
onClick={(e) => {
// ⛔ HOISTED OUT OF THE UPDATER — see the view menu's twin below. React
// nulls `currentTarget` when the handler returns, and a functional
// updater can be re-invoked AFTER that.
const anchor = e.currentTarget;
setFolderMenu((cur) =>
cur?.id === group.folder!.id ? null : { id: group.folder!.id, anchor }
);
}}
>
···
</button>
)}
</div>
)}
{/* A collapsed folder hides its rows but stays a DROP TARGET, so you can
file something into a folder you have tidied away. */}
{!shut &&
group.items.map((view) => {
const active = view.id === activeViewId;
const renaming = renamingId === view.id;
const mode = viewDisplayMode(view);
return (
<div
// Item 12 (C-LOCK) — `cg-view-lockset` marks a view locked to a cohort. The
// rail is where someone chooses which table to open, and a locked view shows a
// different row set than its siblings for a reason no filter states; the mark
// is what makes that visible before the click rather than after it.
className={
"cg-view-row" +
(active ? " is-active" : "") +
(view.config?.cohortLock ? " cg-view-lockset" : "") +
// ⭐ ITEM 5 (C7) — the same two marks the folder drag uses: the row being
// carried fades, and the row it would land ABOVE draws an insertion rule.
(viewDrag === view.id ? " is-viewdrag" : "") +
(viewOver === view.id && viewDrag && viewDrag !== view.id
? " is-drop-above"
: "")
}
key={view.id}
draggable={(foldersOn || viewsReorderable) && !renaming
&& mayEditView(view, viewer)}
onDragStart={(e) => {
// ⛔ TWO TYPES ON ONE DRAG, and that is the discrimination C7 asks for.
// `text/plain` is the FILE-INTO-A-FOLDER channel the folder groups already
// listen on and must keep working; `VIEW_DRAG_TYPE` is what a view ROW listens
// for. Sharing one channel would make a reorder look like a file-into to
// whichever handler fired first — the same collision `FOLD_DRAG_TYPE` exists to
// prevent one level up.
e.dataTransfer.setData("text/plain", view.id);
e.dataTransfer.setData(VIEW_DRAG_TYPE, view.id);
e.dataTransfer.effectAllowed = "move";
setViewDrag(view.id);
}}
// ⚠ A DRAG THAT ENDS ANYWHERE clears the marks. Without this, releasing over a
// non-drop target leaves the row faded and an insertion rule painted, and the
// rail looks stuck mid-gesture until the next render happens to clear it.
onDragEnd={() => {
setViewDrag(null);
setViewOver(null);
}}
{...viewDropHandlers(view.id)}
>
{renaming ? (
<input
className="cg-view-rename cg-input"
autoFocus
defaultValue={view.name}
onBlur={(event) => {
const next = event.target.value.trim();
if (next && next !== view.name) onRename(view.id, next);
setRenamingId(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") setRenamingId(null);
}}
/>
) : (
<button
type="button"
className="cg-view-main"
onClick={() => onSelect(view.id)}
// Item 9 kept this tooltip rather than dropping it as redundant: BOTH lines
// ellipsis at 188px, so the hover is now the only way to read either one in
// full. It used to show the note INSTEAD of the name, which meant a
// described view could not have its own truncated name revealed at all.
title={view.note ? `${view.name} — ${view.note}` : view.name}
>
{/* I12 — the row wears the view's CURRENT display mode, not a kind dot.
Same geometry the mode switcher and the create flyout use, so "what
Calendar looks like" is one drawing everywhere on this surface.
Wave-10 item 3 — and now the same PAINT, not just the same geometry.
The flyout has always drawn these marks in pastel (`ToneModeIcon`);
the list drew the bare `ModeIcon` and index.css forced `--lp-muted`
over it, so a view created as a blue Grid or a yellow Calendar landed
in the rail GREY. There is no per-view "creation colour" to restore —
`MODE_TONE` is a static mode→tone map, never a persisted field
(amendment A-1) — so painting the current mode's tone here reproduces
what the flyout showed, by construction. */}
<ToneModeIcon mode={mode} tone={MODE_TONE[mode]} size={16} />
{/* Wave-10 item 9 — name, then the description as a quiet second line.
Wrapped in one column so the icon and the lock stay on the ROW axis
while the text stacks; without the wrapper the note would become a
third flex sibling and sit beside the name. Both lines ellipsis rather
than wrap: a rail this narrow turns a wrapped sentence into a
four-line row and the list stops being scannable. */}
<span className="cg-view-text">
<span className="cg-view-name">{view.name}</span>
{view.note && (
<span className="cg-view-desc">{view.note}</span>
)}
</span>
{/* ⭐ WAVE 27 · OWNER ITEM 21 / RULING R14 — ALERT ON = BADGE ON.
The LIVE number of records matching this view's filter, in the red
accent, shown for exactly the views that carry an alert. Deleting the
alert removes it, because the badge is not stored anywhere — it is a
rendering of `alertCounts`, which is keyed by the alerts the server
lists.
⛔ OUTSIDE `.cg-view-text`, deliberately. That element is the ellipsising
COLUMN holding the name over its description; a badge inside it becomes
part of the run that gets cut off, so the one number the user asked to
always see is the first thing to disappear on a long name. On the row
axis it takes its width first and the NAME ellipsises around it — which
is the correct priority and what the 188px budget is for. */}
{alertCounts[view.id] !== undefined && (
<span
className="cg-view-count"
// ⭐ WAVE 32 · T24 (R5) — ONE badge, TWO reasons it can be here, and the
// hover has to say WHICH. A view marked important shows the identical red
// pill as an alerted one (deliberatelyR5 asks for the same number in the
// same place), so a single sentence about alerting would tell a reader who
// marked a view that they had created an alert they did not create. The
// number is the same fact; the promise attached to it is not.
title={
view.config?.important
? `${alertCounts[view.id].toLocaleString()} records match this view `
+ `— you marked it important`
: `${alertCounts[view.id].toLocaleString()} records match this `
+ `viewyou are alerted when a new one arrives`
}
>
{alertCounts[view.id].toLocaleString()}
</span>
)}
{/* ⭐ wave17 R1 / C-LOCKV — ONE mark, TWO meanings, and they are genuinely
different things that both read as "locked":
· `kind === "locked"` — the view's ROWS are a hand-curated set. This is
what the retired Cohorts section used to say, and it is the more
consequential of the two: it explains why this view shows fewer
records than its siblings, which no filter chip accounts for.
· `isModeFrozen` — the view's DISPLAY MODE is frozen (the legacy
`locked` flag). It says nothing about which rows are here.
A locked view does NOT get the legacy flag set (the host's projection says
so explicitly), so most rows have exactly one reason. When a row has both,
the row-set meaning leads and the title carries the other — never two
padlocks side by side, which would read as a bug. */}
{/* ⭐ WAVE 20 item 23 (C-SHARE) — THE SECOND MARK, beside the padlock
and never instead of it. They answer different questions: the lock
says what this view may become, this says how it got to you. A
view someone shared with you is one you can be looking at without
having made it — the single most useful thing the rail can tell you
before the click.
⚠ WAVE 21 (item 9, R12/C1): the narrowed cast this used to need is
gone — `shared`, `sharedRole` and `owner` are declared on `SavedView`
and the server now projects them. The mark also says WHICH GRANT: a
read-only share and an editable one look identical in the rail
otherwise, and the reader's first question on finding a menu with no
Rename in it is why. */}
{view.shared ? (
<span
className="cg-view-shared"
role="img"
aria-label={
view.owner
? `${view.name}, shared with you by ${view.owner}`
: `${view.name}, shared with you`
}
title={
(view.owner
? `Shared with you by ${view.owner}.`
: "Shared with yousomeone gave you access to this view.") +
(view.sharedRole === "edit"
? " You can edit it."
: " You can view it, not change it.")
}
>
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
<circle cx="5.6" cy="5.6" r="2.2" fill="none" stroke="currentColor"
strokeWidth="1.3" />
<path d="M1.9 12.6c0-2 1.7-3.2 3.7-3.2s3.7 1.2 3.7 3.2"
fill="none" stroke="currentColor" strokeWidth="1.3"
strokeLinecap="round" />
<path d="M10.6 4.1a2.2 2.2 0 0 1 0 4.2M11.4 9.7c1.6.3 2.7 1.4 2.7 2.9"
fill="none" stroke="currentColor" strokeWidth="1.3"
strokeLinecap="round" />
</svg>
</span>
) : null}
{(view.kind === "locked" || isModeFrozen(view)) && (
<span
className="cg-view-lock"
// Not aria-hidden: "this view is frozen" is information, and the row's
// accessible name is the only place a non-sighted user can receive it.
role="img"
aria-label={
view.kind === "locked"
? `${view.name}, a locked view`
: `${MODE_LABELS[mode]} view, locked`
}
title={
view.kind === "locked"
? "Lockedthis view shows only the records locked into it. " +
"Filters, sorts and columns still narrow within them." +
(isModeFrozen(view)
? ` It also stays a ${MODE_LABELS[mode].toLowerCase()}.`
: "")
: `Lockedthis view stays a ${MODE_LABELS[
mode
].toLowerCase()}. Filters, sorts and columns are still editable.`
}
>
<LockMark />
</span>
)}
</button>
)}
<button
type="button"
className="cg-view-more"
aria-label={`Actions for ${view.name}`}
aria-haspopup="menu"
aria-expanded={menu?.viewId === view.id}
onClick={(event) => {
/**
* ⛔ THE ANCHOR IS READ HERE, NOT INSIDE THE UPDATER — and that one line
* is the difference between this rail working and the whole app going
* white. Found by _qa_live_rail.py on 2026-08-04, reproduced on the
* shipped build.
*
* React sets `event.currentTarget = null` the moment this handler
* returns (`executeDispatch`'s finally). A FUNCTIONAL updater is not
* guaranteed to run inside the handler: the eager-state path evaluates
* it once, synchronously, while `currentTarget` is still the button —
* but React re-invokes it when it processes the queue for real, and by
* then it is null. The menu therefore opened correctly and only died
* later, on a re-render triggered by something else entirely — which is
* why a white screen appeared one interaction AFTER the click that
* caused it. `AnchoredOverlay` then did `"getBoundingClientRect" in
* null` and took the tree down with it.
*/
const anchor = event.currentTarget;
setMenu((current) =>
current?.viewId === view.id ? null : { viewId: view.id, anchor }
);
}}
>
···
</button>
{/* Wave-10 item 9 inverted this: the description now renders HERE, on the
row, and the across-the-table banner in CustomerGrid is gone. Still one
place, not two — and now it is the place that names the view, and EVERY
view shows its own, not just the active one. */}
</div>
);
})}
{group.folder && !shut && group.items.length === 0 && (
<div className="cg-fold-empty">Empty — drag a view here.</div>
)}
</div>
);
})}
{/* ⭐ wave17 R1 / C-LOCKV — THE "COHORTS" SECTION IS GONE, and nothing replaced it here.
Wave 15 projected each cohort as a transient row under its own heading, which made a
locked view a second-class object: it could not be renamed, filed into a folder,
reordered, sorted or shared, because it was not a view. R1: it IS a view. The host
projects each one into the SAME list above (`locked_view_projection` — id preserved,
`config.cohortLock` naming its own id), so every one of those capabilities arrives for
free and this section has nothing left to render.
The lock MARK moved onto the ordinary view row, where `kind === "locked"` drives it. */}
</div>
{/* I11c — the folder "…" menu. Rename · Add to cohort · Duplicate · Delete.
WAVE 20 item 22 — every row now carries the SAME icon vocabulary the view row's
menu uses (`MenuLabel` over `MENU_ICONS`), and Delete is red AT REST rather than
only once armed. Two menus that do the same kind of thing to two kinds of object
were drawn in two registers: one with icons and a red delete, one with neither.
⚠ NO NEW CSS FOR THE COLOUR: this overlay already wears `cg-view-menu`, so adding
`is-danger` to the button reuses `.cg-view-menu button.is-danger` — the exact rule
that paints the view row's Delete, which is what "matching" has to mean. The
`cg-menu-item--danger` class stays for its armed background. */}
{folderMenu && folderMenuF && (
<AnchoredOverlay
anchor={folderMenu.anchor}
className="cg-view-menu"
placement="bottom-end"
role="menu"
ariaLabel={`Actions for folder ${folderMenuF.name}`}
onDismiss={() => {
setFolderMenu(null);
setConfirmDelete(null);
}}
dataKind="folder-menu"
>
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
setRenamingFolder(folderMenuF.id);
setFolderMenu(null);
}}
>
<MenuLabel icon="rename" text="Rename" />
</button>
{folderAddPreview && onFolderAddToList && (
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
setAddTarget(folderMenuF.id);
setAddAnchor(folderMenu.anchor);
setAddName(today ? `${folderMenuF.name} · ${today}` : folderMenuF.name);
setFolderMenu(null);
}}
>
<MenuLabel icon="cohortAdd" text="Add to cohort" />
</button>
)}
{onFolderDuplicate && (
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
onFolderDuplicate(folderMenuF.id);
setFolderMenu(null);
}}
>
<MenuLabel icon="duplicate" text="Duplicate folder and its views" />
</button>
)}
{/* WAVE 20 item 18 (R10) — a folder shares, and its views ride along
(the server's rule, stated in C-SHARE; this row only opens the editor). */}
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
openShare("folder", folderMenuF.id, folderMenuF.name);
setFolderMenu(null);
}}
>
<MenuLabel icon="permissions" text="Share folder" />
</button>
{onFolderDelete && (
<button
type="button"
role="menuitem"
className={
"cg-menu-item cg-menu-item--danger is-danger" +
(confirmDelete === folderMenuF.id ? " is-armed" : "")
}
onClick={() => {
if (confirmDelete !== folderMenuF.id) {
setConfirmDelete(folderMenuF.id);
return;
}
onFolderDelete(folderMenuF.id);
setConfirmDelete(null);
setFolderMenu(null);
}}
>
{/* The ARMED label is a whole sentence and the menu row is `nowrap` with a
260px ceiling, so it used to run out of the panel. It wraps while armed
(one rule in the S4 region) rather than being ellipsised: a confirmation
the reader cannot finish reading is not a confirmation. */}
<MenuLabel
icon="trash"
text={
confirmDelete === folderMenuF.id
? "Delete folder? Its views move to the top level."
: "Delete folder"
}
/>
</button>
)}
</AnchoredOverlay>
)}
{/* Item 12 (C-LOCK) — the lock picker. Lists the cohorts this reader can see, marks the
current one, and offers Unlock when there is one: a lock removable only from
somewhere other than where it was applied is a trap, and the owner asked for a
feature, not a one-way door. */}
{lockFor && onCohortLock && (() => {
const target = views.find((v) => v.id === lockFor.viewId);
const current = target?.config?.cohortLock;
return (
<AnchoredOverlay
anchor={lockFor.anchor}
className="cg-pop cg-lock-pop"
placement="bottom-start"
role="dialog"
ariaLabel="Lock this view to a locked view"
onDismiss={() => setLockFor(null)}
dataKind="view-cohort-lock"
>
<div className="cg-pop-title">Lock to a locked view</div>
<div className="cg-pop-note">
This view can then only ever show the records locked into the view you pick. Filters still narrow
within it, and the lock cannot be removed as a condition.
</div>
{(lists ?? []).map((l) => (
<button
type="button"
key={l.id}
className={"cg-pick-row" + (l.id === current ? " is-on" : "")}
onClick={() => {
onCohortLock(lockFor.viewId, l.id);
setLockFor(null);
}}
>
{l.name}
</button>
))}
{current && (
<button
type="button"
className="cg-pick-row cg-lock-clear"
onClick={() => {
onCohortLock(lockFor.viewId, null);
setLockFor(null);
}}
>
Unlock — show the whole table again
</button>
)}
</AnchoredOverlay>
);
})()}
{/* C4 as amended — the folder-level bulk add. The confirm pane states the
DEDUPED union and names every view it could not compute, because a
partial union that looks complete is the failure this pane exists to
prevent ([[no-unverifiable-aggregates]]). */}
{addTarget && addAnchor && addPreview && onFolderAddToList && (
<AnchoredOverlay
anchor={addAnchor ?? undefined}
className="cg-pop cg-fold-addpop"
placement="bottom-start"
role="dialog"
ariaLabel="Add this folder's customers to a locked view"
onDismiss={() => setAddTarget(null)}
dataKind="folder-add-to-list"
>
<div className="cg-pop-title">Add to cohort</div>
<div className="cg-pop-note">
{addPreview.pids.length.toLocaleString()} customer
{addPreview.pids.length === 1 ? "" : "s"} from {addPreview.counted.toLocaleString()}{" "}
view{addPreview.counted === 1 ? "" : "s"} in this folder, counted once each.
</div>
{addPreview.skipped.length > 0 && (
<div className="cg-pop-note cg-fold-skipped">
Not included:
{addPreview.skipped.map((sk) => (
<span key={sk.name} className="cg-fold-skip">
{sk.name} — {sk.why}
</span>
))}
</div>
)}
{(lists ?? []).map((l) => (
<button
type="button"
key={l.id}
className="cg-pick-row"
onClick={() => {
onFolderAddToList(addTarget, l.id, l.name);
setAddTarget(null);
}}
>
{l.name}
</button>
))}
<div className="cg-view-create">
<label htmlFor="cg-fold-new-list">Lock records into a new view</label>
<input
id="cg-fold-new-list"
className="cg-input"
data-overlay-autofocus
value={addName}
onChange={(e) => setAddName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && addName.trim()) {
onFolderAddToList(addTarget, "", addName.trim());
setAddTarget(null);
}
}}
/>
<button
type="button"
className="cg-btn cg-btn--primary"
disabled={!addName.trim() || addPreview.pids.length === 0}
onClick={() => {
onFolderAddToList(addTarget, "", addName.trim());
setAddTarget(null);
}}
>
Create and add
</button>
</div>
</AnchoredOverlay>
)}
{menu && menuView && (
<AnchoredOverlay
anchor={menu.anchor}
className="cg-view-menu"
placement="bottom-end"
role="menu"
ariaLabel={`Actions for ${menuView.name}`}
onDismiss={() => setMenu(null)}
onKeyDown={onMenuKeyDown}
dataKind="saved-view-menu"
>
{/* W2 (C2): Export sits ABOVE the rename/delete group, thin divider between. */}
{(onExport || onImport) && (
<>
{/* ⭐ WAVE-29 T32 (owner item 16 / R10) — the DATA group's header.
The owner's complaint was that Export "looks like an orphan": it is already the
FIRST row of this menu and the divider under it is the only one, but every row
is styled identically, so the divider reads as a stray line rather than as the
end of a group. A header names the group instead.
⛔ NOT A TOOLBAR BUTTON (R10) — a standalone Export was deliberately removed on
2026-08-03 so this menu is the ONE door; item 6's Import joins it here.
⚠ INLINE STYLE, and it is a fence decision rather than a preference: this menu
has no group-header class anywhere in the stylesheet, and `index.css` belongs to
another session this wave. Tokens only, so it cannot invent a colour. */}
<div
role="presentation"
style={{
padding: "6px 10px 2px",
fontSize: "10px",
fontWeight: 600,
letterSpacing: "0.02em",
color: "var(--lp-muted)",
}}
>
Data
</div>
{onExport && (
<button
type="button"
role="menuitem"
onClick={() => {
setExportFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel icon="download" text="Export" />
</button>
)}
{/* ⭐ WAVE-29 T25 (owner item 6) — IMPORT, beside Export, because they are the same
question in two directions and this menu is the ONE door for both (a standalone
Export button was deliberately removed on 2026-08-03; a second Import entry
anywhere else would re-open that).
⚠ Supplied only on an EDITABLE database — the host passes `undefined` when
records are not mutable, which is what keeps the row off a locked one rather
than a check repeated here.
⚠ `cohortAdd` is the nearest glyph the registry has; there is no upload mark
and `icons.tsx` is not this session's to extend. */}
{onImport && (
<button
type="button"
role="menuitem"
onClick={() => {
onImport();
setMenu(null);
}}
>
<MenuLabel icon="upload" text="Import" />
</button>
)}
<div className="cg-menu-sep" role="separator" aria-hidden />
{/* …and the VIEW group's own header, so the divider now sits BETWEEN two named
groups rather than under one lonely row. R10 asks for both names. */}
<div
role="presentation"
style={{
padding: "2px 10px 2px",
fontSize: "10px",
fontWeight: 600,
letterSpacing: "0.02em",
color: "var(--lp-muted)",
}}
>
View
</div>
</>
)}
{/* I17 (C4) — the view's IDENTITY (name, description, folder, deletion) is what
the view permission governs. Duplicate is deliberately NOT gated: making your
own copy changes nothing about this view, and it is how someone without edit
rights gets a version they can work in. */}
{canEditMenuView && (
<button
type="button"
role="menuitem"
onClick={() => {
setRenamingId(menuView.id);
setMenu(null);
}}
>
<MenuLabel icon="rename" text="Rename" />
</button>
)}
{canEditMenuView && (
<button
type="button"
role="menuitem"
onClick={() => {
setNoteDraft(menuView.note ?? "");
setNoteFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel
icon="description"
text={menuView.note ? "Edit description" : "Add description"}
/>
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
onDuplicate(menuView.id);
setMenu(null);
}}
>
<MenuLabel icon="duplicate" text="Duplicate" />
</button>
{/* WAVE 20 items 23/26 (R10) — "who else can reach this view", the same
editor a folder and a database open. Deliberately NOT gated on
`canEditMenuView`: the server decides who may administer (owner or
admin) and says so in the dialog, and hiding the row from everyone else
would hide the ANSWER too — "who has this?" is a fair question for
anyone the view was shared with. */}
<button
type="button"
role="menuitem"
onClick={() => {
openShare("view", menuView.id, menuView.name);
setMenu(null);
}}
>
<MenuLabel icon="permissions" text="Share view" />
</button>
{/* WAVE 20 item 25 (C-ALERT) — the door that MAKES an alert, on the view it
watches. It belongs here and nowhere else: an alert IS "tell me when a
record enters THIS view", so the only place the question has an obvious
subject is the view's own menu.
⚠ The frame answers, because this rail does not know its own topic (it
holds views; the scope key belongs to the route) — and because the server
refuses a view with no active filter, which is a message the frame is
already in the business of showing. */}
{/* ⭐⭐ WAVE 32 · T24 (owner item 17, ruling R5, contract C4) — "Mark important".
⛔ ABOVE the alert row, which is the done-when's own word and not a nicety: the two
rows produce THE SAME red pill in the rail, and the cheap, private, instantly
reversible one has to be reachable before the one that creates a stored server-side
alert. Offered first, a user who only wanted to keep an eye on a number never has
to make an alert to get one.
⛔ ONE MARK. R5 is explicit: no second severity, no colour variants —
`verify_icons` asserts the absence of the word so a second one cannot arrive by
copy-paste from this very row. The label FLIPS rather than the state being implied,
because "Mark important" on an already-marked view reads as a label for the view. */}
{onToggleImportant && (
<button
type="button"
role="menuitem"
onClick={() => {
onToggleImportant(menuView.id, !menuView.config?.important);
setMenu(null);
}}
>
<MenuLabel
icon={menuView.config?.important ? "unlock" : "permissions"}
text={menuView.config?.important ? "Unmark important" : "Mark important"}
/>
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
window.dispatchEvent(
new CustomEvent("aios:alert-create", {
detail: { viewId: menuView.id, label: menuView.name },
})
);
setMenu(null);
}}
>
<MenuLabel icon={<BellIcon size={16} />} text="Alert me about new records" />
</button>
{/* ⭐ WAVE 21 item 11 (ruling R10) — "paste a list of names and tick those
records". It belongs in the VIEW's menu because the view is what decides
which records are on the table to be found, and the result says so.
⚠ IT SELECTS THE VIEW FIRST when this is not the active one. The dialog
matches over the rows the grid currently holds, so offering it on a view
nobody is looking at would tick records in a DIFFERENT view's row set and
report misses against the wrong table. Switching first makes the menu's
subject and the dialog's subject the same object — and it is what clicking
the row would have done anyway. */}
{onSelectFromFile && (
<button
type="button"
role="menuitem"
onClick={() => {
if (menuView.id !== activeViewId) onSelect(menuView.id);
onSelectFromFile();
setMenu(null);
}}
>
<MenuLabel icon="cohortAdd" text="Select from file" />
</button>
)}
{onAddToList && (
<button
type="button"
role="menuitem"
onClick={() => {
// Item 4: `<view name> · <today>`, today from the PAYLOAD verbatim. No date
// when the host sent none — never the browser clock.
setNewListName(today ? `${menuView.name} · ${today}` : menuView.name);
setAddFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel icon="cohortAdd" text="Add to locked view" />
</button>
)}
{/* Item 12 (C-LOCK) — LOCK the view to a cohort. This menu is the ONLY emitter of
`cohortLock`, which is why it lands after RECORD's engine: an emitter for an
engine that cannot yet read the key writes a config that silently does nothing.
Deliberately NOT beside "Add to locked view" in meaning, though it sits beside it
in the menu: that one COPIES today's matches into a set; this one makes the set the
view's permanent boundary. The submenu lists the sets and offers Unlock when
one is already set, because a lock that cannot be removed from the same place it
was applied is a trap.
⛔ wave17 R1 — HIDDEN ON A LOCKED VIEW ITSELF, and this is not tidiness. A
projected locked view's lock IS its identity: `aios_grid.views_from_defs` re-stamps
`config.cohortLock = view.id` on EVERY READ, unconditionally, so a lock chosen here
would be accepted by the UI, sent, and then silently overwritten by the host on the
next payload. The rail would show no change and nothing would go red — the
present-and-silently-inert affordance this file's own posture forbids. An action
the server will not honour must be ABSENT, not merely ineffective. */}
{onCohortLock && lists.length > 0 && canEditMenuView &&
menuView.kind !== "locked" && (
<button
type="button"
role="menuitem"
onClick={() => {
setLockFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel
icon="permissions"
text={menuView.config?.cohortLock ? "Change the lock" : "Lock to a locked view"}
/>
</button>
)}
{/* I12 (C3) — freeze the DISPLAY MODE. Offered only to the creator or an admin,
and only a courtesy: the host enforces the same rule, because a hidden control
is still a reachable event. */}
{onToggleLock && menuView.kind !== "system" && canEditMenuView &&
mayToggleViewLock(menuView, viewer) && (
<button
type="button"
role="menuitem"
onClick={() => {
onToggleLock(menuView.id, !isModeFrozen(menuView));
setMenu(null);
}}
>
{/* The FROZEN action wears the mode's own mark rather than a second padlock:
"Lock as Kanban" beside the kanban glyph says what gets frozen, which a
padlock cannot. Unlocking is the undo, so it takes the open shackle. */}
<MenuLabel
icon={
isModeFrozen(menuView)
? "unlock"
: <ModeIcon mode={viewDisplayMode(menuView)} size={16} />
}
text={
isModeFrozen(menuView)
? "Unlock view"
: `Lock as ${MODE_LABELS[viewDisplayMode(menuView)]}`
}
/>
</button>
)}
{/* ⚠ Delete is gated on the HOST's actual rule, NOT on `locked`. C3 widens `locked`
to every view and defines it as mode-frozen only; leaving Delete on it would hide
the entry for a user-frozen Kanban that the host would happily delete. */}
{!isUndeletableView(menuView) && canEditMenuView && (
<button
type="button"
role="menuitem"
className="is-danger"
onClick={() => {
onDelete(menuView.id);
setMenu(null);
}}
>
<MenuLabel icon="trash" text="Delete" />
</button>
)}
</AnchoredOverlay>
)}
{/* W2 (C2) — the format pane: CSV · Excel · PDF · JSON, keyboard-navigable like
the menu it came from. Rows/columns are the view's CURRENT matches and visible
fields; generation is client-side (the grid already holds the answer). */}
{exportFor && exportView && onExport && (
<AnchoredOverlay
anchor={exportFor.anchor}
className="cg-view-menu"
placement="bottom-end"
role="menu"
ariaLabel={`Export ${exportView.name}`}
onDismiss={() => setExportFor(null)}
onKeyDown={onMenuKeyDown}
dataKind="view-export"
>
<div className="cg-pop-title cg-export-title">Export {exportView.name}</div>
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
role="menuitem"
onClick={() => {
onExport(exportView.id, format);
setExportFor(null);
}}
>
{EXPORT_LABELS[format]}
</button>
))}
</AnchoredOverlay>
)}
{/* Add to cohort — the view's CURRENT matches become fixed members of a cohort. Worded
as a one-time copy ("Add ... to") rather than a link, because that is what it is:
the cohort does not track the view afterwards, which is the entire difference
between a cohort and a saved view. */}
{addFor && addView && onAddToList && (
<AnchoredOverlay
anchor={addFor.anchor}
className="cg-pop cg-add-list-pop"
placement="bottom-end"
role="dialog"
ariaLabel={`Add ${addView.name} to a locked view`}
onDismiss={() => setAddFor(null)}
dataKind="add-to-list"
>
<div className="cg-pop-title">Add to cohort</div>
<div className="cg-pop-note">
Adds the customers <strong>{addView.name}</strong> matches right now. The cohort
stays fixed as the data changes.
</div>
{lists.map((l) => (
<button
type="button"
key={l.id}
className="cg-pick-row"
onClick={() => {
onAddToList(addView.id, l.id, l.name);
setAddFor(null);
}}
>
{l.name}
</button>
))}
<div className="cg-view-create">
<label htmlFor="cg-new-list">Lock records into a new view</label>
<input
id="cg-new-list"
className="cg-input"
data-overlay-autofocus
value={newListName}
placeholder="e.g. Q3 call plan"
onChange={(event) => setNewListName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newListName.trim()) {
onAddToList(addView.id, "", newListName.trim());
setAddFor(null);
}
}}
/>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
disabled={!newListName.trim()}
onClick={() => {
onAddToList(addView.id, "", newListName.trim());
setAddFor(null);
}}
>
Create and add
</button>
</div>
</div>
</AnchoredOverlay>
)}
{/* Description editor — mirrors the column menu's field-note interaction so the
two "explain this thing" affordances behave identically. */}
{noteFor && noteView && (
<AnchoredOverlay
anchor={noteFor.anchor}
className="cg-pop cg-view-note-pop"
placement="bottom-end"
role="dialog"
ariaLabel={`Description for ${noteView.name}`}
onDismiss={() => setNoteFor(null)}
dataKind="saved-view-note"
>
<div className="cg-pop-title">Description</div>
<label className="cg-column-note">
{/* Wave-10 item 9 moved the description off the grid and onto the view's row,
so this sentence had to move with it — it described a banner that no longer
exists. */}
<span>Shown under this view's name in the list.</span>
<textarea
data-overlay-autofocus
value={noteDraft}
maxLength={2000}
placeholder="What is this list for?…"
onChange={(event) => setNoteDraft(event.target.value)}
/>
</label>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
// Enabled whenever the text CHANGEDincluding changing it to empty,
// which is how a description is removed. Never compared against the
// template seed, only against what is currently stored.
disabled={noteDraft === (noteView.note ?? "")}
onClick={() => {
onNote(noteView.id, noteDraft);
setNoteFor(null);
}}
>
Save description
</button>
<button
type="button"
className="cg-btn"
onClick={() => setNoteFor(null)}
>
Cancel
</button>
</div>
</AnchoredOverlay>
)}
</aside>
);
}