Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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;
}