| 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";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { BellIcon } from "../ui/icons";
|
| import { SHARED_FOLDER_ID, groupByFolder, reorderFolderIds } from "./folders";
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const PERM_TONE: Record<ViewEditMode, "neutral" | "blue" | "green"> = {
|
| personal: "neutral",
|
| collaborative: "blue",
|
| users: "green",
|
| };
|
|
|
| |
|
|
| 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[]> = {
|
|
|
| personal: [person(8)],
|
|
|
| collaborative: [person(5.9), person(10.6)],
|
|
|
| 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>
|
| );
|
| }
|
|
|
| |
| |
| |
| |
|
|
|
|
| interface ViewSidebarProps {
|
| views: SavedView[];
|
| activeViewId: string;
|
| saveState: "saved" | "saving";
|
| onSelect: (id: string) => void;
|
| |
| |
|
|
| onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
|
| onRename: (id: string, name: string) => void;
|
|
|
| onNote: (id: string, note: string) => void;
|
| onDuplicate: (id: string) => void;
|
| onDelete: (id: string) => void;
|
| |
|
|
| lists?: { id: string; name: string }[];
|
| |
|
|
| onAddToList?: (viewId: string, cohortId: string, name: string) => void;
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| onSelectFromFile?: () => void;
|
| |
|
|
| onImport?: () => void;
|
| |
| |
| |
| |
| |
| |
| |
|
|
| onCohortLock?: (viewId: string, cohortId: string | null) => void;
|
| |
| |
| |
| |
| |
|
|
| today?: string;
|
| |
| |
| |
| |
|
|
| onExport?: (viewId: string, format: ExportFormat) => void;
|
| |
| |
| |
| |
| |
|
|
| folders?: GridFolder[];
|
| |
| |
|
|
| folderIdOf?: (viewId: string) => string | null;
|
| |
|
|
| 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;
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| onFolderReorder?: (order: string[]) => void;
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| onViewReorder: (order: string[]) => void;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| alertCounts: Record<string, number>;
|
| |
| |
| |
| |
| |
|
|
| folderAddPreview?: (folderId: string) => {
|
| pids: number[];
|
| counted: number;
|
| skipped: { name: string; why: string }[];
|
| };
|
| onFolderAddToList?: (folderId: string, cohortId: string, name: string) => void;
|
| |
| |
| |
| |
|
|
| viewer?: Viewer;
|
|
|
| onToggleLock?: (viewId: string, locked: boolean) => void;
|
| |
| |
| |
| |
| |
| |
|
|
| onToggleImportant?: (viewId: string, important: boolean) => void;
|
| |
| |
| |
|
|
| |
| |
|
|
| 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) {
|
| |
| |
| |
|
|
| const [creating, setCreating] = useState<DisplayMode | null>(null);
|
|
|
| const [createMenu, setCreateMenu] = useState<HTMLButtonElement | null>(null);
|
| const [name, setName] = useState("");
|
| |
|
|
| 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);
|
|
|
| const [lockFor, setLockFor] = useState<{
|
| viewId: string;
|
| anchor: HTMLButtonElement;
|
| } | null>(null);
|
| const [newListName, setNewListName] = useState("");
|
|
|
| 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;
|
| |
| |
|
|
| const canEditMenuView = !!menuView && mayEditView(menuView, viewer);
|
|
|
|
|
|
|
| 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);
|
| |
| |
| |
| |
| |
|
|
| 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);
|
| |
| |
| |
|
|
| const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
|
| const [addName, setAddName] = useState("");
|
| const [dropTarget, setDropTarget] = useState<string | null>(null);
|
|
|
|
|
|
|
| const [viewQuery, setViewQuery] = useState("");
|
|
|
|
|
| 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 {
|
|
|
| }
|
| return next;
|
| });
|
| const viewNeedle = viewQuery.trim().toLowerCase();
|
| const shownViews = viewNeedle
|
| ? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
|
| : views;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const FOLD_DRAG_TYPE = "application/x-loopable-fold";
|
|
|
| const [foldDrag, setFoldDrag] = useState<string | null>(null);
|
| const [foldOver, setFoldOver] = useState<string | null>(null);
|
| const foldsReorderable = !!folders && !!onFolderReorder;
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const reorderFolders = (draggedId: string, beforeId: string | null) => {
|
| const next = reorderFolderIds((folders ?? []).map((f) => f.id), draggedId, beforeId);
|
|
|
| if (next) onFolderReorder?.(next);
|
| };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const VIEW_DRAG_TYPE = "application/x-loopable-view";
|
| const [viewDrag, setViewDrag] = useState<string | null>(null);
|
| const [viewOver, setViewOver] = useState<string | null>(null);
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const reorderViews = (draggedId: string, beforeId: string | null) => {
|
| const next = reorderFolderIds(views.map((v) => v.id), draggedId, beforeId);
|
| if (next) onViewReorder(next);
|
| };
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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),
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| (v) => v.shared === true
|
| );
|
| const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
|
| const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
| onCreate(value, creating, cleanViewPermissions(
|
| { edit: permEdit, users: permUsers }, "personal", userOptions));
|
| setName("");
|
| setPermEdit("personal");
|
| setPermUsers([]);
|
| setCreating(null);
|
|
|
|
|
|
|
| setCreateMenu(null);
|
| };
|
|
|
| |
| |
| |
| |
|
|
| const closeCreate = () => {
|
| setCreating(null);
|
| setCreateMenu(null);
|
| setName("");
|
| setPermEdit("personal");
|
| setPermUsers([]);
|
| };
|
|
|
| const createFolder = () => {
|
| const value = folderName.trim();
|
| if (!value) return;
|
|
|
|
|
| onFolderCreate?.(value, cleanFolderIcon(folderIcon));
|
| setFolderName("");
|
| setFolderIcon({ shape: DEFAULT_FOLDER_SHAPE, tone: DEFAULT_FOLDER_TONE });
|
| setCreatingFolder(false);
|
| };
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const startView = (mode: DisplayMode) => {
|
| setCreatingFolder(false);
|
| setCreating(mode);
|
| };
|
| const startFolder = () => {
|
| setCreateMenu(null);
|
| setCreating(null);
|
| setCreatingFolder(true);
|
| };
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 20 — ONE 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 steps — so 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;
|
|
|
| if (viewNeedle && group.folder && group.items.length === 0) return null;
|
| const isRoot = gid === null;
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 19 — the 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 group — see `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 19 — the 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 (deliberately — R5 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 `
|
| + `view — you 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 you — someone 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"
|
| ? "Locked — this 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()}.`
|
| : "")
|
| : `Locked — this 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>
|
| );
|
| })}
|
|
|
| { |
| |
| |
| |
| |
| |
| |
| }
|
| </div>
|
|
|
| { |
| |
| |
| |
| |
| |
| |
| |
| }
|
| {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>
|
| )}
|
|
|
| { |
| |
| |
| }
|
| {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>
|
| );
|
| })()}
|
| { |
| |
| |
| }
|
| {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>
|
| </>
|
| )}
|
| { |
| |
| |
| }
|
| {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>
|
| { |
| |
| |
| |
| |
| }
|
| <button
|
| type="button"
|
| role="menuitem"
|
| onClick={() => {
|
| openShare("view", menuView.id, menuView.name);
|
| setMenu(null);
|
| }}
|
| >
|
| <MenuLabel icon="permissions" text="Share view" />
|
| </button>
|
| { |
| |
| |
| |
| |
| |
| |
| }
|
| { |
| |
| |
| |
| |
| |
| |
| |
| |
| }
|
| {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>
|
| { |
| |
| |
| |
| |
| |
| |
| |
| |
| }
|
| {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 CHANGED — including 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>
|
| );
|
| }
|
|
|