diff --git a/__tests__/hooks/useStateMachines.test.ts b/__tests__/hooks/useStateMachines.test.ts
new file mode 100644
index 0000000..6e74d54
--- /dev/null
+++ b/__tests__/hooks/useStateMachines.test.ts
@@ -0,0 +1,87 @@
+/**
+ * Tests for deriveStateMachines — validates field matching, initial/final
+ * classification, transition extraction, and current-state resolution.
+ */
+import { deriveStateMachines } from "~/hooks/useStateMachines";
+import type { ObjectMeta } from "~/hooks/useObjectMeta";
+import type { FieldDefinition } from "~/components/renderers";
+
+const META: ObjectMeta = {
+ name: "crm_opportunity",
+ stateMachines: {
+ lifecycle: {
+ id: "opportunity_pipeline",
+ initial: "prospecting",
+ states: {
+ prospecting: {
+ on: {
+ QUALIFY: { target: "qualification", description: "Qualified" },
+ LOSE: { target: "closed_lost", description: "Lost early" },
+ },
+ },
+ qualification: { on: { WIN: { target: "closed_won" } } },
+ closed_won: {},
+ closed_lost: {},
+ },
+ },
+ },
+} as unknown as ObjectMeta;
+
+const FIELDS: FieldDefinition[] = [
+ { name: "name", type: "text" } as FieldDefinition,
+ {
+ name: "stage",
+ type: "select",
+ options: [
+ { value: "prospecting", label: "Prospecting" },
+ { value: "qualification", label: "Qualification" },
+ { value: "closed_won", label: "Won" },
+ { value: "closed_lost", label: "Lost" },
+ ],
+ } as FieldDefinition,
+];
+
+describe("deriveStateMachines", () => {
+ it("derives states, transitions, and current state from a matched field", () => {
+ const result = deriveStateMachines(META, FIELDS, { stage: "qualification" });
+ expect(result).toHaveLength(1);
+ const sm = result[0];
+
+ expect(sm.key).toBe("lifecycle");
+ expect(sm.field).toBe("stage");
+ expect(sm.currentState).toBe("qualification");
+
+ // Initial / normal / final classification.
+ const byName = Object.fromEntries(sm.states.map((s) => [s.name, s]));
+ expect(byName.prospecting.type).toBe("initial");
+ expect(byName.qualification.type).toBe("normal");
+ expect(byName.closed_won.type).toBe("final");
+ expect(byName.closed_lost.type).toBe("final");
+
+ // Labels come from the matched field's option labels.
+ expect(byName.closed_won.label).toBe("Won");
+
+ // Transitions carry event + description.
+ expect(sm.transitions).toContainEqual({
+ from: "prospecting",
+ to: "qualification",
+ event: "QUALIFY",
+ label: "Qualified",
+ });
+ expect(sm.transitions.filter((t) => t.from === "prospecting")).toHaveLength(2);
+ });
+
+ it("returns no current state when no field matches the state names", () => {
+ const result = deriveStateMachines(META, [FIELDS[0]], { stage: "qualification" });
+ expect(result[0].field).toBeUndefined();
+ expect(result[0].currentState).toBeUndefined();
+ // Falls back to humanized labels without a matched field.
+ const won = result[0].states.find((s) => s.name === "closed_won");
+ expect(won?.label).toBe("Closed Won");
+ });
+
+ it("returns an empty array when the object has no state machines", () => {
+ expect(deriveStateMachines({ name: "x" } as ObjectMeta, FIELDS, {})).toEqual([]);
+ expect(deriveStateMachines(null, FIELDS, null)).toEqual([]);
+ });
+});
diff --git a/app/(app)/[appName]/[objectName]/[id].tsx b/app/(app)/[appName]/[objectName]/[id].tsx
index 53bdfd6..a30f067 100644
--- a/app/(app)/[appName]/[objectName]/[id].tsx
+++ b/app/(app)/[appName]/[objectName]/[id].tsx
@@ -9,6 +9,8 @@ import type { FormViewMeta, ActionMeta } from "~/components/renderers";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { useObjectMeta } from "~/hooks/useObjectMeta";
import { useRecordActions } from "~/hooks/useRecordActions";
+import { useRecordStateMachines } from "~/hooks/useStateMachines";
+import { RecordStateMachines } from "~/components/workflow/RecordStateMachines";
import { isActionVisible } from "~/lib/record-actions";
import { renderRecordTitle } from "~/lib/record-title";
@@ -131,6 +133,9 @@ export default function ObjectDetailScreen() {
onRefresh: fetchRecord,
});
+ /* ---- Lifecycle / state machine diagram(s) ---- */
+ const stateMachines = useRecordStateMachines(meta, fields, record);
+
return (
@@ -155,6 +160,11 @@ export default function ObjectDetailScreen() {
hasPrevious={hasPrevious}
hasNext={hasNext}
positionLabel={positionLabel}
+ footer={
+ stateMachines.length > 0 ? (
+
+ ) : undefined
+ }
/>
{modals}
diff --git a/components/renderers/DetailViewRenderer.tsx b/components/renderers/DetailViewRenderer.tsx
index 463e6ae..e3e6014 100644
--- a/components/renderers/DetailViewRenderer.tsx
+++ b/components/renderers/DetailViewRenderer.tsx
@@ -87,6 +87,8 @@ export interface DetailViewRendererProps {
allowEdit?: boolean;
/** Permission: hide delete button when false */
allowDelete?: boolean;
+ /** Extra content rendered at the end of the scroll body (e.g. lifecycle diagram). */
+ footer?: React.ReactNode;
}
export interface RelatedListConfig {
@@ -418,6 +420,7 @@ export function DetailViewRenderer({
positionLabel,
allowEdit = true,
allowDelete = true,
+ footer,
}: DetailViewRendererProps) {
/* ---- Build sections ---- */
const sections: FormSection[] = useMemo(() => {
@@ -556,6 +559,9 @@ export function DetailViewRenderer({
onRecordPress={onRelatedRecordPress}
/>
))}
+
+ {/* Extra content (e.g. lifecycle / state machine diagram) */}
+ {footer}
);
diff --git a/components/workflow/RecordStateMachines.tsx b/components/workflow/RecordStateMachines.tsx
new file mode 100644
index 0000000..301f369
--- /dev/null
+++ b/components/workflow/RecordStateMachines.tsx
@@ -0,0 +1,41 @@
+import React from "react";
+import { View, Text } from "react-native";
+import { StateMachineViewer } from "./StateMachineViewer";
+import type { RecordStateMachine } from "~/hooks/useStateMachines";
+
+export interface RecordStateMachinesProps {
+ machines: RecordStateMachine[];
+}
+
+/**
+ * Renders a record's state machines as titled cards, each showing the
+ * `StateMachineViewer` lifecycle diagram with the record's current state
+ * highlighted. Designed to sit inside the record detail's scroll view, so the
+ * viewer is rendered non-scrollable.
+ */
+export function RecordStateMachines({ machines }: RecordStateMachinesProps) {
+ if (machines.length === 0) return null;
+
+ return (
+
+ {machines.map((m) => (
+
+
+
+ {m.title}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/workflow/StateMachineViewer.tsx b/components/workflow/StateMachineViewer.tsx
index 9af4386..5a3d345 100644
--- a/components/workflow/StateMachineViewer.tsx
+++ b/components/workflow/StateMachineViewer.tsx
@@ -21,6 +21,13 @@ export interface StateMachineViewerProps {
}>;
/** Name of the currently active state */
currentState?: string;
+ /**
+ * Wrap the diagram in its own scroll view (default `true`, for full-screen
+ * use). Set `false` when embedding inside another scrolling container — e.g.
+ * a record detail section — so it lays out at its natural height instead of
+ * collapsing a nested `flex-1` ScrollView.
+ */
+ scrollable?: boolean;
}
/* ------------------------------------------------------------------ */
@@ -87,6 +94,7 @@ export function StateMachineViewer({
states,
transitions,
currentState,
+ scrollable = true,
}: StateMachineViewerProps) {
if (states.length === 0) {
return (
@@ -98,41 +106,53 @@ export function StateMachineViewer({
);
}
- return (
-
-
- {states.map((state) => {
- const outgoing = getTransitionsFrom(state.name, transitions);
+ const body = (
+
+ {states.map((state) => {
+ const outgoing = getTransitionsFrom(state.name, transitions);
+
+ return (
+
+ {/* State badge */}
+
- return (
-
- {/* State badge */}
-
+ {/* Transitions from this state */}
+ {outgoing.map((t, idx) => (
+
+
+
+ {t.label ?? t.event}
+
+
+ ↓ {formatStateName(t.to)}
+
+
+ ))}
+
+ );
+ })}
+
+ );
- {/* Transitions from this state */}
- {outgoing.map((t, idx) => (
-
-
-
- {t.label ?? t.event}
-
-
- ↓ {formatStateName(t.to)}
-
-
- ))}
-
- );
- })}
+ if (!scrollable) {
+ return (
+
+ {body}
+ );
+ }
+
+ return (
+
+ {body}
);
}
diff --git a/hooks/useStateMachines.ts b/hooks/useStateMachines.ts
new file mode 100644
index 0000000..b7fb521
--- /dev/null
+++ b/hooks/useStateMachines.ts
@@ -0,0 +1,146 @@
+import { useMemo } from "react";
+import type { FieldDefinition } from "~/components/renderers";
+import type { ObjectMeta } from "~/hooks/useObjectMeta";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface SMState {
+ name: string;
+ type: "initial" | "normal" | "final";
+ label?: string;
+}
+
+export interface SMTransition {
+ from: string;
+ to: string;
+ event: string;
+ label?: string;
+}
+
+/**
+ * A state machine resolved against an object's metadata and (optionally) a
+ * record — ready to hand straight to `StateMachineViewer`.
+ */
+export interface RecordStateMachine {
+ /** Machine key from `meta.stateMachines` (e.g. `lifecycle`). */
+ key: string;
+ /** Human title for the section. */
+ title: string;
+ /** The record field whose value tracks the current state, if matched. */
+ field?: string;
+ states: SMState[];
+ transitions: SMTransition[];
+ /** Current state value from the record (when a field could be matched). */
+ currentState?: string;
+}
+
+/* Raw XState-style shape as returned by `/meta/object` → `stateMachines`. */
+interface RawMachine {
+ id?: string;
+ initial?: string;
+ states?: Record<
+ string,
+ { on?: Record }
+ >;
+}
+
+/* ------------------------------------------------------------------ */
+/* Derivation */
+/* ------------------------------------------------------------------ */
+
+function humanize(token: string): string {
+ return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/**
+ * State machines carry no explicit field link, so match a machine to the
+ * select/radio field whose option values cover every state name. That field's
+ * value on the record is the current state.
+ */
+function findStateField(
+ stateNames: string[],
+ fields: FieldDefinition[],
+): FieldDefinition | undefined {
+ return fields.find((f) => {
+ const opts = f.options;
+ if (!opts || opts.length === 0) return false;
+ const values = new Set(opts.map((o) => String(o.value)));
+ return stateNames.every((n) => values.has(n));
+ });
+}
+
+export function deriveStateMachines(
+ meta: ObjectMeta | null,
+ fields: FieldDefinition[],
+ record: Record | null,
+): RecordStateMachine[] {
+ const machines = (meta?.stateMachines ?? {}) as Record;
+ const result: RecordStateMachine[] = [];
+
+ for (const [key, machine] of Object.entries(machines)) {
+ const stateMap = machine.states ?? {};
+ const stateNames = Object.keys(stateMap);
+ if (stateNames.length === 0) continue;
+
+ const field = findStateField(stateNames, fields);
+ const optionLabel = (value: string): string | undefined =>
+ field?.options?.find((o) => String(o.value) === value)?.label;
+
+ const states: SMState[] = stateNames.map((name) => {
+ const on = stateMap[name]?.on ?? {};
+ const isInitial = name === machine.initial;
+ const isFinal = Object.keys(on).length === 0;
+ return {
+ name,
+ label: optionLabel(name) ?? humanize(name),
+ type: isInitial ? "initial" : isFinal ? "final" : "normal",
+ };
+ });
+
+ const transitions: SMTransition[] = stateNames.flatMap((name) => {
+ const on = stateMap[name]?.on ?? {};
+ return Object.entries(on).map(([event, t]) => ({
+ from: name,
+ to: String(t?.target ?? ""),
+ event,
+ label: t?.description,
+ }));
+ });
+
+ const rawCurrent = field && record ? record[field.name] : undefined;
+ const currentState =
+ rawCurrent != null && rawCurrent !== "" ? String(rawCurrent) : undefined;
+
+ result.push({
+ key,
+ title: humanize(key),
+ field: field?.name,
+ states,
+ transitions,
+ currentState,
+ });
+ }
+
+ return result;
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Derive the state machines defined on an object, resolved against a record's
+ * current field values. Memoized on its inputs.
+ */
+export function useRecordStateMachines(
+ meta: ObjectMeta | null,
+ fields: FieldDefinition[],
+ record: Record | null,
+): RecordStateMachine[] {
+ return useMemo(
+ () => deriveStateMachines(meta, fields, record),
+ [meta, fields, record],
+ );
+}