import { useEffect, useState, type ReactNode } from 'react'; import type { TableMeta, ViewMeta } from '@wellformed/shared'; import { useI18n } from '../i18n'; import { useWorkspace } from '../state/workspace'; import { useSession } from '../state/session'; import { useToasts } from '../state/toasts'; import { api } from '../api/client'; import { Menu, useMenu, type MenuEntry } from './Menu'; interface SidebarProps { onAddTable(): void; /** With a table name, the dialog opens pointed at that table. */ onImport(table?: string): void; onAddColumn(): void; onEditTable(table: TableMeta): void; onDeleteTable(table: TableMeta): void; /** Open the tidy-up walk for one table's values. */ onTidy(table: string): void; /** Open as an overlay drawer; only meaningful on narrow screens. */ open: boolean; /** Called after picking a table or view, so the drawer can close itself. */ onNavigate(): void; } export function Sidebar({ onAddTable, onImport, onAddColumn, onEditTable, onDeleteTable, onTidy, open, onNavigate, }: SidebarProps): ReactNode { const ws = useWorkspace(); const session = useSession(); const toasts = useToasts(); const { t } = useI18n(); const tableMenu = useMenu(); const viewMenu = useMenu(); const allViews = ws.meta?.views ?? []; const rules = (ws.meta?.rules ?? []).filter((r) => r.table === ws.tableName); // Ungrouped tables first, then the shelves in the order the table positions // put them (first appearance). The whole sidebar is one flat, saved order — // a shelf is a run of consecutive tables in it — so dragging a table and // dragging a shelf both come down to writing a new permutation. const shelves: { name: string | null; tables: TableMeta[] }[] = []; for (const table of ws.tables) { const shelf = shelves.find((s) => s.name === table.group); if (shelf) shelf.tables.push(table); else shelves.push({ name: table.group, tables: [table] }); } // Stable sort: only floats the unnamed shelf to the top. shelves.sort((a, b) => (a.name === null ? -1 : 0) - (b.name === null ? -1 : 0)); // Shelves start closed — the whole point of a shelf is not having to look // at it. Closed is the default, not a rule: whichever shelf holds the open // table is unfolded for it, because the sidebar must never hide where you are. const [openShelves, setOpenShelves] = useState>(new Set()); const activeGroup = ws.tables.find((tbl) => tbl.name === ws.tableName)?.group ?? null; useEffect(() => { if (activeGroup === null) return; setOpenShelves((current) => current.has(activeGroup) ? current : new Set(current).add(activeGroup), ); }, [activeGroup]); const toggleShelf = (name: string) => { setOpenShelves((current) => { const next = new Set(current); if (next.has(name)) next.delete(name); else next.add(name); return next; }); }; /* ---- reordering by drag ------------------------------------------------ */ const canReorder = session.can('editor'); const [drag, setDrag] = useState<{ kind: 'table' | 'group'; name: string } | null>(null); /** The drop target (`t:` or `g:`) and which side of it. */ const [dropAt, setDropAt] = useState<{ key: string; after: boolean } | null>(null); const endDrag = () => { setDrag(null); setDropAt(null); }; /** The indicator line and the landing spot agree: lower half means after. */ const inLowerHalf = (e: React.DragEvent) => { const box = e.currentTarget.getBoundingClientRect(); return e.clientY > box.top + box.height / 2; }; const dropMark = (key: string): string => dropAt?.key === key ? (dropAt.after ? 'drop-after' : 'drop-before') : ''; /** Write the flat order the given shelves spell out; regroup first if asked. */ async function commitOrder( next: { name: string | null; tables: TableMeta[] }[], regroup?: { table: string; group: string | null }, ) { try { if (regroup) await api.updateTable(regroup.table, { group: regroup.group }); await api.reorderTables(next.flatMap((s) => s.tables.map((tb) => tb.name))); await ws.refresh(); } catch (error) { toasts.fail(error); } } /** * Put a table beside `nextToTable` on the `group` shelf — before or after * it, whichever the indicator line promised — or at the top of that shelf * when no table is named. Crossing onto another shelf is also a regrouping: * where a table sits and what heading it sits under are the same fact. */ function dropTableOn( dragName: string, group: string | null, nextToTable?: string, after = false, ) { if (nextToTable === dragName) return; const next = shelves.map((s) => ({ ...s, tables: [...s.tables] })); const from = next.find((s) => s.tables.some((tb) => tb.name === dragName)); const dest = next.find((s) => s.name === group); if (!from || !dest) return; const moving = from.tables.splice( from.tables.findIndex((tb) => tb.name === dragName), 1, )[0]!; // Located after the removal, so the index cannot be off by the mover. const at = nextToTable ? dest.tables.findIndex((tb) => tb.name === nextToTable) : -1; const idx = !nextToTable ? 0 : at < 0 ? dest.tables.length : at + (after ? 1 : 0); dest.tables.splice(idx, 0, moving); const regroup = moving.group !== group ? { table: dragName, group } : undefined; void commitOrder( next.filter((s) => s.tables.length), regroup, ); } function dropGroupOn(dragGroup: string, targetGroup: string, after = false) { if (dragGroup === targetGroup) return; const next = shelves.map((s) => ({ ...s, tables: [...s.tables] })); const fromIdx = next.findIndex((s) => s.name === dragGroup); if (fromIdx < 0) return; const moved = next.splice(fromIdx, 1); // Located after the removal, so the index cannot be off by the mover. const toIdx = next.findIndex((s) => s.name === targetGroup); if (toIdx < 0) return; next.splice(toIdx + (after ? 1 : 0), 0, ...moved); void commitOrder(next); } return ( ); /** * What a table can have done to it, in the same place a column's menu lives: * under the right button. Dropping a table and renaming one had no home in * the interface at all before this, which made the sidebar a list you could * only ever add to. */ function tableMenuItems(table: TableMeta): MenuEntry[] { const admin = session.can('admin'); return [ { // The SQL name, not the label: this is the one you type into a formula, // the SQL bar or an agent's request. label: t('sidebar.copyTableName'), onSelect: () => void navigator.clipboard.writeText(table.name).catch(() => undefined), }, 'separator', { label: t('sidebar.addColumn'), onSelect: () => { ws.setTable(table.name); onAddColumn(); }, disabled: !admin, }, { label: t('sidebar.importIntoTable'), onSelect: () => onImport(table.name), disabled: !session.can('editor'), }, { label: t('sidebar.tidyTable'), onSelect: () => onTidy(table.name), disabled: !session.can('editor'), }, { label: t('sidebar.editTable'), onSelect: () => onEditTable(table), disabled: !admin, }, 'separator', { label: t('sidebar.deleteTable'), onSelect: () => onDeleteTable(table), disabled: !admin, danger: true, }, ]; } /** * The same idea one level down. Renaming a saved view had no home either — * `PATCH /views/:id` has always taken a name and nothing ever sent one. * * Deleting still asks with `window.confirm`, matching the one in the view bar * rather than inventing a second way to ask the same question. */ function viewMenuItems(view: ViewMeta): MenuEntry[] { const editor = session.can('editor'); return [ { label: t('sidebar.renameView'), onSelect: () => { const name = window.prompt(t('sidebar.renameViewPrompt'), view.name)?.trim(); if (name && name !== view.name) void renameView(view, name); }, disabled: !editor || view.system, }, { label: t('sidebar.duplicateView'), onSelect: () => void duplicateView(view), disabled: !editor, }, 'separator', { label: t('sidebar.deleteView'), onSelect: () => { if (window.confirm(t('sidebar.deleteViewConfirm', view.name))) void removeView(view); }, disabled: !editor || view.system, danger: true, }, ]; } async function renameView(view: ViewMeta, name: string) { try { await api.updateView(view.id, { name }); await ws.refresh(); } catch (error) { toasts.fail(error); } } async function duplicateView(view: ViewMeta) { try { const { view: copy } = await api.createView(view.table, { name: t('sidebar.viewCopyName', view.name), filters: view.filters, sort: view.sort, }); // Refreshed before it is opened, or the id would name a view the // workspace has not heard of yet and it would fall back to the default. await ws.refresh(); ws.openView(view.table, copy.id); toasts.push(t('sidebar.copiedTo', copy.name), 'success'); } catch (error) { toasts.fail(error); } } async function removeView(view: ViewMeta) { try { await api.deleteView(view.id); // Standing in the view that just went would leave the grid on a filter // nothing can explain. if (ws.viewId === view.id) ws.setViewId(null); await ws.refresh(); } catch (error) { toasts.fail(error); } } }