Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bebd3d2
Port the dashboard view + public-repo polish + upstream sync workflow
akim136 Jun 10, 2026
f8f9804
feat(api): aggregation endpoint + dashboards persistence (composer Pa…
akim136 Jun 10, 2026
be4652b
feat(web): reports/dashboards composer UI (composer Part B)
akim136 Jun 10, 2026
8a3e6c0
fix: review findings on the composer (3-branch review pass)
akim136 Jun 10, 2026
8c73f26
feat: linked-field sort/filter, nested filter groups, kanban field co…
akim136 Jun 11, 2026
9470922
feat(grid): field DnD reorder, load-more, bulk delete, kanban config,…
akim136 Jun 12, 2026
b62d08f
feat(grid): bigger left drag handle + custom Kanban column order
akim136 Jun 12, 2026
c3281a8
feat(grid): self-service add/delete fields (incl. rollups) from the F…
akim136 Jun 12, 2026
ba878eb
feat(grid): Calendar date-field config panel (mirrors Kanban setup)
akim136 Jun 12, 2026
90acff0
feat: Gallery + Gantt view types with config panels (synced from upst…
akim136 Jun 12, 2026
4806fa7
fix: review + security findings on dashboards/gallery/gantt (synced f…
akim136 Jun 12, 2026
6381371
refactor: simplify-pass cleanups (synced from upstream)
akim136 Jun 12, 2026
71bc416
feat: column resize, mobile layout fixes, full-set layout views (sync…
akim136 Jun 12, 2026
a81b324
feat: grouped rows, column header menu, kanban quick-add + collapse, …
akim136 Jun 12, 2026
c41dde5
feat: kanban card ordering, keyboard nav + clipboard, summary footer,…
akim136 Jun 12, 2026
70e2d1f
docs: README — full feature overview (views, grid/kanban power featur…
akim136 Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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).

Expand Down
6 changes: 4 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -32,7 +32,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang="en">
<body>
<div className="flex h-screen overflow-hidden">
{/* h-dvh (not h-screen): the dynamic viewport excludes mobile browser
chrome, so bottom controls aren't hidden under the toolbar. */}
<div className="flex h-dvh overflow-hidden">
{error ? null : <Sidebar tables={tables} />}
<main className="flex-1 overflow-auto">
{error ? (
Expand Down
30 changes: 30 additions & 0 deletions apps/web/app/t/[tableId]/[viewId]/actions.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; 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 };
}
42 changes: 27 additions & 15 deletions apps/web/app/t/[tableId]/[viewId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);
Expand All @@ -45,32 +49,40 @@ export default async function ViewPage({

return (
<div className="flex h-full flex-col">
<header className="border-b border-surface-border bg-white px-5 pt-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold tracking-tight text-neutral-900">{table.name}</h1>
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400">{records.length} records</span>
{/* Rows wrap on narrow screens so the toolbar can't push past the viewport
(which dragged its right-anchored popovers off-page on mobile). */}
<header className="border-b border-surface-border bg-white px-3 pt-4 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="min-w-0 truncate text-lg font-semibold tracking-tight text-neutral-900">{table.name}</h1>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-neutral-400">{records.length}{offset ? "+" : ""} records</span>
<ImportButton tableId={tableId} fields={fields} />
<NewRecordButton tableId={tableId} fields={fields} />
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-4">
<ViewSwitcher tableId={tableId} currentViewId={viewId} views={allViews} currentConfig={view.config} />
{view.type !== "form" ? <ViewToolbar viewId={viewId} fields={fields} config={view.config} meta={meta} /> : null}
{view.type !== "form" ? <ViewToolbar viewId={viewId} viewType={view.type} fields={fields} config={view.config} meta={meta} /> : null}
</div>
</header>

<div className={`flex-1 p-5 ${view.type === "kanban" || view.type === "calendar" || view.type === "form" || view.type === "dashboard" ? "overflow-auto" : "overflow-hidden"}`}>
{/* TableView (the table + detail fallthrough) owns its own scroll container,
so its wrapper must clip; every other view scrolls in the wrapper. */}
<div className={`flex-1 p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:p-5 ${view.type === "table" || view.type === "detail" ? "overflow-hidden" : "overflow-auto"}`}>
{view.type === "kanban" ? (
<KanbanView meta={meta} view={view} records={records} labels={labels} />
) : view.type === "calendar" ? (
<CalendarView meta={meta} view={view} records={records} />
) : view.type === "gallery" ? (
<GalleryView meta={meta} view={view} records={records} labels={labels} />
) : view.type === "gantt" ? (
<GanttView meta={meta} view={view} records={records} />
) : view.type === "form" ? (
<FormRenderer tableId={tableId} fields={visibleFields(meta, view)} title={view.config.form?.title ?? view.name} />
) : view.type === "dashboard" ? (
<DashboardView meta={meta} view={view} records={records} />
) : (
<TableView meta={meta} view={view} records={records} labels={labels} />
<TableView meta={meta} view={view} records={records} labels={labels} initialOffset={offset} />
)}
</div>
</div>
Expand Down
13 changes: 8 additions & 5 deletions apps/web/components/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function DashboardRenderer({ dashboard, meta, data }: { dashboard: Dashbo
<h1 className="text-lg font-semibold tracking-tight text-neutral-900">{dashboard.name}</h1>
<div className="flex items-center gap-2">
{busy ? <span className="text-xs text-neutral-400">Saving…</span> : null}
<button onClick={() => setEditing({ widget: null })} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
<button disabled={busy} onClick={() => setEditing({ widget: null })} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
+ Add widget
</button>
</div>
Expand All @@ -64,11 +64,14 @@ export function DashboardRenderer({ dashboard, meta, data }: { dashboard: Dashbo
<div key={w.widgetId} className="rounded-lg border border-surface-border bg-white p-4">
<div className="mb-2 flex items-start justify-between gap-2">
<span className="truncate text-sm font-medium text-neutral-700">{w.title || "(untitled)"}</span>
{/* 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. */}
<span className="flex items-center gap-1.5 text-neutral-300">
<button title="Move left" onClick={() => move(i, -1)} className="hover:text-neutral-600">←</button>
<button title="Move right" onClick={() => move(i, 1)} className="hover:text-neutral-600">→</button>
<button title="Edit" onClick={() => setEditing({ widget: w })} className="hover:text-neutral-600">✎</button>
<button title="Remove" onClick={() => remove(w.widgetId)} className="hover:text-red-500">✕</button>
<button disabled={busy} title="Move left" onClick={() => move(i, -1)} className="hover:text-neutral-600 disabled:opacity-40">←</button>
<button disabled={busy} title="Move right" onClick={() => move(i, 1)} className="hover:text-neutral-600 disabled:opacity-40">→</button>
<button disabled={busy} title="Edit" onClick={() => setEditing({ widget: w })} className="hover:text-neutral-600 disabled:opacity-40">✎</button>
<button disabled={busy} title="Remove" onClick={() => remove(w.widgetId)} className="hover:text-red-500 disabled:opacity-40">✕</button>
</span>
</div>
<DashboardWidget widget={w} meta={meta} result={data[w.widgetId]} />
Expand Down
38 changes: 16 additions & 22 deletions apps/web/components/DashboardWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 "—";
Expand All @@ -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<string, string>();

function Empty({ children }: { children: React.ReactNode }) {
return <div className="flex h-24 items-center justify-center text-xs text-neutral-400">{children}</div>;
Expand All @@ -50,29 +43,30 @@ export function DashboardWidget({ widget, meta, result }: { widget: Widget; meta
return data.length ? <div className="h-40"><BarMini data={data} /></div> : <Empty>No data</Empty>;
}

// 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<typeof f> => Boolean(f));
if (!records.length) return <Empty>No rows</Empty>;
return (
<div className="max-h-44 overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-neutral-400">
{cols.map((c) => (
<th key={c} className="border-b border-surface-border px-2 py-1 font-medium">{fieldById.get(c)?.name ?? c}</th>
{cols.map((f) => (
<th key={f.fieldId} className="border-b border-surface-border px-2 py-1 font-medium">{f.name}</th>
))}
</tr>
</thead>
<tbody>
{records.slice(0, 8).map((rec) => (
<tr key={rec.id} className="border-b border-surface-border/60">
{cols.map((c) => (
<td key={c} className="truncate px-2 py-1 text-neutral-700">{cellText(rec.fields[c])}</td>
{cols.map((f) => (
<td key={f.fieldId} className="truncate px-2 py-1 text-neutral-700">{previewValue(f, rec.fields[f.fieldId], NO_LABELS)}</td>
))}
</tr>
))}
Expand Down
63 changes: 63 additions & 0 deletions apps/web/components/GalleryView.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}) {
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 <p className="py-8 text-center text-neutral-400">No records.</p>;

return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{records.map((rec) => {
const cover = coverId ? rec.fields[coverId] : undefined;
return (
<Link
key={rec.id}
href={`/t/${view.tableId}/${view.viewId}/${rec.id}`}
className="block overflow-hidden rounded-lg border border-surface-border bg-white shadow-sm transition hover:border-blue-300 hover:shadow"
>
{typeof cover === "string" && cover.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element -- arbitrary user-provided host
<img src={cover} alt="" className="h-28 w-full border-b border-surface-border object-cover" />
) : null}
<div className="p-3">
<p className="mb-1.5 truncate font-medium text-neutral-900">{recordTitle(meta, view, rec)}</p>
{cardFields.map((f) => {
const text = previewValue(f, rec.fields[f.fieldId], labels);
if (!text) return null;
return (
<div key={f.fieldId} className="truncate text-xs text-neutral-500">
<span className="text-neutral-400">{f.name}:</span> {text}
</div>
);
})}
</div>
</Link>
);
})}
</div>
);
}
Loading
Loading