Skip to content

Commit 2f82a23

Browse files
os-zhuangclaude
andcommitted
feat(workflow): show object state machines on the record detail page
The StateMachineViewer existed and was unit-tested but had no route into the app. Wire it onto record detail as a "Lifecycle" diagram: - useStateMachines / deriveStateMachines resolve each `meta.stateMachines` entry against the object's fields. State machines carry no explicit field link, so match a machine to the select field whose option values cover its state names; that field's value on the record becomes the current state. States are classified initial/normal/final and labelled from the matched field's (localized) option labels; transitions carry their event + description. - RecordStateMachines renders one titled card per machine. - StateMachineViewer gains a `scrollable` prop (default true) so it lays out at natural height when embedded in the detail scroll view instead of collapsing a nested flex-1 ScrollView. - DetailViewRenderer gains a `footer` slot; the detail screen passes the lifecycle diagram through it (only when a machine is present). Verified in-browser against a local 7.5.0 server: the crm_opportunity "Lifecycle" diagram renders with the current stage highlighted, initial/final markers, and localized state labels. Adds deriveStateMachines unit tests; typecheck clean; full jest suite green apart from the pre-existing snapshots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 32bebe8 commit 2f82a23

6 files changed

Lines changed: 342 additions & 32 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Tests for deriveStateMachines — validates field matching, initial/final
3+
* classification, transition extraction, and current-state resolution.
4+
*/
5+
import { deriveStateMachines } from "~/hooks/useStateMachines";
6+
import type { ObjectMeta } from "~/hooks/useObjectMeta";
7+
import type { FieldDefinition } from "~/components/renderers";
8+
9+
const META: ObjectMeta = {
10+
name: "crm_opportunity",
11+
stateMachines: {
12+
lifecycle: {
13+
id: "opportunity_pipeline",
14+
initial: "prospecting",
15+
states: {
16+
prospecting: {
17+
on: {
18+
QUALIFY: { target: "qualification", description: "Qualified" },
19+
LOSE: { target: "closed_lost", description: "Lost early" },
20+
},
21+
},
22+
qualification: { on: { WIN: { target: "closed_won" } } },
23+
closed_won: {},
24+
closed_lost: {},
25+
},
26+
},
27+
},
28+
} as unknown as ObjectMeta;
29+
30+
const FIELDS: FieldDefinition[] = [
31+
{ name: "name", type: "text" } as FieldDefinition,
32+
{
33+
name: "stage",
34+
type: "select",
35+
options: [
36+
{ value: "prospecting", label: "Prospecting" },
37+
{ value: "qualification", label: "Qualification" },
38+
{ value: "closed_won", label: "Won" },
39+
{ value: "closed_lost", label: "Lost" },
40+
],
41+
} as FieldDefinition,
42+
];
43+
44+
describe("deriveStateMachines", () => {
45+
it("derives states, transitions, and current state from a matched field", () => {
46+
const result = deriveStateMachines(META, FIELDS, { stage: "qualification" });
47+
expect(result).toHaveLength(1);
48+
const sm = result[0];
49+
50+
expect(sm.key).toBe("lifecycle");
51+
expect(sm.field).toBe("stage");
52+
expect(sm.currentState).toBe("qualification");
53+
54+
// Initial / normal / final classification.
55+
const byName = Object.fromEntries(sm.states.map((s) => [s.name, s]));
56+
expect(byName.prospecting.type).toBe("initial");
57+
expect(byName.qualification.type).toBe("normal");
58+
expect(byName.closed_won.type).toBe("final");
59+
expect(byName.closed_lost.type).toBe("final");
60+
61+
// Labels come from the matched field's option labels.
62+
expect(byName.closed_won.label).toBe("Won");
63+
64+
// Transitions carry event + description.
65+
expect(sm.transitions).toContainEqual({
66+
from: "prospecting",
67+
to: "qualification",
68+
event: "QUALIFY",
69+
label: "Qualified",
70+
});
71+
expect(sm.transitions.filter((t) => t.from === "prospecting")).toHaveLength(2);
72+
});
73+
74+
it("returns no current state when no field matches the state names", () => {
75+
const result = deriveStateMachines(META, [FIELDS[0]], { stage: "qualification" });
76+
expect(result[0].field).toBeUndefined();
77+
expect(result[0].currentState).toBeUndefined();
78+
// Falls back to humanized labels without a matched field.
79+
const won = result[0].states.find((s) => s.name === "closed_won");
80+
expect(won?.label).toBe("Closed Won");
81+
});
82+
83+
it("returns an empty array when the object has no state machines", () => {
84+
expect(deriveStateMachines({ name: "x" } as ObjectMeta, FIELDS, {})).toEqual([]);
85+
expect(deriveStateMachines(null, FIELDS, null)).toEqual([]);
86+
});
87+
});

