- {conditions.length === 0 ?
No filters. Records aren’t filtered.
: null}
- {conditions.map((c, i) => {
- const field = fields.find((f) => f.fieldId === c.fieldId);
- const ops = field ? OPS_BY_KIND[kindOf(field)!]! : [];
- const opMeta = ops.find((o) => o.op === c.op);
- return (
-
- {i === 0 ? Where
- : {conjunction}}
-
-
- {opMeta && !opMeta.noValue ? update(i, { value: v })} /> : null}
-
-
- );
- })}
+ {items.length === 0 ?
No filters. Records aren’t filtered.
: null}
+ {items.map((item, i) => isFilterGroup(item) ? (
+
setItem(i, g)} onRemove={() => removeItem(i)}
+ />
+ ) : (
+ setItem(i, { ...item, ...patch })} onRemove={() => removeItem(i)}
+ />
+ ))}
- {conditions.length > 1 ? (
+
+ {items.length > 1 ? (
) : null}
@@ -175,6 +278,53 @@ function FilterEditor({
);
}
+/** A one-level AND/OR group: its own conjunction toggle + leaf condition rows. */
+function GroupRow({
+ group, fields, meta, lead, onChange, onRemove,
+}: {
+ group: FilterGroup;
+ fields: FieldMeta[];
+ meta: Meta;
+ lead: React.ReactNode;
+ onChange: (g: FilterGroup) => void;
+ onRemove: () => void;
+}) {
+ const conds = group.conditions ?? [];
+ const setCond = (i: number, patch: Partial
) =>
+ onChange({ ...group, conditions: conds.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
+ const removeCond = (i: number) => {
+ const next = conds.filter((_, j) => j !== i);
+ if (next.length === 0) { onRemove(); return; }
+ onChange({ ...group, conditions: next });
+ };
+ return (
+
+ {lead}
+
+
+ Group —
+
+
+
+ {conds.map((c, i) => (
+
When : {group.conjunction}}
+ onChange={(patch) => setCond(i, patch)} onRemove={() => removeCond(i)}
+ />
+ ))}
+
+
+
+ );
+}
+
function ValueInput({ field, value, onChange }: { field: FieldMeta; value: unknown; onChange: (v: unknown) => void }) {
const cls = "rounded border border-surface-border px-1 py-0.5 text-xs";
if (field.type === "select" && field.options?.choices) {
@@ -200,24 +350,46 @@ function ValueInput({ field, value, onChange }: { field: FieldMeta; value: unkno
);
}
-function SortEditor({ fields, sorts, onChange }: { fields: FieldMeta[]; sorts: NonNullable; onChange: (s: ViewConfig["sorts"]) => void }) {
+function SortEditor({ fields, meta, sorts, onChange }: { fields: FieldMeta[]; meta: Meta; sorts: NonNullable; onChange: (s: ViewConfig["sorts"]) => void }) {
+ // Default a link sort to the linked primary so it's immediately meaningful.
+ const defaultSort = (f: FieldMeta): NonNullable[number] => {
+ if (f.type === "link") {
+ const subs = linkedSubFields(f, meta);
+ const tid = linkedTableIdOf(f, meta);
+ const primaryId = meta.tables.find((t) => t.tableId === tid)?.primaryFieldId;
+ const linkedFieldId = subs.find((s) => s.fieldId === primaryId)?.fieldId ?? subs[0]?.fieldId;
+ return { fieldId: f.fieldId, linkedFieldId, direction: "asc" };
+ }
+ return { fieldId: f.fieldId, direction: "asc" };
+ };
+ const sel = "rounded border border-surface-border px-1 py-0.5 text-xs";
return (
{sorts.length === 0 ?
No sorts.
: null}
- {sorts.map((s, i) => (
-
-
-
-
-
- ))}
-
diff --git a/apps/web/components/WidgetBuilder.tsx b/apps/web/components/WidgetBuilder.tsx
new file mode 100644
index 0000000..e4dbbbf
--- /dev/null
+++ b/apps/web/components/WidgetBuilder.tsx
@@ -0,0 +1,134 @@
+"use client";
+import { useState } from "react";
+import type { AggFn, DateBucket, Meta, Widget, WidgetType } from "@/lib/types";
+
+const TYPES: Array<{ type: WidgetType; label: string }> = [
+ { type: "kpi", label: "KPI number" },
+ { type: "bar", label: "Bar chart" },
+ { type: "line", label: "Line chart" },
+ { type: "table", label: "Table" },
+];
+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);
+}
+
+const selectCls = "w-full rounded-md border border-neutral-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none";
+
+/** Modal to add/edit one widget: type → table → metric/agg → group-by/bucket → title. */
+export function WidgetBuilder({ meta, initial, onSave, onClose }: { meta: Meta; initial: Widget | null; onSave: (w: Widget) => void; onClose: () => void }) {
+ const [w, setW] = useState(
+ initial ?? { widgetId: newId(), type: "kpi", title: "", tableId: meta.tables[0]?.tableId ?? "", agg: "count" },
+ );
+ const set = (patch: Partial) => setW((s) => ({ ...s, ...patch }));
+
+ const tableFields = meta.fields.filter((f) => f.tableId === w.tableId);
+ const numberFields = tableFields.filter((f) => f.type === "number");
+ const groupable = tableFields.filter((f) => !NON_COLUMN.has(f.type));
+ const groupField = tableFields.find((f) => f.fieldId === w.groupByFieldId);
+ const groupIsDate = groupField?.type === "date" || groupField?.type === "datetime";
+
+ const isChart = w.type === "line" || w.type === "bar";
+ const needsMetric = (w.type === "kpi" || isChart) && w.agg !== "count";
+ const valid =
+ Boolean(w.tableId) &&
+ (!needsMetric || Boolean(w.metricFieldId)) &&
+ (!isChart || Boolean(w.groupByFieldId)); // charts need an x-axis
+
+ function save() {
+ const title = w.title.trim() || defaultTitle(w, meta);
+ onSave({ ...w, title });
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
{initial ? "Edit widget" : "Add widget"}
+
+
+
+ {TYPES.map((t) => (
+ set({ type: t.type, ...(t.type === "kpi" || t.type === "table" ? { groupByFieldId: undefined, bucket: undefined } : {}) })}
+ className={`rounded-md border px-2 py-1.5 text-xs ${w.type === t.type ? "border-blue-500 bg-blue-50 text-blue-700" : "border-neutral-300 text-neutral-600 hover:bg-surface-muted"}`}
+ >
+ {t.label}
+
+ ))}
+
+
+
+
+
+
+
+ {w.type !== "table" ? (
+
+
+
+ ) : null}
+
+ {needsMetric ? (
+
+
+
+ ) : null}
+
+ {isChart ? (
+
+
+
+ ) : null}
+
+ {isChart && groupIsDate ? (
+
+
+
+ ) : null}
+
+
+ set({ title: e.target.value })} />
+
+
+
+
+ Cancel
+ Save
+
+
+
+ );
+}
+
+function Row({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function defaultTitle(w: Widget, meta: Meta): string {
+ const table = meta.tables.find((t) => t.tableId === w.tableId)?.name ?? "records";
+ if (w.type === "table") return table;
+ const metric = w.agg === "count" ? "count" : `${w.agg} of ${meta.fields.find((f) => f.fieldId === w.metricFieldId)?.name ?? "value"}`;
+ const by = w.groupByFieldId ? ` by ${meta.fields.find((f) => f.fieldId === w.groupByFieldId)?.name ?? ""}` : "";
+ return `${table}: ${metric}${by}`;
+}
diff --git a/apps/web/lib/client.ts b/apps/web/lib/client.ts
index 1b6fe45..19d4500 100644
--- a/apps/web/lib/client.ts
+++ b/apps/web/lib/client.ts
@@ -1,5 +1,5 @@
"use client";
-import type { ViewConfig, ViewMeta } from "./types";
+import type { DashboardMeta, ViewConfig, ViewMeta, Widget } from "./types";
/**
* Browser-side calls to the BFF (/api/grid/* → grid-api /v1/*). The Bearer
@@ -32,6 +32,16 @@ export const createView = (input: { tableId: string; name: string; type: string;
export const deleteView = (viewId: string) => call<{ deleted: boolean }>("DELETE", `views/${viewId}`);
+// ---- dashboard mutations --------------------------------------------------
+
+export const createDashboard = (input: { name: string; config?: { widgets: Widget[] } }) =>
+ call("POST", "dashboards", input);
+
+export const updateDashboard = (id: string, input: { name?: string; config?: { widgets: Widget[] }; position?: number; isHidden?: boolean }) =>
+ call("PATCH", `dashboards/${id}`, input);
+
+export const deleteDashboard = (id: string) => call<{ deleted: boolean }>("DELETE", `dashboards/${id}`);
+
// ---- record mutations (typecast: true → resolve selects/links given by name) ----
interface RecordResult {
diff --git a/apps/web/lib/dashboard.test.ts b/apps/web/lib/dashboard.test.ts
index 19888b7..6a71e26 100644
--- a/apps/web/lib/dashboard.test.ts
+++ b/apps/web/lib/dashboard.test.ts
@@ -11,6 +11,7 @@ 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 });
diff --git a/apps/web/lib/server/gridApi.ts b/apps/web/lib/server/gridApi.ts
index 982457e..ab8204a 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 { ListResult, Meta, RecordEnvelope } from "../types";
+import type { AggregateRow, ListResult, Meta, RecordEnvelope, Widget } from "../types";
/**
* Server-only client for grid-api. Server Components call this directly (the
@@ -34,7 +34,7 @@ export const getMeta = cache((): Promise => call("/v1/meta"));
export interface ListParams {
fields?: string[];
filter?: object;
- sort?: Array<{ fieldId: string; direction?: "asc" | "desc" }>;
+ sort?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>;
pageSize?: number;
offset?: string;
}
@@ -42,7 +42,15 @@ export interface ListParams {
export function listRecords(tableId: string, params: ListParams = {}): Promise {
const q = new URLSearchParams();
if (params.fields?.length) q.set("fields", params.fields.join(","));
- if (params.sort?.length) q.set("sort", params.sort.map((s) => `${s.fieldId}:${s.direction ?? "asc"}`).join(","));
+ // Sort token: "fieldId[>linkedFieldId]:dir" — `>` selects a linked sub-field.
+ if (params.sort?.length) {
+ q.set(
+ "sort",
+ params.sort
+ .map((s) => `${s.fieldId}${s.linkedFieldId ? `>${s.linkedFieldId}` : ""}:${s.direction ?? "asc"}`)
+ .join(","),
+ );
+ }
// Plain JSON; URLSearchParams percent-encodes it (handles UTF-8 + +/&/= safely).
if (params.filter) q.set("filter", JSON.stringify(params.filter));
if (params.pageSize) q.set("pageSize", String(params.pageSize));
@@ -54,3 +62,47 @@ export function listRecords(tableId: string, params: ListParams = {}): Promise {
return call(`/v1/tables/${encodeURIComponent(tableId)}/records/${encodeURIComponent(id)}`);
}
+
+/** Grouped aggregation for a dashboard widget. */
+export function getAggregate(
+ tableId: string,
+ spec: { agg: string; metric?: string; groupBy?: string; bucket?: string; filter?: object },
+): Promise<{ rows: AggregateRow[] }> {
+ const q = new URLSearchParams({ agg: spec.agg });
+ if (spec.metric) q.set("metric", spec.metric);
+ if (spec.groupBy) q.set("groupBy", spec.groupBy);
+ if (spec.bucket) q.set("bucket", spec.bucket);
+ if (spec.filter) q.set("filter", JSON.stringify(spec.filter));
+ return call<{ rows: AggregateRow[] }>(`/v1/tables/${encodeURIComponent(tableId)}/aggregate?${q.toString()}`);
+}
+
+/** 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> {
+ const entries = await Promise.all(
+ widgets.map(async (w) => {
+ try {
+ if (w.type === "table") {
+ const res = await listRecords(w.tableId, { fields: w.fieldIds, filter: w.filter, pageSize: 20 });
+ return [w.widgetId, { records: res.records }] as const;
+ }
+ // A KPI is a single total — never group it, even if a stale groupBy lingers.
+ const isKpi = w.type === "kpi";
+ const { rows } = await getAggregate(w.tableId, {
+ agg: w.agg ?? "count",
+ metric: w.metricFieldId,
+ groupBy: isKpi ? undefined : w.groupByFieldId,
+ bucket: isKpi ? undefined : w.bucket,
+ filter: w.filter,
+ });
+ return [w.widgetId, { rows }] as const;
+ } catch (e) {
+ return [w.widgetId, { error: e instanceof Error ? e.message : String(e) }] as const;
+ }
+ }),
+ );
+ return Object.fromEntries(entries);
+}
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index d1fb544..ba45c7c 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -44,19 +44,35 @@ export interface TableMeta {
export interface FilterCondition {
fieldId: string;
+ /** When `fieldId` is a link/lookup field, the sub-field of the linked table to
+ * compare on; defaults to the linked table's primary field. */
+ linkedFieldId?: string;
op: "is" | "isNot" | "isEmpty" | "isNotEmpty" | "contains" | "gt" | "lt" | "before" | "after" | "anyOf";
value?: unknown;
}
+/** A one-level-deep AND/OR group; contains only leaf conditions. */
+export interface FilterGroup {
+ conjunction: "and" | "or";
+ conditions: FilterCondition[];
+}
+
+export type FilterItem = FilterCondition | FilterGroup;
+
+/** True when a filter item is a group rather than a leaf condition. */
+export function isFilterGroup(item: FilterItem): item is FilterGroup {
+ return Array.isArray((item as FilterGroup).conditions);
+}
+
export interface ViewConfig {
fields?: Array<{ fieldId: string; width?: number }>;
filters?: {
conjunction: "and" | "or";
- conditions: FilterCondition[];
+ conditions: FilterItem[];
};
- sorts?: Array<{ fieldId: string; direction?: "asc" | "desc" }>;
+ sorts?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>;
groupBy?: string;
- kanban?: { stackFieldId: string };
+ kanban?: { stackFieldId: string; maxPreviewFields?: number };
calendar?: { dateFieldId: string };
form?: { title?: string; fieldIds: string[]; redirectMessage?: string };
dashboard?: { dateFieldId: string; metricFieldIds: string[] };
@@ -79,6 +95,41 @@ export interface Meta {
tables: TableMeta[];
fields: FieldMeta[];
views: ViewMeta[];
+ dashboards: DashboardMeta[];
+}
+
+// ---- dashboards (the report composer) -------------------------------------
+
+export type AggFn = "count" | "sum" | "avg" | "min" | "max";
+export type DateBucket = "day" | "week" | "month" | "year";
+export type WidgetType = "kpi" | "line" | "bar" | "table";
+
+export interface Widget {
+ widgetId: string;
+ type: WidgetType;
+ title: string;
+ tableId: string;
+ metricFieldId?: string;
+ agg?: AggFn;
+ groupByFieldId?: string;
+ bucket?: DateBucket;
+ fieldIds?: string[];
+ filter?: { conjunction: "and" | "or"; conditions: FilterCondition[] };
+}
+
+export interface DashboardMeta {
+ dashboardId: string;
+ workspaceId: string | null;
+ name: string;
+ position: number;
+ isHidden: boolean;
+ config: { widgets: Widget[] };
+}
+
+/** One aggregated row from GET /v1/tables/:id/aggregate. */
+export interface AggregateRow {
+ groupValue: string | null;
+ value: number;
}
export interface RecordEnvelope {
diff --git a/apps/web/public/grid.png b/apps/web/public/grid.png
new file mode 100644
index 0000000..157728b
Binary files /dev/null and b/apps/web/public/grid.png differ
diff --git a/packages/api/src/aggregate.ts b/packages/api/src/aggregate.ts
index 8ac3a34..a49c4da 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.fieldById, table.slug);
+ const where = buildWhere(spec.filter, reg, 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 3693050..f30b217 100644
--- a/packages/api/src/dashboards.ts
+++ b/packages/api/src/dashboards.ts
@@ -1,8 +1,10 @@
import { HttpError } from "./repo.js";
-import type { AggFn, DashboardConfig, DashboardMeta, Registry, Widget } from "./types.js";
+import { 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 MAX_WIDGETS = 50;
function newDashboardId(): string {
return "dsh" + crypto.randomUUID().replace(/-/g, "").slice(0, 14);
@@ -46,6 +48,7 @@ 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}`);
@@ -54,6 +57,21 @@ 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/filter.test.ts b/packages/api/src/filter.test.ts
index 60db2f1..19c1007 100644
--- a/packages/api/src/filter.test.ts
+++ b/packages/api/src/filter.test.ts
@@ -1,26 +1,47 @@
import { describe, expect, it } from "vitest";
-import { buildWhere } from "./filter.js";
-import type { FieldMeta } from "./types.js";
+import { buildOrderBy, buildWhere } from "./filter.js";
+import type { FieldMeta, Registry, TableMeta } from "./types.js";
function field(partial: Partial & { fieldId: string; type: FieldMeta["type"] }): FieldMeta {
return {
- tableId: "t", name: partial.fieldId, columnName: partial.columnName ?? null,
+ tableId: partial.tableId ?? "t", name: partial.fieldId, columnName: partial.columnName ?? null,
position: 0, options: partial.options ?? null, isComputed: false, isPrimary: false,
...partial,
};
}
-const fields = new Map([
- ["fNum", field({ fieldId: "fNum", type: "number", columnName: "num" })],
- ["fTxt", field({ fieldId: "fTxt", type: "text", columnName: "txt" })],
- ["fChk", field({ fieldId: "fChk", type: "checkbox", columnName: "chk" })],
- ["fDt", field({ fieldId: "fDt", type: "datetime", columnName: "dt" })],
- ["fLink", field({ fieldId: "fLink", type: "link", options: { join: "lj", self: "a_id", other: "b_id", linkedTableId: "t2" } })],
- ["fFormula", field({ fieldId: "fFormula", type: "formula", columnName: null })],
-]);
+const allFields: FieldMeta[] = [
+ field({ fieldId: "fNum", type: "number", columnName: "num" }),
+ field({ fieldId: "fTxt", type: "text", columnName: "txt" }),
+ field({ fieldId: "fChk", type: "checkbox", columnName: "chk" }),
+ field({ fieldId: "fDt", type: "datetime", columnName: "dt" }),
+ field({ fieldId: "fLink", type: "link", options: { join: "lj", self: "a_id", other: "b_id", linkedTableId: "t2" } }),
+ field({ fieldId: "fFormula", type: "formula", columnName: null }),
+ // Linked table (t2 = companies) fields
+ field({ fieldId: "fCoName", type: "text", tableId: "t2", columnName: "co_name", isPrimary: true }),
+ field({ fieldId: "fCoTier", type: "text", tableId: "t2", columnName: "tier" }),
+ // A lookup that pulls the linked company's name via fLink.
+ field({ fieldId: "fLookup", type: "lookup", options: { lookup: { via: "fLink", target: "fCoName" } } }),
+];
-const where = (conds: Array<{ fieldId: string; op: string; value?: unknown }>) =>
- buildWhere({ conjunction: "and", conditions: conds as never }, fields, "tbl");
+const tables: TableMeta[] = [
+ { tableId: "t", workspaceId: null, name: "Roles", slug: "tbl", primaryFieldId: "fTxt", position: 0, sourceKind: "table", sourceRef: null },
+ { tableId: "t2", workspaceId: null, name: "Companies", slug: "companies", primaryFieldId: "fCoName", position: 1, sourceKind: "table", sourceRef: null },
+];
+
+const reg: Registry = {
+ tables,
+ fieldsByTable: new Map([
+ ["t", allFields.filter((f) => f.tableId === "t")],
+ ["t2", allFields.filter((f) => f.tableId === "t2")],
+ ]),
+ fieldById: new Map(allFields.map((f) => [f.fieldId, f])),
+ views: [],
+ dashboards: [],
+};
+
+const where = (conds: unknown[]) =>
+ buildWhere({ conjunction: "and", conditions: conds as never }, reg, "tbl");
describe("buildWhere", () => {
it("skips a value-requiring op with no value (half-built filter row)", () => {
@@ -66,4 +87,98 @@ describe("buildWhere", () => {
expect(r.sql).toBe(`WHERE "txt" = ? AND "num" > ?`);
expect(r.binds).toEqual(["x", 5]);
});
+
+ // ---- Feature C: nested AND/OR groups -------------------------------------
+
+ it("nested group: A AND (B OR C) wraps the group clauses in parens", () => {
+ const r = where([
+ { fieldId: "fTxt", op: "is", value: "x" },
+ {
+ conjunction: "or",
+ conditions: [
+ { fieldId: "fNum", op: "gt", value: 5 },
+ { fieldId: "fNum", op: "lt", value: 0 },
+ ],
+ },
+ ]);
+ expect(r.sql).toBe(`WHERE "txt" = ? AND ("num" > ? OR "num" < ?)`);
+ expect(r.binds).toEqual(["x", 5, 0]);
+ });
+
+ it("top-level OR over a group and a leaf", () => {
+ const r = buildWhere(
+ {
+ conjunction: "or",
+ conditions: [
+ { conjunction: "and", conditions: [{ fieldId: "fTxt", op: "is", value: "a" }, { fieldId: "fNum", op: "gt", value: 1 }] },
+ { fieldId: "fChk", op: "is", value: true },
+ ],
+ } as never,
+ reg,
+ "tbl",
+ );
+ expect(r.sql).toBe(`WHERE ("txt" = ? AND "num" > ?) OR "chk" = 1`);
+ expect(r.binds).toEqual(["a", 1]);
+ });
+
+ // ---- Feature A: linked-field WHERE (EXISTS over the link join) ------------
+
+ it("linked sub-field: link + linkedFieldId → correlated EXISTS joining the linked table", () => {
+ const r = where([{ fieldId: "fLink", linkedFieldId: "fCoName", op: "is", value: "Acme" }]);
+ expect(r.sql).toBe(
+ `WHERE EXISTS (SELECT 1 FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id AND lt."co_name" = ?)`,
+ );
+ expect(r.binds).toEqual(["Acme"]);
+ });
+
+ it("linked sub-field contains uses LIKE inside the EXISTS subquery", () => {
+ const r = where([{ fieldId: "fLink", linkedFieldId: "fCoTier", op: "contains", value: "S" }]);
+ expect(r.sql).toBe(
+ `WHERE EXISTS (SELECT 1 FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id AND lt."tier" LIKE ?)`,
+ );
+ expect(r.binds).toEqual(["%S%"]);
+ });
+
+ it("lookup field filters through its via-link to the target column (no explicit linkedFieldId)", () => {
+ const r = where([{ fieldId: "fLookup", op: "is", value: "Acme" }]);
+ expect(r.sql).toBe(
+ `WHERE EXISTS (SELECT 1 FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id AND lt."co_name" = ?)`,
+ );
+ expect(r.binds).toEqual(["Acme"]);
+ });
+});
+
+describe("buildOrderBy", () => {
+ it("stored columns sort directly", () => {
+ expect(buildOrderBy([{ fieldId: "fTxt", direction: "desc" }], reg, "tbl")).toBe(`ORDER BY "txt" DESC`);
+ });
+
+ it("skips formula/unsortable fields with no column", () => {
+ expect(buildOrderBy([{ fieldId: "fFormula", direction: "asc" }], reg, "tbl")).toBe("");
+ });
+
+ it("linked sub-field sorts by a correlated scalar subquery over the linked table", () => {
+ const sql = buildOrderBy([{ fieldId: "fLink", linkedFieldId: "fCoName", direction: "asc" }], reg, "tbl");
+ expect(sql).toBe(
+ `ORDER BY (SELECT lt."co_name" FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id LIMIT 1) ASC`,
+ );
+ });
+
+ it("lookup field sorts via its via-link target column", () => {
+ const sql = buildOrderBy([{ fieldId: "fLookup", direction: "desc" }], reg, "tbl");
+ expect(sql).toBe(
+ `ORDER BY (SELECT lt."co_name" FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id LIMIT 1) DESC`,
+ );
+ });
+
+ it("mixes a stored sort and a linked sort", () => {
+ const sql = buildOrderBy(
+ [{ fieldId: "fNum", direction: "desc" }, { fieldId: "fLink", linkedFieldId: "fCoName", direction: "asc" }],
+ reg,
+ "tbl",
+ );
+ expect(sql).toBe(
+ `ORDER BY "num" DESC, (SELECT lt."co_name" FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id LIMIT 1) ASC`,
+ );
+ });
});
diff --git a/packages/api/src/filter.ts b/packages/api/src/filter.ts
index 2d3b1ed..4fd8c86 100644
--- a/packages/api/src/filter.ts
+++ b/packages/api/src/filter.ts
@@ -1,10 +1,102 @@
-import type { FieldMeta, FilterCondition, FilterSpec, ViewConfig } from "./types.js";
+import type { FieldMeta, FilterCondition, FilterSpec, Registry, ViewConfig } from "./types.js";
+import { isFilterGroup } from "./types.js";
export interface SqlFragment {
sql: string;
binds: unknown[];
}
+/** Resolve the {join,self,other,linkedTableId} + linked-table slug + target
+ * column for traversing a link/lookup field to a sub-field of the linked
+ * table. Returns null when the shape can't be resolved (caller skips). */
+function resolveLinkTarget(
+ field: FieldMeta,
+ linkedFieldId: string | undefined,
+ reg: Registry,
+): { join: string; self: string; other: string; linkedSlug: string; targetCol: string } | null {
+ // A lookup field traverses its own `via` link; a link field traverses itself.
+ let linkField = field;
+ if (field.type === "lookup") {
+ const via = field.options?.lookup?.via;
+ const vf = via ? reg.fieldById.get(via) : undefined;
+ if (!vf) return null;
+ linkField = vf;
+ }
+ const { join, self, other, linkedTableId } = (linkField.options ?? {}) as {
+ join?: string; self?: string; other?: string; linkedTableId?: string;
+ };
+ if (!join || !self || !other || !linkedTableId) return null;
+ const linkedTable = reg.tables.find((t) => t.tableId === linkedTableId);
+ if (!linkedTable) return null;
+
+ // Default the sub-field to the linked table's primary; a lookup pins `target`.
+ let targetFieldId = linkedFieldId;
+ if (field.type === "lookup" && !targetFieldId) targetFieldId = field.options?.lookup?.target;
+ if (!targetFieldId) targetFieldId = linkedTable.primaryFieldId;
+ const targetField = reg.fieldById.get(targetFieldId);
+ if (!targetField?.columnName) return null;
+
+ return { join, self, other, linkedSlug: linkedTable.slug, targetCol: targetField.columnName };
+}
+
+/** Comparison clause + binds for ` ?` over an already-resolved column,
+ * shared by stored-column and linked-sub-field paths. `dateish` truncates date
+ * ops to the day. Returns null when the op needs a value it doesn't have. */
+function compare(col: string, type: FieldMeta["type"], op: FilterCondition["op"], value: unknown): SqlFragment | null {
+ const dateish = type === "date" || type === "datetime";
+ const val = resolveValue(value);
+ const valueOp = op !== "isEmpty" && op !== "isNotEmpty";
+ if (valueOp && type !== "checkbox" && (val === undefined || val === null)) return null;
+ const cval = type === "checkbox" ? (val ? 1 : 0) : val;
+
+ switch (op) {
+ case "is":
+ if (type === "checkbox") return { sql: cval ? `${col} = 1` : `(${col} IS NULL OR ${col} = 0)`, binds: [] };
+ return { sql: `${col} = ?`, binds: [cval] };
+ case "isNot":
+ if (type === "checkbox") return { sql: cval ? `(${col} IS NULL OR ${col} = 0)` : `${col} = 1`, binds: [] };
+ return { sql: `(${col} IS NULL OR ${col} != ?)`, binds: [cval] };
+ case "isEmpty": return { sql: emptyClause(col, type, false), binds: [] };
+ case "isNotEmpty": return { sql: emptyClause(col, type, true), binds: [] };
+ case "contains": return { sql: `${col} LIKE ?`, binds: [`%${String(cval)}%`] };
+ case "gt": return { sql: `${col} > ?`, binds: [cval] };
+ case "lt": return { sql: `${col} < ?`, binds: [cval] };
+ case "before": return { sql: dateish ? `date(${col}) < date(?)` : `${col} < ?`, binds: [cval] };
+ case "after": return { sql: dateish ? `date(${col}) > date(?)` : `${col} > ?`, binds: [cval] };
+ case "anyOf": {
+ const vals = Array.isArray(cval) ? cval : [cval];
+ if (vals.length === 0) return { sql: "0 = 1", binds: [] };
+ return { sql: `${col} IN (${vals.map(() => "?").join(", ")})`, binds: vals };
+ }
+ default: return null;
+ }
+}
+
+/** Filter on a sub-field of a linked record via a correlated EXISTS that joins
+ * the link's join table to the linked table and compares the target column.
+ * Emptiness is delegated to the link's own EXISTS shape. */
+function linkedFieldClause(
+ field: FieldMeta,
+ cond: FilterCondition,
+ tableSlug: string,
+ reg: Registry,
+): SqlFragment | null {
+ const t = resolveLinkTarget(field, cond.linkedFieldId, reg);
+ if (!t) return null;
+ const linkExists = `SELECT 1 FROM ${t.join} WHERE ${t.join}.${t.self} = ${tableSlug}.id`;
+ if (cond.op === "isEmpty") return { sql: `NOT EXISTS (${linkExists})`, binds: [] };
+ if (cond.op === "isNotEmpty") return { sql: `EXISTS (${linkExists})`, binds: [] };
+
+ // lt. is a bare column inside the subquery, not table-qualified at
+ // the outer level, so compare() gets `lt."col"`.
+ const cmp = compare(`lt."${t.targetCol}"`, reg.fieldById.get(cond.linkedFieldId ?? "")?.type ?? "text", cond.op, cond.value);
+ if (!cmp) return null;
+ const corr =
+ `EXISTS (SELECT 1 FROM ${t.join} j JOIN ${t.linkedSlug} lt ON lt.id = j.${t.other} ` +
+ `WHERE j.${t.self} = ${tableSlug}.id AND ${cmp.sql})`;
+ return { sql: corr, binds: cmp.binds };
+}
+
/** Resolve a relative date value ({relative:'daysAgo', n}) to an ISO date. */
function resolveValue(value: unknown): unknown {
if (value && typeof value === "object" && "relative" in (value as Record)) {
@@ -51,15 +143,41 @@ function linkClause(field: FieldMeta, cond: FilterCondition, tableSlug: string):
}
}
+/** Build the SQL fragment for a single leaf condition (stored column, link
+ * membership, or linked sub-field). Returns null when the condition should be
+ * skipped (unknown field, half-built row, formula). */
+function leafClause(
+ cond: FilterCondition,
+ reg: Registry,
+ tableSlug: string,
+): SqlFragment | null {
+ const field = reg.fieldById.get(cond.fieldId);
+ if (!field) return null;
+
+ // Link/lookup + a named sub-field → correlated EXISTS over the linked table.
+ // A lookup always traverses to its target even without an explicit linkedFieldId.
+ if ((field.type === "link" && cond.linkedFieldId) || field.type === "lookup") {
+ return linkedFieldClause(field, cond, tableSlug, reg);
+ }
+ // Link field with no sub-field → membership / emptiness over the join table.
+ if (field.type === "link" && field.options?.join) {
+ return linkClause(field, cond, tableSlug);
+ }
+ if (!field.columnName) return null; // formula → skip
+ return compare(`"${field.columnName}"`, field.type, cond.op, cond.value);
+}
+
/**
* Build a parameterized WHERE clause from a structured filter spec. Stored
- * columns and link fields are filterable; formula/lookup fields are skipped.
- * Returns an empty fragment when there's nothing to filter. Never interpolates
- * values — all go through bind params.
+ * columns, link fields, and (new) linked sub-fields / lookups are filterable;
+ * formula fields are skipped. Top-level items may be leaf conditions or
+ * one-level AND/OR groups (Airtable-style); a group's leaf clauses are
+ * parenthesized and joined by the group's own conjunction. Returns an empty
+ * fragment when there's nothing to filter. Never interpolates values.
*/
export function buildWhere(
filters: FilterSpec | undefined,
- fieldById: Map,
+ reg: Registry,
tableSlug: string,
): SqlFragment {
if (!filters || filters.conditions.length === 0) return { sql: "", binds: [] };
@@ -67,66 +185,22 @@ export function buildWhere(
const clauses: string[] = [];
const binds: unknown[] = [];
- for (const cond of filters.conditions) {
- const field = fieldById.get(cond.fieldId);
- if (!field) continue;
- if (field.type === "link" && field.options?.join) {
- const frag = linkClause(field, cond, tableSlug);
- if (frag) { clauses.push(frag.sql); binds.push(...frag.binds); }
- continue;
- }
- if (!field.columnName) continue; // formula / lookup → skip
- const col = `"${field.columnName}"`;
- let val = resolveValue(cond.value);
- // Skip value-requiring ops with no value yet (e.g. a half-built filter row in
- // the UI) — otherwise we'd bind undefined and error. Checkbox coerces nullish
- // to "unchecked", a valid filter, so it's exempt.
- const valueOp = cond.op !== "isEmpty" && cond.op !== "isNotEmpty";
- if (valueOp && field.type !== "checkbox" && (val === undefined || val === null)) continue;
- // Checkboxes are stored as 1 / NULL (false is never stored), so normalize.
- if (field.type === "checkbox") val = val ? 1 : 0;
- // before/after are date semantics; truncate both sides to the day so a
- // datetime column ('2026-06-09T09:00') compares correctly against a date.
- const dateish = field.type === "date" || field.type === "datetime";
-
- switch (cond.op) {
- case "is":
- if (field.type === "checkbox") {
- clauses.push(val ? `${col} = 1` : `(${col} IS NULL OR ${col} = 0)`);
- } else {
- clauses.push(`${col} = ?`);
- binds.push(val);
- }
- break;
- case "isNot":
- if (field.type === "checkbox") {
- clauses.push(val ? `(${col} IS NULL OR ${col} = 0)` : `${col} = 1`);
- } else {
- clauses.push(`(${col} IS NULL OR ${col} != ?)`);
- binds.push(val);
- }
- break;
- case "isEmpty": clauses.push(emptyClause(col, field.type, false)); break;
- case "isNotEmpty": clauses.push(emptyClause(col, field.type, true)); break;
- case "contains": clauses.push(`${col} LIKE ?`); binds.push(`%${String(val)}%`); break;
- case "gt": clauses.push(`${col} > ?`); binds.push(val); break;
- case "lt": clauses.push(`${col} < ?`); binds.push(val); break;
- case "before":
- clauses.push(dateish ? `date(${col}) < date(?)` : `${col} < ?`);
- binds.push(val);
- break;
- case "after":
- clauses.push(dateish ? `date(${col}) > date(?)` : `${col} > ?`);
- binds.push(val);
- break;
- case "anyOf": {
- const vals = Array.isArray(val) ? val : [val];
- if (vals.length === 0) { clauses.push("0 = 1"); break; }
- clauses.push(`${col} IN (${vals.map(() => "?").join(", ")})`);
- binds.push(...vals);
- break;
+ for (const item of filters.conditions) {
+ if (isFilterGroup(item)) {
+ const inner: string[] = [];
+ const innerBinds: unknown[] = [];
+ for (const cond of item.conditions) {
+ const frag = leafClause(cond, reg, tableSlug);
+ if (frag) { inner.push(frag.sql); innerBinds.push(...frag.binds); }
}
+ if (inner.length === 0) continue;
+ const joiner = item.conjunction === "or" ? " OR " : " AND ";
+ clauses.push(`(${inner.join(joiner)})`);
+ binds.push(...innerBinds);
+ continue;
}
+ const frag = leafClause(item, reg, tableSlug);
+ if (frag) { clauses.push(frag.sql); binds.push(...frag.binds); }
}
if (clauses.length === 0) return { sql: "", binds: [] };
@@ -134,17 +208,30 @@ export function buildWhere(
return { sql: `WHERE ${clauses.join(joiner)}`, binds };
}
-/** Build an ORDER BY clause from view sorts. Only stored columns are sortable. */
+/** Build an ORDER BY clause from view sorts. Stored columns sort directly;
+ * link/lookup fields sort by a correlated scalar subquery over the linked
+ * table's sub-field (default = linked primary). */
export function buildOrderBy(
sorts: ViewConfig["sorts"],
- fieldById: Map,
+ reg: Registry,
+ tableSlug: string,
): string {
if (!sorts || sorts.length === 0) return "";
const parts: string[] = [];
for (const s of sorts) {
- const field = fieldById.get(s.fieldId);
- if (!field || !field.columnName) continue;
+ const field = reg.fieldById.get(s.fieldId);
+ if (!field) continue;
const dir = s.direction === "desc" ? "DESC" : "ASC";
+ if ((field.type === "link" && s.linkedFieldId) || field.type === "lookup") {
+ const t = resolveLinkTarget(field, s.linkedFieldId, reg);
+ if (!t) continue;
+ parts.push(
+ `(SELECT lt."${t.targetCol}" FROM ${t.join} j JOIN ${t.linkedSlug} lt ON lt.id = j.${t.other} ` +
+ `WHERE j.${t.self} = ${tableSlug}.id LIMIT 1) ${dir}`,
+ );
+ continue;
+ }
+ if (!field.columnName) continue;
parts.push(`"${field.columnName}" ${dir}`);
}
return parts.length ? `ORDER BY ${parts.join(", ")}` : "";
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 443267b..b3c4a06 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -30,6 +30,7 @@ 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 {
@@ -49,6 +50,7 @@ function authorized(request: Request, env: Env): boolean {
const MAX_FILTER_CONDITIONS = 50;
const MAX_ANYOF_VALUES = 500;
+const MAX_FILTER_GROUPS = 10;
const MAX_WRITE_BATCH = 50;
const json = (data: unknown, status = 200) =>
@@ -95,15 +97,51 @@ 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 {
- spec.filter = JSON.parse(filter) as FilterSpec;
+ parsed = 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 = {};
@@ -113,8 +151,14 @@ function parseListOpts(url: URL): ListOpts {
const sort = url.searchParams.get("sort");
if (sort) {
opts.sorts = sort.split(",").filter(Boolean).map((s) => {
- const [fieldId, dir] = s.split(":");
- return { fieldId: fieldId!, direction: dir === "desc" ? "desc" : "asc" };
+ // Token shape: "fieldId[>linkedFieldId]:dir" — `>` selects a linked sub-field.
+ const [spec, dir] = s.split(":");
+ const [fieldId, linkedFieldId] = spec!.split(">");
+ return {
+ fieldId: fieldId!,
+ ...(linkedFieldId ? { linkedFieldId } : {}),
+ direction: dir === "desc" ? "desc" as const : "asc" as const,
+ };
});
}
@@ -127,17 +171,7 @@ function parseListOpts(url: URL): ListOpts {
} catch {
throw new HttpError(400, "invalid filter param (expected JSON)");
}
- // Bound the filter so one request can't build a pathologically large query.
- const conditions = parsed.conditions ?? [];
- if (conditions.length > MAX_FILTER_CONDITIONS) {
- throw new HttpError(400, `too many filter conditions (max ${MAX_FILTER_CONDITIONS})`);
- }
- for (const c of conditions) {
- 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})`);
- }
- }
- opts.filters = parsed;
+ opts.filters = assertFilterBounds(parsed);
}
const maxRecords = url.searchParams.get("maxRecords");
diff --git a/packages/api/src/repo.ts b/packages/api/src/repo.ts
index 31be58a..5751160 100644
--- a/packages/api/src/repo.ts
+++ b/packages/api/src/repo.ts
@@ -155,8 +155,8 @@ export async function listRecords(
const fields = reg.fieldsByTable.get(tableId) ?? [];
const wanted = opts.fields && opts.fields.length ? new Set(opts.fields) : undefined;
- const where = buildWhere(opts.filters, reg.fieldById, table.slug);
- const orderBy = buildOrderBy(opts.sorts, reg.fieldById);
+ const where = buildWhere(opts.filters, reg, table.slug);
+ const orderBy = buildOrderBy(opts.sorts, reg, table.slug);
const offset = Math.max(0, Number.parseInt(opts.offset ?? "0", 10) || 0);
// Clamp to [0, MAX_PAGE]: a negative LIMIT means "no limit" in SQLite, so an
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
index fd66ebc..6660563 100644
--- a/packages/api/src/types.ts
+++ b/packages/api/src/types.ts
@@ -79,21 +79,39 @@ export interface ViewMeta {
export interface FilterCondition {
fieldId: string;
+ /** When `fieldId` is a link (or lookup) field, names the sub-field in the
+ * linked table to compare on; defaults to the linked table's primary field. */
+ linkedFieldId?: string;
op: "is" | "isNot" | "isEmpty" | "isNotEmpty" | "contains" | "gt" | "lt" | "before" | "after" | "anyOf";
value?: unknown;
}
-export interface FilterSpec {
+/** A one-level-deep AND/OR group; contains only leaf conditions. */
+export interface FilterGroup {
conjunction: "and" | "or";
conditions: FilterCondition[];
}
+/** True when an item in a FilterSpec is a group (has nested conditions) rather
+ * than a leaf condition (which carries an `op`). */
+export function isFilterGroup(item: FilterCondition | FilterGroup): item is FilterGroup {
+ return Array.isArray((item as FilterGroup).conditions);
+}
+
+export interface FilterSpec {
+ conjunction: "and" | "or";
+ /** Top-level items: leaf conditions and/or one-level groups. Old flat specs
+ * (conditions only) remain valid. */
+ conditions: Array;
+}
+
export interface ViewConfig {
fields?: Array<{ fieldId: string; width?: number }>;
filters?: FilterSpec;
- sorts?: Array<{ fieldId: string; direction?: "asc" | "desc" }>;
+ /** `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 };
+ kanban?: { stackFieldId: string; maxPreviewFields?: number };
calendar?: { dateFieldId: string };
form?: { title?: string; fieldIds: string[]; redirectMessage?: string };
dashboard?: { dateFieldId: string; metricFieldIds: string[] };
diff --git a/scripts/seed.mjs b/scripts/seed.mjs
index b1fb1fa..a1bb071 100644
--- a/scripts/seed.mjs
+++ b/scripts/seed.mjs
@@ -77,6 +77,20 @@ TABLES.forEach((t, ti) => {
view("dashboard", "Trends", "dashboard", { dashboard: DASHBOARD[t.slug], sorts: [{ fieldId: DASHBOARD[t.slug].dateFieldId, direction: "desc" }] }, 3);
});
+// ---- a demo dashboard (workspace-level report composer over the CRM) -------
+const DEALS_TID = "tbl_6b116201014a93e2";
+const dashboardWidgets = [
+ { widgetId: "wgt_pipeline", type: "kpi", title: "Pipeline value", tableId: DEALS_TID, agg: "sum", metricFieldId: "fld_32535ebf764c27b1" },
+ { widgetId: "wgt_count", type: "kpi", title: "Open deals", tableId: DEALS_TID, agg: "count" },
+ { widgetId: "wgt_by_stage", type: "bar", title: "Deals by stage", tableId: DEALS_TID, agg: "count", groupByFieldId: "fld_ab8460da74d9c748" },
+ { widgetId: "wgt_over_time", type: "line", title: "Amount by month", tableId: DEALS_TID, agg: "sum", metricFieldId: "fld_32535ebf764c27b1", groupByFieldId: "fld_6f0bae1cf3cda9f7", bucket: "month" },
+ { widgetId: "wgt_recent", type: "table", title: "Deals", tableId: DEALS_TID, fieldIds: ["fld_72fbfcec5a293bb7", "fld_32535ebf764c27b1", "fld_ab8460da74d9c748"] },
+];
+out.push(
+ "INSERT OR REPLACE INTO meta_dashboards (dashboard_id, workspace_id, name, position, is_hidden, config) " +
+ `VALUES ('dsh_demo_pipeline', NULL, 'Sales pipeline', 0, 0, ${q(JSON.stringify({ widgets: dashboardWidgets }))});`,
+);
+
// ---- demo data -----------------------------------------------------------
// Fixed record ids so the seed is idempotent and the relation edges line up.
const COMPANIES = [