Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

20.1 kB Ruw bestand
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);
    }
  }
}