Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions __tests__/components/DetailViewHiddenFields.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DetailViewRenderer record={record} fields={fields} />,
);

// 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();
});
});
99 changes: 97 additions & 2 deletions __tests__/hooks/useDashboardData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
});
});
28 changes: 28 additions & 0 deletions __tests__/lib/query-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
serializeFilterTree,
buildProjection,
OPERATOR_META,
resolveFilterMacro,
type FilterOperator,
} from "~/lib/query-builder";

Expand Down Expand Up @@ -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}");
});
});
46 changes: 40 additions & 6 deletions app/(app)/[appName]/dashboard/[dashboardName].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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<string, DatasetMeta>();
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 {
Expand Down
72 changes: 46 additions & 26 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> | null;
Expand Down Expand Up @@ -101,22 +100,43 @@ export default function RootLayout() {
return (
<ObjectStackProvider client={client}>
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<ToastProvider>
<ConfirmProvider>
<PushNotificationsManager enabled={!!token} onDeepLink={handleDeepLink} />
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(app)" />
<Stack.Screen name="account" />
<Stack.Screen name="ai" />
</Stack>
</ConfirmProvider>
</ToastProvider>
</SafeAreaProvider>
<PushNotificationsManager enabled={!!token} onDeepLink={handleDeepLink} />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(app)" />
<Stack.Screen name="account" />
<Stack.Screen name="ai" />
</Stack>
</QueryClientProvider>
</ObjectStackProvider>
);
}

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 (
<SafeAreaProvider>
<ToastProvider>
<ConfirmProvider>
<StatusBar style="auto" />
{isReady ? (
<AppShell serverUrl={serverUrl} />
) : (
<View className="flex-1 items-center justify-center bg-background">
<ActivityIndicator size="large" />
</View>
)}
</ConfirmProvider>
</ToastProvider>
</SafeAreaProvider>
);
}
28 changes: 27 additions & 1 deletion components/renderers/DetailViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -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<string, unknown>)[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[] = [];
Expand Down
Loading
Loading