Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<TableMeta>();
const viewMenu = useMenu<ViewMeta>();
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<ReadonlySet<string>>(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:<table>` or `g:<group>`) 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 (
<nav
id="sidebar"
className={'side' + (open ? ' open' : '')}
aria-label={t('sidebar.tablesAndViews')}
>
<h3>
{t('sidebar.tables')}
{session.can('editor') && (
<button
// Wrapped, or the click event would arrive where a table name goes.
onClick={() => onImport()}
aria-label={t('sidebar.importSpreadsheet')}
title={t('sidebar.importSpreadsheet')}
>
⭱
</button>
)}
{session.can('admin') && (
<button
onClick={onAddTable}
aria-label={t('sidebar.newTable')}
title={t('sidebar.newTable')}
>
+
</button>
)}
</h3>
{shelves.map((shelf) => {
const shelfOpen = shelf.name === null || openShelves.has(shelf.name);
return (
// The class carries one bit the CSS cannot see on its own: tables on
// a named shelf indent under their heading; ungrouped ones do not.
<div key={shelf.name ?? ' ungrouped'} className={shelf.name !== null ? 'shelf' : undefined}>
{shelf.name !== null && (
<h4 className="tblgroup">
<button
className={
[
drag?.kind === 'group' && drag.name === shelf.name ? 'dragging' : '',
dropMark(`g:${shelf.name}`),
]
.filter(Boolean)
.join(' ') || undefined
}
onClick={() => toggleShelf(shelf.name!)}
aria-expanded={shelfOpen}
draggable={canReorder}
onDragStart={(e) => {
setDrag({ kind: 'group', name: shelf.name! });
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', shelf.name!);
}}
onDragOver={(e) => {
// A shelf takes another shelf (reorder) or a table (regroup).
// A table lands at the top of the shelf, right under the
// heading, so its line sits at the heading's lower edge.
if (!drag) return;
e.preventDefault();
setDropAt({
key: `g:${shelf.name}`,
after: drag.kind === 'table' || inLowerHalf(e),
});
}}
onDragLeave={() => setDropAt((d) => (d?.key === `g:${shelf.name}` ? null : d))}
onDrop={(e) => {
e.preventDefault();
if (drag?.kind === 'group') dropGroupOn(drag.name, shelf.name!, inLowerHalf(e));
else if (drag?.kind === 'table') dropTableOn(drag.name, shelf.name);
endDrag();
}}
onDragEnd={endDrag}
>
{shelf.name}
<span className="caret" aria-hidden="true">
{shelfOpen ? '▾' : '▸'}
</span>
</button>
</h4>
)}
{shelfOpen &&
shelf.tables.map((table) => {
const active = table.name === ws.tableName;
// The built-in "All orders" is the table with nothing taken away,
// which is what the table's own row already opens — listing it
// underneath put the same name and count on screen twice.
const views = allViews.filter(
(v) => v.table === table.name && !(v.system && v.filters.length === 0),
);
return (
// The drop line lives on the block, not the button: a table
// dropped "after" lands below the views that travel with the
// target, so that is where the line must sit.
<div
key={table.name}
className={['tblblock', dropMark(`t:${table.name}`)].filter(Boolean).join(' ')}
// The block is the drop target, views included: hovering a
// view targets its table, and halves are of the whole block.
onDragOver={(e) => {
// A table takes only a table; shelves land on headings.
if (drag?.kind !== 'table') return;
e.preventDefault();
setDropAt({ key: `t:${table.name}`, after: inLowerHalf(e) });
}}
onDragLeave={() => setDropAt((d) => (d?.key === `t:${table.name}` ? null : d))}
onDrop={(e) => {
e.preventDefault();
if (drag?.kind === 'table') {
dropTableOn(drag.name, shelf.name, table.name, inLowerHalf(e));
}
endDrag();
}}
>
<button
className={
'tbl-item' +
(active ? ' on' : '') +
(drag?.kind === 'table' && drag.name === table.name ? ' dragging' : '')
}
onClick={() => {
ws.setTable(table.name);
onNavigate();
}}
onContextMenu={(e) => {
e.preventDefault();
tableMenu.open(e, table);
}}
aria-current={active ? 'true' : undefined}
draggable={canReorder}
onDragStart={(e) => {
setDrag({ kind: 'table', name: table.name });
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', table.name);
}}
onDragEnd={endDrag}
>
{table.label}{' '}
{/* The space is not decoration: without it a screen reader
announces the label and the count as one word. */}
<span className="n">{table.rowCount}</span>
</button>
{views.length > 0 && (
<div className="viewlist">
{views.map((v) => {
const current = active && v.id === ws.viewId;
return (
<button
key={v.id}
className={'view-item' + (current ? ' on' : '')}
aria-current={current ? 'true' : undefined}
onClick={() => {
ws.openView(table.name, v.id);
onNavigate();
}}
onContextMenu={(e) => {
e.preventDefault();
viewMenu.open(e, v);
}}
>
{v.name}{' '}
{/* A filtered view's whole point is how much it leaves;
null means its filters no longer compile, and a wrong
number would be worse than none. */}
{v.rowCount !== null && <span className="n">{v.rowCount}</span>}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
);
})}
{rules.length > 0 && (
<>
<h3>{t('sidebar.workflowRules')}</h3>
<div className="schema" style={{ marginTop: 0 }}>
{rules.map((rule) => {
// Spoken in labels, like everything above the SQL bar. The rule
// itself stores identifiers; a dropped column falls back to its
// identifier rather than to silence.
const ruleTable = ws.tables.find((tb) => tb.name === rule.table);
const colLabel = (name: string) =>
ruleTable?.columns.find((c) => c.name === name)?.label ?? name;
return (
<div key={rule.id} className={'rule-row' + (rule.enabled ? '' : ' off')}>
<div>
<b>
{ruleTable?.label ?? rule.table} · {colLabel(rule.column)}
</b>
{' = '}
{rule.equals}
</div>
{rule.actions.map((a, i) => (
<div key={i} style={{ paddingLeft: 10 }}>
{t('sidebar.ruleAction', colLabel(a.column), a.value)}
</div>
))}
{session.can('admin') && (
// A bare word under the rule read as more rule text. It is a
// control that turns a rule off, so it is shaped like one.
<button
className="ruletoggle"
aria-pressed={rule.enabled}
title={
rule.enabled ? t('sidebar.ruleDisableTitle') : t('sidebar.ruleEnableTitle')
}
onClick={() => {
void api
.setRuleEnabled(rule.id, !rule.enabled)
.then(() => ws.refresh())
.catch((e: unknown) => toasts.fail(e));
}}
>
{rule.enabled ? t('sidebar.disable') : t('sidebar.enable')}
</button>
)}
</div>
);
})}
</div>
</>
)}
{tableMenu.state && (
<Menu
x={tableMenu.state.x}
y={tableMenu.state.y}
label={t('sidebar.tableMenuLabel', tableMenu.state.payload.label)}
onClose={tableMenu.close}
items={tableMenuItems(tableMenu.state.payload)}
/>
)}
{viewMenu.state && (
<Menu
x={viewMenu.state.x}
y={viewMenu.state.y}
label={t('sidebar.viewMenuLabel', viewMenu.state.payload.name)}
onClose={viewMenu.close}
items={viewMenuItems(viewMenu.state.payload)}
/>
)}
</nav>
);
/**
* 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);
}
}
}