Spreadsheet-UX op een echte relationele engine

main

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

13.2 kB Ruw bestand
import { useEffect, useState, type ReactNode } from 'react';
import type { ColumnMeta, TableMeta } from '@wellformed/shared';
import { api } from './api/client';
import { usePublicConfig } from './api/hooks';
import { useI18n } from './i18n';
import { useSession } from './state/session';
import { useDismissSsr } from './state/ssr';
import { useWorkspace, WorkspaceProvider } from './state/workspace';
import { useToasts } from './state/toasts';
import { readRoute, type Route } from './state/route';
import { LoginScreen } from './components/LoginScreen';
import { SignupScreen } from './components/SignupScreen';
import { InviteScreen } from './components/InviteScreen';
import { ForgotScreen, ResetScreen, VerifyScreen } from './components/Recovery';
import { MarketingScreen } from './components/MarketingScreen';
import { EmptyWorkspace } from './components/EmptyWorkspace';
import { ImportDialog } from './components/ImportDialog';
import { TidyDialog } from './components/TidyDialog';
import { BillingDialog } from './components/BillingDialog';
import { PlanBanner } from './components/PlanBanner';
import { WorkspacePicker } from './components/WorkspacePicker';
import { TopBar } from './components/TopBar';
import { Sidebar } from './components/Sidebar';
import { ViewBar } from './components/ViewBar';
import { FormulaBar } from './components/FormulaBar';
import { Grid } from './components/Grid';
import { SqlBar } from './components/SqlBar';
import { AuditPanel } from './components/AuditPanel';
import { ColumnDialog } from './components/ColumnDialog';
import { TightenDialog } from './components/TightenDialog';
import { TableDialog } from './components/TableDialog';
import { PeopleDialog } from './components/PeopleDialog';
import { AgentKeysDialog } from './components/AgentKeysDialog';
import { AccountDialog } from './components/AccountDialog';
import { WorkspaceDialog } from './components/WorkspaceDialog';
import { ConfirmDialog } from './components/Modal';

export function App({ route = readRoute() }: { route?: Route }): ReactNode {
  const session = useSession();
  const { t } = useI18n();

  if (session.loading) {
    return <div className="gridstate">{t('app.starting')}</div>;
  }

  if (!session.reachable) {
    return (
      <Splash tagline={t('app.unreachableTagline')}>
        {t('app.unreachableBefore')}
        <span className="mono">npm run dev</span>
        {t('app.unreachableAfter')}
      </Splash>
    );
  }

  // Links that arrive by mail have to work for someone with no account, or no
  // usable password, so they are answered before the sign-in gate rather than
  // behind it. A reset link in particular is aimed at exactly the person who
  // cannot get past that gate.
  if (route.kind === 'invite') return <InviteScreen token={route.token} />;
  if (route.kind === 'reset') return <ResetScreen token={route.token} />;
  if (route.kind === 'verify') return <VerifyScreen token={route.token} />;

  if (route.kind === 'pricing') return <FrontDoor focus="pricing" />;

  if (!session.account) {
    if (route.kind === 'signup') return <SignupScreen />;
    if (route.kind === 'forgot') return <ForgotScreen />;
    if (route.kind === 'signin') return <LoginScreen />;

    // A named workspace with no session is either the published demo, which
    // reads without one, or somewhere this browser has to sign in to reach.
    if (route.kind === 'workspace') {
      if (!session.workspace) return <LoginScreen />;
      return (
        <WorkspaceProvider>
          <Workspace />
        </WorkspaceProvider>
      );
    }

    // Landing here with nothing in the URL is someone who has not decided yet.
    return <FrontDoor />;
  }

  // Signed in, but the URL names no workspace — or names one this account
  // cannot open. Either way the honest answer is the list of the ones it can.
  if (!session.workspace) {
    return (
      <WorkspacePicker
        unknownSlug={route.kind === 'workspace' ? route.slug : undefined}
        startCreating={route.kind === 'signup'}
      />
    );
  }

  return (
    <WorkspaceProvider>
      <Workspace />
    </WorkspaceProvider>
  );
}

