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} -
@@ -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. */} - - - - + + + +
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) => ( - + {cols.map((f) => ( + ))} {records.slice(0, 8).map((rec) => ( - {cols.map((c) => ( - + {cols.map((f) => ( + ))} ))} 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 ( + + ); } -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 ( - +
    +
  • + + 📊 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 ( +
+ + {cols.map((f, i) => { + const value = rec.fields[f.fieldId]; + const isPrimary = f.fieldId === primaryId; + const focused = cursor?.r === ri && cursor.c === i; + return ( + + ); + })} + + ); + } return ( -
-
{fieldById.get(c)?.name ?? c}{f.name}
{cellText(rec.fields[c])}{previewValue(f, rec.fields[f.fieldId], NO_LABELS)}
+ toggleOne(rec.id)} /> + 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)"} + + ) : ( + + )} +
- - - {cols.map((f, i) => ( - + + + ); + })} + + +
- {f.name} - {f.isComputed ? ƒ : null} +
+ {selected.size > 0 ? ( +
+ {selected.size} selected + + +
+ ) : null} +
+ + + + - ))} - - - - {records.length === 0 ? ( - - + {cols.map((f, i) => ( + + ))} - ) : ( - records.map((rec) => ( - - {cols.map((f, i) => { - const value = rec.fields[f.fieldId]; - const isPrimary = f.fieldId === primaryId; - return ( - - ); - })} + + + {rows.length === 0 ? ( + + - )) - )} - -
+
No records. + + {f.name} + {f.isComputed ? ƒ : null} + + {/* per-column menu: sort / group / hide without opening the toolbar */} + + {headerMenu === f.fieldId ? ( + e.stopPropagation()}> + {f.type !== "formula" && f.type !== "rollup" ? ( + <> + + + + ) : null} + {!f.isComputed && f.type !== "link" ? ( + view.config.groupBy === f.fieldId ? ( + + ) : ( + + ) + ) : null} + {f.type === "select" ? ( + view.config.colorBy === f.fieldId ? ( + + ) : ( + + ) + ) : null} + {f.fieldId !== primaryId ? ( + + ) : null} + + ) : null} + {/* drag the right edge to resize; persists to the view config */} + onResizeStart(f.fieldId, e)} + className="absolute inset-y-0 right-0 w-1.5 cursor-col-resize touch-none border-r-2 border-transparent hover:border-blue-400 group-hover/th:border-surface-border" + aria-hidden + /> +
- {isPrimary ? ( - - {value != null && value !== "" ? String(value) : "(untitled)"} - - ) : ( - - )} -
No records.
-
- + ) : 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 ( + + + {footerMenuOpen === f.fieldId ? ( + e.stopPropagation()}> + {(numeric ? ["count", "sum", "avg", "min", "max"] : ["count"]).map((a) => ( + + ))} + {agg ? ( + + ) : null} + + ) : null} +
+ {/* Safe-area padding keeps Add/Load-more clear of mobile browser chrome. */} +
+ + {offset ? ( + + ) : 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 /> + + + ) : 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 ( + <> + + + + + + {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) => ( ))}
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 ( -
+
@@ -126,6 +159,11 @@ export function ViewToolbar({ + {typeEditor ? ( + + ) : 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 ( +
+ + +
+ ); +} + +/** 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 ? ( +
+ + + +
+ {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} - - -
- ))} + // 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 ? ( + + ) : 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 ( + + ); + } + return ( +
+ setName(e.target.value)} /> + + {(type === "select" || type === "multiselect") ? ( + setChoices(e.target.value)} /> + ) : null} + {type === "rollup" ? ( + <> + + + {agg !== "count" ? ( + + ) : null} + {linkFields.length === 0 ?

This table has no link fields to roll up.

: null} + + ) : null} +
+ + +
); } 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, fieldIds: undefined, filter: undefined })}> {meta.tables.map((t) => )} 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(