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(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 (
{/* 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 && {ws.view.name}} {ws.allFilters.map((f, i) => { const saved = i < savedCount; return ( {labelOf(f.col)} {opLabel(t, f.op)} {isUnaryOp(f.op) ? '' : f.val} {!saved && ( )} ); })} {draft.col && ( <> {!isUnaryOp(draft.op) && ( setDraft({ ...draft, val: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') commitDraft(); if (e.key === 'Escape') setDraft(BLANK_DRAFT); }} /> )} )}
{dirty && session.can('editor') && ( <> {ws.view && !ws.view.system && ( )} )} {ws.view && !ws.view.system && session.can('editor') && !dirty && ( )}
); } /** 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; }