app/(app)/[appName]/[objectName]/[id].tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { FormViewMeta, ActionMeta } from "~/components/renderers";
99
import { ScreenHeader } from "~/components/common/ScreenHeader";
1010
import { useObjectMeta } from "~/hooks/useObjectMeta";
1111
import { useRecordActions } from "~/hooks/useRecordActions";
12+
import { useRecordStateMachines } from "~/hooks/useStateMachines";
13+
import { RecordStateMachines } from "~/components/workflow/RecordStateMachines";
1214
import { isActionVisible } from "~/lib/record-actions";
1315
import { renderRecordTitle } from "~/lib/record-title";
1416

@@ -131,6 +133,9 @@ export default function ObjectDetailScreen() {
131133
onRefresh: fetchRecord,
132134
});
133135

136+
/* ---- Lifecycle / state machine diagram(s) ---- */
137+
const stateMachines = useRecordStateMachines(meta, fields, record);
138+
134139
return (
135140
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
136141
<ScreenHeader title={String(displayName)} subtitle={positionLabel} />
@@ -155,6 +160,11 @@ export default function ObjectDetailScreen() {
155160
hasPrevious={hasPrevious}
156161
hasNext={hasNext}
157162
positionLabel={positionLabel}
163+
footer={
164+
stateMachines.length > 0 ? (
165+
<RecordStateMachines machines={stateMachines} />
166+
) : undefined
167+
}
158168
/>
159169
{modals}
160170
</SafeAreaView>

components/renderers/DetailViewRenderer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export interface DetailViewRendererProps {
8787
allowEdit?: boolean;
8888
/** Permission: hide delete button when false */
8989
allowDelete?: boolean;
90+
/** Extra content rendered at the end of the scroll body (e.g. lifecycle diagram). */
91+
footer?: React.ReactNode;
9092
}
9193

9294
export interface RelatedListConfig {
@@ -418,6 +420,7 @@ export function DetailViewRenderer({
418420
positionLabel,
419421
allowEdit = true,
420422
allowDelete = true,
423+
footer,
421424
}: DetailViewRendererProps) {
422425
/* ---- Build sections ---- */
423426
const sections: FormSection[] = useMemo(() => {
@@ -556,6 +559,9 @@ export function DetailViewRenderer({
556559
onRecordPress={onRelatedRecordPress}
557560
/>
558561
))}
562+
563+
{/* Extra content (e.g. lifecycle / state machine diagram) */}
564+
{footer}
559565
</ScrollView>
560566
</View>
561567
);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from "react";
2+
import { View, Text } from "react-native";
3+
import { StateMachineViewer } from "./StateMachineViewer";
4+
import type { RecordStateMachine } from "~/hooks/useStateMachines";
5+
6+
export interface RecordStateMachinesProps {
7+
machines: RecordStateMachine[];
8+
}
9+
10+
/**
11+
* Renders a record's state machines as titled cards, each showing the
12+
* `StateMachineViewer` lifecycle diagram with the record's current state
13+
* highlighted. Designed to sit inside the record detail's scroll view, so the
14+
* viewer is rendered non-scrollable.
15+
*/
16+
export function RecordStateMachines({ machines }: RecordStateMachinesProps) {
17+
if (machines.length === 0) return null;
18+
19+
return (
20+
<View>
21+
{machines.map((m) => (
22+
<View
23+
key={m.key}
24+
className="mb-4 overflow-hidden rounded-xl border border-border bg-card"
25+
>
26+
<View className="border-b border-border px-4 py-3">
27+
<Text className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
28+
{m.title}
29+
</Text>
30+
</View>
31+
<StateMachineViewer
32+
states={m.states}
33+
transitions={m.transitions}
34+
currentState={m.currentState}
35+
scrollable={false}
36+
/>
37+
</View>
38+
))}
39+
</View>
40+
);
41+
}

components/workflow/StateMachineViewer.tsx

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ export interface StateMachineViewerProps {
2121
}>;
2222
/** Name of the currently active state */
2323
currentState?: string;
24+
/**
25+
* Wrap the diagram in its own scroll view (default `true`, for full-screen
26+
* use). Set `false` when embedding inside another scrolling container — e.g.
27+
* a record detail section — so it lays out at its natural height instead of
28+
* collapsing a nested `flex-1` ScrollView.
29+
*/
30+
scrollable?: boolean;
2431
}
2532

2633
/* ------------------------------------------------------------------ */
@@ -87,6 +94,7 @@ export function StateMachineViewer({
8794
states,
8895
transitions,
8996
currentState,
97+
scrollable = true,
9098
}: StateMachineViewerProps) {
9199
if (states.length === 0) {
92100
return (
@@ -98,41 +106,53 @@ export function StateMachineViewer({
98106
);
99107
}
100108

101-
return (
102-
<ScrollView className="flex-1" accessibilityRole="list" accessibilityLabel="State machine diagram">
103-
<View className="gap-3 px-4 py-3">
104-
{states.map((state) => {
105-
const outgoing = getTransitionsFrom(state.name, transitions);
109+
const body = (
110+
<View className="gap-3 px-4 py-3">
111+
{states.map((state) => {
112+
const outgoing = getTransitionsFrom(state.name, transitions);
113+
114+
return (
115+
<View key={state.name} className="items-center gap-1.5">
116+
{/* State badge */}
117+
<StateBadge
118+
name={state.name}
119+
type={state.type}
120+
label={state.label}
121+
isCurrent={state.name === currentState}
122+
/>
106123

107-
return (
108-
<View key={state.name} className="items-center gap-1.5">
109-
{/* State badge */}
110-
<StateBadge
111-
name={state.name}
112-
type={state.type}
113-
label={state.label}
114-
isCurrent={state.name === currentState}
115-
/>
124+
{/* Transitions from this state */}
125+
{outgoing.map((t, idx) => (
126+
<View
127+
key={`${t.from}-${t.to}-${idx}`}
128+
className="items-center gap-0.5"
129+
>
130+
<View className="h-3 w-px bg-muted-foreground/30" />
131+
<Text className="text-[10px] text-muted-foreground">
132+
{t.label ?? t.event}
133+
</Text>
134+
<Text className="text-xs text-muted-foreground">
135+
{formatStateName(t.to)}
136+
</Text>
137+
</View>
138+
))}
139+
</View>
140+
);
141+
})}
142+
</View>
143+
);
116144

117-
{/* Transitions from this state */}
118-
{outgoing.map((t, idx) => (
119-
<View
120-
key={`${t.from}-${t.to}-${idx}`}
121-
className="items-center gap-0.5"
122-
>
123-
<View className="h-3 w-px bg-muted-foreground/30" />
124-
<Text className="text-[10px] text-muted-foreground">
125-
{t.label ?? t.event}
126-
</Text>
127-
<Text className="text-xs text-muted-foreground">
128-
{formatStateName(t.to)}
129-
</Text>
130-
</View>
131-
))}
132-
</View>
133-
);
134-
})}
145+
if (!scrollable) {
146+
return (
147+
<View accessibilityRole="list" accessibilityLabel="State machine diagram">
148+
{body}
135149
</View>
150+
);
151+
}
152+
153+
return (
154+
<ScrollView className="flex-1" accessibilityRole="list" accessibilityLabel="State machine diagram">
155+
{body}
136156
</ScrollView>
137157
);
138158
}

0 commit comments

Comments
 (0)