TableDocumentView.tsx12.1 KBView on GitHub 'use client';
/**
* A whole `table` document: the grid plus its toolbar.
*
* The only surface a caller needs — `FileEditor`, the artifact panel and the conversation
* Files tab all mount this where they would otherwise mount `<Document />`. Provider
* lifecycle lives in `useYTable`; `useDocEvents` is here for the same reason `<Document />`
* calls it, so an agent's row-granular writes stream into the open grid over SSE.
*/
import { useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Eye, EyeOff, FileSpreadsheet, Minimize2, Plus, Rows3, Table2, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { useDocEvents } from '@/modules/documents/yjs';
import { useTRPC, useTRPCClient } from '@/providers/query-provider';
import { formatCellRef } from './cell-refs';
import { compactTableDocument, formatByteCount } from './compact-table';
import { downloadTableExport, type TableExportFormat } from './download-table';
import { insertColumn, patchColumn } from './schema-edits';
import { TableGrid } from './TableGrid';
import type { CreateTaskForRowInput } from './TableRow';
import { useYTable } from './useYTable';
export interface TableDocumentViewProps {
documentId: string;
/**
* The table's name, drawn as the first row of the surface.
*
* Passed IN rather than fetched, because every caller already knows it — `FilesTab` holds the
* resolved `Doc`, `FileBrowser` holds the tree node — and a second query for a string the
* mount is already rendering elsewhere is a request per table open for nothing.
*
* Omitted by `FileArtifactPanel`, which carries its own (editable, shareable) title row
* directly above this one. Two titles for one table is the "never stack containers" mistake
* spelt in text.
*/
title?: string;
className?: string;
}
export function TableDocumentView({ documentId, title, className }: TableDocumentViewProps) {
const table = useYTable(documentId);
useDocEvents(documentId);
const trpc = useTRPC();
const trpcClient = useTRPCClient();
const { schema, rows, isDeleted, setSchema, addRow, setCell } = table;
const hiddenColumns = schema?.columns.filter((column) => column.hidden) ?? [];
const { mutate: createTask, isPending: isCreatingTask } = useMutation(
trpc.userTasks.createStandaloneTask.mutationOptions(),
);
/**
* "Create task for this row" — create the `user_tasks` row, then write `[[task: id]]` back
* into the row's first `task`-typed column so the table stays the audit trail. With no such
* column the task is still created; the reference simply has nowhere to live, which is worth
* saying out loud rather than silently dropping.
*/
const handleCreateTaskForRow = useCallback(
({ rowId, title, taskColumnKey }: CreateTaskForRowInput) => {
const description = title.trim();
if (!description) {
toast.error('Give this row a title first — it becomes the task description.');
return;
}
createTask(
{ description },
{
onSuccess: (result) => {
const taskId = (result as { task?: { id?: string } }).task?.id;
if (taskId && taskColumnKey) {
setCell(rowId, taskColumnKey, formatCellRef('task', taskId));
toast.success('Task created and linked');
return;
}
toast.success(
taskColumnKey
? 'Task created'
: 'Task created — add a Task column to link it into the row',
);
},
onError: (error) => toast.error(error.message),
},
);
},
[createTask, setCell],
);
/**
* Manual compaction. Deliberately not a primary control — it changes no value a user can
* see, so it lives behind the maintenance menu and reports itself in bytes, which is the
* only thing about it that is observable.
*/
const { mutate: compact, isPending: isCompacting } = useMutation({
mutationFn: () =>
compactTableDocument(trpcClient, documentId),
onSuccess: (result) => {
if (!result.success) {
// The server skips a rebuild while a subscriber is attached, rather than risk
// discarding a viewer's unflushed edits, and defers to the next eligible write.
toast.info('Compaction was deferred — the table is open elsewhere. It will run shortly.');
return;
}
if (result.reclaimedBytes <= 0) {
toast.success('Already compact — nothing to reclaim');
return;
}
const before = formatByteCount(result.bytesBefore);
const after = formatByteCount(result.bytesAfter);
toast.success(`Reclaimed ${formatByteCount(result.reclaimedBytes)} — ${before} → ${after}`);
},
onError: (error: Error) => toast.error(error.message),
});
const handleExport = useCallback(
(format: TableExportFormat) => {
void downloadTableExport(trpcClient, documentId, format).catch(
(error: unknown) => toast.error(error instanceof Error ? error.message : 'Export failed'),
);
},
[trpcClient, documentId],
);
/**
* RECORDS, not rows in the array.
*
* A section heading occupies a row but is not a row of data, and "12 rows" over a table with
* two headings and ten records is a number that matches nothing the reader can count. The
* gutter numbers records for the same reason, so the last one agrees with this.
*
* `metadata.rowCount` on the server deliberately stays a TOTAL — it is the denominator of the
* bloat estimate, where a heading costs Y items like any other row.
*/
const recordCount = rows.reduce((n, handle) => (handle.isSection ? n : n + 1), 0);
// A table that no longer exists gets a sentence, not a grid. Rendering the cached copy would
// be worse than an empty state: it looks live, it takes edits, and every one of them is
// flushed at a row the server deleted. See `isDeleted` in useYTable.
if (isDeleted) {
return (
<div className={cn('flex h-full min-h-0 flex-col items-center justify-center p-6', className)}>
<p className="text-sm font-medium text-foreground">This table was deleted</p>
<p className="mt-1 text-xs text-muted-foreground">
It is no longer on the server, so what was cached here has been cleared.
</p>
</div>
);
}
return (
<div className={cn('flex h-full min-h-0 flex-col', className)}>
{/* Title, then description, then the commands — in that order, because that is the order
the questions arrive in: what is this table, what is it for, what can I do to it. The
toolbar used to lead and the purpose sat UNDER it, so the first thing a reader met on
opening a table was a row of verbs for a thing they could not yet name. Neither line
takes a rule of its own; the toolbar's `border-b` is the single seam under the whole
header block. */}
{title && (
<h2 className="text-foreground shrink-0 truncate px-3 pt-2.5 text-base font-semibold">
{title}
</h2>
)}
{schema?.purpose && (
<p className={cn('text-muted-foreground shrink-0 px-3 text-xs', title ? 'pt-1' : 'pt-2.5')}>
{schema.purpose}
</p>
)}
<div
className={cn(
'flex shrink-0 items-center gap-1 border-b border-border px-3 py-2',
// The commands sit tighter under a heading than they do alone — the gap above them
// belongs to the block they follow, not to the row itself.
(title || schema?.purpose) && 'pt-1.5',
)}
>
<button
type="button"
onClick={() => addRow()}
className="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
<Rows3 className="size-3.5" />
Add row
</button>
<button
type="button"
disabled={!schema}
onClick={() => schema && setSchema(insertColumn(schema, schema.columns.length))}
className="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus className="size-3.5" />
Add column
</button>
{hiddenColumns.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
<EyeOff className="size-3.5" />
{hiddenColumns.length} hidden
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Hidden columns</DropdownMenuLabel>
{hiddenColumns.map((column) => (
<DropdownMenuItem
key=[redacted]
className="cursor-pointer"
onSelect={() =>
schema && setSchema(patchColumn(schema, column.key, { hidden: undefined }))
}
>
<Eye className="size-4 shrink-0" />
<span className="truncate">{column.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
<Table2 className="size-3.5" />
Export
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem className="cursor-pointer" onSelect={() => handleExport('xlsx')}>
<FileSpreadsheet className="size-4 shrink-0" />
<span className="truncate">Download as Excel</span>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onSelect={() => handleExport('csv')}>
<FileSpreadsheet className="size-4 shrink-0" />
<span className="truncate">Download as CSV</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="ml-auto text-xs text-muted-foreground">
{isCreatingTask ? 'Creating task…' : `${recordCount} ${recordCount === 1 ? 'row' : 'rows'}`}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Table maintenance"
className="ml-1 flex cursor-pointer items-center rounded p-1 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
<Wrench className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel>Maintenance</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-pointer"
disabled={isCompacting}
onSelect={() => compact()}
>
<Minimize2 className="size-4 shrink-0" />
<span className="truncate">{isCompacting ? 'Compacting…' : 'Compact table'}</span>
</DropdownMenuItem>
<p className="px-2 pb-1 pt-1 text-xs text-muted-foreground">
Rebuilds the document to drop accumulated edit history. Every value stays exactly
as it is.
</p>
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableGrid table={table} onCreateTaskForRow={handleCreateTaskForRow} />
</div>
);
}