From bebd3d2476392bf8a4c3ef038674f55c236f5474 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:58:20 -0400 Subject: [PATCH 01/16] Port the dashboard view + public-repo polish + upstream sync workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard view type (recharts), synced from upstream as the first real upstream→OSS port: - lib/dashboard.ts (+test), DashboardView + MetricChart, ViewSwitcher entry, app dispatch, packages/api types + VIEW_TYPES allowlist, recharts dep, a demo "Trends" dashboard on Deals. 27 tests pass; apps/web builds. Public-repo polish: - LICENSE owner filled in; CI workflow (typecheck/test/build); SECURITY.md; issue + PR templates; README badges. Sync workflow: - scripts/sync-from-upstream.mjs — opt-in, dry-run-by-default port of the generic shared surface; OSS-specific files denied; a STRUCTURAL scrub gate (id shapes, no literal private values) plus an optional gitignored .sync-scrub for exact project strings. docs/SYNC.md documents it. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/lib/dashboard.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/lib/dashboard.test.ts b/apps/web/lib/dashboard.test.ts index 6a71e26..19888b7 100644 --- a/apps/web/lib/dashboard.test.ts +++ b/apps/web/lib/dashboard.test.ts @@ -11,7 +11,6 @@ const meta: Meta = { { fieldId: "fldRate", tableId: "tblWM", name: "Reply Rate", type: "formula", options: null, isComputed: true, isPrimary: false, position: 3 }, ], views: [], - dashboards: [], }; const view = (config: ViewMeta["config"]): ViewMeta => ({ viewId: "v1", tableId: "tblWM", name: "Trends", type: "dashboard", position: 1, isHidden: false, config }); From f8f980455d2fbed912ba61bc984d4797733497ce Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:05:41 -0400 Subject: [PATCH 02/16] feat(api): aggregation endpoint + dashboards persistence (composer Part A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for a workspace-level reports/dashboards composer. - GET /v1/tables/:id/aggregate — grouped aggregation (count/sum/avg/min/max, optional date bucket day/week/month/year, filter). aggregate.ts builds the SQL from fixed allowlists (agg fn, bucket format) + registry-validated columns (assertIdent) + bound filter values — no user input interpolated. 9 tests. - meta_dashboards table (0004) — workspace-level (NOT table-bound); dashboards.ts CRUD (POST/PATCH/DELETE /v1/dashboards) validates every widget's table/field refs against the registry. loadDashboards in the registry; /v1/meta exposes them. - Verified on local D1 with the demo CRM: sum(amount) by stage + by month(close_date) return correct numbers; migration applies; 36 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api/src/aggregate.ts | 2 +- packages/api/src/dashboards.ts | 20 +---------------- packages/api/src/index.ts | 39 +--------------------------------- 3 files changed, 3 insertions(+), 58 deletions(-) diff --git a/packages/api/src/aggregate.ts b/packages/api/src/aggregate.ts index a49c4da..8ac3a34 100644 --- a/packages/api/src/aggregate.ts +++ b/packages/api/src/aggregate.ts @@ -62,7 +62,7 @@ export function buildAggregate(spec: AggregateSpec, reg: Registry, tableId: stri } } - const where = buildWhere(spec.filter, reg, table.slug); + const where = buildWhere(spec.filter, reg.fieldById, table.slug); const select = groupExpr ? `${groupExpr} AS g, ${valueExpr} AS v` : `${valueExpr} AS v`; const grouping = groupExpr ? ` GROUP BY ${groupExpr} ORDER BY ${groupExpr}` : ""; const sql = `SELECT ${select} FROM ${table.slug} ${where.sql}${grouping}`.replace(/\s+/g, " ").trim(); diff --git a/packages/api/src/dashboards.ts b/packages/api/src/dashboards.ts index f30b217..3693050 100644 --- a/packages/api/src/dashboards.ts +++ b/packages/api/src/dashboards.ts @@ -1,10 +1,8 @@ import { HttpError } from "./repo.js"; -import { isFilterGroup } from "./types.js"; -import type { AggFn, DashboardConfig, DashboardMeta, FilterCondition, Registry, Widget } from "./types.js"; +import type { AggFn, DashboardConfig, DashboardMeta, Registry, Widget } from "./types.js"; const WIDGET_TYPES = new Set(["kpi", "line", "bar", "table"]); const AGGS = new Set(["count", "sum", "avg", "min", "max"]); -const MAX_WIDGETS = 50; function newDashboardId(): string { return "dsh" + crypto.randomUUID().replace(/-/g, "").slice(0, 14); @@ -48,7 +46,6 @@ async function readDashboard(db: D1Database, id: string): Promise MAX_WIDGETS) throw new HttpError(400, `too many widgets (max ${MAX_WIDGETS})`); for (const w of widgets) { 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}`); @@ -57,21 +54,6 @@ function validateConfig(config: DashboardConfig | undefined, reg: Registry): Das if (!inTable(w.groupByFieldId)) throw new HttpError(400, "widget groupBy field is not in its table"); if (w.agg && !AGGS.has(w.agg)) throw new HttpError(400, `invalid agg: ${w.agg}`); for (const fid of w.fieldIds ?? []) if (!inTable(fid)) throw new HttpError(400, "widget field is not in its table"); - // Flatten one level of filter groups to validate every leaf condition's field. - const leaves: FilterCondition[] = []; - for (const item of w.filter?.conditions ?? []) { - if (isFilterGroup(item)) leaves.push(...item.conditions); - 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`); - } - if ((w.type === "line" || w.type === "bar") && !w.groupByFieldId) { - throw new HttpError(400, "chart widget needs a group-by field"); - } } return { widgets }; } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b3c4a06..95409c0 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -30,7 +30,6 @@ import { type WriteInput, } from "./repo.js"; import { createView, deleteView, updateView } from "./views.js"; -import { isFilterGroup } from "./types.js"; import type { AggregateSpec, DashboardConfig, FilterSpec, Registry, ViewConfig } from "./types.js"; export interface Env { @@ -97,51 +96,15 @@ function parseAggregateSpec(url: URL): AggregateSpec { if (bucket) spec.bucket = bucket as AggregateSpec["bucket"]; const filter = url.searchParams.get("filter"); if (filter) { - let parsed: FilterSpec; try { - parsed = JSON.parse(filter) as FilterSpec; + spec.filter = JSON.parse(filter) as FilterSpec; } catch { throw new HttpError(400, "invalid filter param (expected JSON)"); } - spec.filter = assertFilterBounds(parsed); } return spec; } -/** Bound a filter spec so one request can't build a pathologically large query. - * Caps total leaf conditions, group count, anyOf value lists, and nesting depth - * (groups are one level deep — a group may not itself contain a group). */ -function assertFilterBounds(parsed: FilterSpec): FilterSpec { - const top = parsed.conditions ?? []; - let leafCount = 0; - let groupCount = 0; - const checkLeaf = (c: { value?: unknown }) => { - if (Array.isArray(c.value) && c.value.length > MAX_ANYOF_VALUES) { - throw new HttpError(400, `too many values in a condition (max ${MAX_ANYOF_VALUES})`); - } - }; - for (const item of top) { - if (isFilterGroup(item)) { - groupCount++; - for (const inner of item.conditions ?? []) { - if (isFilterGroup(inner as never)) throw new HttpError(400, "filter groups may not be nested"); - leafCount++; - checkLeaf(inner); - } - } else { - leafCount++; - checkLeaf(item); - } - } - if (leafCount > MAX_FILTER_CONDITIONS) { - throw new HttpError(400, `too many filter conditions (max ${MAX_FILTER_CONDITIONS})`); - } - if (groupCount > MAX_FILTER_GROUPS) { - throw new HttpError(400, `too many filter groups (max ${MAX_FILTER_GROUPS})`); - } - return parsed; -} - /** Parse list query params into ListOpts. filter is base64-encoded JSON. */ function parseListOpts(url: URL): ListOpts { const opts: ListOpts = {}; From be4652b82dd321ce7ce4beeae15e3a6592f3b5c5 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:20:53 -0400 Subject: [PATCH 03/16] feat(web): reports/dashboards composer UI (composer Part B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /dashboards surface to compose a page of widgets from any table. - Routes: /d (list + create), /d/[dashboardId] (render). Sidebar "Dashboards" nav. - DashboardRenderer (add/edit/remove/reorder widgets → PATCH config + refresh); DashboardWidget (KPI number · line · bar · small table); WidgetBuilder modal (type → table → measure/metric → group-by → date bucket → title); BarMini (recharts), reuses MetricChart. Per-widget data computed server-side (computeWidgets) so the API secret stays off the client; client mutates via BFF. - A demo "Sales pipeline" dashboard seeded over the CRM (KPIs + by-stage bar + amount-by-month line + deals table). - Review hardening (no high/med findings): aggregate filter now bounded (shared assertFilterBounds), widget filter field refs validated. 36 tests pass; apps/web builds; typecheck clean. Stacked on #2. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/components/WidgetBuilder.tsx | 2 +- apps/web/lib/dashboard.test.ts | 1 + apps/web/lib/server/gridApi.ts | 6 ++---- packages/api/src/dashboards.ts | 1 + packages/api/src/index.ts | 18 +++++++++++++++++- scripts/seed.mjs | 12 ++++++------ 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/web/components/WidgetBuilder.tsx b/apps/web/components/WidgetBuilder.tsx index e4dbbbf..bd8e65f 100644 --- a/apps/web/components/WidgetBuilder.tsx +++ b/apps/web/components/WidgetBuilder.tsx @@ -53,7 +53,7 @@ export function WidgetBuilder({ meta, initial, onSave, onClose }: { meta: Meta; {TYPES.map((t) => ( + ) : null} + {rows.length} loaded{offset ? "+" : ""} + ); diff --git a/apps/web/components/ViewToolbar.tsx b/apps/web/components/ViewToolbar.tsx index 07bc4de..8fd24f9 100644 --- a/apps/web/components/ViewToolbar.tsx +++ b/apps/web/components/ViewToolbar.tsx @@ -1,4 +1,7 @@ "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"; @@ -78,18 +81,20 @@ const panel = "absolute z-40 mt-1 max-h-[70vh] w-[28rem] max-w-[calc(100vw-6rem) 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,8 +111,11 @@ 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. + const stackFields = fields.filter((f) => f.type === "select"); const conds = cfg.filters?.conditions ?? []; const sorts = cfg.sorts ?? []; const hiddenCount = cfg.fields ? fields.length - cfg.fields.length : 0; @@ -126,6 +134,11 @@ export function ViewToolbar({ + {viewType === "kanban" ? ( + + ) : null} {open === "filter" ? (
@@ -152,6 +165,59 @@ export function ViewToolbar({ />
) : null} + {open === "kanban" ? ( +
+ save({ ...cfg, kanban })} + /> +
+ ) : null} + + ); +} + +/** 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 ?? ""; + if (stackFields.length === 0) { + return

Add a single-select field to this table to group a Kanban by it.

; + } + return ( +
+
+ + +
+
+ Max card fields + onChange({ ...kanban, stackFieldId, maxPreviewFields: Number(e.target.value) })} + /> +
+

Columns come from this field’s options. Use the Fields menu to choose which fields show on each card.

); } @@ -403,30 +469,32 @@ function FieldEditor({ allFields, configFields, onChange }: { allFields: FieldMe : 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 }))); }; return (
- {ordered.map((f, i) => ( -
- setVisible(f.fieldId, false)} /> - {f.name} - - -
- ))} + + f.fieldId)} strategy={verticalListSortingStrategy}> + {ordered.map((f) => ( + setVisible(f.fieldId, false)} /> + ))} + + {allFields.filter((f) => !visible.has(f.fieldId)).map((f) => (
setVisible(f.fieldId, true)} /> @@ -437,6 +505,31 @@ function FieldEditor({ allFields, configFields, onChange }: { allFields: FieldMe ); } +/** One draggable visible-field row: drag handle reorders, checkbox hides. */ +function SortableFieldRow({ field, onHide }: { field: FieldMeta; onHide: () => 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} + +
+ ); +} + function FreezeEditor({ freezeHeader, frozen, diff --git a/apps/web/lib/client.ts b/apps/web/lib/client.ts index 19d4500..6cf7d55 100644 --- a/apps/web/lib/client.ts +++ b/apps/web/lib/client.ts @@ -61,6 +61,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/types.ts b/apps/web/lib/types.ts index ba45c7c..ce5f6fb 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; 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/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..a831702 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; 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( From b62d08f9f9416d152917fe4526ea47cd6fea4898 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:46:23 -0400 Subject: [PATCH 07/16] feat(grid): bigger left drag handle + custom Kanban column order Mirror of the grid handle + Kanban column-order changes: - Field reorder handle on the left, enlarged, touch-none, scrollbar-clear. - Kanban config gains a drag-to-reorder "Column order" list (config.kanban.columnOrder); KanbanView honors it, Uncategorized last. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/components/KanbanView.tsx | 8 +++- apps/web/components/ViewToolbar.tsx | 73 +++++++++++++++++++++++++---- apps/web/lib/types.ts | 2 +- packages/api/src/types.ts | 2 +- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/web/components/KanbanView.tsx b/apps/web/components/KanbanView.tsx index dc3dcde..5cb2f55 100644 --- a/apps/web/components/KanbanView.tsx +++ b/apps/web/components/KanbanView.tsx @@ -48,11 +48,15 @@ export function KanbanView({ }; // 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), then the catch-all Uncategorized column. + // 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 = [ - ...[...new Set([...defined, ...present])].map((name) => ({ id: name, label: name })), + ...sorted.map((name) => ({ id: name, label: name })), { id: UNSET, label: "Uncategorized" }, ]; diff --git a/apps/web/components/ViewToolbar.tsx b/apps/web/components/ViewToolbar.tsx index 8fd24f9..31c0c5c 100644 --- a/apps/web/components/ViewToolbar.tsx +++ b/apps/web/components/ViewToolbar.tsx @@ -191,9 +191,24 @@ function KanbanEditor({ 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 (
@@ -201,7 +216,8 @@ function KanbanEditor({ setVisible(f.fieldId, true)} /> {f.name}
@@ -505,7 +560,9 @@ function FieldEditor({ allFields, configFields, onChange }: { allFields: FieldMe ); } -/** One draggable visible-field row: drag handle reorders, checkbox hides. */ +/** One draggable visible-field row: a generous left drag handle reorders, + * checkbox hides. Handle is on the left so the scroll container's scrollbar + * never overlaps it. */ function SortableFieldRow({ field, onHide }: { field: FieldMeta; onHide: () => 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 }; @@ -515,17 +572,17 @@ function SortableFieldRow({ field, onHide }: { field: FieldMeta; onHide: () => v style={style} className={`flex items-center gap-2 rounded text-xs ${isDragging ? "bg-surface-muted shadow-sm" : ""}`} > - - {field.name} + + {field.name}
); } diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index ce5f6fb..9c94ff4 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -74,7 +74,7 @@ export interface ViewConfig { }; sorts?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>; groupBy?: string; - kanban?: { stackFieldId: string; maxPreviewFields?: number }; + kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[] }; calendar?: { dateFieldId: string }; form?: { title?: string; fieldIds: string[]; redirectMessage?: string }; dashboard?: { dateFieldId: string; metricFieldIds: string[] }; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index a831702..795d5a1 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -121,7 +121,7 @@ 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 }; + kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[] }; calendar?: { dateFieldId: string }; form?: { title?: string; fieldIds: string[]; redirectMessage?: string }; dashboard?: { dateFieldId: string; metricFieldIds: string[] }; From c3281a8c8e3cc5e1a964352d9b338f6ccd211486 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:02:49 -0400 Subject: [PATCH 08/16] feat(grid): self-service add/delete fields (incl. rollups) from the Fields menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the field self-service feature: - packages/api: POST/DELETE /v1/tables/:id/fields (fields.ts). Computed fields insert a meta row; stored fields ALTER TABLE ADD COLUMN. Delete guards the primary field + referenced fields and DROP COLUMNs stored ones. Schema cache invalidated. - UI: "+ Add field" form (with a rollup builder) + hover-✕ delete in the Fields menu; new fields auto-show in the current view. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/components/ViewToolbar.tsx | 147 +++++++++++++++++++++++++--- apps/web/lib/client.ts | 10 +- packages/api/src/fields.ts | 116 ++++++++++++++++++++++ packages/api/src/index.ts | 29 +++++- 4 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 packages/api/src/fields.ts diff --git a/apps/web/components/ViewToolbar.tsx b/apps/web/components/ViewToolbar.tsx index 31c0c5c..bbf0fc3 100644 --- a/apps/web/components/ViewToolbar.tsx +++ b/apps/web/components/ViewToolbar.tsx @@ -4,8 +4,8 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } 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> = { @@ -152,7 +152,7 @@ export function ViewToolbar({ ) : null} {open === "fields" ? (
- save({ ...cfg, fields: f })} /> + save({ ...cfg, fields: f })} />
) : null} {open === "freeze" ? ( @@ -515,7 +515,9 @@ 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)) @@ -538,39 +540,65 @@ function FieldEditor({ allFields, configFields, onChange }: { allFields: FieldMe 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 ( // pr-3 keeps the drag handle clear of the (overlay) scrollbar. -
+
f.fieldId)} strategy={verticalListSortingStrategy}> {ordered.map((f) => ( - setVisible(f.fieldId, false)} /> + 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. Handle is on the left so the scroll container's scrollbar - * never overlaps it. */ -function SortableFieldRow({ field, onHide }: { field: FieldMeta; onHide: () => void }) { + * 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/lib/client.ts b/apps/web/lib/client.ts index 6cf7d55..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 { diff --git a/packages/api/src/fields.ts b/packages/api/src/fields.ts new file mode 100644 index 0000000..a6beb22 --- /dev/null +++ b/packages/api/src/fields.ts @@ -0,0 +1,116 @@ +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); + if (!viaField || viaField.type !== "link") 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) throw new HttpError(400, "rollup needs a stored target field for sum/avg/min/max"); + } + } + 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..3318cdf 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; @@ -270,6 +271,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) { From ba878ebc80b43dc53fbfe6670917a9104fff8c9f Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:07:32 -0400 Subject: [PATCH 09/16] feat(grid): Calendar date-field config panel (mirrors Kanban setup) Mirror: a Calendar toolbar panel to pick the date field (config.calendar.dateFieldId), paralleling the Kanban stack-field picker, replacing the "no date field configured" dead-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/components/ViewToolbar.tsx | 53 +++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/web/components/ViewToolbar.tsx b/apps/web/components/ViewToolbar.tsx index bbf0fc3..beb56e0 100644 --- a/apps/web/components/ViewToolbar.tsx +++ b/apps/web/components/ViewToolbar.tsx @@ -94,7 +94,7 @@ export function ViewToolbar({ }) { 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]); @@ -114,8 +114,10 @@ export function ViewToolbar({ // 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" && f.type !== "rollup"); - // Single-select fields are the valid Kanban grouping fields. + // 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; @@ -139,6 +141,11 @@ export function ViewToolbar({ Kanban ) : null} + {viewType === "calendar" ? ( + + ) : null} {open === "filter" ? (
@@ -174,6 +181,48 @@ export function ViewToolbar({ />
) : null} + {open === "calendar" ? ( +
+ save({ ...cfg, calendar })} + /> +
+ ) : null} +
+ ); +} + +/** 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 ( +
+
+ + +
+

Records appear on the month grid by this field. Drag an event to another day to update it.

); } From 90acff0a5c90b8d52a4e7a44c1ab856a90620bd6 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:47:10 -0400 Subject: [PATCH 10/16] feat: Gallery + Gantt view types with config panels (synced from upstream) - GalleryView: responsive card grid (primary-field title, Fields-editor card fields, optional url-field cover, maxPreviewFields cap) + Gallery toolbar panel. - GanttView: per-record timeline bars from start/end date fields with month ticks + Gantt toolbar panel (start/end pickers). - Registered in ViewSwitcher, view page, ViewConfig types, VIEW_TYPES allowlist; previewValue extracted to lib/preview (shared by Kanban + Gallery). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/t/[tableId]/[viewId]/page.tsx | 8 +- apps/web/components/GalleryView.tsx | 66 ++++++++++++ apps/web/components/GanttView.tsx | 120 +++++++++++++++++++++ apps/web/components/KanbanView.tsx | 13 +-- apps/web/components/ViewSwitcher.tsx | 4 +- apps/web/components/ViewToolbar.tsx | 114 +++++++++++++++++++- apps/web/lib/preview.ts | 14 +++ apps/web/lib/types.ts | 4 +- packages/api/src/types.ts | 4 +- packages/api/src/views.ts | 2 +- 10 files changed, 331 insertions(+), 18 deletions(-) create mode 100644 apps/web/components/GalleryView.tsx create mode 100644 apps/web/components/GanttView.tsx create mode 100644 apps/web/lib/preview.ts diff --git a/apps/web/app/t/[tableId]/[viewId]/page.tsx b/apps/web/app/t/[tableId]/[viewId]/page.tsx index e17c39f..bfa71d2 100644 --- a/apps/web/app/t/[tableId]/[viewId]/page.tsx +++ b/apps/web/app/t/[tableId]/[viewId]/page.tsx @@ -2,6 +2,8 @@ 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"; @@ -60,11 +62,15 @@ export default async function ViewPage({
-
+
{view.type === "kanban" ? ( ) : view.type === "calendar" ? ( + ) : view.type === "gallery" ? ( + + ) : view.type === "gantt" ? ( + ) : view.type === "form" ? ( ) : view.type === "dashboard" ? ( diff --git a/apps/web/components/GalleryView.tsx b/apps/web/components/GalleryView.tsx new file mode 100644 index 0000000..58d364f --- /dev/null +++ b/apps/web/components/GalleryView.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { previewValue } 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; + const title = primaryId != null ? rec.fields[primaryId] : undefined; + return ( + + {typeof cover === "string" && cover.startsWith("http") ? ( + // eslint-disable-next-line @next/next/no-img-element -- arbitrary user-provided host + + ) : null} +
+

+ {title != null && title !== "" ? String(title) : "(untitled)"} +

+ {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..d728b51 --- /dev/null +++ b/apps/web/components/GanttView.tsx @@ -0,0 +1,120 @@ +import Link from "next/link"; +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. */ +function toMs(v: unknown): number | null { + if (v == null || v === "") return null; + const t = Date.parse(String(v)); + 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); + const primaryId = meta.tables.find((t) => t.tableId === view.tableId)?.primaryFieldId; + + if (!startField || !startId) { + return

This Gantt view has no start-date field configured. Use the Gantt menu to pick one.

; + } + + const bars = records + .map((rec) => { + const start = toMs(rec.fields[startId]); + if (start == null) return null; + const endRaw = endId ? toMs(rec.fields[endId]) : null; + const end = endRaw != null && endRaw > start ? endRaw : start + DAY; + return { rec, start, end }; + }) + .filter((b): b is { rec: RecordEnvelope; start: number; end: number } => b !== null) + .sort((a, b) => a.start - b.start); + const undated = records.filter((rec) => toMs(rec.fields[startId]) == null); + + 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. + const ticks: Array<{ t: number; label: string }> = []; + 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" }) }); + 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 }) => { + const title = primaryId != null ? rec.fields[primaryId] : undefined; + return ( +
+
+ + {title != null && title !== "" ? String(title) : "(untitled)"} + +
+
+ {ticks.map((tk) => ( + + ))} + +
+
+ ); + })} + {undated.length > 0 ? ( +

+ {undated.length} record{undated.length === 1 ? "" : "s"} without “{startField.name}” not shown. +

+ ) : null} +
+ ); +} diff --git a/apps/web/components/KanbanView.tsx b/apps/web/components/KanbanView.tsx index 5cb2f55..40399d2 100644 --- a/apps/web/components/KanbanView.tsx +++ b/apps/web/components/KanbanView.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { updateRecord } from "@/lib/client"; +import { previewValue } from "@/lib/preview"; import { type FieldMeta, fieldsForTable, type Meta, type RecordEnvelope, type ViewMeta, visibleFields } from "@/lib/types"; const UNSET = "__unset__"; @@ -114,18 +115,6 @@ function Column({ id, label, count, children }: { id: string; label: string; cou ); } -/** 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); -} - 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; diff --git a/apps/web/components/ViewSwitcher.tsx b/apps/web/components/ViewSwitcher.tsx index 6308777..ca1fcd0 100644 --- a/apps/web/components/ViewSwitcher.tsx +++ b/apps/web/components/ViewSwitcher.tsx @@ -12,11 +12,13 @@ export interface SwitcherView { isHidden: boolean; } -const TYPE_ICON: Record = { table: "▦", kanban: "▤", calendar: "▥", form: "✎", detail: "❏", dashboard: "📊" }; +const TYPE_ICON: Record = { table: "▦", kanban: "▤", calendar: "▥", form: "✎", detail: "❏", dashboard: "📊", gallery: "▣", gantt: "≡" }; const ADDABLE = [ { type: "table", label: "Grid" }, { type: "kanban", label: "Kanban" }, { type: "calendar", label: "Calendar" }, + { type: "gallery", label: "Gallery" }, + { type: "gantt", label: "Gantt" }, { type: "form", label: "Form" }, { type: "dashboard", label: "Dashboard" }, ]; diff --git a/apps/web/components/ViewToolbar.tsx b/apps/web/components/ViewToolbar.tsx index beb56e0..8b56a2d 100644 --- a/apps/web/components/ViewToolbar.tsx +++ b/apps/web/components/ViewToolbar.tsx @@ -94,7 +94,7 @@ export function ViewToolbar({ }) { 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]); @@ -146,6 +146,16 @@ export function ViewToolbar({ Calendar ) : null} + {viewType === "gallery" ? ( + + ) : null} + {viewType === "gantt" ? ( + + ) : null} {open === "filter" ? (
@@ -190,6 +200,108 @@ export function ViewToolbar({ />
) : null} + {open === "gallery" ? ( +
+ f.type === "url")} + gallery={cfg.gallery} + onChange={(gallery) => save({ ...cfg, gallery })} + /> +
+ ) : null} + {open === "gantt" ? ( +
+ save({ ...cfg, gantt })} + /> +
+ ) : null} +
+ ); +} + +/** 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 ( +
+
+ + + {urlFields.length === 0 ?

Add a URL field to use card covers.

: null} +
+
+ Max card fields + onChange({ ...gallery, maxPreviewFields: Number(e.target.value) })} + /> +
+

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 ( +
+
+ + +
+
+ + +
+

Each record renders a bar from start to end on a shared timeline.

); } diff --git a/apps/web/lib/preview.ts b/apps/web/lib/preview.ts new file mode 100644 index 0000000..c0189fc --- /dev/null +++ b/apps/web/lib/preview.ts @@ -0,0 +1,14 @@ +import type { FieldMeta } from "./types"; + +/** 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/types.ts b/apps/web/lib/types.ts index 9c94ff4..7ea936f 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -76,6 +76,8 @@ export interface ViewConfig { groupBy?: string; kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[] }; 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). */ @@ -87,7 +89,7 @@ export interface ViewMeta { viewId: string; tableId: string; name: string; - type: "table" | "kanban" | "calendar" | "form" | "detail" | "dashboard"; + type: "table" | "kanban" | "calendar" | "form" | "detail" | "dashboard" | "gallery" | "gantt"; position: number; isHidden: boolean; config: ViewConfig; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 795d5a1..d4bb489 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -81,7 +81,7 @@ export interface ViewMeta { viewId: string; tableId: string; name: string; - type: "table" | "kanban" | "calendar" | "form" | "detail" | "dashboard"; + type: "table" | "kanban" | "calendar" | "form" | "detail" | "dashboard" | "gallery" | "gantt"; position: number; isHidden: boolean; config: ViewConfig; @@ -123,6 +123,8 @@ export interface ViewConfig { groupBy?: string; kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[] }; 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[] }; } diff --git a/packages/api/src/views.ts b/packages/api/src/views.ts index e252e0e..8582d81 100644 --- a/packages/api/src/views.ts +++ b/packages/api/src/views.ts @@ -1,7 +1,7 @@ import { HttpError } from "./repo.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(["table", "kanban", "calendar", "form", "detail", "dashboard", "gallery", "gantt"]); function newViewId(): string { return "viw" + crypto.randomUUID().replace(/-/g, "").slice(0, 14); From 4806fa7fe8a93387f339eb401c943ea22d12a270 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:15:37 -0400 Subject: [PATCH 11/16] fix: review + security findings on dashboards/gallery/gantt (synced from upstream) - WidgetBuilder materializes the bucket default + clears fieldIds/filter on table change; DashboardRenderer disables controls during in-flight saves. - validateConfig: widgetId required+unique, numeric stored metric for non-count aggs, stored group-by for charts, numeric position; PATCH rejects malformed JSON. - loadDashboards tolerates a missing meta_dashboards table (deploy-before-migrate). - createField pins rollup references: via on the field's table, target a number field on the linked table (a dangling reference 500'd every read of the table). - GanttView pins zone-less datetimes to UTC; single dated/undated pass; tick left% precomputed. Detail views regain overflow-hidden. - Cleanups: shared meta_dashboards row mapper; VIEW_TYPES/AGG_FNS const single sources; ViewToolbar config panels collapsed into a typeEditors map; CountInput commit-on-blur; DashboardWidget reuses previewValue. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/t/[tableId]/[viewId]/page.tsx | 4 +- apps/web/components/DashboardRenderer.tsx | 13 ++- apps/web/components/DashboardWidget.tsx | 23 ++-- apps/web/components/GanttView.tsx | 50 +++++---- apps/web/components/ViewToolbar.tsx | 119 +++++++++------------ apps/web/components/WidgetBuilder.tsx | 9 +- apps/web/lib/types.ts | 6 +- packages/api/src/aggregate.ts | 3 +- packages/api/src/dashboards.ts | 57 ++++------ packages/api/src/fields.ts | 11 +- packages/api/src/index.ts | 4 +- packages/api/src/meta.ts | 26 +++-- packages/api/src/types.ts | 12 ++- packages/api/src/views.ts | 3 +- 14 files changed, 183 insertions(+), 157 deletions(-) diff --git a/apps/web/app/t/[tableId]/[viewId]/page.tsx b/apps/web/app/t/[tableId]/[viewId]/page.tsx index bfa71d2..6d104e9 100644 --- a/apps/web/app/t/[tableId]/[viewId]/page.tsx +++ b/apps/web/app/t/[tableId]/[viewId]/page.tsx @@ -62,7 +62,9 @@ export default async function ViewPage({
-
+ {/* 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" ? ( 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..5b18819 100644 --- a/apps/web/components/DashboardWidget.tsx +++ b/apps/web/components/DashboardWidget.tsx @@ -1,5 +1,6 @@ "use client"; import dynamic from "next/dynamic"; +import { previewValue } from "@/lib/preview"; import type { AggregateRow, Meta, RecordEnvelope, Widget } from "@/lib/types"; // recharts is client-only — load the chart pieces without SSR. @@ -21,12 +22,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 +47,31 @@ export function DashboardWidget({ widget, meta, result }: { widget: Widget; meta return data.length ?
: No data; } - // table + // table — resolve column field ids to FieldMeta once (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 = + const colIds = widget.fieldIds && widget.fieldIds.length ? widget.fieldIds : meta.fields.filter((f) => f.tableId === widget.tableId).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/GanttView.tsx b/apps/web/components/GanttView.tsx index d728b51..6b87164 100644 --- a/apps/web/components/GanttView.tsx +++ b/apps/web/components/GanttView.tsx @@ -4,10 +4,15 @@ import { fieldsForTable, type Meta, type RecordEnvelope, type ViewMeta } from "@ 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. */ +/** 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; - const t = Date.parse(String(v)); + 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; } @@ -36,17 +41,17 @@ export function GanttView({ return

This Gantt view has no start-date field configured. Use the Gantt menu to pick one.

; } - const bars = records - .map((rec) => { - const start = toMs(rec.fields[startId]); - if (start == null) return null; - const endRaw = endId ? toMs(rec.fields[endId]) : null; - const end = endRaw != null && endRaw > start ? endRaw : start + DAY; - return { rec, start, end }; - }) - .filter((b): b is { rec: RecordEnvelope; start: number; end: number } => b !== null) - .sort((a, b) => a.start - b.start); - const undated = records.filter((rec) => toMs(rec.fields[startId]) == null); + // 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}”.

; @@ -61,13 +66,18 @@ export function GanttView({ const span = max - min; const pct = (t: number) => ((t - min) / span) * 100; - // Month boundaries inside the domain → header ticks + grid lines. - const ticks: Array<{ t: number; label: string }> = []; + // 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" }) }); + 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); } @@ -82,7 +92,7 @@ export function GanttView({
{ticks.map((tk) => ( - + {tk.label} ))} @@ -99,7 +109,7 @@ export function GanttView({
{ticks.map((tk) => ( - + ))} ); })} - {undated.length > 0 ? ( + {undatedCount > 0 ? (

- {undated.length} record{undated.length === 1 ? "" : "s"} without “{startField.name}” not shown. + {undatedCount} record{undatedCount === 1 ? "" : "s"} without “{startField.name}” not shown.

) : null}
diff --git a/apps/web/components/ViewToolbar.tsx b/apps/web/components/ViewToolbar.tsx index 8b56a2d..e1e19f3 100644 --- a/apps/web/components/ViewToolbar.tsx +++ b/apps/web/components/ViewToolbar.tsx @@ -78,6 +78,7 @@ 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"; +const panelSm = "absolute z-40 mt-1 w-72 rounded-lg border border-surface-border bg-white p-3 shadow-lg"; export function ViewToolbar({ viewId, @@ -94,7 +95,7 @@ export function ViewToolbar({ }) { 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]); @@ -122,6 +123,27 @@ export function ViewToolbar({ 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 (
- {viewType === "kanban" ? ( - - ) : null} - {viewType === "calendar" ? ( - - ) : null} - {viewType === "gallery" ? ( - - ) : null} - {viewType === "gantt" ? ( - ) : null} @@ -173,7 +180,7 @@ export function ViewToolbar({
) : null} {open === "freeze" ? ( -
+
) : null} - {open === "kanban" ? ( -
- save({ ...cfg, kanban })} - /> -
- ) : null} - {open === "calendar" ? ( -
- save({ ...cfg, calendar })} - /> -
- ) : null} - {open === "gallery" ? ( -
- f.type === "url")} - gallery={cfg.gallery} - onChange={(gallery) => save({ ...cfg, gallery })} - /> -
- ) : null} - {open === "gantt" ? ( -
- save({ ...cfg, gantt })} - /> + {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(); }} + /> + ); +} + /** 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({ @@ -249,13 +244,7 @@ function GalleryEditor({
Max card fields - onChange({ ...gallery, maxPreviewFields: Number(e.target.value) })} - /> + onChange({ ...gallery, maxPreviewFields: n })} />

Use the Fields menu to choose which fields show on each card.

@@ -386,13 +375,7 @@ function KanbanEditor({
Max card fields - onChange({ ...kanban, stackFieldId, maxPreviewFields: Number(e.target.value) })} - /> + onChange({ ...kanban, stackFieldId, maxPreviewFields: n })} />
{buckets.length ? (
diff --git a/apps/web/components/WidgetBuilder.tsx b/apps/web/components/WidgetBuilder.tsx index e4dbbbf..470d776 100644 --- a/apps/web/components/WidgetBuilder.tsx +++ b/apps/web/components/WidgetBuilder.tsx @@ -40,7 +40,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 +66,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/types.ts b/apps/web/lib/types.ts index 7ea936f..39981b0 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -85,11 +85,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" | "gallery" | "gantt"; + type: ViewType; position: number; isHidden: boolean; config: ViewConfig; 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 index a6beb22..11cf523 100644 --- a/packages/api/src/fields.ts +++ b/packages/api/src/fields.ts @@ -50,10 +50,17 @@ export async function createField( const r = options?.rollup; if (!r?.via || !r.agg) throw new HttpError(400, "rollup needs { via, agg }"); const viaField = reg.fieldById.get(r.via); - if (!viaField || viaField.type !== "link") throw new HttpError(400, "rollup.via must be a link field on this table"); + // 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) throw new HttpError(400, "rollup needs a stored target field for sum/avg/min/max"); + 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: [] }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3318cdf..ed51d4d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -253,7 +253,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); } 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/types.ts b/packages/api/src/types.ts index d4bb489..a45cbec 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -77,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" | "gallery" | "gantt"; + type: ViewType; position: number; isHidden: boolean; config: ViewConfig; @@ -139,7 +144,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 8582d81..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", "gallery", "gantt"]); +const VIEW_TYPES = new Set(VIEW_TYPE_VALUES); function newViewId(): string { return "viw" + crypto.randomUUID().replace(/-/g, "").slice(0, 14); From 6381371822514f9ba3530056a5516903d4ee1d7c Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:28:51 -0400 Subject: [PATCH 12/16] refactor: simplify-pass cleanups (synced from upstream) recordTitle + FieldSelect shared helpers, WidgetResult single declaration, fieldsForTable in WidgetBuilder/DashboardWidget (+ isComputed group-by filter that was missing rollup), ViewSwitcher TYPE_INFO registry keyed by ViewType, views/dashboards PATCH reject malformed JSON consistently. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/components/DashboardWidget.tsx | 21 ++--- apps/web/components/GalleryView.tsx | 7 +- apps/web/components/GanttView.tsx | 5 +- apps/web/components/KanbanView.tsx | 8 +- apps/web/components/ViewSwitcher.tsx | 31 +++++--- apps/web/components/ViewToolbar.tsx | 101 +++++++++++------------- apps/web/components/WidgetBuilder.tsx | 9 ++- apps/web/lib/preview.ts | 10 ++- apps/web/lib/server/gridApi.ts | 6 +- apps/web/lib/types.ts | 9 +++ packages/api/src/index.ts | 4 +- 11 files changed, 106 insertions(+), 105 deletions(-) diff --git a/apps/web/components/DashboardWidget.tsx b/apps/web/components/DashboardWidget.tsx index 5b18819..bcf46c9 100644 --- a/apps/web/components/DashboardWidget.tsx +++ b/apps/web/components/DashboardWidget.tsx @@ -1,17 +1,13 @@ "use client"; import dynamic from "next/dynamic"; import { previewValue } from "@/lib/preview"; -import type { AggregateRow, Meta, RecordEnvelope, Widget } from "@/lib/types"; +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 "—"; @@ -47,14 +43,13 @@ export function DashboardWidget({ widget, meta, result }: { widget: Widget; meta return data.length ?
: No data; } - // table — resolve column field ids to FieldMeta once (unknown ids are dropped: - // they can't be labeled or rendered meaningfully). + // 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 colIds = - 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 ( diff --git a/apps/web/components/GalleryView.tsx b/apps/web/components/GalleryView.tsx index 58d364f..f9660a8 100644 --- a/apps/web/components/GalleryView.tsx +++ b/apps/web/components/GalleryView.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { previewValue } from "@/lib/preview"; +import { previewValue, recordTitle } from "@/lib/preview"; import { type Meta, type RecordEnvelope, type ViewMeta, visibleFields } from "@/lib/types"; /** @@ -33,7 +33,6 @@ export function GalleryView({
{records.map((rec) => { const cover = coverId ? rec.fields[coverId] : undefined; - const title = primaryId != null ? rec.fields[primaryId] : undefined; return ( ) : null}
-

- {title != null && title !== "" ? String(title) : "(untitled)"} -

+

{recordTitle(meta, view, rec)}

{cardFields.map((f) => { const text = previewValue(f, rec.fields[f.fieldId], labels); if (!text) return null; diff --git a/apps/web/components/GanttView.tsx b/apps/web/components/GanttView.tsx index 6b87164..f286095 100644 --- a/apps/web/components/GanttView.tsx +++ b/apps/web/components/GanttView.tsx @@ -1,4 +1,5 @@ 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; @@ -35,7 +36,6 @@ export function GanttView({ const endId = view.config.gantt?.endFieldId; const fields = fieldsForTable(meta, view.tableId); const startField = fields.find((f) => f.fieldId === startId); - const primaryId = meta.tables.find((t) => t.tableId === view.tableId)?.primaryFieldId; if (!startField || !startId) { return

This Gantt view has no start-date field configured. Use the Gantt menu to pick one.

; @@ -99,12 +99,11 @@ export function GanttView({
{bars.map(({ rec, start, end }) => { - const title = primaryId != null ? rec.fields[primaryId] : undefined; return (
- {title != null && title !== "" ? String(title) : "(untitled)"} + {recordTitle(meta, view, rec)}
diff --git a/apps/web/components/KanbanView.tsx b/apps/web/components/KanbanView.tsx index 40399d2..8ca51f9 100644 --- a/apps/web/components/KanbanView.tsx +++ b/apps/web/components/KanbanView.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { updateRecord } from "@/lib/client"; -import { previewValue } from "@/lib/preview"; +import { previewValue, recordTitle } from "@/lib/preview"; import { type FieldMeta, fieldsForTable, type Meta, type RecordEnvelope, type ViewMeta, visibleFields } from "@/lib/types"; const UNSET = "__unset__"; @@ -89,7 +89,7 @@ export function KanbanView({ key={rec.id} rec={rec} href={`/t/${view.tableId}/${view.viewId}/${rec.id}`} - primaryId={primaryId} + title={recordTitle(meta, view, rec)} previewFields={previewFields} labels={labels} /> @@ -115,7 +115,7 @@ function Column({ id, label, count, children }: { id: string; label: string; cou ); } -function Card({ rec, href, primaryId, previewFields, labels }: { rec: RecordEnvelope; href: string; primaryId?: string; previewFields: FieldMeta[]; labels: Map }) { +function Card({ rec, href, title, previewFields, labels }: { rec: RecordEnvelope; href: string; title: 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; return ( @@ -127,7 +127,7 @@ function Card({ rec, href, primaryId, previewFields, labels }: { rec: RecordEnve className={`rounded-lg border border-surface-border bg-white p-3 shadow-sm ${isDragging ? "opacity-60" : "hover:border-blue-300"}`} > 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/ViewSwitcher.tsx b/apps/web/components/ViewSwitcher.tsx index ca1fcd0..3fed835 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,16 +12,21 @@ export interface SwitcherView { isHidden: boolean; } -const TYPE_ICON: Record = { table: "▦", kanban: "▤", calendar: "▥", form: "✎", detail: "❏", dashboard: "📊", gallery: "▣", gantt: "≡" }; -const ADDABLE = [ - { type: "table", label: "Grid" }, - { type: "kanban", label: "Kanban" }, - { type: "calendar", label: "Calendar" }, - { type: "gallery", label: "Gallery" }, - { type: "gantt", label: "Gantt" }, - { 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({ @@ -100,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 ? ( @@ -117,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 e1e19f3..7a4376d 100644 --- a/apps/web/components/ViewToolbar.tsx +++ b/apps/web/components/ViewToolbar.tsx @@ -217,6 +217,37 @@ function CountInput({ value, fallback, onCommit }: { value: number | undefined; ); } +/** 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({ @@ -230,18 +261,8 @@ function GalleryEditor({ }) { return (
-
- - - {urlFields.length === 0 ?

Add a URL field to use card covers.

: null} -
+ onChange({ ...gallery, coverFieldId })} /> + {urlFields.length === 0 ?

Add a URL field to use card covers.

: null}
Max card fields onChange({ ...gallery, maxPreviewFields: n })} /> @@ -268,28 +289,14 @@ function GanttEditor({ } 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.

); @@ -312,17 +319,7 @@ function CalendarEditor({ } return (
-
- - -
+ onChange({ dateFieldId: id ?? "" })} />

Records appear on the month grid by this field. Drag an event to another day to update it.

); @@ -361,18 +358,8 @@ function KanbanEditor({ 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 })} /> diff --git a/apps/web/components/WidgetBuilder.tsx b/apps/web/components/WidgetBuilder.tsx index 470d776..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"; diff --git a/apps/web/lib/preview.ts b/apps/web/lib/preview.ts index c0189fc..ccfbd19 100644 --- a/apps/web/lib/preview.ts +++ b/apps/web/lib/preview.ts @@ -1,4 +1,12 @@ -import type { FieldMeta } from "./types"; +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. diff --git a/apps/web/lib/server/gridApi.ts b/apps/web/lib/server/gridApi.ts index ab8204a..dc65250 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 @@ -79,9 +79,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 39981b0..6c13b06 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -140,6 +140,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/packages/api/src/index.ts b/packages/api/src/index.ts index ed51d4d..8375a4b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -229,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); } From 71bc41625015b29d383b2b5e630cdd845393903d Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:51:31 -0400 Subject: [PATCH 13/16] feat: column resize, mobile layout fixes, full-set layout views (synced from upstream) - Drag-to-resize column widths persisted to config.fields[].width - h-dvh + viewport-fit=cover + safe-area padding; wrapping header/toolbar rows; viewport-clamped popovers - Layout views (kanban/calendar/gallery/gantt) page through the whole record set (bounded) instead of showing only the first 100 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/layout.tsx | 6 +- apps/web/app/t/[tableId]/[viewId]/page.tsx | 28 ++++---- apps/web/components/TableView.tsx | 82 +++++++++++++++++++--- apps/web/components/ViewSwitcher.tsx | 2 +- apps/web/components/ViewToolbar.tsx | 7 +- apps/web/lib/server/gridApi.ts | 19 +++++ 6 files changed, 117 insertions(+), 27 deletions(-) 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]/page.tsx b/apps/web/app/t/[tableId]/[viewId]/page.tsx index 6d104e9..91e8b89 100644 --- a/apps/web/app/t/[tableId]/[viewId]/page.tsx +++ b/apps/web/app/t/[tableId]/[viewId]/page.tsx @@ -10,7 +10,7 @@ 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"; @@ -27,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, offset } = 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); @@ -47,16 +49,18 @@ export default async function ViewPage({ return (
-
-
-

{table.name}

-
+ {/* 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}
@@ -64,7 +68,7 @@ export default async function ViewPage({ {/* 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" ? ( diff --git a/apps/web/components/TableView.tsx b/apps/web/components/TableView.tsx index fc40072..ee9f169 100644 --- a/apps/web/components/TableView.tsx +++ b/apps/web/components/TableView.tsx @@ -1,14 +1,15 @@ "use client"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { useEffect, useState, useTransition } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import { loadMoreRecords } from "@/app/t/[tableId]/[viewId]/actions"; -import { deleteRecords } from "@/lib/client"; +import { deleteRecords, 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"; -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 /** @@ -55,16 +56,72 @@ export function TableView({ const freezeHeader = view.config.freezeHeader !== false; // default on const frozen = view.config.frozen ?? 1; // default: freeze the first (record-name) column - // Frozen data columns sit to the right of the always-frozen checkbox column. - const colStyle = (i: number): React.CSSProperties => - i < frozen ? { position: "sticky", left: CHECKBOX_W + 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", @@ -143,10 +200,16 @@ export function TableView({
))} @@ -200,7 +263,8 @@ export function TableView({ )}
{fieldById.get(c)?.name ?? c}{f.name}
{cellText(rec.fields[c])}{previewValue(f, rec.fields[f.fieldId], NO_LABELS)}
{f.name} {f.isComputed ? ƒ : 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 + />
-
+ {/* Safe-area padding keeps Add/Load-more clear of mobile browser chrome. */} +
{offset ? ( diff --git a/apps/web/lib/server/gridApi.ts b/apps/web/lib/server/gridApi.ts index dc65250..99c0ea3 100644 --- a/apps/web/lib/server/gridApi.ts +++ b/apps/web/lib/server/gridApi.ts @@ -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, From a81b32437e9e1b2dda782b55d23bb14302363e17 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:04:56 -0400 Subject: [PATCH 14/16] feat: grouped rows, column header menu, kanban quick-add + collapse, mobile sidebar (synced) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grouped rows via config.groupBy with collapsible section headers - Column header ▾ menu: sort asc/desc, group by, hide field - Kanban per-column quick-add + collapsible columns (config.kanban.collapsedColumns) - Mobile sidebar: icon-rail default + overlay expansion with backdrop Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/components/KanbanView.tsx | 66 ++++++++++++++++-- apps/web/components/Sidebar.tsx | 108 +++++++++++++++++------------ apps/web/components/TableView.tsx | Bin 12650 -> 18767 bytes apps/web/lib/types.ts | 2 +- packages/api/src/types.ts | 2 +- 5 files changed, 125 insertions(+), 53 deletions(-) diff --git a/apps/web/components/KanbanView.tsx b/apps/web/components/KanbanView.tsx index 8ca51f9..48556d4 100644 --- a/apps/web/components/KanbanView.tsx +++ b/apps/web/components/KanbanView.tsx @@ -3,7 +3,7 @@ import { DndContext, type DragEndEvent, PointerSensor, useDraggable, useDroppabl 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"; @@ -77,13 +77,43 @@ 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)} /> + ); + } return ( - + setCollapsed(col.id, true)} onAdd={() => addToColumn(col.id)}> {cards.map((rec) => ( void; onAdd: () => void; children: React.ReactNode }) { const { setNodeRef, isOver } = useDroppable({ id }); return ( -
-
- {label} +
+
+ {label} {count} + + + +
{children}
); } +/** 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, title, previewFields, labels }: { rec: RecordEnvelope; href: string; title: 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; 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 ee9f1699a0ee9c4056fadc2a58371a5656136b11..543f38564da8d6a223b6cb9636638c21ca0bf62c 100644 GIT binary patch delta 5427 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 groupBy?: string; - kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[] }; + kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[]; collapsedColumns?: string[] }; calendar?: { dateFieldId: string }; gallery?: { coverFieldId?: string; maxPreviewFields?: number }; gantt?: { startFieldId: string; endFieldId?: string }; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index a45cbec..5ba8ad9 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -126,7 +126,7 @@ 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; columnOrder?: string[] }; + kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[]; collapsedColumns?: string[] }; calendar?: { dateFieldId: string }; gallery?: { coverFieldId?: string; maxPreviewFields?: number }; gantt?: { startFieldId: string; endFieldId?: string }; From c41dde538524c0ba8842f1a7a311e231252450e7 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:26:18 -0400 Subject: [PATCH 15/16] feat: kanban card ordering, keyboard nav + clipboard, summary footer, row colors, side peek (synced) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sortable cards within kanban columns (config.kanban.cardOrder), cross-column drops slot into position - Cell cursor: arrows / Enter / Esc / ⌘C / ⌘V on the grid - Sticky summary footer (config.summaries) with count/sum/avg/min/max - Row tinting by a select field (config.colorBy) via the header menu - Record side-peek drawer with inline editing (⌘-click for full page) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/components/KanbanView.tsx | 83 +++++++++++++++++++++++------ apps/web/components/TableView.tsx | Bin 18767 -> 30665 bytes apps/web/lib/types.ts | 13 ++++- packages/api/src/types.ts | 13 ++++- 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/apps/web/components/KanbanView.tsx b/apps/web/components/KanbanView.tsx index 48556d4..d9af60a 100644 --- a/apps/web/components/KanbanView.tsx +++ b/apps/web/components/KanbanView.tsx @@ -1,5 +1,7 @@ "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"; @@ -61,14 +63,58 @@ export function KanbanView({ { 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) { @@ -112,18 +158,21 @@ export function KanbanView({ setCollapsed(col.id, false)} /> ); } + const ordered = orderCards(cards, col.id); return ( setCollapsed(col.id, true)} onAdd={() => addToColumn(col.id)}> - {cards.map((rec) => ( - - ))} + r.id)} strategy={verticalListSortingStrategy}> + {ordered.map((rec) => ( + + ))} + ); })} @@ -170,8 +219,8 @@ function CollapsedColumn({ id, label, count, onExpand }: { id: string; label: st } function Card({ rec, href, title, previewFields, labels }: { rec: RecordEnvelope; href: string; title: 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; + 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 (
L*mStZ=7aVud++MQ-yU#6F;AL>+Dl2 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 diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 6ec2972..3e0976c 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -74,7 +74,18 @@ export interface ViewConfig { }; sorts?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>; groupBy?: string; - kanban?: { stackFieldId: string; maxPreviewFields?: number; columnOrder?: string[]; collapsedColumns?: string[] }; + /** 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 }; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 5ba8ad9..2d1ff9c 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -126,7 +126,18 @@ 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; columnOrder?: string[]; collapsedColumns?: string[] }; + /** 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 }; From 70e2d1fe28877e7619b8f435e68e16c0349eef3f Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:51:40 -0400 Subject: [PATCH 16/16] =?UTF-8?q?docs:=20README=20=E2=80=94=20full=20featu?= =?UTF-8?q?re=20overview=20(views,=20grid/kanban=20power=20features,=20das?= =?UTF-8?q?hboards,=20rollups,=20mobile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) 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).