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