| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
| 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> |
| </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> |
| ); |
| } |
|
|