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 { 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>
);
}