/**
 * What answers at the root, which is not the same page on every install.
 *
 * A hosted Wellformed opens with Wellformed selling itself: a headline, a
 * screenshot, four selling points and a price list. None of that belongs on an
 * install somebody bought and runs themselves. The prices are not theirs, the
 * pitch is aimed at a decision their people already made, and "Start free" is
 * an offer nobody on that server can accept — so the front door is the door.
 *
 * Held back until the answer is actually known. Guessing "hosted" and
 * correcting a moment later would flash Wellformed's own sales page across the
 * front of somebody else's business, which is the one thing this must not do;
 * guessing the other way costs a hosted visitor a few hundred milliseconds of
 * the same card the app already shows while it starts.
 */
function FrontDoor({ focus }: { focus?: 'pricing' }): ReactNode {
  const install = usePublicConfig();
  const { t } = useI18n();
  if (install.isPending) return <div className="gridstate">{t('app.starting')}</div>;
  if (install.data && !install.data.metered) return <LoginScreen />;
  return <MarketingScreen focus={focus} />;
}

/** The centred card the app falls back to before it has a grid to show. */
export function Splash({
  tagline,
  children,
}: {
  tagline: string;
  children?: ReactNode;
}): ReactNode {
  // Splash only renders as a terminal state (the API is unreachable), and a
  // server-rendered page whose buttons all need that API should not stay up
  // to disagree with it.
  useDismissSsr();
  return (
    <div className="login">
      <div className="login-card">
        <div className="wordmark">
          Well<span>formed</span>
        </div>
        <div className="tagline">{tagline}</div>
        {children && <p className="hint">{children}</p>}
      </div>
    </div>
  );
}

type Dialog =
  | { kind: 'addColumn' }
  | { kind: 'editColumn'; column: ColumnMeta }
  | { kind: 'tightenColumn'; column: ColumnMeta }
  | { kind: 'deleteColumn'; column: ColumnMeta }
  | { kind: 'addTable' }
  | { kind: 'editTable'; table: TableMeta }
  | { kind: 'deleteTable'; table: TableMeta }
  | { kind: 'people' }
  | { kind: 'agentKeys' }
  | { kind: 'account' }
  | { kind: 'workspace' }
  | { kind: 'import'; table?: string }
  | { kind: 'tidy'; table: string }
  | { kind: 'billing' }
  | null;

