Spreadsheet-UX op een echte relationele engine

main

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

8.0 kB Ruw bestand
import { useEffect, useState, type ReactNode } from 'react';
import { usePublicConfig } from '../api/hooks';
import { useI18n } from '../i18n';
import { useWorkspace } from '../state/workspace';
import { useSession } from '../state/session';
import { useToasts } from '../state/toasts';
import { goTo, workspacePath } from '../state/route';
import { Menu, useMenu } from './Menu';
import { SearchIcon } from './Icons';

interface TopBarProps {
  onOpenAccount(): void;
  onOpenPeople(): void;
  onOpenKeys(): void;
  onOpenWorkspace(): void;
  onOpenBilling(): void;
  sidebarOpen: boolean;
  onToggleSidebar(): void;
}

export function TopBar({
  onOpenAccount,
  onOpenPeople,
  onOpenKeys,
  onOpenWorkspace,
  onOpenBilling,
  sidebarOpen,
  onToggleSidebar,
}: TopBarProps): ReactNode {
  const ws = useWorkspace();
  const session = useSession();
  const toasts = useToasts();
  const { t } = useI18n();
  // An install under its own licence has no billing to open — the dialog is
  // still worth reaching for what it counts, so the entry stays and says so.
  const metered = usePublicConfig().data?.metered ?? true;
  const accountMenu = useMenu<null>();
  const workspaceMenu = useMenu<null>();

  // Debounce the search box: every keystroke is a fresh SQL query otherwise.
  const { setSearch } = ws;
  const [draft, setDraft] = useState(ws.search);
  useEffect(() => {
    const handle = window.setTimeout(() => setSearch(draft.trim()), 250);
    return () => window.clearTimeout(handle);
  }, [draft, setSearch]);

  return (
    <header className="top">
      {/* Only rendered wide enough to matter by CSS: below 980px the sidebar
          becomes a drawer and this is the only way back to it. */}
      <button
        className="iconbtn sidebar-toggle"
        onClick={onToggleSidebar}
        aria-expanded={sidebarOpen}
        aria-controls="sidebar"
        aria-label={t('sidebar.tablesAndViews')}
        title={t('sidebar.tablesAndViews')}
      >
        ☰
      </button>

      <div className="wordmark">
        Well<span>formed</span>
      </div>

      {/* The workspace is the first thing on screen, because on a shared
          account it is the thing you most need to be sure of. A visitor has
          nothing to switch to, so it is a label rather than a menu. */}
      {session.visiting ? (
        <span className="wsbutton static" title={`/w/${session.workspace?.slug ?? ''}`}>
          {session.workspace?.name ?? t('topbar.workspace')}
          <span className="tag">{t('topbar.demoReadOnly')}</span>
        </span>
      ) : (
        <button
          className="wsbutton"
          onClick={(e) => workspaceMenu.open(e, null)}
          title={`/w/${session.workspace?.slug ?? ''}`}
        >
          <span className="wsname">{session.workspace?.name ?? t('topbar.workspace')}</span> ▾
        </button>
      )}

      <div className="searchbox">
        <SearchIcon />
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder={t('topbar.searchIn', ws.table?.label.toLowerCase() ?? '')}
          aria-label={t('topbar.searchCurrentTable')}
          onKeyDown={(e) => {
            if (e.key === 'Escape') setDraft('');
          }}
        />
        {draft && (
          <button onClick={() => setDraft('')} aria-label={t('topbar.clearSearch')}>
            ×
          </button>
        )}
      </div>

      <div className="grow" />

      {/* Undo is a write, so it is not offered to someone who cannot write.
          Both halves carry a word and a tooltip naming what they will do; the
          words fold away on a narrow screen, the glyphs and titles do not. */}
      {!session.visiting && (
        <>
          <button
            className="iconbtn"
            onClick={() => void ws.undo.undo().catch((e: unknown) => toasts.fail(e))}
            disabled={!ws.undo.canUndo}
            title={
              ws.undo.nextUndoLabel
                ? t('topbar.undoAction', ws.undo.nextUndoLabel)
                : t('topbar.nothingToUndo')
            }
            aria-label={
              ws.undo.nextUndoLabel
                ? t('topbar.undoAction', ws.undo.nextUndoLabel)
                : t('common.undo')
            }
          >
            ↶ <span className="lbl">{t('common.undo')}</span>
          </button>
          <button
            className="iconbtn"
            onClick={() => void ws.undo.redo().catch((e: unknown) => toasts.fail(e))}
            disabled={!ws.undo.canRedo}
            title={
              ws.undo.nextRedoLabel
                ? t('topbar.redoAction', ws.undo.nextRedoLabel)
                : t('topbar.nothingToRedo')
            }
            aria-label={
              ws.undo.nextRedoLabel
                ? t('topbar.redoAction', ws.undo.nextRedoLabel)
                : t('topbar.redo')
            }
          >
            ↷ <span className="lbl">{t('topbar.redo')}</span>
          </button>
        </>
      )}

      <button
        className={'iconbtn' + (ws.showAudit ? ' active' : '')}
        onClick={() => ws.setShowAudit(!ws.showAudit)}
        aria-pressed={ws.showAudit}
        title={t('topbar.auditTrail')}
        aria-label={t('topbar.auditTrail')}
      >
        ◔ <span className="lbl">{t('topbar.auditTrail')}</span>
      </button>

      {session.visiting ? (
        <a className="btn primary" href="/signup">
          {t('topbar.createYourOwn')}
        </a>
      ) : (
        <button
          className="iconbtn"
          onClick={(e) => accountMenu.open(e, null)}
          title={session.account?.name ?? t('topbar.account')}
          aria-label={session.account?.name ?? t('topbar.account')}
        >
          {/* Two bare chevrons side by side say nothing about which is which,
              so the account keeps an initial where its name will not fit. */}
          <span className="initial" aria-hidden="true">
            {(session.account?.name ?? 'A').trim().charAt(0).toUpperCase()}
          </span>
          <span className="lbl">{session.account?.name ?? t('topbar.account')}</span> ▾
        </button>
      )}

      {workspaceMenu.state && (
        <Menu
          x={workspaceMenu.state.x}
          y={workspaceMenu.state.y}
          label={t('topbar.workspace')}
          onClose={workspaceMenu.close}
          items={[
            { label: t('topbar.workspaceSettings'), onSelect: onOpenWorkspace },
            { label: t('topbar.people'), onSelect: onOpenPeople },
            { label: t('topbar.agentKeys'), onSelect: onOpenKeys },
            ...(session.can('admin')
              ? [
                  {
                    label: metered ? t('topbar.planAndBilling') : t('topbar.usage'),
                    onSelect: onOpenBilling,
                  },
                ]
              : []),
            'separator',
            // Switching is a real navigation: two workspaces can then be open
            // in two tabs, and no cached row can cross between them.
            ...session.workspaces
              .filter((w) => w.slug !== session.workspace?.slug)
              .map((w) => ({
                label: t('topbar.switchTo', w.name),
                // Names are not unique; the address is. Showing it is the only
                // way to tell two workspaces called "Client Work" apart.
                hint: w.slug,
                onSelect: () => goTo(workspacePath(w.slug)),
              })),
            { label: t('topbar.allWorkspaces'), onSelect: () => goTo('/') },
          ]}
        />
      )}

      {accountMenu.state && (
        <Menu
          x={accountMenu.state.x}
          y={accountMenu.state.y}
          label={t('topbar.account')}
          onClose={accountMenu.close}
          items={[
            { label: t('topbar.yourAccount'), onSelect: onOpenAccount },
            'separator',
            {
              label: t('chrome.signOut'),
              onSelect: () =>
                void session
                  .signOut()
                  .then(() => goTo('/'))
                  .catch((e: unknown) => toasts.fail(e)),
            },
          ]}
        />
      )}
    </header>
  );
}