diff --git a/README.md b/README.md
index 4a477ee..2b2c388 100644
--- a/README.md
+++ b/README.md
@@ -17,18 +17,42 @@ other open-source Airtable clones, which own their database.
## Features
-- **Views:** Table (inline-editable grid), Kanban (drag-to-restack), Calendar
- (month grid, drag-to-reschedule), Detail panel, and Form (focused entry).
+- **Views:** Table (inline-editable grid), Kanban, Calendar (month grid,
+ drag-to-reschedule), Gallery (card grid with optional cover images), Gantt
+ (timeline bars from start/end date fields), Detail panel, and Form (focused
+ entry). Every view type has an in-toolbar config panel (stack field, date
+ field, cover field, start/end fields, …).
+- **Grid power features:** drag-to-resize and drag-to-reorder columns; grouped
+ rows with collapsible section headers; a per-column header menu (sort,
+ group-by, hide, color rows); a sticky summary footer (count / sum / avg /
+ min / max per column); row coloring by a select field; row selection with
+ bulk delete; cursor-key navigation with Enter-to-edit and ⌘C/⌘V clipboard;
+ a record **side-peek** drawer with inline editing; "Load more" pagination.
+- **Kanban power features:** drag cards between columns (writes the stack
+ field) and **reorder cards within a column** (persisted); per-column
+ quick-add; collapsible columns; custom column order; card preview fields
+ follow the shared field editor.
+- **Dashboards & reports:** a workspace-level report composer (`/d`) — KPI,
+ bar, line, and table widgets over any table, powered by a grouped-aggregation
+ endpoint with date bucketing; widgets are validated at write time.
- **Relations:** linked records with searchable link pickers; click through to
navigate; collapsible long link lists.
-- **Formulas & lookups:** declarative `sum` / `ratio` / `bucket` formulas computed
- on read; lookups that pull a field across a link.
-- **Filters, sorts, freeze:** per-view saved filters, sorts, and field
- show/hide/reorder; freeze header row and leading columns.
+- **Computed fields:** declarative `sum` / `ratio` / `bucket` formulas; lookups
+ that pull a field across a link; **rollups** (count / sum / avg / min / max
+ across a link) — all computed on read.
+- **Self-service schema:** add and delete fields from the UI (including a
+ rollup builder); stored fields get an additive `ALTER TABLE`, computed fields
+ are metadata-only.
+- **Filters, sorts, freeze:** per-view saved filters (with one-level AND/OR
+ groups), sorts (including by linked sub-fields), field show/hide/reorder;
+ freeze header row and leading columns.
- **Editing:** inline cell editing per type, add/delete records, a new-record
modal, and **CSV import** (column → field mapping, chunked create).
- **Field types:** text, longtext, number, date, datetime, single/multi select,
- checkbox, url, email, json, formula, link, lookup.
+ checkbox, url, email, json, formula, link, lookup, rollup.
+- **Mobile-aware:** dynamic-viewport layout, safe-area padding, wrapping
+ toolbars, viewport-clamped popovers, and a sidebar that collapses to an icon
+ rail and expands as an overlay on phones.
- **Seams for production:** every entity table carries a nullable `workspace_id`
(multi-tenant seam) and `meta_tables.source_kind` / `source_ref` (connector seam).
@@ -77,7 +101,8 @@ cd ../../apps/web && GRID_API_URL=http://localhost:8799 pnpm dev
```
Open the web app and you'll see the demo CRM (Companies / Contacts / Deals) with
-Table, Kanban, and Calendar views.
+Table, Kanban, Calendar, Gallery, and Gantt views plus the dashboards composer
+at `/d`.
To deploy, see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 95fbb49..642ef67 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,5 +1,5 @@
import "./globals.css";
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import { Sidebar, type SidebarTable } from "@/components/Sidebar";
import { getMeta } from "@/lib/server/gridApi";
@@ -32,7 +32,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
-
+ {/* h-dvh (not h-screen): the dynamic viewport excludes mobile browser
+ chrome, so bottom controls aren't hidden under the toolbar. */}
+
{error ? null :
}
{error ? (
diff --git a/apps/web/app/t/[tableId]/[viewId]/actions.ts b/apps/web/app/t/[tableId]/[viewId]/actions.ts
new file mode 100644
index 0000000..12258bb
--- /dev/null
+++ b/apps/web/app/t/[tableId]/[viewId]/actions.ts
@@ -0,0 +1,30 @@
+"use server";
+import { getMeta, listRecords } from "@/lib/server/gridApi";
+import { resolveLinkLabels } from "@/lib/server/labels";
+import { fieldsForTable } from "@/lib/types";
+import type { RecordEnvelope } from "@/lib/types";
+
+/**
+ * Fetch the next page of a view's records (same sort/filter as the first page,
+ * continued from `offset`) plus link labels for the new rows. Called by the
+ * client TableView's "Load more" button. The labels Map serializes fine across
+ * the server-action boundary.
+ */
+export async function loadMoreRecords(
+ tableId: string,
+ viewId: string,
+ offset: string,
+): Promise<{ records: RecordEnvelope[]; labels: Map; offset?: string }> {
+ const meta = await getMeta();
+ const view = meta.views.find((v) => v.viewId === viewId && v.tableId === tableId);
+ if (!view) throw new Error("view not found");
+
+ const { records, offset: next } = await listRecords(tableId, {
+ sort: view.config.sorts,
+ filter: view.config.filters,
+ pageSize: 100,
+ offset,
+ });
+ const labels = await resolveLinkLabels(meta, fieldsForTable(meta, tableId), records);
+ return { records, labels, offset: next };
+}
diff --git a/apps/web/app/t/[tableId]/[viewId]/page.tsx b/apps/web/app/t/[tableId]/[viewId]/page.tsx
index 6241391..91e8b89 100644
--- a/apps/web/app/t/[tableId]/[viewId]/page.tsx
+++ b/apps/web/app/t/[tableId]/[viewId]/page.tsx
@@ -2,13 +2,15 @@ import { notFound } from "next/navigation";
import { CalendarView } from "@/components/CalendarView";
import { DashboardView } from "@/components/DashboardView";
import { FormRenderer } from "@/components/FormRenderer";
+import { GalleryView } from "@/components/GalleryView";
+import { GanttView } from "@/components/GanttView";
import { ImportButton } from "@/components/ImportDialog";
import { KanbanView } from "@/components/KanbanView";
import { NewRecordButton } from "@/components/NewRecordDialog";
import { TableView } from "@/components/TableView";
import { ViewSwitcher } from "@/components/ViewSwitcher";
import { ViewToolbar } from "@/components/ViewToolbar";
-import { getMeta, listRecords } from "@/lib/server/gridApi";
+import { getMeta, listAllRecords, listRecords } from "@/lib/server/gridApi";
import { fieldsForTable, visibleFields } from "@/lib/types";
import { resolveLinkLabels } from "@/lib/server/labels";
@@ -25,11 +27,13 @@ export default async function ViewPage({
const view = meta.views.find((v) => v.viewId === viewId && v.tableId === tableId);
if (!table || !view) notFound();
- const { records } = await listRecords(tableId, {
- sort: view.config.sorts,
- filter: view.config.filters,
- pageSize: 100,
- });
+ // The table view paginates ("Load more"); layout views render the whole set,
+ // so they fetch every page up front (bounded) — otherwise kanban/calendar/
+ // gallery/gantt silently showed only the first 100 records.
+ const isLayoutView = view.type === "kanban" || view.type === "calendar" || view.type === "gallery" || view.type === "gantt";
+ const { records, offset } = isLayoutView
+ ? { ...(await listAllRecords(tableId, { sort: view.config.sorts, filter: view.config.filters })), offset: undefined }
+ : await listRecords(tableId, { sort: view.config.sorts, filter: view.config.filters, pageSize: 100 });
const fields = fieldsForTable(meta, tableId);
const labels = await resolveLinkLabels(meta, fields, records);
@@ -45,32 +49,40 @@ export default async function ViewPage({
return (
-
-
-
{table.name}
-
-
{records.length} records
+ {/* Rows wrap on narrow screens so the toolbar can't push past the viewport
+ (which dragged its right-anchored popovers off-page on mobile). */}
+
+
+
{table.name}
+
+ {records.length}{offset ? "+" : ""} records
-
+
- {view.type !== "form" ? : null}
+ {view.type !== "form" ? : null}
-
+ {/* TableView (the table + detail fallthrough) owns its own scroll container,
+ so its wrapper must clip; every other view scrolls in the wrapper. */}
+
{view.type === "kanban" ? (
) : view.type === "calendar" ? (
+ ) : view.type === "gallery" ? (
+
+ ) : view.type === "gantt" ? (
+
) : view.type === "form" ? (
) : view.type === "dashboard" ? (
) : (
-
+
)}
diff --git a/apps/web/components/DashboardRenderer.tsx b/apps/web/components/DashboardRenderer.tsx
index 883e34b..fc0b3da 100644
--- a/apps/web/components/DashboardRenderer.tsx
+++ b/apps/web/components/DashboardRenderer.tsx
@@ -48,7 +48,7 @@ export function DashboardRenderer({ dashboard, meta, data }: { dashboard: Dashbo
{dashboard.name}
{busy ? Saving… : null}
- setEditing({ widget: null })} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
+ setEditing({ widget: null })} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
+ Add widget
@@ -64,11 +64,14 @@ export function DashboardRenderer({ dashboard, meta, data }: { dashboard: Dashbo
{w.title || "(untitled)"}
+ {/* Disabled while a PATCH is in flight: every action rewrites the
+ whole widgets[] from the (stale until refresh) prop, so a second
+ click would clobber the first write. */}
- move(i, -1)} className="hover:text-neutral-600">←
- move(i, 1)} className="hover:text-neutral-600">→
- setEditing({ widget: w })} className="hover:text-neutral-600">✎
- remove(w.widgetId)} className="hover:text-red-500">✕
+ move(i, -1)} className="hover:text-neutral-600 disabled:opacity-40">←
+ move(i, 1)} className="hover:text-neutral-600 disabled:opacity-40">→
+ setEditing({ widget: w })} className="hover:text-neutral-600 disabled:opacity-40">✎
+ remove(w.widgetId)} className="hover:text-red-500 disabled:opacity-40">✕
diff --git a/apps/web/components/DashboardWidget.tsx b/apps/web/components/DashboardWidget.tsx
index ac604ee..bcf46c9 100644
--- a/apps/web/components/DashboardWidget.tsx
+++ b/apps/web/components/DashboardWidget.tsx
@@ -1,16 +1,13 @@
"use client";
import dynamic from "next/dynamic";
-import type { AggregateRow, Meta, RecordEnvelope, Widget } from "@/lib/types";
+import { previewValue } from "@/lib/preview";
+import { fieldsForTable, type Meta, type Widget, type WidgetResult } from "@/lib/types";
// recharts is client-only — load the chart pieces without SSR.
const MetricChart = dynamic(() => import("./MetricChart").then((m) => m.MetricChart), { ssr: false });
const BarMini = dynamic(() => import("./BarMini").then((m) => m.BarMini), { ssr: false });
-export interface WidgetResult {
- rows?: AggregateRow[];
- records?: RecordEnvelope[];
- error?: string;
-}
+export type { WidgetResult } from "@/lib/types";
function fmtNum(v: number): string {
if (!Number.isFinite(v)) return "—";
@@ -21,12 +18,8 @@ function label(s: string | null): string {
if (!s) return "—";
return s.length > 14 ? `${s.slice(0, 13)}…` : s;
}
-function cellText(v: unknown): string {
- if (v == null) return "";
- if (Array.isArray(v)) return v.map((x) => (x && typeof x === "object" && "name" in x ? String((x as { name: unknown }).name) : String(x))).join(", ");
- if (typeof v === "object") return "name" in (v as object) ? String((v as { name: unknown }).name) : JSON.stringify(v);
- return String(v);
-}
+// Dashboard table widgets don't fetch link labels — previewValue falls back to ids.
+const NO_LABELS = new Map
();
function Empty({ children }: { children: React.ReactNode }) {
return {children}
;
@@ -50,29 +43,30 @@ export function DashboardWidget({ widget, meta, result }: { widget: Widget; meta
return data.length ?
: No data ;
}
- // table
+ // table — resolve column field ids to FieldMeta once, scoped to the widget's
+ // table and in position order (unknown ids are dropped: they can't be labeled
+ // or rendered meaningfully).
const records = result?.records ?? [];
- const fieldById = new Map(meta.fields.map((f) => [f.fieldId, f]));
- const cols =
- widget.fieldIds && widget.fieldIds.length
- ? widget.fieldIds
- : meta.fields.filter((f) => f.tableId === widget.tableId).slice(0, 4).map((f) => f.fieldId);
+ const tableFields = fieldsForTable(meta, widget.tableId);
+ const fieldById = new Map(tableFields.map((f) => [f.fieldId, f]));
+ const colIds = widget.fieldIds && widget.fieldIds.length ? widget.fieldIds : tableFields.slice(0, 4).map((f) => f.fieldId);
+ const cols = colIds.map((c) => fieldById.get(c)).filter((f): f is NonNullable => Boolean(f));
if (!records.length) return No rows ;
return (
- {cols.map((c) => (
- {fieldById.get(c)?.name ?? c}
+ {cols.map((f) => (
+ {f.name}
))}
{records.slice(0, 8).map((rec) => (
- {cols.map((c) => (
- {cellText(rec.fields[c])}
+ {cols.map((f) => (
+ {previewValue(f, rec.fields[f.fieldId], NO_LABELS)}
))}
))}
diff --git a/apps/web/components/GalleryView.tsx b/apps/web/components/GalleryView.tsx
new file mode 100644
index 0000000..f9660a8
--- /dev/null
+++ b/apps/web/components/GalleryView.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import { previewValue, recordTitle } from "@/lib/preview";
+import { type Meta, type RecordEnvelope, type ViewMeta, visibleFields } from "@/lib/types";
+
+/**
+ * Gallery: a responsive grid of large record cards. Card title = the primary
+ * field (links to the record); card fields follow the view's FieldEditor
+ * (show/hide/reorder), capped by config.gallery.maxPreviewFields. An optional
+ * cover field (a url field) renders as an image strip on top of each card.
+ */
+export function GalleryView({
+ meta,
+ view,
+ records,
+ labels,
+}: {
+ meta: Meta;
+ view: ViewMeta;
+ records: RecordEnvelope[];
+ labels: Map;
+}) {
+ const fields = visibleFields(meta, view);
+ const primaryId = meta.tables.find((t) => t.tableId === view.tableId)?.primaryFieldId;
+ const coverId = view.config.gallery?.coverFieldId;
+ const maxPreview = view.config.gallery?.maxPreviewFields ?? 6;
+ const cardFields = fields
+ .filter((f) => f.fieldId !== primaryId && f.fieldId !== coverId)
+ .slice(0, maxPreview);
+
+ if (records.length === 0) return No records.
;
+
+ return (
+
+ {records.map((rec) => {
+ const cover = coverId ? rec.fields[coverId] : undefined;
+ return (
+
+ {typeof cover === "string" && cover.startsWith("http") ? (
+ // eslint-disable-next-line @next/next/no-img-element -- arbitrary user-provided host
+
+ ) : null}
+
+
{recordTitle(meta, view, rec)}
+ {cardFields.map((f) => {
+ const text = previewValue(f, rec.fields[f.fieldId], labels);
+ if (!text) return null;
+ return (
+
+ {f.name}: {text}
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/components/GanttView.tsx b/apps/web/components/GanttView.tsx
new file mode 100644
index 0000000..f286095
--- /dev/null
+++ b/apps/web/components/GanttView.tsx
@@ -0,0 +1,129 @@
+import Link from "next/link";
+import { recordTitle } from "@/lib/preview";
+import { fieldsForTable, type Meta, type RecordEnvelope, type ViewMeta } from "@/lib/types";
+
+const DAY = 86_400_000;
+const LABEL_W = 220; // fixed label column so track percentages line up
+
+/** Parse a stored date/datetime cell to a UTC ms timestamp, or null. Zone-less
+ * datetimes ("YYYY-MM-DD HH:MM[:SS]", as SQLite stores them) are pinned to UTC —
+ * bare Date.parse would read them in the server's local zone, misaligning bars
+ * against date-only values and the UTC-computed month ticks. */
+function toMs(v: unknown): number | null {
+ if (v == null || v === "") return null;
+ let s = String(v).trim();
+ if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(s)) s = `${s.replace(" ", "T")}Z`;
+ const t = Date.parse(s);
+ return Number.isFinite(t) ? t : null;
+}
+
+/**
+ * Gantt: one row per record with a horizontal bar from the start-date field to
+ * the end-date field (a missing end renders a 1-day bar). The time domain spans
+ * all bars with a little padding; the header marks month boundaries. Rows sort
+ * by start date; records without a start date are listed beneath the chart.
+ */
+export function GanttView({
+ meta,
+ view,
+ records,
+}: {
+ meta: Meta;
+ view: ViewMeta;
+ records: RecordEnvelope[];
+}) {
+ const startId = view.config.gantt?.startFieldId;
+ const endId = view.config.gantt?.endFieldId;
+ const fields = fieldsForTable(meta, view.tableId);
+ const startField = fields.find((f) => f.fieldId === startId);
+
+ if (!startField || !startId) {
+ return This Gantt view has no start-date field configured. Use the Gantt menu to pick one.
;
+ }
+
+ // One pass splits dated from undated, so the two can never disagree.
+ const bars: Array<{ rec: RecordEnvelope; start: number; end: number }> = [];
+ let undatedCount = 0;
+ for (const rec of records) {
+ const start = toMs(rec.fields[startId]);
+ if (start == null) { undatedCount++; continue; }
+ const endRaw = endId ? toMs(rec.fields[endId]) : null;
+ const end = endRaw != null && endRaw > start ? endRaw : start + DAY;
+ bars.push({ rec, start, end });
+ }
+ bars.sort((a, b) => a.start - b.start);
+
+ if (bars.length === 0) {
+ return No records have a value in “{startField.name}”.
;
+ }
+
+ // Domain with ~3% padding either side so edge bars aren't flush.
+ const rawMin = bars[0]!.start;
+ const rawMax = Math.max(...bars.map((b) => b.end));
+ const pad = Math.max(DAY, (rawMax - rawMin) * 0.03);
+ const min = rawMin - pad;
+ const max = rawMax + pad;
+ const span = max - min;
+ const pct = (t: number) => ((t - min) / span) * 100;
+
+ // Month boundaries inside the domain → header ticks + grid lines. left% is
+ // row-invariant, so compute it once here rather than per row.
+ const ticks: Array<{ t: number; label: string; left: number }> = [];
+ const d = new Date(min);
+ d.setUTCDate(1); d.setUTCHours(0, 0, 0, 0);
+ d.setUTCMonth(d.getUTCMonth() + 1);
+ while (d.getTime() < max) {
+ ticks.push({
+ t: d.getTime(),
+ label: d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: "UTC" }),
+ left: pct(d.getTime()),
+ });
+ d.setUTCMonth(d.getUTCMonth() + 1);
+ }
+
+ const fmt = (t: number) => new Date(t).toISOString().slice(0, 10);
+
+ return (
+
+ {/* time axis */}
+
+
+ {bars.length} records
+
+
+ {ticks.map((tk) => (
+
+ {tk.label}
+
+ ))}
+
+
+ {bars.map(({ rec, start, end }) => {
+ return (
+
+
+
+ {recordTitle(meta, view, rec)}
+
+
+
+ {ticks.map((tk) => (
+
+ ))}
+
+
+
+ );
+ })}
+ {undatedCount > 0 ? (
+
+ {undatedCount} record{undatedCount === 1 ? "" : "s"} without “{startField.name}” not shown.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/components/KanbanView.tsx b/apps/web/components/KanbanView.tsx
index deed6aa..d9af60a 100644
--- a/apps/web/components/KanbanView.tsx
+++ b/apps/web/components/KanbanView.tsx
@@ -1,9 +1,12 @@
"use client";
-import { DndContext, type DragEndEvent, PointerSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
+import { DndContext, type DragEndEvent, PointerSensor, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
+import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
-import { updateRecord } from "@/lib/client";
+import { createRecord, updateRecord, updateViewConfig } from "@/lib/client";
+import { previewValue, recordTitle } from "@/lib/preview";
import { type FieldMeta, fieldsForTable, type Meta, type RecordEnvelope, type ViewMeta, visibleFields } from "@/lib/types";
const UNSET = "__unset__";
@@ -42,23 +45,76 @@ export function KanbanView({
const previewFields = fields
.filter((f) => f.fieldId !== primaryId && f.fieldId !== stackId)
.slice(0, maxPreview);
- const columns = [
- ...(stackField.options?.choices?.map((c) => ({ id: c.name, label: c.name })) ?? []),
- { id: UNSET, label: "Uncategorized" },
- ];
const bucketOf = (rec: RecordEnvelope) => {
const v = rec.fields[stackId];
return v == null || v === "" ? UNSET : String(v);
};
+ // Columns = the field's defined options, plus any value present in records that
+ // isn't a defined option (so no record silently vanishes when choices are
+ // incomplete). A saved columnOrder pins the left-to-right order; anything not
+ // listed follows in natural order. Uncategorized is always last.
+ const defined = stackField.options?.choices?.map((c) => c.name) ?? [];
+ const present = recs.map(bucketOf).filter((b) => b !== UNSET);
+ const all = [...new Set([...defined, ...present])];
+ const order = view.config.kanban?.columnOrder ?? [];
+ const sorted = [...order.filter((n) => all.includes(n)), ...all.filter((n) => !order.includes(n))];
+ const columns = [
+ ...sorted.map((name) => ({ id: name, label: name })),
+ { id: UNSET, label: "Uncategorized" },
+ ];
+
+ // Manual card order per column (sparse: listed ids first, rest natural).
+ // Local state is optimistic; persistence is fire-and-forget.
+ const [orderMap, setOrderMap] = useState>(view.config.kanban?.cardOrder ?? {});
+ useEffect(() => setOrderMap(view.config.kanban?.cardOrder ?? {}), [view.config.kanban?.cardOrder]);
+ const orderCards = (cards: RecordEnvelope[], columnId: string): RecordEnvelope[] => {
+ const order = orderMap[columnId];
+ if (!order?.length) return cards;
+ const byId = new Map(cards.map((c) => [c.id, c]));
+ const ranked = order.map((id) => byId.get(id)).filter((c): c is RecordEnvelope => Boolean(c));
+ const rankedIds = new Set(ranked.map((c) => c.id));
+ return [...ranked, ...cards.filter((c) => !rankedIds.has(c.id))];
+ };
+ function persistOrder(next: Record) {
+ setOrderMap(next);
+ updateViewConfig(view.viewId, { ...view.config, kanban: { ...view.config.kanban!, cardOrder: next } }).catch((err) => {
+ setOrderMap(view.config.kanban?.cardOrder ?? {});
+ alert(`Reorder save failed: ${err instanceof Error ? err.message : String(err)}`);
+ });
+ }
async function onDragEnd(e: DragEndEvent) {
const recordId = String(e.active.id);
- const target = e.over ? String(e.over.id) : null;
- if (!target) return;
+ if (!e.over) return;
+ const overId = String(e.over.id);
const rec = recs.find((r) => r.id === recordId);
- if (!rec || bucketOf(rec) === target) return;
- const newValue = target === UNSET ? null : target;
+ if (!rec || overId === recordId) return;
+
+ const fromCol = bucketOf(rec);
+ const overRec = recs.find((r) => r.id === overId);
+ const toCol = overRec ? bucketOf(overRec) : overId; // over a card → its column; else a column id
+
+ // Current visual order of the target column (ids).
+ const colIds = (col: string) => orderCards(recs.filter((r) => bucketOf(r) === col), col).map((r) => r.id);
+
+ if (toCol === fromCol) {
+ // Reorder within the column: move the card to the over-card's slot.
+ if (!overRec) return;
+ const ids = colIds(fromCol);
+ const from = ids.indexOf(recordId);
+ const to = ids.indexOf(overId);
+ if (from < 0 || to < 0 || from === to) return;
+ persistOrder({ ...orderMap, [fromCol]: arrayMove(ids, from, to) });
+ return;
+ }
+
+ // Cross-column: PATCH the stack value and slot the card into the target order.
+ const newValue = toCol === UNSET ? null : toCol;
+ const targetIds = colIds(toCol).filter((id) => id !== recordId);
+ const insertAt = overRec ? targetIds.indexOf(overId) : targetIds.length;
+ targetIds.splice(insertAt < 0 ? targetIds.length : insertAt, 0, recordId);
setRecs((rs) => rs.map((r) => (r.id === recordId ? { ...r, fields: { ...r.fields, [stackId!]: newValue ?? undefined } } : r)));
+ persistOrder({ ...orderMap, [toCol]: targetIds, [fromCol]: (orderMap[fromCol] ?? []).filter((id) => id !== recordId) });
try {
await updateRecord(view.tableId, recordId, { [stackId!]: newValue });
} catch (err) {
@@ -67,23 +123,56 @@ export function KanbanView({
}
}
+ // Quick-add: a blank record pre-filled with this column's stack value, ready
+ // for inline editing on its detail page.
+ async function addToColumn(columnId: string) {
+ try {
+ const res = await createRecord(view.tableId, columnId === UNSET ? {} : { [stackId!]: columnId });
+ const id = res.records[0]?.id;
+ if (id) router.push(`/t/${view.tableId}/${view.viewId}/${id}`);
+ router.refresh();
+ } catch (err) {
+ alert(`Add failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
+ // Collapsed columns shrink to a slim vertical bar (persisted per view).
+ const collapsed = new Set(view.config.kanban?.collapsedColumns ?? []);
+ async function setCollapsed(columnId: string, on: boolean) {
+ const next = on ? [...collapsed, columnId] : [...collapsed].filter((c) => c !== columnId);
+ try {
+ await updateViewConfig(view.viewId, { ...view.config, kanban: { ...view.config.kanban!, collapsedColumns: next } });
+ router.refresh();
+ } catch (err) {
+ alert(`Save failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
return (
{columns.map((col) => {
const cards = recs.filter((r) => bucketOf(r) === col.id);
+ if (collapsed.has(col.id)) {
+ return (
+
setCollapsed(col.id, false)} />
+ );
+ }
+ const ordered = orderCards(cards, col.id);
return (
-
- {cards.map((rec) => (
-
- ))}
+ setCollapsed(col.id, true)} onAdd={() => addToColumn(col.id)}>
+ r.id)} strategy={verticalListSortingStrategy}>
+ {ordered.map((rec) => (
+
+ ))}
+
);
})}
@@ -92,34 +181,46 @@ export function KanbanView({
);
}
-function Column({ id, label, count, children }: { id: string; label: string; count: number; children: React.ReactNode }) {
+function Column({ id, label, count, onCollapse, onAdd, children }: { id: string; label: string; count: number; onCollapse: () => void; onAdd: () => void; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
-
-
-
{label}
+
+
+ {label}
{count}
+
+ +
+ ⇤
+
{children}
);
}
-/** Render a card-preview cell value: link/lookup arrays become comma-joined
- * labels (via the id→label map); scalars stringify. Returns "" when empty. */
-function previewValue(field: FieldMeta, value: unknown, labels: Map
): string {
- if (value == null || value === "") return "";
- if (field.type === "link") {
- const ids = Array.isArray(value) ? (value as string[]) : [String(value)];
- return ids.map((id) => labels.get(id) ?? id).join(", ");
- }
- if (Array.isArray(value)) return value.filter((v) => v != null && v !== "").map((v) => String(v)).join(", ");
- return String(value);
+/** A collapsed bucket: slim vertical bar showing the name + count; still a drop
+ * target, click to expand. */
+function CollapsedColumn({ id, label, count, onExpand }: { id: string; label: string; count: number; onExpand: () => void }) {
+ const { setNodeRef, isOver } = useDroppable({ id });
+ return (
+
+ {count}
+
+ {label}
+
+
+ );
}
-function Card({ rec, href, primaryId, previewFields, labels }: { rec: RecordEnvelope; href: string; primaryId?: string; previewFields: FieldMeta[]; labels: Map }) {
- const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: rec.id });
- const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)`, zIndex: 50 } : undefined;
+function Card({ rec, href, title, previewFields, labels }: { rec: RecordEnvelope; href: string; title: string; previewFields: FieldMeta[]; labels: Map }) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rec.id });
+ const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined };
return (
isDragging && e.preventDefault()}>
- {primaryId && rec.fields[primaryId] ? String(rec.fields[primaryId]) : "(untitled)"}
+ {title}
{previewFields.map((f) => {
const text = previewValue(f, rec.fields[f.fieldId], labels);
diff --git a/apps/web/components/Sidebar.tsx b/apps/web/components/Sidebar.tsx
index 38e5c59..d28069b 100644
--- a/apps/web/components/Sidebar.tsx
+++ b/apps/web/components/Sidebar.tsx
@@ -12,12 +12,19 @@ export interface SidebarTable {
const KEY = "grid-sidebar-collapsed";
-/** Left rail listing every table; links to each table's first view. Collapsible. */
+/** Below this width the expanded sidebar overlays the content instead of
+ * squeezing it, and the default state is collapsed. */
+const MOBILE_QUERY = "(max-width: 639px)";
+
+/** Left rail listing every table; links to each table's first view. Collapsible;
+ * on phones it defaults to the icon rail and expands as an overlay. */
export function Sidebar({ tables }: { tables: SidebarTable[] }) {
const pathname = usePathname();
- const [collapsed, setCollapsed] = useState(false);
+ const [collapsed, setCollapsed] = useState(true);
useEffect(() => {
- setCollapsed(localStorage.getItem(KEY) === "1");
+ const stored = localStorage.getItem(KEY);
+ // No saved preference → collapse on small screens, expand on desktop.
+ setCollapsed(stored === null ? window.matchMedia(MOBILE_QUERY).matches : stored === "1");
}, []);
const toggle = () => {
setCollapsed((c) => {
@@ -26,6 +33,10 @@ export function Sidebar({ tables }: { tables: SidebarTable[] }) {
return next;
});
};
+ // After navigating on a phone, fold the overlay out of the way.
+ const collapseIfMobile = () => {
+ if (window.matchMedia(MOBILE_QUERY).matches) setCollapsed(true);
+ };
if (collapsed) {
return (
@@ -70,49 +81,56 @@ export function Sidebar({ tables }: { tables: SidebarTable[] }) {
}
return (
-
-
-
-
-
-
-
-
gridbase
-
workspace
+ <>
+ {/* Mobile: the expanded sidebar floats over the content (tap the backdrop
+ to close) so it doesn't eat the viewport; ≥sm it sits in the flow. */}
+
+
+
-
- «
-
-
-
-
-
- 📊 Dashboards
-
-
- Tables
- {tables.map((t) => {
- const active = pathname.startsWith(`/t/${t.tableId}/`);
- return (
-
-
- {t.name}
-
-
- );
- })}
-
-
+
+
+
+ 📊 Dashboards
+
+
+ Tables
+ {tables.map((t) => {
+ const active = pathname.startsWith(`/t/${t.tableId}/`);
+ return (
+
+
+ {t.name}
+
+
+ );
+ })}
+
+
+ >
);
}
diff --git a/apps/web/components/TableView.tsx b/apps/web/components/TableView.tsx
index 9c1aa6d..1e28a5b 100644
--- a/apps/web/components/TableView.tsx
+++ b/apps/web/components/TableView.tsx
@@ -1,26 +1,54 @@
+"use client";
+import { useRouter } from "next/navigation";
import Link from "next/link";
+import { useEffect, useRef, useState, useTransition } from "react";
+import { loadMoreRecords } from "@/app/t/[tableId]/[viewId]/actions";
+import { deleteRecords, updateRecord, updateViewConfig } from "@/lib/client";
import { EditableCell } from "./EditableCell";
import { AddRowButton } from "./RecordActions";
-import { linkPrimaries as buildLinkPrimaries, linkTargets as buildLinkTargets, type Meta, type RecordEnvelope, type ViewMeta, visibleFields } from "@/lib/types";
+import { linkPrimaries as buildLinkPrimaries, linkTargets as buildLinkTargets, fieldsForTable, type Meta, type RecordEnvelope, type ViewMeta, visibleFields } from "@/lib/types";
-const FROZEN_W = 200; // fixed width for frozen columns so left offsets are computable
+const DEFAULT_W = 200; // default column width (also the frozen-offset unit)
+const MIN_W = 80; // resize floor
+const CHECKBOX_W = 40; // leading selection column
/**
* Generic table/grid view. Header row freezes by default (stays visible scrolling
* down); the first `config.frozen` columns freeze (stay visible scrolling right) —
* both saved per view. The whole table is one scroll container so sticky works.
+ *
+ * Client component: it owns row selection (bulk delete) and cursor "load more"
+ * pagination, seeded from the server-rendered first page.
*/
export function TableView({
meta,
view,
records,
labels,
+ initialOffset,
}: {
meta: Meta;
view: ViewMeta;
records: RecordEnvelope[];
labels: Map
;
+ initialOffset?: string;
}) {
+ const router = useRouter();
+ const [rows, setRows] = useState(records);
+ const [labelMap, setLabelMap] = useState(labels);
+ const [offset, setOffset] = useState(initialOffset);
+ const [selected, setSelected] = useState>(new Set());
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [deleting, startDelete] = useTransition();
+
+ // A server refresh (e.g. after delete/edit) re-seeds from the fresh first page.
+ useEffect(() => {
+ setRows(records);
+ setLabelMap(labels);
+ setOffset(initialOffset);
+ setSelected(new Set());
+ }, [records, labels, initialOffset]);
+
const targets = buildLinkTargets(meta);
const primaries = buildLinkPrimaries(meta);
const cols = visibleFields(meta, view);
@@ -28,79 +56,582 @@ export function TableView({
const freezeHeader = view.config.freezeHeader !== false; // default on
const frozen = view.config.frozen ?? 1; // default: freeze the first (record-name) column
- const colStyle = (i: number): React.CSSProperties =>
- i < frozen ? { position: "sticky", left: i * FROZEN_W, minWidth: FROZEN_W, width: FROZEN_W, maxWidth: FROZEN_W } : {};
+ // Column widths: saved per view in config.fields[].width; drag the header's
+ // right edge to resize (live local state, persisted on release).
+ const savedWidths = new Map((view.config.fields ?? []).filter((f) => f.width).map((f) => [f.fieldId, f.width!]));
+ const [widths, setWidths] = useState>(savedWidths);
+ useEffect(() => {
+ setWidths(new Map((view.config.fields ?? []).filter((f) => f.width).map((f) => [f.fieldId, f.width!])));
+ }, [view.config.fields]);
+ const widthOf = (fieldId: string) => widths.get(fieldId) ?? DEFAULT_W;
+ const drag = useRef<{ fieldId: string; startX: number; startW: number } | null>(null);
+
+ const onResizeStart = (fieldId: string, e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ drag.current = { fieldId, startX: e.clientX, startW: widthOf(fieldId) };
+ const move = (ev: PointerEvent) => {
+ const d = drag.current;
+ if (!d) return;
+ const w = Math.max(MIN_W, Math.round(d.startW + (ev.clientX - d.startX)));
+ setWidths((prev) => new Map(prev).set(d.fieldId, w));
+ };
+ const up = async (ev: PointerEvent) => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ const d = drag.current;
+ drag.current = null;
+ if (!d) return;
+ const w = Math.max(MIN_W, Math.round(d.startW + (ev.clientX - d.startX)));
+ // Persist: merge into the explicit field list (create one from the current
+ // visible order when the view doesn't pin fields yet).
+ const base = view.config.fields && view.config.fields.length
+ ? view.config.fields
+ : cols.map((f) => ({ fieldId: f.fieldId }));
+ const next = base.map((f) => (f.fieldId === d.fieldId ? { ...f, width: w } : f));
+ try {
+ await updateViewConfig(view.viewId, { ...view.config, fields: next });
+ router.refresh();
+ } catch (err) {
+ alert(`Resize save failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ };
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ };
+
+ // Frozen data columns sit to the right of the always-frozen checkbox column;
+ // their left offsets accumulate the actual widths of the frozen columns before them.
+ const frozenLeft = (i: number) => {
+ let left = CHECKBOX_W;
+ for (let j = 0; j < i; j++) left += widthOf(cols[j]!.fieldId);
+ return left;
+ };
+ const colStyle = (i: number): React.CSSProperties => {
+ const explicit = widths.get(cols[i]!.fieldId);
+ const w = explicit ?? DEFAULT_W;
+ // Frozen columns always need a fixed width (offsets depend on it); other
+ // columns stay auto-width until the user resizes them.
+ if (i < frozen) return { position: "sticky", left: frozenLeft(i), minWidth: w, width: w, maxWidth: w };
+ return explicit ? { minWidth: explicit, width: explicit, maxWidth: explicit } : {};
+ };
const thStyle = (i: number): React.CSSProperties => ({
...(freezeHeader || i < frozen ? { position: "sticky" } : {}),
...(freezeHeader ? { top: 0 } : {}),
...colStyle(i),
zIndex: freezeHeader && i < frozen ? 30 : freezeHeader ? 20 : i < frozen ? 10 : undefined,
});
- const tdStyle = (i: number): React.CSSProperties => (i < frozen ? { ...colStyle(i), zIndex: 10 } : {});
+ const tdStyle = (i: number): React.CSSProperties => (i < frozen ? { ...colStyle(i), zIndex: 10 } : colStyle(i));
+ // The selection column is always frozen at the far left.
+ const selStyle = (header: boolean): React.CSSProperties => ({
+ position: "sticky",
+ left: 0,
+ minWidth: CHECKBOX_W,
+ width: CHECKBOX_W,
+ ...(header && freezeHeader ? { top: 0 } : {}),
+ zIndex: header ? 30 : 11,
+ });
+
+ const allSelected = rows.length > 0 && selected.size === rows.length;
+ const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
+ const toggleOne = (id: string) =>
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+
+ // ---- column header menu (sort / group / hide without opening the toolbar) ----
+ const [headerMenu, setHeaderMenu] = useState(null);
+ const [footerMenuOpen, setFooterMenuOpen] = useState(null);
+ useEffect(() => {
+ if (!headerMenu && !footerMenuOpen) return;
+ const close = () => { setHeaderMenu(null); setFooterMenuOpen(null); };
+ window.addEventListener("click", close);
+ return () => window.removeEventListener("click", close);
+ }, [headerMenu, footerMenuOpen]);
+ async function saveConfig(next: typeof view.config) {
+ try {
+ await updateViewConfig(view.viewId, next);
+ router.refresh();
+ } catch (e) {
+ alert(`Save failed: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ }
+ const sortBy = (fieldId: string, direction: "asc" | "desc") =>
+ saveConfig({ ...view.config, sorts: [{ fieldId, direction }] });
+ const setSummary = (fieldId: string, agg: "count" | "sum" | "avg" | "min" | "max" | undefined) => {
+ const next = { ...(view.config.summaries ?? {}) };
+ if (agg) next[fieldId] = agg; else delete next[fieldId];
+ void saveConfig({ ...view.config, summaries: Object.keys(next).length ? next : undefined });
+ };
+ const colorBy = (fieldId: string | undefined) => saveConfig({ ...view.config, colorBy: fieldId });
+ const hideField = (fieldId: string) => {
+ const base = view.config.fields && view.config.fields.length
+ ? view.config.fields
+ : cols.map((f) => ({ fieldId: f.fieldId }));
+ saveConfig({ ...view.config, fields: base.filter((f) => f.fieldId !== fieldId) });
+ };
+ const groupByField = (fieldId: string | undefined) => saveConfig({ ...view.config, groupBy: fieldId });
+
+ // ---- keyboard navigation + clipboard --------------------------------------
+ // Click selects a cell (the cursor); arrows move it, Enter edits, Esc clears,
+ // ⌘/Ctrl+C copies the cell text, ⌘/Ctrl+V pastes into editable scalar fields.
+ const [cursor, setCursor] = useState<{ r: number; c: number } | null>(null);
+ const gridRef = useRef(null);
+ const PASTEABLE = new Set(["text", "longtext", "number", "select", "date", "datetime", "url", "email"]);
+
+ const cellEl = (r: number, c: number) =>
+ gridRef.current?.querySelector(`td[data-r="${r}"][data-c="${c}"]`) ?? null;
+
+ async function onGridKeyDown(e: React.KeyboardEvent) {
+ // Let an open editor (input/textarea/select) own the keyboard.
+ const tag = (e.target as HTMLElement).tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
+ if (!cursor) return;
+ const move = (dr: number, dc: number) => {
+ e.preventDefault();
+ const r = Math.max(0, Math.min(rows.length - 1, cursor.r + dr));
+ const c = Math.max(0, Math.min(cols.length - 1, cursor.c + dc));
+ setCursor({ r, c });
+ cellEl(r, c)?.scrollIntoView({ block: "nearest", inline: "nearest" });
+ };
+ if (e.key === "ArrowDown") return move(1, 0);
+ if (e.key === "ArrowUp") return move(-1, 0);
+ if (e.key === "ArrowRight") return move(0, 1);
+ if (e.key === "ArrowLeft") return move(0, -1);
+ if (e.key === "Escape") return setCursor(null);
+ if (e.key === "Enter") {
+ e.preventDefault();
+ // Open the cell's editor (or follow the primary link's behavior).
+ const el = cellEl(cursor.r, cursor.c);
+ el?.querySelector("div.cursor-text, button, a")?.click();
+ return;
+ }
+ const mod = e.metaKey || e.ctrlKey;
+ if (mod && e.key === "c") {
+ const el = cellEl(cursor.r, cursor.c);
+ if (el) void navigator.clipboard.writeText(el.innerText.trim());
+ return;
+ }
+ if (mod && e.key === "v") {
+ const rec = flatRows[cursor.r];
+ const field = cols[cursor.c];
+ if (!rec || !field || field.fieldId === primaryId || !PASTEABLE.has(field.type)) return;
+ e.preventDefault();
+ try {
+ const text = (await navigator.clipboard.readText()).trim();
+ const value = field.type === "number" ? Number(text) : text;
+ if (field.type === "number" && !Number.isFinite(value as number)) return;
+ await updateRecord(view.tableId, rec.id, { [field.fieldId]: text === "" ? null : value });
+ router.refresh();
+ } catch (err) {
+ alert(`Paste failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+ }
+
+ // ---- grouped rows: config.groupBy segments the loaded rows under section
+ // headers. Select fields keep their option order; other values sort naturally;
+ // the empty bucket renders last. ----
+ const groupField = view.config.groupBy ? cols.find((f) => f.fieldId === view.config.groupBy)
+ ?? visibleFields(meta, view).find((f) => f.fieldId === view.config.groupBy) : undefined;
+ const EMPTY = "__empty__";
+ let sections: Array<{ label: string; rows: RecordEnvelope[] }> | null = null;
+ if (groupField) {
+ const byVal = new Map();
+ for (const rec of rows) {
+ const v = rec.fields[groupField.fieldId];
+ const key = v == null || v === "" ? EMPTY : String(v);
+ const arr = byVal.get(key) ?? [];
+ arr.push(rec);
+ byVal.set(key, arr);
+ }
+ const choiceOrder = groupField.options?.choices?.map((c) => c.name) ?? [];
+ const keys = [...byVal.keys()].sort((a, b) => {
+ if (a === EMPTY) return 1;
+ if (b === EMPTY) return -1;
+ const ia = choiceOrder.indexOf(a), ib = choiceOrder.indexOf(b);
+ if (ia !== -1 || ib !== -1) return (ia === -1 ? 1e9 : ia) - (ib === -1 ? 1e9 : ib);
+ return a.localeCompare(b, undefined, { numeric: true });
+ });
+ sections = keys.map((k) => ({ label: k === EMPTY ? "(empty)" : k, rows: byVal.get(k)! }));
+ }
+ // Flat visual row order (sections concatenated) — the keyboard cursor's space.
+ const flatRows = sections ? sections.flatMap((s) => s.rows) : rows;
+ const rowIndex = new Map(flatRows.map((r, i) => [r.id, i]));
+
+ // ---- row coloring: tint each row by a select field's value ----
+ const PALETTE = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"];
+ const colorField = view.config.colorBy ? cols.find((f) => f.fieldId === view.config.colorBy) : undefined;
+ const colorOf = (rec: RecordEnvelope): string | undefined => {
+ if (!colorField) return undefined;
+ const v = rec.fields[colorField.fieldId];
+ if (v == null || v === "") return undefined;
+ const choices = colorField.options?.choices ?? [];
+ const idx = choices.findIndex((c) => c.name === String(v));
+ return choices[idx]?.color ?? PALETTE[(idx + PALETTE.length) % PALETTE.length];
+ };
+
+ // ---- summary footer: per-column aggregates over the loaded rows ----
+ const summaries = view.config.summaries ?? {};
+ const summaryValue = (fieldId: string, agg: string): string => {
+ const vals = flatRows.map((r) => r.fields[fieldId]);
+ if (agg === "count") return String(vals.filter((v) => v != null && v !== "").length);
+ const nums = vals.map((v) => (typeof v === "number" ? v : Number(v))).filter((n) => Number.isFinite(n));
+ if (nums.length === 0) return "—";
+ const sum = nums.reduce((a, b) => a + b, 0);
+ const out = agg === "sum" ? sum : agg === "avg" ? sum / nums.length : agg === "min" ? Math.min(...nums) : Math.max(...nums);
+ return Number.isInteger(out) ? String(out) : out.toFixed(2);
+ };
+
+ // ---- side peek: open a record in a slide-over without leaving the grid ----
+ const [peekId, setPeekId] = useState(null);
+ useEffect(() => {
+ if (!peekId) return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setPeekId(null); };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [peekId]);
+ const peekRec = peekId ? rows.find((r) => r.id === peekId) : undefined;
+
+ async function onLoadMore() {
+ if (!offset) return;
+ setLoadingMore(true);
+ try {
+ const res = await loadMoreRecords(view.tableId, view.viewId, offset);
+ setRows((prev) => [...prev, ...res.records]);
+ setLabelMap((prev) => new Map([...prev, ...res.labels]));
+ setOffset(res.offset);
+ } catch (e) {
+ alert(`Load more failed: ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setLoadingMore(false);
+ }
+ }
+
+ function onDeleteSelected() {
+ const ids = [...selected];
+ if (ids.length === 0) return;
+ if (!window.confirm(`Delete ${ids.length} record${ids.length === 1 ? "" : "s"}? This can’t be undone.`)) return;
+ startDelete(async () => {
+ try {
+ await deleteRecords(view.tableId, ids);
+ setSelected(new Set());
+ router.refresh();
+ } catch (e) {
+ alert(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ });
+ }
+
+ function renderRow(rec: RecordEnvelope) {
+ const checked = selected.has(rec.id);
+ const ri = rowIndex.get(rec.id)!;
+ const tint = colorOf(rec);
+ return (
+
+
+ toggleOne(rec.id)} />
+
+ {cols.map((f, i) => {
+ const value = rec.fields[f.fieldId];
+ const isPrimary = f.fieldId === primaryId;
+ const focused = cursor?.r === ri && cursor.c === i;
+ return (
+ setCursor({ r: ri, c: i })}
+ style={tdStyle(i)}
+ className={`px-3 py-2 align-top ${i < frozen ? "border-r border-surface-border bg-white" : "group-hover:bg-surface-muted/60"} ${focused ? "ring-2 ring-inset ring-blue-500" : ""}`}
+ >
+ {isPrimary ? (
+ {
+ // Plain click peeks; ⌘/Ctrl-click keeps the full-page behavior.
+ if (e.metaKey || e.ctrlKey) return;
+ e.preventDefault();
+ setPeekId(rec.id);
+ }}
+ className="font-medium text-neutral-900 hover:text-blue-600 hover:underline"
+ >
+ {value != null && value !== "" ? String(value) : "(untitled)"}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ );
+ }
return (
-
-
-
-
- {cols.map((f, i) => (
-
- {f.name}
- {f.isComputed ? ƒ : null}
+
+ {selected.size > 0 ? (
+
+ {selected.size} selected
+
+ {deleting ? "Deleting…" : "Delete"}
+
+ setSelected(new Set())}>
+ Clear
+
+
+ ) : null}
+
+
-
-
+ ) : sections ? (
+ sections.map((s) => (
+
+ {s.rows.map(renderRow)}
+
+ ))
+ ) : (
+ rows.map(renderRow)
+ )}
+
+ {/* Summary footer: click a cell to pick an aggregate (computed over the
+ loaded rows). Sticky so it stays visible while scrolling. */}
+
+
+
+ {cols.map((f, i) => {
+ const agg = summaries[f.fieldId];
+ const numeric = f.type === "number" || f.type === "rollup";
+ return (
+
+ { e.stopPropagation(); setFooterMenuOpen(footerMenuOpen === f.fieldId ? null : f.fieldId); }}
+ className={`w-full truncate text-left text-[11px] ${agg ? "font-medium text-neutral-700" : "text-neutral-300 hover:text-neutral-500"}`}
+ title={agg ? `${agg} of ${f.name} (loaded rows)` : `Summarize ${f.name}`}
+ >
+ {agg ? `${agg === "count" ? "filled" : agg} ${summaryValue(f.fieldId, agg)}` : "Σ"}
+
+ {footerMenuOpen === f.fieldId ? (
+ e.stopPropagation()}>
+ {(numeric ? ["count", "sum", "avg", "min", "max"] : ["count"]).map((a) => (
+ { setFooterMenuOpen(null); setSummary(f.fieldId, a as "count"); }}>
+ {a === "count" ? "filled count" : a}
+
+ ))}
+ {agg ? (
+ { setFooterMenuOpen(null); setSummary(f.fieldId, undefined); }}>
+ none
+
+ ) : null}
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ {/* Safe-area padding keeps Add/Load-more clear of mobile browser chrome. */}
+
+
+ {offset ? (
+
+ {loadingMore ? "Loading…" : "Load more"}
+
+ ) : null}
+
{rows.length} loaded{offset ? "+" : ""}
+
+
+ {/* Side peek: edit a record in a slide-over without leaving the grid.
+ ⌘/Ctrl-click the record name still opens the full page. */}
+ {peekRec ? (
+ <>
+ setPeekId(null)} aria-hidden />
+
+
+
+ {primaryId != null && peekRec.fields[primaryId] != null && peekRec.fields[primaryId] !== ""
+ ? String(peekRec.fields[primaryId])
+ : "(untitled)"}
+
+
+
+ Open full page →
+
+ setPeekId(null)} className="rounded-md px-2 py-1 text-neutral-400 hover:bg-surface-muted hover:text-neutral-700" aria-label="Close">
+ ✕
+
+
+
+
+ {fieldsForTable(meta, view.tableId).map((f) => (
+
+
+ {f.name}
+ {f.isComputed ? ƒ : null}
+
+
+
+
+
+ ))}
+
+
+ >
+ ) : null}
);
}
+
+/** One group section: a sticky-left header row (value + count, click to
+ * collapse/expand) followed by its record rows. */
+function SectionRows({
+ label,
+ count,
+ colCount,
+ groupName,
+ children,
+}: {
+ label: string;
+ count: number;
+ colCount: number;
+ groupName: string;
+ children: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(true);
+ return (
+ <>
+
+
+ setOpen((o) => !o)}
+ className="sticky left-0 flex max-w-[100vw] items-center gap-2 px-3 py-1.5 text-xs"
+ title={`${groupName}: ${label}`}
+ >
+ {open ? "▾" : "▸"}
+ {label}
+ {count}
+
+
+
+ {open ? children : null}
+ >
+ );
+}
diff --git a/apps/web/components/ViewSwitcher.tsx b/apps/web/components/ViewSwitcher.tsx
index 6308777..ecaa107 100644
--- a/apps/web/components/ViewSwitcher.tsx
+++ b/apps/web/components/ViewSwitcher.tsx
@@ -3,7 +3,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { createView, deleteView, hideView, renameView } from "@/lib/client";
-import type { ViewConfig } from "@/lib/types";
+import { VIEW_TYPES, type ViewConfig, type ViewType } from "@/lib/types";
export interface SwitcherView {
viewId: string;
@@ -12,14 +12,21 @@ export interface SwitcherView {
isHidden: boolean;
}
-const TYPE_ICON: Record = { table: "▦", kanban: "▤", calendar: "▥", form: "✎", detail: "❏", dashboard: "📊" };
-const ADDABLE = [
- { type: "table", label: "Grid" },
- { type: "kanban", label: "Kanban" },
- { type: "calendar", label: "Calendar" },
- { type: "form", label: "Form" },
- { type: "dashboard", label: "Dashboard" },
-];
+// One registry per view type, keyed by the shared ViewType union — adding a view
+// type without an entry here is a compile error, so the icon/label/addable lists
+// can't silently drift. (detail is API-creatable but not offered in the menu.)
+const TYPE_INFO: Record = {
+ table: { icon: "▦", label: "Grid", addable: true },
+ kanban: { icon: "▤", label: "Kanban", addable: true },
+ calendar: { icon: "▥", label: "Calendar", addable: true },
+ gallery: { icon: "▣", label: "Gallery", addable: true },
+ gantt: { icon: "≡", label: "Gantt", addable: true },
+ form: { icon: "✎", label: "Form", addable: true },
+ dashboard: { icon: "📊", label: "Dashboard", addable: true },
+ detail: { icon: "❏", label: "Detail", addable: false },
+};
+const ADDABLE = VIEW_TYPES.filter((t) => TYPE_INFO[t].addable).map((type) => ({ type, label: TYPE_INFO[type].label }));
+const iconOf = (type: string): string => TYPE_INFO[type as ViewType]?.icon ?? "▦";
/** Tabs across a table's views + a manager (add / rename / duplicate / hide / delete). */
export function ViewSwitcher({
@@ -87,7 +94,7 @@ export function ViewSwitcher({
}
return (
-
+
{visible.map((v) => {
const active = v.viewId === currentViewId;
return (
@@ -98,7 +105,7 @@ export function ViewSwitcher({
active ? "border-blue-600 font-medium text-blue-700" : "border-transparent text-neutral-500 hover:text-neutral-800"
}`}
>
-
{TYPE_ICON[v.type] ?? "▦"}
+
{iconOf(v.type)}
{v.name}
{active ? (
@@ -115,7 +122,7 @@ export function ViewSwitcher({
Add view
{ADDABLE.map((a) => (
add(a.type)}>
- {TYPE_ICON[a.type]} {a.label}
+ {iconOf(a.type)} {a.label}
))}
diff --git a/apps/web/components/ViewToolbar.tsx b/apps/web/components/ViewToolbar.tsx
index 07bc4de..065f486 100644
--- a/apps/web/components/ViewToolbar.tsx
+++ b/apps/web/components/ViewToolbar.tsx
@@ -1,8 +1,11 @@
"use client";
+import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
+import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
-import { updateViewConfig } from "@/lib/client";
-import type { FieldMeta, FilterCondition, FilterGroup, FilterItem, Meta, ViewConfig } from "@/lib/types";
+import { createField, deleteField, updateViewConfig } from "@/lib/client";
+import type { FieldMeta, FieldOptions, FieldType, FilterCondition, FilterGroup, FilterItem, Meta, ViewConfig } from "@/lib/types";
import { fieldsForTable, isFilterGroup } from "@/lib/types";
const OPS_BY_KIND: Record
> = {
@@ -74,22 +77,26 @@ function effectiveField(field: FieldMeta, linkedFieldId: string | undefined, met
}
const btn = "rounded-md border border-surface-border px-2.5 py-1 text-xs text-neutral-600 hover:bg-surface-muted";
-const panel = "absolute z-40 mt-1 max-h-[70vh] w-[28rem] max-w-[calc(100vw-6rem)] overflow-auto rounded-lg border border-surface-border bg-white p-3 shadow-lg";
+// Popovers clamp to the viewport so they can't run off-page on narrow screens.
+const panel = "absolute z-40 mt-1 max-h-[70vh] w-[28rem] max-w-[calc(100vw-1.5rem)] overflow-auto rounded-lg border border-surface-border bg-white p-3 shadow-lg";
+const panelSm = "absolute z-40 mt-1 w-72 max-w-[calc(100vw-1.5rem)] rounded-lg border border-surface-border bg-white p-3 shadow-lg";
export function ViewToolbar({
viewId,
+ viewType,
fields,
config,
meta,
}: {
viewId: string;
+ viewType: string;
fields: FieldMeta[];
config: ViewConfig;
meta: Meta;
}) {
const router = useRouter();
const [cfg, setCfg] = useState(config);
- const [open, setOpen] = useState(null);
+ const [open, setOpen] = useState(null);
const ref = useRef(null);
useEffect(() => setCfg(config), [config]);
@@ -106,14 +113,40 @@ export function ViewToolbar({
}
// Link + lookup fields are now filterable/sortable (via a linked sub-field).
+ // Computed fields (formula/rollup) aren't sortable — they aren't stored columns.
const filterable = fields.filter((f) => kindOf(f) !== null);
- const sortable = fields.filter((f) => f.type !== "formula");
+ const sortable = fields.filter((f) => f.type !== "formula" && f.type !== "rollup");
+ // Single-select fields are the valid Kanban grouping fields; date/datetime
+ // fields are the valid Calendar placement fields.
+ const stackFields = fields.filter((f) => f.type === "select");
+ const dateFields = fields.filter((f) => f.type === "date" || f.type === "datetime");
const conds = cfg.filters?.conditions ?? [];
const sorts = cfg.sorts ?? [];
const hiddenCount = cfg.fields ? fields.length - cfg.fields.length : 0;
+ // Per-view-type config popover (one button + one panel; add new view types here).
+ const typeEditors: Record = {
+ kanban: {
+ label: "Kanban",
+ el: save({ ...cfg, kanban })} />,
+ },
+ calendar: {
+ label: "Calendar",
+ el: save({ ...cfg, calendar })} />,
+ },
+ gallery: {
+ label: "Gallery",
+ el: f.type === "url")} gallery={cfg.gallery} onChange={(gallery) => save({ ...cfg, gallery })} />,
+ },
+ gantt: {
+ label: "Gantt",
+ el: save({ ...cfg, gantt })} />,
+ },
+ };
+ const typeEditor = typeEditors[viewType];
+
return (
-
+
setOpen(open === "filter" ? null : "filter")}>
Filter{conds.length ? ` (${conds.length})` : ""}
@@ -126,6 +159,11 @@ export function ViewToolbar({
setOpen(open === "freeze" ? null : "freeze")}>
Freeze
+ {typeEditor ? (
+
setOpen(open === "config" ? null : "config")}>
+ {typeEditor.label}
+
+ ) : null}
{open === "filter" ? (
@@ -139,11 +177,11 @@ export function ViewToolbar({
) : null}
{open === "fields" ? (
- save({ ...cfg, fields: f })} />
+ save({ ...cfg, fields: f })} />
) : null}
{open === "freeze" ? (
-
+
) : null}
+ {open === "config" && typeEditor ? (
+
+ {typeEditor.el}
+
+ ) : null}
+
+ );
+}
+
+/** Non-negative-integer input that commits on blur/Enter — no save per keystroke,
+ * and clearing it restores the default (commits undefined). */
+function CountInput({ value, fallback, onCommit }: { value: number | undefined; fallback: number; onCommit: (n: number | undefined) => void }) {
+ return (
+
{
+ const raw = e.target.value.trim();
+ const n = Math.max(0, Math.floor(Number(raw)));
+ onCommit(raw !== "" && Number.isFinite(n) ? n : undefined);
+ }}
+ onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
+ />
+ );
+}
+
+/** Labeled single-field picker shared by the view-type config editors. With
+ * `none` set, an empty choice (labelled by it) maps to undefined; otherwise the
+ * placeholder is a disabled "Choose a field…". */
+function FieldSelect({
+ label,
+ fields,
+ value,
+ none,
+ onChange,
+}: {
+ label: string;
+ fields: FieldMeta[];
+ value: string;
+ none?: string;
+ onChange: (fieldId: string | undefined) => void;
+}) {
+ return (
+
+ {label}
+ onChange(e.target.value || undefined)}
+ >
+ {none != null ? {none} : Choose a field… }
+ {fields.map((f) => {f.name} )}
+
+
+ );
+}
+
+/** Configure a Gallery view: an optional cover-image field (url) + how many
+ * fields each card previews. Card field selection is the shared Fields editor. */
+function GalleryEditor({
+ urlFields,
+ gallery,
+ onChange,
+}: {
+ urlFields: FieldMeta[];
+ gallery: ViewConfig["gallery"];
+ onChange: (g: NonNullable
) => void;
+}) {
+ return (
+
+
onChange({ ...gallery, coverFieldId })} />
+ {urlFields.length === 0 ? Add a URL field to use card covers.
: null}
+
+ Max card fields
+ onChange({ ...gallery, maxPreviewFields: n })} />
+
+ Use the Fields menu to choose which fields show on each card.
+
+ );
+}
+
+/** Configure a Gantt view: the start (required) and end (optional) date fields
+ * that define each record's bar. */
+function GanttEditor({
+ dateFields,
+ gantt,
+ onChange,
+}: {
+ dateFields: FieldMeta[];
+ gantt: ViewConfig["gantt"];
+ onChange: (g: NonNullable) => void;
+}) {
+ const startFieldId = gantt?.startFieldId ?? "";
+ if (dateFields.length === 0) {
+ return Add a date or date-&-time field to plot records on a timeline.
;
+ }
+ return (
+
+
onChange({ ...gantt, startFieldId: id ?? "" })} />
+ f.fieldId !== startFieldId)}
+ value={gantt?.endFieldId ?? ""}
+ none="None — 1-day bars"
+ onChange={(endFieldId) => onChange({ ...gantt, startFieldId, endFieldId })}
+ />
+ Each record renders a bar from start to end on a shared timeline.
+
+ );
+}
+
+/** Configure a Calendar view: which date/datetime field places records on the
+ * month grid. Mirrors the Kanban stack-field picker. */
+function CalendarEditor({
+ dateFields,
+ calendar,
+ onChange,
+}: {
+ dateFields: FieldMeta[];
+ calendar: ViewConfig["calendar"];
+ onChange: (c: NonNullable) => void;
+}) {
+ const dateFieldId = calendar?.dateFieldId ?? "";
+ if (dateFields.length === 0) {
+ return Add a date or date-&-time field to place records on a calendar.
;
+ }
+ return (
+
+
onChange({ dateFieldId: id ?? "" })} />
+ Records appear on the month grid by this field. Drag an event to another day to update it.
+
+ );
+}
+
+/** Configure a Kanban view: which single-select field stacks into columns, and
+ * a soft cap on how many fields each card previews. Card field selection itself
+ * is the shared "Fields" editor. */
+function KanbanEditor({
+ stackFields,
+ kanban,
+ onChange,
+}: {
+ stackFields: FieldMeta[];
+ kanban: ViewConfig["kanban"];
+ onChange: (k: NonNullable) => void;
+}) {
+ const stackFieldId = kanban?.stackFieldId ?? "";
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
+ if (stackFields.length === 0) {
+ return Add a single-select field to this table to group a Kanban by it.
;
+ }
+ // Buckets to order = the chosen field's options, in the saved order first.
+ const stackField = stackFields.find((f) => f.fieldId === stackFieldId);
+ const choices = stackField?.options?.choices?.map((c) => c.name) ?? [];
+ const order = kanban?.columnOrder ?? [];
+ const buckets = [...order.filter((n) => choices.includes(n)), ...choices.filter((n) => !order.includes(n))];
+
+ const onBucketDragEnd = (e: DragEndEvent) => {
+ if (!e.over || e.active.id === e.over.id) return;
+ const from = buckets.indexOf(String(e.active.id));
+ const to = buckets.indexOf(String(e.over.id));
+ if (from < 0 || to < 0) return;
+ onChange({ ...kanban, stackFieldId, columnOrder: arrayMove(buckets, from, to) });
+ };
+
+ return (
+
+ {/* Changing the field invalidates the old field's column order. */}
+
onChange({ ...kanban, stackFieldId: id ?? "", columnOrder: undefined })} />
+
+ Max card fields
+ onChange({ ...kanban, stackFieldId, maxPreviewFields: n })} />
+
+ {buckets.length ? (
+
+
Column order
+
+
+
+ {buckets.map((name) => )}
+
+
+
+
Drag to set left-to-right column order. “Uncategorized” always shows last.
+
+ ) : null}
+ Use the Fields menu to choose which fields show on each card.
+
+ );
+}
+
+/** One draggable Kanban bucket in the column-order list. */
+function SortableBucketRow({ name }: { name: string }) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: name });
+ const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined };
+ return (
+
+
+ ⠿
+
+ {name}
);
}
@@ -396,43 +647,199 @@ function SortEditor({ fields, meta, sorts, onChange }: { fields: FieldMeta[]; me
);
}
-function FieldEditor({ allFields, configFields, onChange }: { allFields: FieldMeta[]; configFields: ViewConfig["fields"]; onChange: (f: ViewConfig["fields"]) => void }) {
+function FieldEditor({ allFields, configFields, meta, onChange }: { allFields: FieldMeta[]; configFields: ViewConfig["fields"]; meta: Meta; onChange: (f: ViewConfig["fields"]) => void }) {
+ const router = useRouter();
+ const tableId = allFields[0]?.tableId;
// Current ordered+visible list; default = all fields by position.
const ordered = configFields && configFields.length
? configFields.map((c) => allFields.find((f) => f.fieldId === c.fieldId)).filter((f): f is FieldMeta => Boolean(f))
: allFields;
const visible = new Set(ordered.map((f) => f.fieldId));
+ // A short drag distance keeps the checkbox clickable without starting a drag.
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
+
const setVisible = (fieldId: string, on: boolean) => {
const next = on
? [...ordered, allFields.find((f) => f.fieldId === fieldId)!]
: ordered.filter((f) => f.fieldId !== fieldId);
onChange(next.map((f) => ({ fieldId: f.fieldId })));
};
- const move = (i: number, dir: -1 | 1) => {
- const j = i + dir;
- if (j < 0 || j >= ordered.length) return;
- const next = [...ordered];
- [next[i], next[j]] = [next[j]!, next[i]!];
- onChange(next.map((f) => ({ fieldId: f.fieldId })));
+ const onDragEnd = (e: DragEndEvent) => {
+ if (!e.over || e.active.id === e.over.id) return;
+ const from = ordered.findIndex((f) => f.fieldId === e.active.id);
+ const to = ordered.findIndex((f) => f.fieldId === e.over!.id);
+ if (from < 0 || to < 0) return;
+ onChange(arrayMove(ordered, from, to).map((f) => ({ fieldId: f.fieldId })));
+ };
+ const onDelete = async (field: FieldMeta) => {
+ if (!tableId) return;
+ if (!window.confirm(`Delete the field “${field.name}”? This removes its data and can’t be undone.`)) return;
+ try {
+ await deleteField(tableId, field.fieldId);
+ router.refresh();
+ } catch (e) {
+ alert(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
+ }
};
return (
-
- {ordered.map((f, i) => (
-
- setVisible(f.fieldId, false)} />
- {f.name}
- move(i, -1)}>↑
- move(i, 1)}>↓
-
- ))}
+ // pr-3 keeps the drag handle clear of the (overlay) scrollbar.
+
+
+ f.fieldId)} strategy={verticalListSortingStrategy}>
+ {ordered.map((f) => (
+ setVisible(f.fieldId, false)} onDelete={() => onDelete(f)} />
+ ))}
+
+
{allFields.filter((f) => !visible.has(f.fieldId)).map((f) => (
-
+ // pl-8 aligns the hidden rows under the visible rows' checkbox (past the handle).
+
setVisible(f.fieldId, true)} />
{f.name}
+ {!f.isPrimary ? (
+ onDelete(f)}>✕
+ ) : null}
))}
+ {tableId ? (
+
{
+ // If the view pins an explicit field list, append the new field so it
+ // shows right away (save() refreshes); otherwise all fields show already.
+ if (configFields && configFields.length) onChange([...configFields, { fieldId: field.fieldId }]);
+ else router.refresh();
+ }}
+ />
+ ) : null}
+
+ );
+}
+
+/** One draggable visible-field row: a generous left drag handle reorders,
+ * checkbox hides, and a hover ✕ deletes (non-primary only). The handle is on
+ * the left so the scroll container's scrollbar never overlaps it. */
+function SortableFieldRow({ field, onHide, onDelete }: { field: FieldMeta; onHide: () => void; onDelete: () => void }) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.fieldId });
+ const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined };
+ return (
+
+
+ ⠿
+
+
+ {field.name}
+ {!field.isPrimary ? (
+ ✕
+ ) : null}
+
+ );
+}
+
+const AGGS: Array<"count" | "sum" | "avg" | "min" | "max"> = ["count", "sum", "avg", "min", "max"];
+const CREATE_TYPES: Array<{ type: FieldType; label: string }> = [
+ { type: "text", label: "Text" }, { type: "longtext", label: "Long text" }, { type: "number", label: "Number" },
+ { type: "checkbox", label: "Checkbox" }, { type: "date", label: "Date" }, { type: "datetime", label: "Date & time" },
+ { type: "url", label: "URL" }, { type: "email", label: "Email" },
+ { type: "select", label: "Single select" }, { type: "multiselect", label: "Multi select" },
+ { type: "rollup", label: "Rollup" },
+];
+
+/** Self-service "add field" form. Stored types create a column; rollups aggregate
+ * a number across a link field; select types take comma-separated choices. */
+function AddFieldForm({ tableId, fields, meta, onCreated }: { tableId: string; fields: FieldMeta[]; meta: Meta; onCreated: (field: FieldMeta) => void }) {
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [type, setType] = useState
("text");
+ const [choices, setChoices] = useState("");
+ const [via, setVia] = useState("");
+ const [agg, setAgg] = useState<"count" | "sum" | "avg" | "min" | "max">("count");
+ const [target, setTarget] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const linkFields = fields.filter((f) => f.type === "link");
+ const viaField = linkFields.find((f) => f.fieldId === via);
+ const targetFields = viaField?.options?.linkedTableId
+ ? fieldsForTable(meta, viaField.options.linkedTableId).filter((f) => f.type === "number")
+ : [];
+ const sel = "w-full rounded border border-surface-border px-1.5 py-1 text-xs";
+
+ const reset = () => { setName(""); setType("text"); setChoices(""); setVia(""); setAgg("count"); setTarget(""); };
+ const submit = async () => {
+ if (!name.trim()) return;
+ let options: FieldOptions | null = null;
+ if (type === "rollup") {
+ if (!via) { alert("Pick a link field to roll up."); return; }
+ if (agg !== "count" && !target) { alert("Pick a number field to aggregate."); return; }
+ options = { rollup: { via, agg, ...(agg !== "count" && target ? { target } : {}) } };
+ } else if (type === "select" || type === "multiselect") {
+ options = { choices: choices.split(",").map((s) => s.trim()).filter(Boolean).map((nm) => ({ name: nm })) };
+ }
+ setBusy(true);
+ try {
+ const created = await createField(tableId, { name: name.trim(), type, options });
+ reset(); setOpen(false); onCreated(created);
+ } catch (e) {
+ alert(`Create failed: ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (!open) {
+ return (
+ setOpen(true)}>
+ + Add field
+
+ );
+ }
+ return (
+
+
setName(e.target.value)} />
+
setType(e.target.value as FieldType)}>
+ {CREATE_TYPES.map((t) => {t.label} )}
+
+ {(type === "select" || type === "multiselect") ? (
+
setChoices(e.target.value)} />
+ ) : null}
+ {type === "rollup" ? (
+ <>
+
{ setVia(e.target.value); setTarget(""); }}>
+ Roll up via… (a link field)
+ {linkFields.map((f) => {f.name} )}
+
+
setAgg(e.target.value as typeof agg)}>
+ {AGGS.map((a) => {a.toUpperCase()} )}
+
+ {agg !== "count" ? (
+
setTarget(e.target.value)}>
+ Number field to aggregate…
+ {targetFields.map((f) => {f.name} )}
+
+ ) : null}
+ {linkFields.length === 0 ?
This table has no link fields to roll up.
: null}
+ >
+ ) : null}
+
+
+ {busy ? "Adding…" : "Add field"}
+
+ { reset(); setOpen(false); }}>Cancel
+
);
}
diff --git a/apps/web/components/WidgetBuilder.tsx b/apps/web/components/WidgetBuilder.tsx
index e4dbbbf..ecf2d4d 100644
--- a/apps/web/components/WidgetBuilder.tsx
+++ b/apps/web/components/WidgetBuilder.tsx
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
-import type { AggFn, DateBucket, Meta, Widget, WidgetType } from "@/lib/types";
+import { type AggFn, type DateBucket, fieldsForTable, type Meta, type Widget, type WidgetType } from "@/lib/types";
const TYPES: Array<{ type: WidgetType; label: string }> = [
{ type: "kpi", label: "KPI number" },
@@ -10,7 +10,6 @@ const TYPES: Array<{ type: WidgetType; label: string }> = [
];
const AGGS: AggFn[] = ["count", "sum", "avg", "min", "max"];
const BUCKETS: DateBucket[] = ["day", "week", "month", "year"];
-const NON_COLUMN = new Set(["formula", "lookup", "link"]);
function newId(): string {
return "wgt" + Math.random().toString(36).slice(2, 12);
@@ -25,9 +24,11 @@ export function WidgetBuilder({ meta, initial, onSave, onClose }: { meta: Meta;
);
const set = (patch: Partial) => setW((s) => ({ ...s, ...patch }));
- const tableFields = meta.fields.filter((f) => f.tableId === w.tableId);
+ const tableFields = fieldsForTable(meta, w.tableId);
const numberFields = tableFields.filter((f) => f.type === "number");
- const groupable = tableFields.filter((f) => !NON_COLUMN.has(f.type));
+ // Group-by must be a stored column: computed fields (formula/lookup/rollup)
+ // and links have none, and the server rejects them.
+ const groupable = tableFields.filter((f) => !f.isComputed && f.type !== "link");
const groupField = tableFields.find((f) => f.fieldId === w.groupByFieldId);
const groupIsDate = groupField?.type === "date" || groupField?.type === "datetime";
@@ -40,7 +41,10 @@ export function WidgetBuilder({ meta, initial, onSave, onClose }: { meta: Meta;
function save() {
const title = w.title.trim() || defaultTitle(w, meta);
- onSave({ ...w, title });
+ // Materialize the Bucket select's displayed default ("month") so the stored
+ // widget matches what the editor shows; clear it when grouping isn't a date.
+ const bucket = isChart && groupIsDate ? (w.bucket ?? "month") : undefined;
+ onSave({ ...w, title, bucket });
}
return (
@@ -63,7 +67,9 @@ export function WidgetBuilder({ meta, initial, onSave, onClose }: { meta: Meta;
- set({ tableId: e.target.value, metricFieldId: undefined, groupByFieldId: undefined })}>
+ {/* Switching tables invalidates every field reference — clear them all,
+ or the server rejects the save (fields must belong to the table). */}
+ set({ tableId: e.target.value, metricFieldId: undefined, groupByFieldId: undefined, fieldIds: undefined, filter: undefined })}>
{meta.tables.map((t) => {t.name} )}
diff --git a/apps/web/lib/client.ts b/apps/web/lib/client.ts
index 19d4500..d55c673 100644
--- a/apps/web/lib/client.ts
+++ b/apps/web/lib/client.ts
@@ -1,5 +1,5 @@
"use client";
-import type { DashboardMeta, ViewConfig, ViewMeta, Widget } from "./types";
+import type { DashboardMeta, FieldMeta, FieldOptions, FieldType, ViewConfig, ViewMeta, Widget } from "./types";
/**
* Browser-side calls to the BFF (/api/grid/* → grid-api /v1/*). The Bearer
@@ -42,6 +42,14 @@ export const updateDashboard = (id: string, input: { name?: string; config?: { w
export const deleteDashboard = (id: string) => call<{ deleted: boolean }>("DELETE", `dashboards/${id}`);
+// ---- field (schema) mutations ----
+
+export const createField = (tableId: string, input: { name: string; type: FieldType; options?: FieldOptions | null }) =>
+ call
("POST", `tables/${tableId}/fields`, input);
+
+export const deleteField = (tableId: string, fieldId: string) =>
+ call<{ deleted: boolean; id: string }>("DELETE", `tables/${tableId}/fields/${encodeURIComponent(fieldId)}`);
+
// ---- record mutations (typecast: true → resolve selects/links given by name) ----
interface RecordResult {
@@ -61,6 +69,15 @@ export const createRecords = (tableId: string, rows: Array
call<{ records: Array<{ id: string; deleted: boolean }> }>("DELETE", `tables/${tableId}/records?records[]=${encodeURIComponent(id)}`);
+/** Batch-delete records, chunked to grid-api's MAX_WRITE_BATCH (50) per call. */
+export async function deleteRecords(tableId: string, ids: string[]): Promise {
+ for (let i = 0; i < ids.length; i += 50) {
+ const chunk = ids.slice(i, i + 50);
+ const qs = chunk.map((id) => `records[]=${encodeURIComponent(id)}`).join("&");
+ await call<{ records: Array<{ id: string; deleted: boolean }> }>("DELETE", `tables/${tableId}/records?${qs}`);
+ }
+}
+
/** List a table's records (id + chosen fields) — used by the linked-record picker. */
export const listRecords = (tableId: string, fields: string[]) =>
call("GET", `tables/${tableId}/records?fields=${fields.join(",")}&pageSize=100`);
diff --git a/apps/web/lib/preview.ts b/apps/web/lib/preview.ts
new file mode 100644
index 0000000..ccfbd19
--- /dev/null
+++ b/apps/web/lib/preview.ts
@@ -0,0 +1,22 @@
+import type { FieldMeta, Meta, RecordEnvelope, ViewMeta } from "./types";
+
+/** A record's display title: its primary-field value, or "(untitled)". Shared by
+ * every card/row renderer (Kanban, Gallery, Gantt) so the fallback stays uniform. */
+export function recordTitle(meta: Meta, view: ViewMeta, rec: RecordEnvelope): string {
+ const primaryId = meta.tables.find((t) => t.tableId === view.tableId)?.primaryFieldId;
+ const v = primaryId != null ? rec.fields[primaryId] : undefined;
+ return v != null && v !== "" ? String(v) : "(untitled)";
+}
+
+/** Render a card-preview cell value: link/lookup arrays become comma-joined
+ * labels (via the id→label map); scalars stringify. Returns "" when empty.
+ * Shared by the Kanban and Gallery card renderers. */
+export function previewValue(field: FieldMeta, value: unknown, labels: Map): string {
+ if (value == null || value === "") return "";
+ if (field.type === "link") {
+ const ids = Array.isArray(value) ? (value as string[]) : [String(value)];
+ return ids.map((id) => labels.get(id) ?? id).join(", ");
+ }
+ if (Array.isArray(value)) return value.filter((v) => v != null && v !== "").map((v) => String(v)).join(", ");
+ return String(value);
+}
diff --git a/apps/web/lib/server/gridApi.ts b/apps/web/lib/server/gridApi.ts
index ab8204a..99c0ea3 100644
--- a/apps/web/lib/server/gridApi.ts
+++ b/apps/web/lib/server/gridApi.ts
@@ -1,6 +1,6 @@
import "server-only";
import { cache } from "react";
-import type { AggregateRow, ListResult, Meta, RecordEnvelope, Widget } from "../types";
+import type { AggregateRow, ListResult, Meta, RecordEnvelope, Widget, WidgetResult } from "../types";
/**
* Server-only client for grid-api. Server Components call this directly (the
@@ -63,6 +63,25 @@ export function getRecord(tableId: string, id: string): Promise
return call(`/v1/tables/${encodeURIComponent(tableId)}/records/${encodeURIComponent(id)}`);
}
+/** Page through a table until done (bounded). The table view paginates client-side
+ * via "Load more", but layout views (kanban/calendar/gallery/gantt) render the
+ * whole set at once — without this they silently showed only the first page. */
+export async function listAllRecords(
+ tableId: string,
+ params: Omit = {},
+ maxPages = 20,
+): Promise<{ records: RecordEnvelope[]; truncated: boolean }> {
+ const records: RecordEnvelope[] = [];
+ let offset: string | undefined;
+ for (let i = 0; i < maxPages; i++) {
+ const page = await listRecords(tableId, { ...params, pageSize: 100, offset });
+ records.push(...page.records);
+ offset = page.offset;
+ if (!offset) return { records, truncated: false };
+ }
+ return { records, truncated: true };
+}
+
/** Grouped aggregation for a dashboard widget. */
export function getAggregate(
tableId: string,
@@ -79,9 +98,7 @@ export function getAggregate(
/** Compute every widget's data server-side (secret stays on the server). Returns a
* map widgetId → result, tolerating a single widget's failure (so one bad widget
* can't blank the whole dashboard). */
-export async function computeWidgets(
- widgets: Widget[],
-): Promise> {
+export async function computeWidgets(widgets: Widget[]): Promise> {
const entries = await Promise.all(
widgets.map(async (w) => {
try {
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index ba45c7c..3e0976c 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -3,7 +3,7 @@
export type FieldType =
| "text" | "longtext" | "number" | "date" | "datetime"
| "select" | "multiselect" | "checkbox" | "url" | "email" | "json"
- | "formula" | "link" | "lookup";
+ | "formula" | "link" | "lookup" | "rollup";
export interface SelectChoice {
id?: string;
@@ -14,6 +14,8 @@ export interface SelectChoice {
export interface FieldOptions {
choices?: SelectChoice[];
formula?: unknown;
+ lookup?: unknown;
+ rollup?: unknown;
timezone?: string;
join?: string;
self?: string;
@@ -72,8 +74,21 @@ export interface ViewConfig {
};
sorts?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>;
groupBy?: string;
- kanban?: { stackFieldId: string; maxPreviewFields?: number };
+ /** Table view: per-column footer aggregates, keyed by fieldId. */
+ summaries?: Record;
+ /** Table view: tint rows by this single-select field's value. */
+ colorBy?: string;
+ kanban?: {
+ stackFieldId: string;
+ maxPreviewFields?: number;
+ columnOrder?: string[];
+ collapsedColumns?: string[];
+ /** Sparse manual card order per column: listed ids first, rest in natural order. */
+ cardOrder?: Record;
+ };
calendar?: { dateFieldId: string };
+ gallery?: { coverFieldId?: string; maxPreviewFields?: number };
+ gantt?: { startFieldId: string; endFieldId?: string };
form?: { title?: string; fieldIds: string[]; redirectMessage?: string };
dashboard?: { dateFieldId: string; metricFieldIds: string[] };
/** Table view: freeze the header row (default true) + N leading columns (default 1). */
@@ -81,11 +96,15 @@ export interface ViewConfig {
frozen?: number;
}
+/** Every view type the API accepts (mirror of @gridbase/api's VIEW_TYPES). */
+export const VIEW_TYPES = ["table", "kanban", "calendar", "form", "detail", "dashboard", "gallery", "gantt"] as const;
+export type ViewType = (typeof VIEW_TYPES)[number];
+
export interface ViewMeta {
viewId: string;
tableId: string;
name: string;
- type: "table" | "kanban" | "calendar" | "form" | "detail" | "dashboard";
+ type: ViewType;
position: number;
isHidden: boolean;
config: ViewConfig;
@@ -132,6 +151,15 @@ export interface AggregateRow {
value: number;
}
+/** Server-computed data for one widget: rows for aggregates, records for table
+ * widgets, or the error that kept it from loading. The contract between
+ * computeWidgets (server) and DashboardWidget (client). */
+export interface WidgetResult {
+ rows?: AggregateRow[];
+ records?: RecordEnvelope[];
+ error?: string;
+}
+
export interface RecordEnvelope {
id: string;
createdTime: string;
diff --git a/apps/web/package.json b/apps/web/package.json
index 264692e..c0bf94f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -12,6 +12,8 @@
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"next": "^15.1.3",
"papaparse": "^5.4.1",
"react": "^19.0.0",
diff --git a/examples/demo-schema.mjs b/examples/demo-schema.mjs
index ab6bc2c..724b18d 100644
--- a/examples/demo-schema.mjs
+++ b/examples/demo-schema.mjs
@@ -49,6 +49,11 @@ const formula = (id, name, spec) => ({ id, name, type: "formula", options: { for
const lookup = (id, name, viaFieldId, targetFieldId) => ({
id, name, type: "lookup", options: { lookup: { via: viaFieldId, target: targetFieldId } },
});
+// Rollup: aggregate across record(s) linked via `viaFieldId`. `count` ignores
+// targetFieldId; sum/avg/min/max aggregate that numeric target. Computed on read.
+const rollup = (id, name, viaFieldId, agg, targetFieldId) => ({
+ id, name, type: "rollup", options: { rollup: { via: viaFieldId, agg, ...(targetFieldId ? { target: targetFieldId } : {}) } },
+});
const choices = (...names) => names.map((n) => ({ name: n }));
/** @typedef {{id:string,name:string,slug:string,primaryFieldId:string,fields:object[]}} TableSpec */
@@ -75,6 +80,9 @@ export const TABLES = [
f("fld_277b1a34818263ca", "Notes", "longtext", "notes"),
link("fld_c4b2c9df60aa659c", "Contacts", "link_company_contacts", "company_id", "contact_id", TBL_CONTACTS, "one-to-many"),
link("fld_f4d4754c0f773ee9", "Deals", "link_company_deals", "company_id", "deal_id", TBL_DEALS, "one-to-many"),
+ // Rollups over the Deals link: how many deals, and their total amount.
+ rollup("fld_rollup_dealcount", "# Deals", "fld_f4d4754c0f773ee9", "count"),
+ rollup("fld_rollup_dealtotal", "Total Deal Amount", "fld_f4d4754c0f773ee9", "sum", FD_AMOUNT),
],
},
{
diff --git a/packages/api/src/aggregate.ts b/packages/api/src/aggregate.ts
index a49c4da..3437bba 100644
--- a/packages/api/src/aggregate.ts
+++ b/packages/api/src/aggregate.ts
@@ -1,9 +1,10 @@
import { buildWhere } from "./filter.js";
import { HttpError } from "./repo.js";
+import { AGG_FNS } from "./types.js";
import type { AggFn, AggregateSpec, Registry } from "./types.js";
/** Aggregation functions (fixed allowlist — never interpolate user input). */
-const AGGS: Set = new Set(["count", "sum", "avg", "min", "max"]);
+const AGGS: Set = new Set(AGG_FNS);
/** Date-bucket → strftime format (fixed allowlist; the key is validated, the
* value is a constant, so nothing user-controlled reaches the SQL). */
const BUCKET_FMT: Record = {
diff --git a/packages/api/src/dashboards.ts b/packages/api/src/dashboards.ts
index f30b217..598cdd6 100644
--- a/packages/api/src/dashboards.ts
+++ b/packages/api/src/dashboards.ts
@@ -1,42 +1,16 @@
+import { type MetaDashboardRow, rowToDashboard } from "./meta.js";
import { HttpError } from "./repo.js";
-import { isFilterGroup } from "./types.js";
+import { AGG_FNS, isFilterGroup } from "./types.js";
import type { AggFn, DashboardConfig, DashboardMeta, FilterCondition, Registry, Widget } from "./types.js";
const WIDGET_TYPES = new Set(["kpi", "line", "bar", "table"]);
-const AGGS = new Set(["count", "sum", "avg", "min", "max"]);
+const AGGS = new Set(AGG_FNS);
const MAX_WIDGETS = 50;
function newDashboardId(): string {
return "dsh" + crypto.randomUUID().replace(/-/g, "").slice(0, 14);
}
-interface MetaDashboardRow {
- dashboard_id: string;
- workspace_id: string | null;
- name: string;
- position: number;
- is_hidden: number;
- config: string;
-}
-
-function rowToDashboard(r: MetaDashboardRow): DashboardMeta {
- let config: DashboardConfig = { widgets: [] };
- try {
- const p = JSON.parse(r.config) as DashboardConfig;
- if (p && Array.isArray(p.widgets)) config = p;
- } catch {
- /* keep default */
- }
- return {
- dashboardId: r.dashboard_id,
- workspaceId: r.workspace_id,
- name: r.name,
- position: r.position,
- isHidden: r.is_hidden === 1,
- config,
- };
-}
-
async function readDashboard(db: D1Database, id: string): Promise {
const row = await db.prepare("SELECT * FROM meta_dashboards WHERE dashboard_id = ?").bind(id).first();
return row ? rowToDashboard(row) : null;
@@ -49,7 +23,12 @@ function validateConfig(config: DashboardConfig | undefined, reg: Registry): Das
const widgets = config?.widgets ?? [];
if (!Array.isArray(widgets)) throw new HttpError(400, "config.widgets must be an array");
if (widgets.length > MAX_WIDGETS) throw new HttpError(400, `too many widgets (max ${MAX_WIDGETS})`);
+ const seenIds = new Set();
for (const w of widgets) {
+ // widgetId keys the computed-data map and the React list — require + dedupe.
+ if (!w.widgetId || typeof w.widgetId !== "string") throw new HttpError(400, "every widget needs a widgetId");
+ if (seenIds.has(w.widgetId)) throw new HttpError(400, `duplicate widgetId: ${w.widgetId}`);
+ seenIds.add(w.widgetId);
if (!WIDGET_TYPES.has(w.type)) throw new HttpError(400, `invalid widget type: ${w.type}`);
if (!reg.tables.some((t) => t.tableId === w.tableId)) throw new HttpError(400, `widget references unknown table: ${w.tableId}`);
const inTable = (fid?: string) => !fid || reg.fieldById.get(fid)?.tableId === w.tableId;
@@ -64,13 +43,18 @@ function validateConfig(config: DashboardConfig | undefined, reg: Registry): Das
else leaves.push(item);
}
for (const c of leaves) if (!inTable(c.fieldId)) throw new HttpError(400, "widget filter field is not in its table");
- // Fail fast on a widget that can't run: aggregates beyond count need a metric;
- // charts need an x-axis.
- if (w.type !== "table" && (w.agg ?? "count") !== "count" && !w.metricFieldId) {
- throw new HttpError(400, `${w.agg} widget needs a metric field`);
+ // Fail fast on a widget that can't run, mirroring aggregate.ts's query-time
+ // rules so a stored widget always renders: non-count aggs need a numeric
+ // stored metric; charts need a stored-column x-axis.
+ if (w.type !== "table" && (w.agg ?? "count") !== "count") {
+ const mf = w.metricFieldId ? reg.fieldById.get(w.metricFieldId) : undefined;
+ if (!mf || mf.type !== "number" || !mf.columnName) {
+ throw new HttpError(400, `${w.agg} widget needs a numeric metric field`);
+ }
}
- if ((w.type === "line" || w.type === "bar") && !w.groupByFieldId) {
- throw new HttpError(400, "chart widget needs a group-by field");
+ if (w.type === "line" || w.type === "bar") {
+ const gf = w.groupByFieldId ? reg.fieldById.get(w.groupByFieldId) : undefined;
+ if (!gf?.columnName) throw new HttpError(400, "chart widget needs a stored group-by field");
}
}
return { widgets };
@@ -123,6 +107,9 @@ export async function updateDashboard(db: D1Database, reg: Registry, id: string,
binds.push(input.isHidden ? 1 : 0);
}
if (input.position !== undefined) {
+ if (typeof input.position !== "number" || !Number.isFinite(input.position)) {
+ throw new HttpError(400, "position must be a number");
+ }
sets.push("position = ?");
binds.push(input.position);
}
diff --git a/packages/api/src/fields.ts b/packages/api/src/fields.ts
new file mode 100644
index 0000000..11cf523
--- /dev/null
+++ b/packages/api/src/fields.ts
@@ -0,0 +1,123 @@
+import { HttpError } from "./repo.js";
+import type { FieldMeta, FieldOptions, FieldType, Registry } from "./types.js";
+
+/** Stored (non-computed) types → their SQLite column affinity. Types absent here
+ * are either computed (no column) or not user-creatable (link). */
+const STORED_SQL: Partial> = {
+ text: "TEXT", longtext: "TEXT", url: "TEXT", email: "TEXT", json: "TEXT",
+ select: "TEXT", multiselect: "TEXT", date: "TEXT", datetime: "TEXT",
+ number: "REAL", checkbox: "INTEGER",
+};
+const COMPUTED = new Set(["formula", "lookup", "rollup"]);
+const CREATABLE = new Set([
+ ...(Object.keys(STORED_SQL) as FieldType[]),
+ ...COMPUTED,
+]); // `link` is intentionally excluded — it needs a join table.
+
+function rand(n: number): string {
+ return crypto.randomUUID().replace(/-/g, "").slice(0, n);
+}
+
+export interface CreateFieldInput {
+ name: string;
+ type: FieldType;
+ options?: FieldOptions | null;
+}
+
+/**
+ * Create a field on a table. Computed fields (formula/lookup/rollup) only insert
+ * a meta_fields row; stored fields also `ALTER TABLE ADD COLUMN` (additive +
+ * nullable, so it's safe on a populated table). The physical column name is
+ * server-generated, never user input, so it can be interpolated into DDL safely.
+ */
+export async function createField(
+ db: D1Database,
+ reg: Registry,
+ tableId: string,
+ input: CreateFieldInput,
+): Promise {
+ const table = reg.tables.find((t) => t.tableId === tableId);
+ if (!table) throw new HttpError(404, `unknown table: ${tableId}`);
+
+ const name = (input.name ?? "").trim();
+ if (!name) throw new HttpError(400, "field name is required");
+ const type = input.type;
+ if (!CREATABLE.has(type)) throw new HttpError(400, `cannot create a field of type "${type}"`);
+
+ let options: FieldOptions | null = input.options ?? null;
+
+ if (type === "rollup") {
+ const r = options?.rollup;
+ if (!r?.via || !r.agg) throw new HttpError(400, "rollup needs { via, agg }");
+ const viaField = reg.fieldById.get(r.via);
+ // Pin the reference chain: via must be a link ON THIS table, and the target a
+ // number field ON THE LINKED table — a dangling reference would make every
+ // subsequent read of this table throw when resolveRollups queries it.
+ if (!viaField || viaField.type !== "link" || viaField.tableId !== tableId) {
+ throw new HttpError(400, "rollup.via must be a link field on this table");
+ }
+ if (r.agg !== "count") {
+ const tf = r.target ? reg.fieldById.get(r.target) : undefined;
+ if (!tf?.columnName || tf.type !== "number" || tf.tableId !== viaField.options?.linkedTableId) {
+ throw new HttpError(400, "rollup target must be a number field on the linked table");
+ }
+ }
+ }
+ if ((type === "select" || type === "multiselect") && !options) options = { choices: [] };
+
+ const fields = reg.fieldsByTable.get(tableId) ?? [];
+ const position = fields.reduce((m, f) => Math.max(m, f.position), -1) + 1;
+ const fieldId = "fld" + rand(14);
+ const isComputed = COMPUTED.has(type);
+ const columnName = isComputed ? null : "usr_" + rand(12);
+
+ if (columnName) {
+ await db.prepare(`ALTER TABLE ${table.slug} ADD COLUMN "${columnName}" ${STORED_SQL[type]}`).run();
+ }
+ await db
+ .prepare(
+ `INSERT INTO meta_fields (field_id, table_id, name, type, column_name, position, options, is_computed, is_primary)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ )
+ .bind(fieldId, tableId, name, type, columnName, position, options ? JSON.stringify(options) : null, isComputed ? 1 : 0)
+ .run();
+
+ return { fieldId, tableId, name, type, columnName, position, options, isComputed, isPrimary: false };
+}
+
+/**
+ * Delete a field. Refuses the primary field and any field another computed field
+ * still references (so the registry can't be left with dangling specs). Stored
+ * fields also drop their column; a deleted field id lingering in a view's
+ * config.fields is harmless (visibleFields filters unknown ids).
+ */
+export async function deleteField(
+ db: D1Database,
+ reg: Registry,
+ tableId: string,
+ fieldId: string,
+): Promise {
+ const field = reg.fieldById.get(fieldId);
+ if (!field || field.tableId !== tableId) return false;
+ if (field.isPrimary) throw new HttpError(400, "cannot delete the primary field");
+
+ const referencedBy = (reg.fieldsByTable.get(tableId) ?? []).find((f) => {
+ const o = f.options;
+ if (!o) return false;
+ return (
+ o.rollup?.via === fieldId || o.rollup?.target === fieldId ||
+ o.lookup?.via === fieldId || o.lookup?.target === fieldId ||
+ (o.formula && JSON.stringify(o.formula).includes(`"${fieldId}"`))
+ );
+ });
+ if (referencedBy) throw new HttpError(409, `"${referencedBy.name}" depends on this field — delete it first`);
+
+ const table = reg.tables.find((t) => t.tableId === tableId);
+ await db.prepare(`DELETE FROM meta_fields WHERE field_id = ?`).bind(fieldId).run();
+ if (field.columnName && table) {
+ // DROP COLUMN can fail if the column is otherwise constrained; the meta row
+ // is already gone, so swallow and leave an orphan column rather than 500.
+ try { await db.prepare(`ALTER TABLE ${table.slug} DROP COLUMN "${field.columnName}"`).run(); } catch { /* noop */ }
+ }
+ return true;
+}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index b3c4a06..8375a4b 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -18,7 +18,7 @@
*/
import { aggregate } from "./aggregate.js";
import { createDashboard, deleteDashboard, updateDashboard } from "./dashboards.js";
-import { loadRegistry } from "./meta.js";
+import { invalidateRegistry, loadRegistry } from "./meta.js";
import {
createRecords,
deleteRecords,
@@ -30,8 +30,9 @@ import {
type WriteInput,
} from "./repo.js";
import { createView, deleteView, updateView } from "./views.js";
+import { createField, deleteField } from "./fields.js";
import { isFilterGroup } from "./types.js";
-import type { AggregateSpec, DashboardConfig, FilterSpec, Registry, ViewConfig } from "./types.js";
+import type { AggregateSpec, DashboardConfig, FieldType, FilterSpec, Registry, ViewConfig } from "./types.js";
export interface Env {
DB: D1Database;
@@ -228,7 +229,9 @@ export default {
if (vm) {
const viewId = decodeURIComponent(vm[1]!);
if (request.method === "PATCH") {
- const body = (await request.json().catch(() => ({}))) as Record;
+ // A parse failure must be a 400, not a silent no-op 200.
+ const body = (await request.json().catch(() => null)) as Record | null;
+ if (!body || typeof body !== "object") return json({ error: "invalid JSON body" }, 400);
const view = await updateView(env.DB, viewId, body);
return json(view);
}
@@ -252,7 +255,9 @@ export default {
if (dm) {
const dashboardId = decodeURIComponent(dm[1]!);
if (request.method === "PATCH") {
- const body = (await request.json().catch(() => ({}))) as Record;
+ // A parse failure must be a 400, not a silent no-op 200.
+ const body = (await request.json().catch(() => null)) as Record | null;
+ if (!body || typeof body !== "object") return json({ error: "invalid JSON body" }, 400);
const dash = await updateDashboard(env.DB, reg, dashboardId, body);
return json(dash);
}
@@ -270,6 +275,30 @@ export default {
return json({ rows });
}
+ // /v1/tables/:tableId/fields (create) and /v1/tables/:tableId/fields/:fieldId (delete)
+ const fm = pathname.match(/^\/v1\/tables\/([^/]+)\/fields(?:\/([^/]+))?$/);
+ if (fm) {
+ const tableId = decodeURIComponent(fm[1]!);
+ const fieldId = fm[2] ? decodeURIComponent(fm[2]) : undefined;
+ if (request.method === "POST" && !fieldId) {
+ const body = (await request.json().catch(() => null)) as
+ | { name?: string; type?: string; options?: Record | null }
+ | null;
+ if (!body?.name || !body.type) return json({ error: "expected { name, type, options? }" }, 400);
+ const field = await createField(env.DB, reg, tableId, {
+ name: body.name, type: body.type as FieldType, options: body.options ?? null,
+ });
+ invalidateRegistry(); // schema (tables+fields) is isolate-cached; DDL just changed it
+ return json(field);
+ }
+ if (request.method === "DELETE" && fieldId) {
+ const ok = await deleteField(env.DB, reg, tableId, fieldId);
+ invalidateRegistry();
+ return ok ? json({ deleted: true, id: fieldId }) : json({ error: "not found" }, 404);
+ }
+ return json({ error: "method not allowed" }, 405);
+ }
+
// /v1/tables/:tableId/records and /v1/tables/:tableId/records/:id
const m = pathname.match(/^\/v1\/tables\/([^/]+)\/records(?:\/([^/]+))?$/);
if (m) {
diff --git a/packages/api/src/meta.ts b/packages/api/src/meta.ts
index b4a604c..593fcf5 100644
--- a/packages/api/src/meta.ts
+++ b/packages/api/src/meta.ts
@@ -82,7 +82,7 @@ export async function loadRegistry(db: D1Database): Promise {
return { ...schema, views, dashboards };
}
-interface MetaDashboardRow {
+export interface MetaDashboardRow {
dashboard_id: string;
workspace_id: string | null;
name: string;
@@ -91,16 +91,30 @@ interface MetaDashboardRow {
config: string;
}
-async function loadDashboards(db: D1Database): Promise {
- const res = await db.prepare("SELECT * FROM meta_dashboards ORDER BY position, name").all();
- return (res.results ?? []).map((r) => ({
+/** Canonical meta_dashboards row → DashboardMeta mapper (also used by the CRUD
+ * module, so both read paths normalize a malformed config the same way). */
+export function rowToDashboard(r: MetaDashboardRow): DashboardMeta {
+ const parsed = parseJSON(r.config, { widgets: [] });
+ return {
dashboardId: r.dashboard_id,
workspaceId: r.workspace_id,
name: r.name,
position: r.position,
isHidden: r.is_hidden === 1,
- config: parseJSON(r.config, { widgets: [] }),
- }));
+ config: Array.isArray(parsed.widgets) ? parsed : { widgets: [] },
+ };
+}
+
+async function loadDashboards(db: D1Database): Promise {
+ try {
+ const res = await db.prepare("SELECT * FROM meta_dashboards ORDER BY position, name").all();
+ return (res.results ?? []).map(rowToDashboard);
+ } catch (err) {
+ // Dashboards are optional: on a DB whose 0004 migration hasn't run yet
+ // (deploy-before-migrate), don't take every /v1 route down with them.
+ if (String(err).includes("no such table")) return [];
+ throw err;
+ }
}
async function loadViews(db: D1Database): Promise {
diff --git a/packages/api/src/records.test.ts b/packages/api/src/records.test.ts
new file mode 100644
index 0000000..c11d44d
--- /dev/null
+++ b/packages/api/src/records.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { aggregateRollup } from "./records.js";
+
+describe("aggregateRollup", () => {
+ it("count is the value count, regardless of magnitudes", () => {
+ expect(aggregateRollup("count", [5, 5, 5])).toBe(3);
+ expect(aggregateRollup("count", [])).toBe(0);
+ });
+
+ it("sum / avg / min / max over numbers", () => {
+ const nums = [2, 4, 9];
+ expect(aggregateRollup("sum", nums)).toBe(15);
+ expect(aggregateRollup("avg", nums)).toBe(5);
+ expect(aggregateRollup("min", nums)).toBe(2);
+ expect(aggregateRollup("max", nums)).toBe(9);
+ });
+
+ it("empty input is 0 for every numeric agg (no linked rows ⇒ 0, not NaN)", () => {
+ for (const agg of ["sum", "avg", "min", "max"] as const) {
+ expect(aggregateRollup(agg, [])).toBe(0);
+ }
+ });
+
+ it("avg can be fractional", () => {
+ expect(aggregateRollup("avg", [1, 2])).toBe(1.5);
+ });
+});
diff --git a/packages/api/src/records.ts b/packages/api/src/records.ts
index b533a42..be3b942 100644
--- a/packages/api/src/records.ts
+++ b/packages/api/src/records.ts
@@ -79,6 +79,23 @@ export function evalFormula(spec: FormulaSpec, row: Record, reg
}
}
+/** Reduce a rollup's gathered numbers to a scalar. `count` is the value count;
+ * empty input is 0 for every agg (no linked rows ⇒ 0). */
+export function aggregateRollup(agg: "count" | "sum" | "avg" | "min" | "max", nums: number[]): number {
+ if (agg === "count") return nums.length;
+ if (nums.length === 0) return 0;
+ switch (agg) {
+ case "sum": return nums.reduce((a, b) => a + b, 0);
+ case "avg": return nums.reduce((a, b) => a + b, 0) / nums.length;
+ case "min": return Math.min(...nums);
+ case "max": return Math.max(...nums);
+ default: {
+ const _never: never = agg;
+ throw new Error(`unknown rollup agg: ${JSON.stringify(_never)}`);
+ }
+ }
+}
+
/**
* Assemble one Airtable-shaped record from a raw entity row plus resolved link
* arrays (fieldId → linked ids) and lookup arrays (fieldId → linked values).
@@ -91,6 +108,7 @@ export function assembleRecord(
row: Record,
links: Map,
lookups: Map,
+ rollups: Map,
reg: Registry,
wanted?: Set,
): RecordEnvelope {
@@ -107,6 +125,11 @@ export function assembleRecord(
if (vals && vals.length) out[field.fieldId] = vals;
continue;
}
+ if (field.type === "rollup") {
+ const n = rollups.get(field.fieldId);
+ if (n !== undefined) out[field.fieldId] = n;
+ continue;
+ }
if (field.type === "formula") {
const spec = field.options?.formula;
if (spec) out[field.fieldId] = evalFormula(spec, row, reg);
diff --git a/packages/api/src/repo.ts b/packages/api/src/repo.ts
index 5751160..ccd7db8 100644
--- a/packages/api/src/repo.ts
+++ b/packages/api/src/repo.ts
@@ -1,5 +1,5 @@
import { buildOrderBy, buildWhere } from "./filter.js";
-import { assembleRecord, coerceLookupValue } from "./records.js";
+import { aggregateRollup, assembleRecord, coerceLookupValue } from "./records.js";
import type {
FieldMeta,
FilterSpec,
@@ -128,6 +128,78 @@ async function resolveLookups(
return byRow;
}
+/** rowId → (rollupFieldId → aggregated scalar). `count` uses the `via` link's
+ * edge count; sum/avg/min/max read the numeric `target` column from the linked
+ * table (same edge walk as lookups) and reduce to one number per row. */
+async function resolveRollups(
+ db: D1Database,
+ reg: Registry,
+ fields: FieldMeta[],
+ linkByRow: Map>,
+ rowIds: string[],
+ wanted: Set | undefined,
+): Promise>> {
+ const byRow = new Map>();
+ const rollupFields = fields.filter(
+ (f) => f.type === "rollup" && f.options?.rollup && (!wanted || wanted.has(f.fieldId)),
+ );
+ if (rollupFields.length === 0 || rowIds.length === 0) return byRow;
+
+ const put = (rid: string, fid: string, n: number) => {
+ let m = byRow.get(rid);
+ if (!m) { m = new Map(); byRow.set(rid, m); }
+ m.set(fid, n);
+ };
+
+ for (const rf of rollupFields) {
+ const { via, target, agg } = rf.options!.rollup!;
+
+ // count needs only the edge count — no linked-table read.
+ if (agg === "count") {
+ for (const rid of rowIds) put(rid, rf.fieldId, (linkByRow.get(rid)?.get(via) ?? []).length);
+ continue;
+ }
+
+ const viaField = reg.fieldById.get(via);
+ const targetField = target ? reg.fieldById.get(target) : undefined;
+ if (!viaField?.options?.linkedTableId || !targetField?.columnName) continue;
+ const linkedTable = reg.tables.find((t) => t.tableId === viaField.options!.linkedTableId);
+ if (!linkedTable) continue;
+
+ const allIds = new Set();
+ for (const rid of rowIds) for (const id of linkByRow.get(rid)?.get(via) ?? []) allIds.add(id);
+ if (allIds.size === 0) {
+ for (const rid of rowIds) put(rid, rf.fieldId, 0);
+ continue;
+ }
+
+ // Chunk the IN(...) to stay under D1's 100-bound-parameter cap (a page can
+ // reference far more linked rows than that).
+ const idArr = [...allIds];
+ const valById = new Map();
+ for (let i = 0; i < idArr.length; i += 90) {
+ const chunk = idArr.slice(i, i + 90);
+ const ph = chunk.map(() => "?").join(", ");
+ const res = await db
+ .prepare(`SELECT id, "${targetField.columnName}" AS v FROM ${linkedTable.slug} WHERE id IN (${ph})`)
+ .bind(...chunk)
+ .all<{ id: string; v: unknown }>();
+ for (const r of res.results ?? []) valById.set(String(r.id), r.v);
+ }
+
+ for (const rid of rowIds) {
+ const ids = linkByRow.get(rid)?.get(via) ?? [];
+ const nums = ids
+ .map((id) => valById.get(id))
+ .filter((v) => v != null && v !== "")
+ .map((v) => (typeof v === "number" ? v : Number(v)))
+ .filter((n) => Number.isFinite(n));
+ put(rid, rf.fieldId, aggregateRollup(agg, nums));
+ }
+ }
+ return byRow;
+}
+
async function assembleRows(
db: D1Database,
reg: Registry,
@@ -139,9 +211,10 @@ async function assembleRows(
const rowIds = rows.map((r) => String(r["id"]));
const links = await resolveLinks(db, fields, rowIds);
const lookups = await resolveLookups(db, reg, fields, links, rowIds, wanted);
+ const rollups = await resolveRollups(db, reg, fields, links, rowIds, wanted);
return rows.map((row) => {
const id = String(row["id"]);
- return assembleRecord(table, fields, row, links.get(id) ?? new Map(), lookups.get(id) ?? new Map(), reg, wanted);
+ return assembleRecord(table, fields, row, links.get(id) ?? new Map(), lookups.get(id) ?? new Map(), rollups.get(id) ?? new Map(), reg, wanted);
});
}
@@ -254,7 +327,7 @@ async function writeRecord(
for (const [fieldId, value] of Object.entries(input)) {
const field = byId.get(fieldId);
if (!field) continue;
- if (field.type === "formula" || field.type === "lookup") continue; // not settable
+ if (field.type === "formula" || field.type === "lookup" || field.type === "rollup") continue; // not settable
if (field.type === "link") {
const raw = Array.isArray(value) ? value : value == null ? [] : [value];
const ids: string[] = [];
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
index 6660563..2d1ff9c 100644
--- a/packages/api/src/types.ts
+++ b/packages/api/src/types.ts
@@ -3,7 +3,7 @@
export type FieldType =
| "text" | "longtext" | "number" | "date" | "datetime"
| "select" | "multiselect" | "checkbox" | "url" | "email" | "json"
- | "formula" | "link" | "lookup";
+ | "formula" | "link" | "lookup" | "rollup";
export interface SelectChoice {
id?: string;
@@ -23,6 +23,15 @@ export interface LookupSpec {
target: string;
}
+/** Rollup: aggregate `target` across the record(s) linked via the `via` link
+ * field. `count` ignores `target` (counts linked rows); sum/avg/min/max read
+ * the numeric `target` column. */
+export interface RollupSpec {
+ via: string;
+ target?: string;
+ agg: "count" | "sum" | "avg" | "min" | "max";
+}
+
export interface LinkOptions {
join: string; // join-table name
self: string; // this row's endpoint column in the join table
@@ -35,6 +44,7 @@ export interface FieldOptions {
choices?: SelectChoice[];
formula?: FormulaSpec;
lookup?: LookupSpec;
+ rollup?: RollupSpec;
timezone?: string;
// link options are flattened here too
join?: string;
@@ -67,11 +77,16 @@ export interface TableMeta {
sourceRef: string | null;
}
+/** Every view type the API accepts — single source for the TS union and the
+ * create-time allowlist in views.ts. */
+export const VIEW_TYPES = ["table", "kanban", "calendar", "form", "detail", "dashboard", "gallery", "gantt"] as const;
+export type ViewType = (typeof VIEW_TYPES)[number];
+
export interface ViewMeta {
viewId: string;
tableId: string;
name: string;
- type: "table" | "kanban" | "calendar" | "form" | "detail" | "dashboard";
+ type: ViewType;
position: number;
isHidden: boolean;
config: ViewConfig;
@@ -111,8 +126,21 @@ export interface ViewConfig {
/** `linkedFieldId` sorts by a sub-field of the linked table (see FilterCondition). */
sorts?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>;
groupBy?: string;
- kanban?: { stackFieldId: string; maxPreviewFields?: number };
+ /** Table view: per-column footer aggregates, keyed by fieldId. */
+ summaries?: Record;
+ /** Table view: tint rows by this single-select field's value. */
+ colorBy?: string;
+ kanban?: {
+ stackFieldId: string;
+ maxPreviewFields?: number;
+ columnOrder?: string[];
+ collapsedColumns?: string[];
+ /** Sparse manual card order per column: listed ids first, rest in natural order. */
+ cardOrder?: Record;
+ };
calendar?: { dateFieldId: string };
+ gallery?: { coverFieldId?: string; maxPreviewFields?: number };
+ gantt?: { startFieldId: string; endFieldId?: string };
form?: { title?: string; fieldIds: string[]; redirectMessage?: string };
dashboard?: { dateFieldId: string; metricFieldIds: string[] };
}
@@ -127,7 +155,10 @@ export interface Registry {
// ---- dashboards: a workspace-level report composer -------------------------
-export type AggFn = "count" | "sum" | "avg" | "min" | "max";
+/** Aggregation functions — single source for the TS union and the allowlists in
+ * aggregate.ts (query time) and dashboards.ts (write time). */
+export const AGG_FNS = ["count", "sum", "avg", "min", "max"] as const;
+export type AggFn = (typeof AGG_FNS)[number];
export type DateBucket = "day" | "week" | "month" | "year";
/** A grouped aggregation over one table: e.g. SUM(amount) GROUP BY month(close_date). */
diff --git a/packages/api/src/views.ts b/packages/api/src/views.ts
index e252e0e..031d2c2 100644
--- a/packages/api/src/views.ts
+++ b/packages/api/src/views.ts
@@ -1,7 +1,8 @@
import { HttpError } from "./repo.js";
+import { VIEW_TYPES as VIEW_TYPE_VALUES } from "./types.js";
import type { Registry, ViewConfig, ViewMeta } from "./types.js";
-const VIEW_TYPES = new Set(["table", "kanban", "calendar", "form", "detail", "dashboard"]);
+const VIEW_TYPES = new Set(VIEW_TYPE_VALUES);
function newViewId(): string {
return "viw" + crypto.randomUUID().replace(/-/g, "").slice(0, 14);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index aaa6235..d9fc0c7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,12 @@ importers:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@dnd-kit/sortable':
+ specifier: ^8.0.0
+ version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.7)
next:
specifier: ^15.1.3
version: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -142,6 +148,12 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@dnd-kit/sortable@8.0.0':
+ resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.1.0
+ react: '>=16.8.0'
+
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
@@ -1845,6 +1857,13 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
+ '@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.7)
+ react: 19.2.7
+ tslib: 2.8.1
+
'@dnd-kit/utilities@3.2.2(react@19.2.7)':
dependencies:
react: 19.2.7
diff --git a/scripts/seed.mjs b/scripts/seed.mjs
index a1bb071..ba10442 100644
--- a/scripts/seed.mjs
+++ b/scripts/seed.mjs
@@ -52,9 +52,10 @@ TABLES.forEach((t, ti) => {
else if (fld.type === "lookup") options = fld.options ?? null;
else if (fld.options) options = fld.options;
- const noColumn = fld.type === "link" || fld.type === "formula" || fld.type === "lookup";
+ // Computed fields (formula/lookup/rollup) have no physical column.
+ const noColumn = fld.type === "link" || fld.type === "formula" || fld.type === "lookup" || fld.type === "rollup";
const columnName = noColumn ? null : fld.col;
- const isComputed = fld.type === "formula" || fld.type === "lookup" ? 1 : 0;
+ const isComputed = fld.type === "formula" || fld.type === "lookup" || fld.type === "rollup" ? 1 : 0;
const isPrimary = fld.id === t.primaryFieldId ? 1 : 0;
out.push(