|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { useEffect, useRef, useState } from "react";
|
| import type { ReactNode } from "react";
|
| import { fetchSchema } from "./nav";
|
| import type { NavFolder, SchemaPayload, TableFootprint } from "./nav";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { FolderMark } from "../customer-grid/icons";
|
| import { FOLDER_SHAPE_LABELS, FOLDER_TONE_LABELS } from "../customer-grid/iconShapes";
|
| import {
|
| DEFAULT_FOLDER_SHAPE,
|
| DEFAULT_FOLDER_TONE,
|
| FOLDER_SHAPES,
|
| FOLDER_TONES,
|
| } from "../customer-grid/types";
|
| import type { FolderIcon } from "../customer-grid/types";
|
| import "./navExtras.css";
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function DotsIcon() {
|
| return (
|
| <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-dots-icon">
|
| <circle cx="3.2" cy="8" r="1.35" />
|
| <circle cx="8" cy="8" r="1.35" />
|
| <circle cx="12.8" cy="8" r="1.35" />
|
| </svg>
|
| );
|
| }
|
|
|
| function FolderIcon() {
|
| return (
|
| <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-nav-icon">
|
| <path d="M2.2 4.4c0-.66.54-1.2 1.2-1.2h3l1.4 1.6h4.8c.66 0 1.2.54 1.2 1.2v6c0 .66-.54 1.2-1.2 1.2H3.4c-.66 0-1.2-.54-1.2-1.2z" />
|
| </svg>
|
| );
|
| }
|
|
|
| |
| |
|
|
| function useMenu() {
|
| const [at, setAt] = useState<{ x: number; y: number } | null>(null);
|
| const ref = useRef<HTMLDivElement>(null);
|
| useEffect(() => {
|
| if (!at) return;
|
| const onDown = (e: MouseEvent) => {
|
| if (ref.current && !ref.current.contains(e.target as Node)) setAt(null);
|
| };
|
| const onKey = (e: KeyboardEvent) => {
|
| if (e.key === "Escape") setAt(null);
|
| };
|
| document.addEventListener("mousedown", onDown, true);
|
| document.addEventListener("keydown", onKey, true);
|
| return () => {
|
| document.removeEventListener("mousedown", onDown, true);
|
| document.removeEventListener("keydown", onKey, true);
|
| };
|
| }, [at]);
|
| const openFrom = (el: HTMLElement) => {
|
| const r = el.getBoundingClientRect();
|
| setAt({ x: Math.round(r.right + 4), y: Math.round(r.top) });
|
| };
|
| return { at, ref, openFrom, close: () => setAt(null) };
|
| }
|
|
|
| function MenuShell({
|
| at,
|
| menuRef,
|
| wide,
|
| children,
|
| }: {
|
| at: { x: number; y: number };
|
| menuRef: React.RefObject<HTMLDivElement>;
|
| /** WAVE 21 item 6 — the delete confirm needs prose width; a menu of one-line
|
| * actions does not. The CLAMP moves with the class, because a wider panel
|
| * clamped at the narrow width spills off the right edge on exactly the rows
|
| * furthest from it. */
|
| wide?: boolean;
|
| children: ReactNode;
|
| }) {
|
|
|
| const width = wide ? 320 : 248;
|
| const style = {
|
| left: Math.min(at.x, Math.max(8, window.innerWidth - width)),
|
| top: Math.min(at.y, Math.max(8, window.innerHeight - 260)),
|
| };
|
| return (
|
| <div
|
| className={"shell-navmenu" + (wide ? " is-wide" : "")}
|
| style={style}
|
| ref={menuRef}
|
| role="menu"
|
| >
|
| {children}
|
| </div>
|
| );
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function IconPicker({
|
| value,
|
| onPick,
|
| onClear,
|
| }: {
|
| value?: FolderIcon;
|
| onPick: (icon: FolderIcon) => void;
|
| /** Absent ⇒ nothing to clear (the row is already on the default mark). */
|
| onClear?: () => void;
|
| }) {
|
| const shape = value?.shape ?? DEFAULT_FOLDER_SHAPE;
|
| const tone = value?.tone ?? DEFAULT_FOLDER_TONE;
|
| return (
|
| <div className="shell-navmenu-pick" role="group" aria-label="Database icon">
|
| <div className="shell-navmenu-pickrow">
|
| {FOLDER_SHAPES.map((s) => (
|
| <button
|
| key={s}
|
| type="button"
|
| className={"shell-navmenu-swatch" + (value && shape === s ? " is-on" : "")}
|
| aria-pressed={!!value && shape === s}
|
| aria-label={FOLDER_SHAPE_LABELS[s]}
|
| title={FOLDER_SHAPE_LABELS[s]}
|
| onClick={() => onPick({ shape: s, tone })}
|
| >
|
| <FolderMark icon={{ shape: s, tone }} size={15} />
|
| </button>
|
| ))}
|
| </div>
|
| <div className="shell-navmenu-pickrow">
|
| {FOLDER_TONES.map((t) => (
|
| <button
|
| key={t}
|
| type="button"
|
| className={"shell-navmenu-swatch" + (value && tone === t ? " is-on" : "")}
|
| aria-pressed={!!value && tone === t}
|
| aria-label={FOLDER_TONE_LABELS[t]}
|
| title={FOLDER_TONE_LABELS[t]}
|
| onClick={() => onPick({ shape, tone: t })}
|
| >
|
| <FolderMark icon={{ shape, tone: t }} size={15} />
|
| </button>
|
| ))}
|
| </div>
|
| {/* ⛔ AN EXPLICIT WAY BACK, because this picker deliberately does NOT copy
|
| `cleanFolderIcon`'s "a fully-default icon is no icon" rule. That rule is
|
| right for a FOLDER, whose default mark IS the folder shape; a database's
|
| default mark is the cylinder, so dropping folder+neutral would answer a
|
| user who picked "Folder" with a picture of a database — the silent
|
| disappearance the tone whitelist's own note warns about. Absent = the
|
| cylinder, chosen = exactly what was chosen, and clearing is a click that
|
| says what it does. */}
|
| {onClear ? (
|
| <button type="button" className="shell-navmenu-item is-quiet" onClick={onClear}>
|
| Use the default icon
|
| </button>
|
| ) : null}
|
| </div>
|
| );
|
| }
|
|
|
| export function RowMenu({
|
| entryLabel,
|
| canSchema,
|
| onSchema,
|
| canRename,
|
| onRename,
|
| canIcon,
|
| icon,
|
| onIcon,
|
| onIconClear,
|
| onShare,
|
| canDelete,
|
| onLoadFootprint,
|
| onDelete,
|
| }: {
|
| entryLabel: string;
|
| canSchema: boolean;
|
| onSchema: () => void;
|
| /**
|
| * WAVE 19 R8 — rename is `ut_*` (custom) DATABASES ONLY, and this flag is how
|
| * the row says so. A built-in label is a compiled registry literal: renaming
|
| * `customer_data` would mean the nav and every other reader of
|
| * `core/registry.py` disagreeing about what the module is called. The SERVER
|
| * refuses a `name` for a non-`ut_` key regardless (C1, fail-closed) — this
|
| * only spares the user a control whose write would bounce.
|
| */
|
| canRename?: boolean;
|
| onRename?: (name: string) => void;
|
| /** Icons ride ALL databases (R8), unlike rename. */
|
| canIcon?: boolean;
|
| icon?: FolderIcon;
|
| onIcon?: (icon: FolderIcon) => void;
|
| onIconClear?: () => void;
|
| /** WAVE 20 item 18 (C-SHARE) — open the access editor for THIS database.
|
| * Absent = the row does not offer sharing (a group head has nothing to share). */
|
| onShare?: () => void;
|
| /**
|
| * ⭐ WAVE 21 item 6 (ruling R3, contract C3, wiring W-5) — may this session
|
| * DELETE this database?
|
| *
|
| * ⛔ REQUIRED, NOT OPTIONAL, and that is the wave-20 lesson written into a type.
|
| * Four features shipped dead behind 43 green gates because the prop that carried
|
| * them across an ownership fence was `?`-marked: an unmounted optional prop is
|
| * `undefined`, which reads as "the feature is off" and is indistinguishable from
|
| * "the feature was never built". A required prop makes the unmounted case a
|
| * COMPILE ERROR. The value is the server's (`NavPage.canDelete`), narrower than
|
| * `manage` on purpose — see the note there.
|
| */
|
| canDelete: boolean;
|
| /** What deleting would destroy. Resolves null when the server would not say —
|
| * the face then asks WITHOUT counts rather than inventing zeros. */
|
| onLoadFootprint: () => Promise<TableFootprint | null>;
|
| /** Do it. Resolves the server's own words on refusal, which the face shows in
|
| * place of the confirmation — a delete that failed must never look like one
|
| * that worked. */
|
| onDelete: () => Promise<{ ok: boolean; error?: string }>;
|
| }) {
|
|
|
|
|
|
|
| const { at, ref, openFrom, close } = useMenu();
|
|
|
|
|
| const [mode, setMode] = useState<"menu" | "rename" | "icon" | "delete">("menu");
|
| const [name, setName] = useState(entryLabel);
|
| |
| |
| |
| |
|
|
| const [del, setDel] = useState<{
|
| footprint?: TableFootprint | null;
|
| busy: boolean;
|
| error: string;
|
| }>({ busy: false, error: "" });
|
| const renameOn = !!canRename && !!onRename;
|
| const iconOn = !!canIcon && !!onIcon;
|
| if (!canSchema && !renameOn && !iconOn && !onShare && !canDelete) return null;
|
| const submitRename = () => {
|
| const clean = name.trim().replace(/\s+/g, " ");
|
| if (clean && clean !== entryLabel) onRename?.(clean);
|
| close();
|
| };
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const openDelete = () => {
|
| setDel({ busy: false, error: "" });
|
| setMode("delete");
|
| void onLoadFootprint().then((f) => setDel((cur) => ({ ...cur, footprint: f })));
|
| };
|
| const runDelete = () => {
|
| setDel((cur) => ({ ...cur, busy: true, error: "" }));
|
| void onDelete().then((r) => {
|
| if (r.ok) {
|
| close();
|
| setDel({ busy: false, error: "" });
|
| return;
|
| }
|
|
|
|
|
|
|
| setDel((cur) => ({ ...cur, busy: false, error: r.error || "It could not be deleted." }));
|
| });
|
| };
|
| return (
|
| <>
|
| <button
|
| type="button"
|
| className="shell-dots"
|
| aria-label={`Options for ${entryLabel}`}
|
| aria-haspopup="menu"
|
| onClick={(e) => {
|
| e.preventDefault();
|
| e.stopPropagation();
|
| setMode("menu");
|
| setName(entryLabel);
|
| openFrom(e.currentTarget);
|
| }}
|
| >
|
| <DotsIcon />
|
| </button>
|
| {at && (
|
| <MenuShell at={at} menuRef={ref} wide={mode === "delete"}>
|
| {mode === "delete" && canDelete ? (
|
| <DeleteFace
|
| entryLabel={entryLabel}
|
| state={del}
|
| onCancel={() => setMode("menu")}
|
| onConfirm={runDelete}
|
| />
|
| ) : mode === "rename" && renameOn ? (
|
| <form
|
| className="shell-navmenu-form"
|
| onSubmit={(e) => {
|
| e.preventDefault();
|
| submitRename();
|
| }}
|
| >
|
| <input
|
| className="shell-navmenu-input"
|
| autoFocus
|
| maxLength={60}
|
| value={name}
|
| aria-label={`Rename ${entryLabel}`}
|
| onChange={(e) => setName(e.target.value)}
|
| onKeyDown={(e) => {
|
| if (e.key === "Escape") close();
|
| }}
|
| />
|
| <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| Save
|
| </button>
|
| </form>
|
| ) : mode === "icon" && iconOn ? (
|
| /* ⚠ PICKING DOES NOT CLOSE THE MENU, and that is not a convenience.
|
| Shape and tone are two independent choices: a picker that closed
|
| on the first click would make "a green bolt" a two-open job, and
|
| the second open would have to find the row again. Each click
|
| commits (they are cheap and rare), the rail repaints from the
|
| server, and the swatch marks follow what was actually stored — so
|
| a refused write shows up as a mark that does not move, beside the
|
| toast that says why. Clearing DOES close: "use the default" is a
|
| terminal action, and the control removes itself once taken. */
|
| <IconPicker
|
| value={icon}
|
| onPick={(next) => onIcon?.(next)}
|
| {...(icon && onIconClear
|
| ? {
|
| onClear: () => {
|
| close();
|
| onIconClear();
|
| },
|
| }
|
| : {})}
|
| />
|
| ) : (
|
| <>
|
| {renameOn ? (
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => setMode("rename")}
|
| >
|
| Rename
|
| </button>
|
| ) : null}
|
| {iconOn ? (
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => setMode("icon")}
|
| >
|
| Change icon
|
| </button>
|
| ) : null}
|
| {canSchema ? (
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => {
|
| close();
|
| onSchema();
|
| }}
|
| >
|
| View schema
|
| </button>
|
| ) : null}
|
| {/* WAVE 20 item 18 (R10, C-SHARE) — a DATABASE shares with the same two
|
| roles a view does. Gated on `onShare`, which the Shell passes only
|
| for a real database row, so a family head or a hand-off link never
|
| offers to share something that has no id on the server. */}
|
| {onShare ? (
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => {
|
| close();
|
| onShare();
|
| }}
|
| >
|
| Share…
|
| </button>
|
| ) : null}
|
| {/* ⭐ WAVE 21 item 6 (R3) — LAST, and the only destructive row in this
|
| menu. Gated on the server's `canDelete` alone: R3 scopes the verb to
|
| the CREATOR or an admin and refuses it outright for connector-backed
|
| databases, so a row that may not be deleted does not say so — it
|
| simply has nothing here to click. (An entry that explains why it is
|
| disabled is the right pattern for a thing you could earn; this one
|
| you cannot.) */}
|
| {canDelete ? (
|
| <button
|
| type="button"
|
| className="shell-navmenu-item is-danger"
|
| onClick={openDelete}
|
| >
|
| Delete database…
|
| </button>
|
| ) : null}
|
| </>
|
| )}
|
| </MenuShell>
|
| )}
|
| </>
|
| );
|
| }
|
|
|
| |
| |
|
|
| function countLine(n: number, one: string, many: string): string {
|
| return `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function DeleteFace({
|
| entryLabel,
|
| state,
|
| onCancel,
|
| onConfirm,
|
| }: {
|
| entryLabel: string;
|
| state: { footprint?: TableFootprint | null; busy: boolean; error: string };
|
| onCancel: () => void;
|
| onConfirm: () => void;
|
| }) {
|
| const f = state.footprint;
|
| const asking = f === undefined;
|
| return (
|
| <div className="shell-navconfirm" role="alertdialog" aria-modal="true"
|
| aria-label={`Delete ${entryLabel}`}>
|
| <h4 className="shell-navconfirm-h">Delete “{entryLabel}”?</h4>
|
| {asking ? (
|
| <p className="shell-navmenu-note">Checking what this database holds…</p>
|
| ) : f === null ? (
|
| <p className="shell-navmenu-note">
|
| The server did not say what this database holds. Deleting it removes its records,
|
| columns, views, comments, documents and sharing — permanently.
|
| </p>
|
| ) : (
|
| <>
|
| <p className="shell-navmenu-note">This cannot be undone. It permanently removes:</p>
|
| <ul className="shell-navconfirm-list">
|
| <li>{countLine(f.rows, "record", "records")}</li>
|
| <li>{countLine(f.fields, "column", "columns")}</li>
|
| <li>{countLine(f.views, "view", "views")}</li>
|
| <li>
|
| {countLine(f.sharedUsers, "person it is shared with",
|
| "people it is shared with")}
|
| </li>
|
| </ul>
|
| {f.automations.length ? (
|
| <p className="shell-navmenu-note">
|
| {/* The VERB agrees too. "1 automation write here" was on screen before the
|
| first screenshot was read — the kind of thing every assertion passes. */}
|
| {countLine(f.automations.length, "automation", "automations")}{" "}
|
| {f.automations.length === 1 ? "writes" : "write"} here (
|
| {f.automations.map((a) => a.name).join(", ")}) — {f.automations.length === 1
|
| ? "it is"
|
| : "they are"}{" "}
|
| switched off, not deleted.
|
| </p>
|
| ) : null}
|
| </>
|
| )}
|
| {state.error ? <p className="shell-navconfirm-err">{state.error}</p> : null}
|
| <div className="shell-navconfirm-actions">
|
| <button
|
| type="button"
|
| className="shell-navconfirm-go"
|
| disabled={asking || state.busy}
|
| onClick={onConfirm}
|
| >
|
| {state.busy ? "Deleting…" : "Delete"}
|
| </button>
|
| <button
|
| type="button"
|
| className="shell-navmenu-item is-quiet"
|
| disabled={state.busy}
|
| onClick={onCancel}
|
| >
|
| Keep it
|
| </button>
|
| </div>
|
| </div>
|
| );
|
| }
|
|
|
| function PlusIcon() {
|
| return (
|
| <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-newthing-plus">
|
| <path d="M8 3.4v9.2M3.4 8h9.2" />
|
| </svg>
|
| );
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export function CreateNewRow({
|
| collapsed,
|
| onExpand,
|
| onCreateFolder,
|
| onNewDatabase,
|
| onFromTemplate,
|
| canFolder,
|
| }: {
|
| collapsed: boolean;
|
| /** Collapsed, the row's one honest behaviour is "open the rail first". */
|
| onExpand: () => void;
|
| onCreateFolder: (name: string) => void;
|
| onNewDatabase: () => void;
|
| /** R9's second row — the template picker, over a database you already have. */
|
| onFromTemplate: () => void;
|
| /* ⛔ `onAutomated` LEFT WITH THE ROW IT OPENED (wave 25 item 5a, R8). */
|
| /** Folders over an empty list are an empty gesture — the row is omitted, not the menu. */
|
| canFolder: boolean;
|
| }) {
|
| const { at, ref, openFrom, close } = useMenu();
|
| const [naming, setNaming] = useState(false);
|
| const [name, setName] = useState("");
|
| const submit = () => {
|
| const clean = name.trim().replace(/\s+/g, " ");
|
| if (!clean) return;
|
| onCreateFolder(clean);
|
| setName("");
|
| setNaming(false);
|
| };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (collapsed) {
|
| return (
|
| <button
|
| type="button"
|
| className="shell-newfolder is-collapsed"
|
| aria-label="Create new"
|
| title="Create new"
|
| onClick={onExpand}
|
| >
|
| <PlusIcon />
|
| </button>
|
| );
|
| }
|
| if (!naming) {
|
| return (
|
| <>
|
| {/* THE LABEL NO LONGER FOLLOWS `canFolder`. In the rail it had to: with one
|
| real branch, "+ Create new…" beside an `aria-haspopup` was a control
|
| promising a choice it was not about to offer. In the footer there are
|
| always at least two (database / template), so the menu is real whatever
|
| the tenant has and the label can simply be true.
|
| ⚠ TWO, NOT THREE, since R8 took the automated row (wave 25) — still a
|
| genuine choice, which is the property the label depends on. If a future
|
| ruling takes the template row as well, this reasoning inverts and the
|
| control has to become a plain button again. */}
|
| <button
|
| type="button"
|
| className="shell-newfolder"
|
| aria-haspopup="menu"
|
| onClick={(e) => openFrom(e.currentTarget)}
|
| >
|
| + Create new…
|
| </button>
|
| {at && (
|
| <MenuShell at={at} menuRef={ref}>
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => {
|
| close();
|
| onNewDatabase();
|
| }}
|
| >
|
| New database
|
| </button>
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => {
|
| close();
|
| onFromTemplate();
|
| }}
|
| >
|
| From a template
|
| </button>
|
| {canFolder ? (
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => {
|
| close();
|
| setNaming(true);
|
| }}
|
| >
|
| New folder
|
| </button>
|
| ) : null}
|
| </MenuShell>
|
| )}
|
| </>
|
| );
|
| }
|
| return (
|
| <form
|
| className="shell-navmenu-form shell-newfolder-form"
|
| onSubmit={(e) => {
|
| e.preventDefault();
|
| submit();
|
| }}
|
| >
|
| <input
|
| className="shell-navmenu-input"
|
| autoFocus
|
| maxLength={40}
|
| placeholder="Folder name"
|
| value={name}
|
| onChange={(e) => setName(e.target.value)}
|
| onKeyDown={(e) => {
|
| if (e.key === "Escape") {
|
| setName("");
|
| setNaming(false);
|
| }
|
| }}
|
| />
|
| <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| Add
|
| </button>
|
| </form>
|
| );
|
| }
|
|
|
| export function FolderHead({
|
| folder,
|
| count,
|
| open,
|
| collapsed,
|
| onToggle,
|
| onRename,
|
| onDelete,
|
| isDrop,
|
| dropProps,
|
| }: {
|
| folder: NavFolder;
|
| count: number;
|
| /* ⛔ WAVE 35 · T07 (owner item 9) — the `important?: NavImportant` prop is DELETED. It carried
|
| wave-34 R1's per-folder SUM of mark-important numbers; item 9 retires that badge, and `tsc`'s
|
| unused-locals check is what named this prop the moment its only reader went. */
|
| open: boolean;
|
| collapsed: boolean;
|
| onToggle: () => void;
|
| onRename: (name: string) => void;
|
| onDelete: () => void;
|
| /** Wave 14 C-NAVFOLD — a database row is being dragged over this folder. */
|
| isDrop?: boolean;
|
| /** dragover/drop handlers, owned by the Shell (it holds the placement writer). */
|
| dropProps?: React.HTMLAttributes<HTMLDivElement>;
|
| }) {
|
| const { at, ref, openFrom, close } = useMenu();
|
| const [renaming, setRenaming] = useState(false);
|
| const [name, setName] = useState(folder.name);
|
| const submit = () => {
|
| const clean = name.trim().replace(/\s+/g, " ");
|
| if (clean && clean !== folder.name) onRename(clean);
|
| setRenaming(false);
|
| close();
|
| };
|
| if (collapsed) {
|
|
|
|
|
| return null;
|
| }
|
| return (
|
| <div
|
| className={"shell-nav-folder" + (isDrop ? " is-drop" : "")}
|
| {...(dropProps ?? {})}
|
| >
|
| {/* R3: no chevron — the Views-rail folder look. Open state reads from the members
|
| below it (and aria-expanded says it aloud); the mark + bold label ARE the row. */}
|
| <button
|
| type="button"
|
| className="shell-nav-folderbtn"
|
| aria-expanded={open}
|
| onClick={onToggle}
|
| >
|
| <FolderIcon />
|
| <span className="shell-nav-label">{folder.name}</span>
|
| <span className="shell-nav-foldercount">{count}</span>
|
| {/* ⛔⛔ WAVE 35 · T07 (owner item 9, R4) — THE IMPORTANT SUM IS GONE FROM HERE.
|
| Owner: remove the count *"anywhere else but the View itself"*. Wave 34's R1/C1 put a
|
| sum of marked RECORDS beside this folder row; item 9 is later and retires it. The star
|
| count now reaches the user through Home and the Starred module instead (W35-T16/T17).
|
| ⚠ `count` STAYS, and the distinction is the whole reason this note is here rather than
|
| the row simply being deleted: `count` is how many BLOCKS the folder holds and has meant
|
| that since wave 14 — it is not a star count and owner item 9 does not name it. Deleting
|
| it would retire an unrelated wave-14 feature under cover of this ruling
|
| (PRD amendment A2). */}
|
| </button>
|
| <button
|
| type="button"
|
| className="shell-dots"
|
| aria-label={`Options for folder ${folder.name}`}
|
| aria-haspopup="menu"
|
| onClick={(e) => {
|
| e.stopPropagation();
|
| setName(folder.name);
|
| setRenaming(false);
|
| openFrom(e.currentTarget);
|
| }}
|
| >
|
| <DotsIcon />
|
| </button>
|
| {at && (
|
| <MenuShell at={at} menuRef={ref}>
|
| {renaming ? (
|
| <form
|
| className="shell-navmenu-form"
|
| onSubmit={(e) => {
|
| e.preventDefault();
|
| submit();
|
| }}
|
| >
|
| <input
|
| className="shell-navmenu-input"
|
| autoFocus
|
| maxLength={40}
|
| value={name}
|
| onChange={(e) => setName(e.target.value)}
|
| />
|
| <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| Save
|
| </button>
|
| </form>
|
| ) : (
|
| <button
|
| type="button"
|
| className="shell-navmenu-item"
|
| onClick={() => setRenaming(true)}
|
| >
|
| Rename folder
|
| </button>
|
| )}
|
| <button
|
| type="button"
|
| className="shell-navmenu-item is-danger"
|
| onClick={() => {
|
| close();
|
| onDelete();
|
| }}
|
| >
|
| Delete folder
|
| </button>
|
| </MenuShell>
|
| )}
|
| </div>
|
| );
|
| }
|
|
|
| export function SchemaDrawer({
|
| schemaKey,
|
| onClose,
|
| }: {
|
| schemaKey: string;
|
| onClose: () => void;
|
| }) {
|
| const [state, setState] = useState<
|
| { phase: "loading" } | { phase: "ready"; schema: SchemaPayload } | { phase: "error" }
|
| >({ phase: "loading" });
|
| useEffect(() => {
|
| let cancelled = false;
|
| setState({ phase: "loading" });
|
| fetchSchema(schemaKey).then((schema) => {
|
| if (cancelled) return;
|
| setState(schema ? { phase: "ready", schema } : { phase: "error" });
|
| });
|
| return () => {
|
| cancelled = true;
|
| };
|
| }, [schemaKey]);
|
| useEffect(() => {
|
| const onKey = (e: KeyboardEvent) => {
|
| if (e.key === "Escape") onClose();
|
| };
|
| document.addEventListener("keydown", onKey, true);
|
| return () => document.removeEventListener("keydown", onKey, true);
|
| }, [onClose]);
|
| return (
|
| <div className="shell-schema-backdrop" onMouseDown={onClose}>
|
| <aside
|
| className="shell-schema"
|
| role="dialog"
|
| aria-label="Database schema"
|
| onMouseDown={(e) => e.stopPropagation()}
|
| >
|
| {/* wave17 item 3 (R6) — the drawer opens on a mark, not on a sentence. */}
|
| {state.phase === "loading" && (
|
| <div className="shell-schema-empty is-spin">
|
| <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| </div>
|
| )}
|
| {state.phase === "error" && (
|
| <div className="shell-schema-empty">
|
| The schema could not be loaded. Close this panel and try again.
|
| </div>
|
| )}
|
| {state.phase === "ready" && (
|
| <>
|
| <header className="shell-schema-head">
|
| <div>
|
| <div className="shell-schema-title">{state.schema.label}</div>
|
| {state.schema.source && (
|
| <div className="shell-schema-sub">Source · {state.schema.source}</div>
|
| )}
|
| </div>
|
| <button
|
| type="button"
|
| className="shell-schema-close"
|
| aria-label="Close schema"
|
| onClick={onClose}
|
| >
|
| ×
|
| </button>
|
| </header>
|
| <div className="shell-schema-body">
|
| {state.schema.note && (
|
| <div className="shell-schema-note">{state.schema.note}</div>
|
| )}
|
| {state.schema.fields.length > 0 && (
|
| <>
|
| <div className="shell-schema-kicker">
|
| Fields · {state.schema.fields.length}
|
| </div>
|
| <table className="shell-schema-table">
|
| <thead>
|
| <tr>
|
| <th>Field</th>
|
| <th>Type</th>
|
| <th>Source</th>
|
| </tr>
|
| </thead>
|
| <tbody>
|
| {state.schema.fields.map((f) => (
|
| <tr key={f.key}>
|
| <td>
|
| <div className="shell-schema-fname">{f.label}</div>
|
| {f.description && (
|
| <div className="shell-schema-fdesc">{f.description}</div>
|
| )}
|
| {f.options && (
|
| <div className="shell-schema-fdesc">
|
| Choices: {f.options.join(" · ")}
|
| </div>
|
| )}
|
| </td>
|
| <td className="shell-schema-type">{f.type}</td>
|
| <td className="shell-schema-type">{f.source}</td>
|
| </tr>
|
| ))}
|
| </tbody>
|
| </table>
|
| </>
|
| )}
|
| {state.schema.measures.length > 0 && (
|
| <>
|
| <div className="shell-schema-kicker">
|
| Semantic measures · {state.schema.measures.length}
|
| </div>
|
| <div className="shell-schema-measures">
|
| {state.schema.measures.map((m) => (
|
| <div key={m.key} className="shell-schema-measure">
|
| <span className="shell-schema-fname">{m.label}</span>
|
| <span className="shell-schema-type">{m.type}</span>
|
| </div>
|
| ))}
|
| </div>
|
| </>
|
| )}
|
| </div>
|
| </>
|
| )}
|
| </aside>
|
| </div>
|
| );
|
| }
|
|
|