Spreadsheet-UX op een echte relationele engine

main

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

6.7 kB Ruw bestand
import { useState, type ReactNode } from 'react';
import { FILTER_OPS, isUnaryOp, type Filter, type FilterOp } from '@wellformed/shared';
import { api } from '../api/client';
import { useI18n, type TranslateFn } from '../i18n';
import { useWorkspace } from '../state/workspace';
import { useSession } from '../state/session';
import { useToasts } from '../state/toasts';

const BLANK_DRAFT = { col: '', op: '=' as FilterOp, val: '' };

export function ViewBar(): ReactNode {
  const ws = useWorkspace();
  const session = useSession();
  const toasts = useToasts();
  const { t } = useI18n();

  const [draft, setDraft] = useState<Filter>(BLANK_DRAFT);
  const [saving, setSaving] = useState(false);

  const table = ws.table;
  if (!table) return null;

  const savedCount = ws.view?.filters.length ?? 0;
  const dirty =
    ws.adHocFilters.length > 0 ||
    (ws.sortOverride !== undefined &&
      JSON.stringify(ws.sortOverride) !== JSON.stringify(ws.view?.sort ?? null)) ||
    // The aggregate row is part of what a view is, so changing it is a change
    // to the view — offered to be saved, not quietly kept for this session.
    (ws.summariesOverride !== undefined &&
      JSON.stringify(ws.summariesOverride) !== JSON.stringify(ws.view?.summaries ?? {})) ||
    // So are the hidden columns: the view's SELECT is the columns it shows.
    (ws.hiddenOverride !== undefined &&
      JSON.stringify([...ws.hiddenOverride].sort()) !==
        JSON.stringify([...(ws.view?.hidden ?? [])].sort()));

  function commitDraft() {
    if (!draft.col) return;
    if (!isUnaryOp(draft.op) && draft.val === '') return;
    ws.addFilter(draft);
    setDraft(BLANK_DRAFT);
  }

  async function saveAsView() {
    const name = window.prompt(t('grid.nameThisView'), suggestName());
    if (!name) return;
    setSaving(true);
    try {
      const { view } = await api.createView(table!.name, {
        name: name.trim(),
        filters: ws.allFilters,
        sort: ws.sort,
        summaries: ws.summaries,
        hidden: ws.hiddenCols,
      });
      await ws.refresh();
      ws.setViewId(view.id);
      toasts.push(t('grid.savedView', view.name), 'success');
    } catch (error) {
      toasts.fail(error);
    } finally {
      setSaving(false);
    }
  }

  async function updateCurrentView() {
    if (!ws.view) return;
    setSaving(true);
    try {
      await api.updateView(ws.view.id, {
        filters: ws.allFilters,
        sort: ws.sort,
        summaries: ws.summaries,
        hidden: ws.hiddenCols,
      });
      await ws.refresh();
      ws.clearFilters();
      ws.setSort(undefined);
      ws.clearSummaries();
      ws.clearHiddenOverride();
      toasts.push(t('grid.updatedView', ws.view.name), 'success');
    } catch (error) {
      toasts.fail(error);
    } finally {
      setSaving(false);
    }
  }

  async function deleteCurrentView() {
    if (!ws.view || ws.view.system) return;
    if (!window.confirm(t('grid.deleteViewConfirm', ws.view.name))) return;
    try {
      await api.deleteView(ws.view.id);
      ws.setViewId(null);
      await ws.refresh();
    } catch (error) {
      toasts.fail(error);
    }
  }

  function suggestName(): string {
    const first = ws.allFilters[0];
    if (!first) return t('grid.newView');
    return `${labelOf(first.col)} ${first.op} ${first.val}`.slice(0, 60);
  }

  /** Chips and suggestions speak the column's label; `col` stays the key. */
  function labelOf(col: string): string {
    return table!.columns.find((c) => c.name === col)?.label ?? col;
  }

  return (
    <div className="viewbar">
      {/* The sidebar owns view navigation; this only says what you are looking
          at, which matters when the sidebar is collapsed on a narrow screen. */}
      {ws.view && <span className="viewname">{ws.view.name}</span>}

      {ws.allFilters.map((f, i) => {
        const saved = i < savedCount;
        return (
          <span className={saved ? 'filterchip saved' : 'filterchip'} key={`${f.col}-${f.op}-${i}`}>
            {labelOf(f.col)} {opLabel(t, f.op)} {isUnaryOp(f.op) ? '' : f.val}
            {!saved && (
              <button
                aria-label={t('grid.removeFilter', `${labelOf(f.col)} ${opLabel(t, f.op)} ${f.val}`)}
                onClick={() => ws.removeFilter(i - savedCount)}
              >
                ×
              </button>
            )}
          </span>
        );
      })}

      <select
        value={draft.col}
        onChange={(e) => setDraft({ ...draft, col: e.target.value })}
        aria-label={t('grid.filterColumn')}
      >
        <option value="">{t('grid.addFilterOption')}</option>
        {table.columns.map((c) => (
          <option key={c.name} value={c.name}>
            {c.label}
          </option>
        ))}
      </select>

      {draft.col && (
        <>
          <select
            value={draft.op}
            onChange={(e) => setDraft({ ...draft, op: e.target.value as FilterOp })}
            aria-label={t('grid.filterOperator')}
          >
            {FILTER_OPS.map((op) => (
              <option key={op} value={op}>
                {opLabel(t, op)}
              </option>
            ))}
          </select>

          {!isUnaryOp(draft.op) && (
            <input
              value={draft.val}
              placeholder={t('grid.valuePlaceholder')}
              aria-label={t('grid.filterValue')}
              onChange={(e) => setDraft({ ...draft, val: e.target.value })}
              onKeyDown={(e) => {
                if (e.key === 'Enter') commitDraft();
                if (e.key === 'Escape') setDraft(BLANK_DRAFT);
              }}
            />
          )}

          <button className="iconbtn" onClick={commitDraft}>
            {t('grid.applyFilter')}
          </button>
        </>
      )}

      <div className="grow" />

      {dirty && session.can('editor') && (
        <>
          <button className="iconbtn" onClick={() => void saveAsView()} disabled={saving}>
            {t('grid.saveAsView')}
          </button>
          {ws.view && !ws.view.system && (
            <button className="iconbtn" onClick={() => void updateCurrentView()} disabled={saving}>
              {t('grid.updateView', ws.view.name)}
            </button>
          )}
        </>
      )}

      {ws.view && !ws.view.system && session.can('editor') && !dirty && (
        <button className="iconbtn danger" onClick={() => void deleteCurrentView()}>
          {t('grid.deleteView')}
        </button>
      )}
    </div>
  );
}

/** The symbolic ops read the same everywhere; only the worded ones translate. */
function opLabel(t: TranslateFn, op: FilterOp): string {
  if (op === 'contains') return t('grid.op.contains');
  if (op === 'is empty') return t('grid.op.isEmpty');
  if (op === 'is not empty') return t('grid.op.isNotEmpty');
  return op;
}