function Workspace(): ReactNode {
  const ws = useWorkspace();
  const toasts = useToasts();
  const { t, locale } = useI18n();
  const [dialog, setDialog] = useState<Dialog>(null);
  const [busy, setBusy] = useState(false);
  const [sidebarOpen, setSidebarOpen] = useState(false);

  // The drawer traps nothing, so Escape has to be an explicit way out of it.
  useEffect(() => {
    if (!sidebarOpen) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setSidebarOpen(false);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [sidebarOpen]);

  if (ws.metaLoading) {
    return <div className="gridstate">{t('app.loadingCatalogue')}</div>;
  }

  if (ws.metaError) {
    return (
      <div className="gridstate error">
        {ws.metaError instanceof Error ? ws.metaError.message : t('app.catalogueFailed')}
      </div>
    );
  }

  async function confirmDeleteColumn(column: ColumnMeta) {
    setBusy(true);
    try {
      await api.dropColumn(ws.tableName, column.name);
      await ws.refresh();
      toasts.push(t('app.droppedThing', column.label), 'success');
      setDialog(null);
    } catch (error) {
      toasts.fail(error);
    } finally {
      setBusy(false);
    }
  }

  async function confirmDeleteTable(table: TableMeta) {
    setBusy(true);
    try {
      await api.dropTable(table.name);
      // The table being looked at may be the one that just went; the workspace
      // state falls back to the first table left on the next render.
      await ws.refresh();
      toasts.push(t('app.droppedThing', table.name), 'success');
      setDialog(null);
    } catch (error) {
      toasts.fail(error);
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="app">
      <TopBar
        onOpenAccount={() => setDialog({ kind: 'account' })}
        onOpenPeople={() => setDialog({ kind: 'people' })}
        onOpenKeys={() => setDialog({ kind: 'agentKeys' })}
        onOpenWorkspace={() => setDialog({ kind: 'workspace' })}
        onOpenBilling={() => setDialog({ kind: 'billing' })}
        sidebarOpen={sidebarOpen}
        onToggleSidebar={() => setSidebarOpen((v) => !v)}
      />

      <PlanBanner onOpen={() => setDialog({ kind: 'billing' })} />

      <div className="body">
        <Sidebar
          onAddTable={() => setDialog({ kind: 'addTable' })}
          onImport={(table) => setDialog({ kind: 'import', table })}
          onAddColumn={() => setDialog({ kind: 'addColumn' })}
          onEditTable={(table) => setDialog({ kind: 'editTable', table })}
          onDeleteTable={(table) => setDialog({ kind: 'deleteTable', table })}
          onTidy={(table) => setDialog({ kind: 'tidy', table })}
          open={sidebarOpen}
          onNavigate={() => setSidebarOpen(false)}
        />

        {sidebarOpen && (
          <div className="backdrop" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
        )}

        <main className="main">
          {/* A workspace with no tables has no grid to draw, no view bar to
              fill and no query to echo — the whole chrome would be empty
              furniture around the one thing that matters here. */}
          {ws.tables.length === 0 ? (
            <EmptyWorkspace
              onAddTable={() => setDialog({ kind: 'addTable' })}
              onImport={() => setDialog({ kind: 'import' })}
            />
          ) : (
            <>
              <ViewBar />
              <FormulaBar />
              <Grid
                onAddColumn={() => setDialog({ kind: 'addColumn' })}
                onEditColumn={(column) => setDialog({ kind: 'editColumn', column })}
                onTightenColumn={(column) => setDialog({ kind: 'tightenColumn', column })}
                onDeleteColumn={(column) => setDialog({ kind: 'deleteColumn', column })}
              />
              <SqlBar />
            </>
          )}
        </main>

        {ws.showAudit && <AuditPanel />}
      </div>

      {dialog?.kind === 'addColumn' && <ColumnDialog onClose={() => setDialog(null)} />}

      {dialog?.kind === 'editColumn' && (
        <ColumnDialog column={dialog.column} onClose={() => setDialog(null)} />
      )}

      {dialog?.kind === 'tightenColumn' && (
        <TightenDialog column={dialog.column} onClose={() => setDialog(null)} />
      )}

      {dialog?.kind === 'deleteColumn' && (
        <ConfirmDialog
          title={t('app.deleteColumnTitle', dialog.column.label)}
          destructive
          busy={busy}
          confirmLabel={t('app.dropColumn')}
          message={
            <>
              <p style={{ margin: 0 }}>
                {t('app.dropColumnBefore')}
                <span className="mono">
                  ALTER TABLE {ws.tableName} DROP COLUMN {dialog.column.name}
                </span>
                {t('app.dropColumnAfter')}
              </p>
              <p className="hint" style={{ margin: 0 }}>
                {t('app.dropColumnRefuses')}
              </p>
            </>
          }
          onCancel={() => setDialog(null)}
          onConfirm={() => void confirmDeleteColumn(dialog.column)}
        />
      )}

      {dialog?.kind === 'addTable' && <TableDialog onClose={() => setDialog(null)} />}

      {dialog?.kind === 'editTable' && (
        <TableDialog table={dialog.table} onClose={() => setDialog(null)} />
      )}

      {dialog?.kind === 'deleteTable' && (
        <ConfirmDialog
          title={t('app.deleteTableTitle', dialog.table.label)}
          destructive
          busy={busy}
          confirmLabel={t('app.dropTable')}
          message={
            <>
              <p style={{ margin: 0 }}>
                {t('app.dropTableBefore')}
                <span className="mono">DROP TABLE {dialog.table.name}</span>
                {t('app.dropTableAfter', dialog.table.rowCount, locale)}
              </p>
              <p className="hint" style={{ margin: 0 }}>
                {t('app.dropTableRefuses')}
              </p>
            </>
          }
          onCancel={() => setDialog(null)}
          onConfirm={() => void confirmDeleteTable(dialog.table)}
        />
      )}

      {dialog?.kind === 'import' && (
        <ImportDialog
          table={dialog.table}
          onClose={() => setDialog(null)}
          onTidy={(table) => setDialog({ kind: 'tidy', table })}
        />
      )}
      {dialog?.kind === 'tidy' && (
        <TidyDialog table={dialog.table} onClose={() => setDialog(null)} />
      )}
      {dialog?.kind === 'billing' && <BillingDialog onClose={() => setDialog(null)} />}
      {dialog?.kind === 'people' && <PeopleDialog onClose={() => setDialog(null)} />}
      {dialog?.kind === 'agentKeys' && <AgentKeysDialog onClose={() => setDialog(null)} />}
      {dialog?.kind === 'account' && <AccountDialog onClose={() => setDialog(null)} />}
      {dialog?.kind === 'workspace' && <WorkspaceDialog onClose={() => setDialog(null)} />}
    </div>
  );
}