diff --git a/__tests__/components/DetailViewHiddenFields.test.tsx b/__tests__/components/DetailViewHiddenFields.test.tsx new file mode 100644 index 0000000..ef3b0b5 --- /dev/null +++ b/__tests__/components/DetailViewHiddenFields.test.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { render } from "@testing-library/react-native"; + +import { DetailViewRenderer } from "~/components/renderers/DetailViewRenderer"; +import type { FieldDefinition } from "~/components/renderers/types"; + +/** + * The detail screen falls back to auto-laying-out a record's keys when there is + * no curated form view. That fallback must mirror the form's `isEntryField` + * filtering so internal plumbing (multi-tenancy keys the server injects) and + * metadata-hidden fields never leak into the layout — see the P0 UX fix where + * `Organization Id` was leading the task detail. + */ +describe("DetailViewRenderer — fallback field filtering", () => { + const record = { + id: "rec_1", + subject: "Learn ObjectStack", + status: "completed", + organization_id: "org_123", + secret_token: "shhh", + created_at: "2026-01-01T00:00:00.000Z", + }; + + const fields: FieldDefinition[] = [ + { name: "subject", label: "Subject", type: "text" }, + { name: "status", label: "Status", type: "text" }, + // Declared but hidden by metadata — must be filtered like the form does. + { name: "secret_token", label: "Secret Token", type: "text", hidden: true }, + ]; + + it("drops injected tenancy fields and metadata-hidden fields, keeps business fields", () => { + const { queryByText, getByText } = render( + , + ); + + // Business fields remain. + expect(getByText("Subject")).toBeTruthy(); + expect(getByText("Status")).toBeTruthy(); + + // Internal tenancy plumbing never surfaces. + expect(queryByText("Organization Id")).toBeNull(); + expect(queryByText(/organization/i)).toBeNull(); + + // A field the metadata marks hidden is filtered out of the fallback too. + expect(queryByText("Secret Token")).toBeNull(); + }); +}); diff --git a/__tests__/hooks/useDashboardData.test.ts b/__tests__/hooks/useDashboardData.test.ts index 0626d69..e7dc91d 100644 --- a/__tests__/hooks/useDashboardData.test.ts +++ b/__tests__/hooks/useDashboardData.test.ts @@ -11,8 +11,11 @@ jest.mock("@objectstack/client-react", () => ({ useQuery: (...args: unknown[]) => mockUseQuery(...args), })); -import { useWidgetQuery } from "~/hooks/useDashboardData"; -import type { DashboardWidgetMeta } from "~/components/renderers/types"; +import { resolveDatasetWidget, useWidgetQuery } from "~/hooks/useDashboardData"; +import type { + DashboardWidgetMeta, + DatasetMeta, +} from "~/components/renderers/types"; beforeEach(() => { mockUseQuery.mockReset(); @@ -152,4 +155,96 @@ describe("useWidgetQuery", () => { const { result: maxResult } = renderHook(() => useWidgetQuery(maxWidget)); expect(maxResult.current.value).toBe(50); }); + + it("returns a terminal empty state (never loading) when there is no object", () => { + // A dataset widget whose metadata failed to resolve leaves `object` + // undefined; `useQuery` is disabled and its `isLoading` stays true forever, + // so the hook must short-circuit rather than spin. + mockUseQuery.mockReturnValue({ data: null, isLoading: true }); + const widget: DashboardWidgetMeta = { name: "orphan", type: "metric" }; + const { result } = renderHook(() => useWidgetQuery(widget)); + expect(result.current.isLoading).toBe(false); + expect(result.current.value).toBeUndefined(); + }); + + it("counts rows per bucket for a count-aggregate chart (no valueField)", () => { + // The common dataset case: a `count` measure has no source field, so chart + // buckets must count rows — not aggregate an absent value field (→ all 0). + mockUseQuery.mockReturnValue({ + data: { + records: [ + { id: "1", status: "open" }, + { id: "2", status: "open" }, + { id: "3", status: "done" }, + ], + }, + isLoading: false, + }); + const widget: DashboardWidgetMeta = { + name: "by_status", + object: "tasks", + type: "bar", + aggregate: "count", + categoryField: "status", + }; + const { result } = renderHook(() => useWidgetQuery(widget)); + const series = result.current.chartData ?? []; + const open = series.find((p) => p.label === "open"); + const done = series.find((p) => p.label === "done"); + expect(open?.value).toBe(2); + expect(done?.value).toBe(1); + }); +}); + +describe("resolveDatasetWidget", () => { + const dataset: DatasetMeta = { + name: "task_metrics", + object: "todo_task", + dimensions: [{ name: "status", field: "status", type: "string" }], + measures: [ + { name: "task_count", aggregate: "count" }, + { name: "est_hours", aggregate: "sum", field: "estimated_hours", format: "0.0" }, + ], + }; + + it("passes a non-dataset widget through unchanged", () => { + const widget: DashboardWidgetMeta = { name: "w", object: "tasks", type: "metric" }; + expect(resolveDatasetWidget(widget, undefined)).toBe(widget); + }); + + it("resolves a count-measure metric to the base object", () => { + const widget: DashboardWidgetMeta = { + name: "total", + type: "metric", + dataset: "task_metrics", + values: ["task_count"], + layout: { w: 3 }, + options: { color: "#3B82F6" }, + }; + const resolved = resolveDatasetWidget(widget, dataset); + expect(resolved.object).toBe("todo_task"); + expect(resolved.aggregate).toBe("count"); + // A count measure has no source field — counts rows instead. + expect(resolved.valueField).toBeUndefined(); + expect(resolved.span).toBe(1); + expect(resolved.chartConfig?.colors).toEqual(["#3B82F6"]); + }); + + it("resolves a sum measure + dimension and maps a wide layout to span 2", () => { + const widget: DashboardWidgetMeta = { + name: "hours_by_status", + type: "bar", + dataset: "task_metrics", + values: ["est_hours"], + dimensions: ["status"], + layout: { w: 8 }, + }; + const resolved = resolveDatasetWidget(widget, dataset); + expect(resolved.object).toBe("todo_task"); + expect(resolved.aggregate).toBe("sum"); + expect(resolved.valueField).toBe("estimated_hours"); + expect(resolved.categoryField).toBe("status"); + expect(resolved.span).toBe(2); + expect(resolved.chartConfig?.format).toBe("0.0"); + }); }); diff --git a/__tests__/lib/query-builder.test.ts b/__tests__/lib/query-builder.test.ts index f00ce5e..f9a2dcf 100644 --- a/__tests__/lib/query-builder.test.ts +++ b/__tests__/lib/query-builder.test.ts @@ -8,6 +8,7 @@ import { serializeFilterTree, buildProjection, OPERATOR_META, + resolveFilterMacro, type FilterOperator, } from "~/lib/query-builder"; @@ -206,3 +207,30 @@ describe("OPERATOR_META", () => { }); }); }); + +describe("resolveFilterMacro — week tokens", () => { + const DAY = 86_400_000; + + it("resolves {current_week_start} to the most recent Monday, start of day", () => { + const v = resolveFilterMacro("{current_week_start}"); + expect(typeof v).toBe("number"); + const d = new Date(v as number); + expect(d.getDay()).toBe(1); // Monday + expect([d.getHours(), d.getMinutes(), d.getSeconds()]).toEqual([0, 0, 0]); + expect(v as number).toBeLessThanOrEqual(Date.now()); + }); + + it("resolves {N_weeks_ago} and {last_N_weeks} to N*7 days before now", () => { + const before = Date.now(); + const ago = resolveFilterMacro("{4_weeks_ago}") as number; + const after = Date.now(); + // Within the window [before - 28d, after - 28d]. + expect(ago).toBeGreaterThanOrEqual(before - 28 * DAY - 5); + expect(ago).toBeLessThanOrEqual(after - 28 * DAY + 5); + expect(typeof resolveFilterMacro("{last_2_weeks}")).toBe("number"); + }); + + it("leaves an unknown macro untouched (visibly inert, not silently zero)", () => { + expect(resolveFilterMacro("{not_a_real_macro}")).toBe("{not_a_real_macro}"); + }); +}); diff --git a/app/(app)/[appName]/dashboard/[dashboardName].tsx b/app/(app)/[appName]/dashboard/[dashboardName].tsx index cfe3918..4e58869 100644 --- a/app/(app)/[appName]/dashboard/[dashboardName].tsx +++ b/app/(app)/[appName]/dashboard/[dashboardName].tsx @@ -4,9 +4,13 @@ import { useLocalSearchParams } from "expo-router"; import { useClient } from "@objectstack/client-react"; import { ScreenHeader } from "~/components/common/ScreenHeader"; import { DashboardViewRenderer } from "~/components/renderers"; -import type { DashboardMeta, DashboardWidgetMeta } from "~/components/renderers"; +import type { + DashboardMeta, + DashboardWidgetMeta, + DatasetMeta, +} from "~/components/renderers"; import type { WidgetDataPayload } from "~/components/renderers"; -import { useWidgetQuery } from "~/hooks/useDashboardData"; +import { resolveDatasetWidget, useWidgetQuery } from "~/hooks/useDashboardData"; /* ------------------------------------------------------------------ */ /* Widget data fetcher (calls hook per-widget, reports via callback) */ @@ -59,12 +63,42 @@ export default function DashboardScreen() { }; // Spec dashboards key each widget by `id`; the renderer/data-fetcher key // off `name`, so normalize once here. + const widgets = (raw.widgets ?? []).map((w) => ({ + ...w, + name: w.name ?? w.id ?? "", + })); + + // 8.0-spec widgets reference an analytics `dataset` instead of a raw + // `object`. Fetch each distinct dataset's metadata (an analytics view + // over a base object) once, then resolve every widget into the + // object-query shape the data hook understands. + const datasetNames = [ + ...new Set( + widgets + .map((w) => w.dataset) + .filter((d): d is string => typeof d === "string" && d.length > 0), + ), + ]; + const datasets = new Map(); + await Promise.all( + datasetNames.map(async (name) => { + try { + const ds = (await client.meta.getItem("dataset", name)) as + | (DatasetMeta & { dataset?: DatasetMeta }) + | undefined; + const resolved = ds?.dataset ?? ds; + if (resolved?.object) datasets.set(name, resolved); + } catch { + // Dataset metadata unavailable — widget falls back to empty state. + } + }), + ); + const meta: DashboardMeta = { ...raw, - widgets: (raw.widgets ?? []).map((w) => ({ - ...w, - name: w.name ?? w.id ?? "", - })), + widgets: widgets.map((w) => + resolveDatasetWidget(w, w.dataset ? datasets.get(w.dataset) : undefined), + ), }; setDashboard(meta); } catch { diff --git a/app/_layout.tsx b/app/_layout.tsx index 95bebd2..44a5984 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,6 +2,7 @@ import "../global.css"; import "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation() import { useCallback, useEffect, useMemo } from "react"; +import { ActivityIndicator, View } from "react-native"; import { Stack, useRouter, useSegments } from "expo-router"; import { StatusBar } from "expo-status-bar"; import * as Linking from "expo-linking"; @@ -62,17 +63,15 @@ function useProtectedRoute(serverUrl: string | null, isReady: boolean) { }, [session, isPending, segments, serverUrl, isReady, router]); } -export default function RootLayout() { - const serverUrl = useServerStore((s) => s.serverUrl); - const isReady = useServerStore((s) => s.isReady); - const hydrate = useServerStore((s) => s.hydrate); - - // On mount, load the persisted server URL and reinitialize clients - useEffect(() => { - void hydrate(); - }, [hydrate]); - - useProtectedRoute(serverUrl, isReady); +/** + * The signed-in app shell. Mounted only after `hydrate()` has re-targeted the + * auth/data clients at the persisted server URL — so the very first + * `useSession()` (and every screen data hook) hits the configured server, not + * the default API host. Mounting any of this earlier fires a storm of + * connection-refused requests (and a dev-overlay error) on every cold start. + */ +function AppShell({ serverUrl }: { serverUrl: string | null }) { + useProtectedRoute(serverUrl, true); const { data: session } = authClient.useSession(); const sessionRecord = session as Record | null; @@ -101,22 +100,43 @@ export default function RootLayout() { return ( - - - - - - - - - - - - - - - + + + + + + + + ); } + +export default function RootLayout() { + const serverUrl = useServerStore((s) => s.serverUrl); + const isReady = useServerStore((s) => s.isReady); + const hydrate = useServerStore((s) => s.hydrate); + + // On mount, load the persisted server URL and reinitialize clients + useEffect(() => { + void hydrate(); + }, [hydrate]); + + return ( + + + + + {isReady ? ( + + ) : ( + + + + )} + + + + ); +} diff --git a/components/renderers/DetailViewRenderer.tsx b/components/renderers/DetailViewRenderer.tsx index 2d167a8..10c90e0 100644 --- a/components/renderers/DetailViewRenderer.tsx +++ b/components/renderers/DetailViewRenderer.tsx @@ -135,6 +135,14 @@ const SYSTEM_FIELDS = new Set([ "last_modified_by", ]); +/** + * Internal plumbing fields the server injects onto every record (multi-tenancy + * / sharding keys). They aren't part of the object's declared fields, carry no + * business meaning, and must never surface in the auto-layout — unlike the + * audit fields above, they don't even belong in "System Information". + */ +const INTERNAL_FIELDS = new Set(["organization_id", "tenant_id", "space_id"]); + /* ------------------------------------------------------------------ */ /* Action Bar */ /* ------------------------------------------------------------------ */ @@ -458,8 +466,26 @@ export function DetailViewRenderer({ // Fallback: auto-layout, business fields first then a trailing "System // Information" section for audit fields. + // Fields whose object metadata marks them hidden/system — the curated form + // view filters these via `isEntryField`; the fallback must match so a + // hidden field never leaks into the detail layout. + const hiddenByMeta = new Set( + fields + .filter((f) => { + const flag = (k: string) => (f as Record)[k] === true; + return flag("hidden") || flag("system"); + }) + .map((f) => f.name), + ); + const buildSections = (allKeys: string[]): FormSection[] => { - const keys = allKeys.filter((k) => !k.startsWith("_") && k !== "id"); + const keys = allKeys.filter( + (k) => + !k.startsWith("_") && + k !== "id" && + !INTERNAL_FIELDS.has(k) && + !hiddenByMeta.has(k), + ); const business = keys.filter((k) => !SYSTEM_FIELDS.has(k)); const system = keys.filter((k) => SYSTEM_FIELDS.has(k)); const result: FormSection[] = []; diff --git a/components/renderers/index.ts b/components/renderers/index.ts index 30e0a5b..0ef4a84 100644 --- a/components/renderers/index.ts +++ b/components/renderers/index.ts @@ -73,6 +73,9 @@ export type { FormViewMeta, DashboardMeta, DashboardWidgetMeta, + DatasetMeta, + DatasetMeasure, + DatasetDimension, ActionMeta, ActionParamMeta, FieldDefinition, diff --git a/components/renderers/types.ts b/components/renderers/types.ts index 8b79668..32174bb 100644 --- a/components/renderers/types.ts +++ b/components/renderers/types.ts @@ -182,7 +182,12 @@ export interface DashboardWidgetMeta { name: string; /** Spec dashboards key widgets by `id`; normalized into `name` on load. */ id?: string; - object: string; + /** + * Data source object. Optional because 8.0 spec widgets declare a `dataset` + * (an analytics view) instead; `resolveDatasetWidget` resolves the dataset's + * base object into this field before the widget is queried. + */ + object?: string; type?: string; title?: string; description?: string; @@ -195,6 +200,48 @@ export interface DashboardWidgetMeta { options?: unknown; /** Number of grid columns this widget spans (default 1) */ span?: number; + /* --- 8.0 spec dashboard widget shape (resolved via `dataset` metadata) --- */ + /** Analytics dataset this widget aggregates over (8.0 spec). */ + dataset?: string; + /** Measure names selected from the dataset (8.0 spec); first drives the value. */ + values?: string[]; + /** Dimension names to group by (8.0 spec); first drives the chart category. */ + dimensions?: string[]; + /** Grid placement (8.0 spec): `{ x, y, w, h }` in a 12-column grid. */ + layout?: { x?: number; y?: number; w?: number; h?: number }; +} + +/** A measure (aggregation) declared by an analytics dataset (8.0 spec). */ +export interface DatasetMeasure { + name: string; + label?: string; + aggregate?: "count" | "sum" | "avg" | "min" | "max"; + /** Source object field the aggregate runs over (absent for `count`). */ + field?: string; + format?: string; +} + +/** A dimension (groupable field) declared by an analytics dataset (8.0 spec). */ +export interface DatasetDimension { + name: string; + label?: string; + /** Source object field this dimension maps to. */ + field?: string; + type?: string; +} + +/** + * Analytics dataset metadata (8.0 spec, served at `/meta/dataset/`). + * A dataset is an analytics view over a base `object`, exposing groupable + * `dimensions` and aggregatable `measures`. + */ +export interface DatasetMeta { + name: string; + label?: string; + description?: string; + object: string; + dimensions?: DatasetDimension[]; + measures?: DatasetMeasure[]; } export interface DashboardMeta { diff --git a/hooks/useDashboardData.ts b/hooks/useDashboardData.ts index fd95b90..18f07f5 100644 --- a/hooks/useDashboardData.ts +++ b/hooks/useDashboardData.ts @@ -1,7 +1,10 @@ import { useMemo } from "react"; import { useQuery } from "@objectstack/client-react"; import { mongoFilterToAst } from "~/lib/query-builder"; -import type { DashboardWidgetMeta } from "~/components/renderers/types"; +import type { + DashboardWidgetMeta, + DatasetMeta, +} from "~/components/renderers/types"; import type { WidgetDataPayload } from "~/components/renderers/DashboardViewRenderer"; /* ------------------------------------------------------------------ */ @@ -119,7 +122,7 @@ function buildChartData( ): Array<{ label: string; value: number }> { if (!categoryField || records.length === 0) return []; - const buckets = new Map(); + const buckets = new Map(); let isDateCategory = false; for (const rec of records) { @@ -127,9 +130,10 @@ function buildChartData( if (isDate) isDateCategory = true; let bucket = buckets.get(key); if (!bucket) { - bucket = { label, nums: [] }; + bucket = { label, nums: [], count: 0 }; buckets.set(key, bucket); } + bucket.count += 1; if (valueField) { const n = Number(rec[valueField]); if (!isNaN(n)) bucket.nums.push(n); @@ -139,7 +143,9 @@ function buildChartData( const series = Array.from(buckets.entries()).map(([key, b]) => ({ key, label: b.label, - value: applyAggregate(aggregate, b.nums), + // `count` aggregates over rows, not a value field — use the bucket size so + // count charts (the common dataset case, no `valueField`) aren't all zero. + value: aggregate === "count" ? b.count : applyAggregate(aggregate, b.nums), })); if (isDateCategory) { @@ -152,6 +158,59 @@ function buildChartData( return series.slice(0, MAX_CHART_BUCKETS).map(({ label, value }) => ({ label, value })); } +/* ------------------------------------------------------------------ */ +/* Dataset → object-query resolution (8.0 spec) */ +/* ------------------------------------------------------------------ */ + +/** Map a grid column width (12-col system) to the renderer's 1–2 span. */ +function spanFromLayoutWidth(w: number | undefined): number { + return typeof w === "number" && w >= 8 ? 2 : 1; +} + +/** + * Resolve an 8.0-spec dashboard widget — which references an analytics + * `dataset` plus `values` (measures) / `dimensions` — into the object-query + * shape (`object` / `aggregate` / `valueField` / `categoryField`) the widget + * data hook already understands. The dataset is an analytics view over a base + * object, so we aggregate the base object's rows client-side rather than + * depending on a server analytics endpoint (which the published runtime may + * not mount). Widgets without a `dataset` pass through unchanged. + */ +export function resolveDatasetWidget( + widget: DashboardWidgetMeta, + dataset: DatasetMeta | undefined, +): DashboardWidgetMeta { + if (!widget.dataset || !dataset) return widget; + + const measureName = widget.values?.[0]; + const measure = measureName + ? dataset.measures?.find((m) => m.name === measureName) + : undefined; + const dimensionName = widget.dimensions?.[0]; + const dimension = dimensionName + ? dataset.dimensions?.find((d) => d.name === dimensionName) + : undefined; + + const options = (widget.options ?? {}) as Record; + const color = typeof options.color === "string" ? options.color : undefined; + + return { + ...widget, + object: dataset.object, + aggregate: measure?.aggregate ?? "count", + // `count` measures have no source field — count rows instead. + valueField: + measure && measure.aggregate !== "count" ? measure.field : undefined, + categoryField: dimension?.field, + span: widget.span ?? spanFromLayoutWidth(widget.layout?.w), + chartConfig: { + ...options, + ...(color ? { colors: [color] } : null), + ...(measure?.format ? { format: measure.format } : null), + }, + }; +} + /* ------------------------------------------------------------------ */ /* Hook */ /* ------------------------------------------------------------------ */ @@ -162,6 +221,11 @@ function buildChartData( * Each widget declares an `object` (the data source) and optional * `aggregate`, `valueField`, and `categoryField` hints. This hook * runs a live query that keeps the widget up-to-date. + * + * A widget with no resolvable `object` (e.g. a dataset whose metadata failed + * to load) returns a terminal empty payload rather than a perpetual loading + * state — `useQuery` leaves `isLoading: true` forever when disabled, which + * would otherwise spin the widget indefinitely. */ export function useWidgetQuery(widget: DashboardWidgetMeta): WidgetDataPayload { const type = widget.type ?? "metric"; @@ -189,13 +253,19 @@ export function useWidgetQuery(widget: DashboardWidgetMeta): WidgetDataPayload { [filterKey], ); - const { data, isLoading } = useQuery(widget.object, { + const { data, isLoading } = useQuery(widget.object ?? "", { top, filters: astFilter, enabled: !!widget.object, }); return useMemo(() => { + // No data source to query — `useQuery` is disabled and its `isLoading` + // stays `true` forever, so short-circuit to a terminal empty state. + if (!widget.object) { + return { value: undefined, records: [], isLoading: false }; + } + const records: Record[] = data?.records ?? []; if (isLoading) { diff --git a/lib/query-builder.ts b/lib/query-builder.ts index c968663..ed3c762 100644 --- a/lib/query-builder.ts +++ b/lib/query-builder.ts @@ -299,6 +299,9 @@ export function resolveFilterMacro(value: unknown): unknown { if ((m = /^(?:last_(\d+)_days|(\d+)_days_ago)$/.exec(token))) { return now.getTime() - Number(m[1] ?? m[2]) * 86_400_000; } + if ((m = /^(?:last_(\d+)_weeks?|(\d+)_weeks?_ago)$/.exec(token))) { + return now.getTime() - Number(m[1] ?? m[2]) * 7 * 86_400_000; + } if ((m = /^(?:last_(\d+)_months|(\d+)_months_ago)$/.exec(token))) { const months = Number(m[1] ?? m[2]); return new Date(y, now.getMonth() - months, now.getDate()).getTime(); @@ -313,6 +316,11 @@ export function resolveFilterMacro(value: unknown): unknown { if (token === "current_year_end" || token === "this_year_end") { return new Date(y, 11, 31, 23, 59, 59, 999).getTime(); } + if (token === "current_week_start" || token === "this_week_start") { + // Week starts Monday (ISO): shift back to the most recent Monday. + const dow = (now.getDay() + 6) % 7; // 0 = Monday … 6 = Sunday + return startOfDay(now) - dow * 86_400_000; + } if (token === "current_month_start" || token === "this_month_start") { return new Date(y, now.getMonth(), 1).getTime(); }