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
87 changes: 87 additions & 0 deletions __tests__/hooks/useStateMachines.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
10 changes: 10 additions & 0 deletions app/(app)/[appName]/[objectName]/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -131,6 +133,9 @@ export default function ObjectDetailScreen() {
onRefresh: fetchRecord,
});

/* ---- Lifecycle / state machine diagram(s) ---- */
const stateMachines = useRecordStateMachines(meta, fields, record);

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={String(displayName)} subtitle={positionLabel} />
Expand All @@ -155,6 +160,11 @@ export default function ObjectDetailScreen() {
hasPrevious={hasPrevious}
hasNext={hasNext}
positionLabel={positionLabel}
footer={
stateMachines.length > 0 ? (
<RecordStateMachines machines={stateMachines} />
) : undefined
}
/>
{modals}
</SafeAreaView>
Expand Down
6 changes: 6 additions & 0 deletions components/renderers/DetailViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -418,6 +420,7 @@ export function DetailViewRenderer({
positionLabel,
allowEdit = true,
allowDelete = true,
footer,
}: DetailViewRendererProps) {
/* ---- Build sections ---- */
const sections: FormSection[] = useMemo(() => {
Expand Down Expand Up @@ -556,6 +559,9 @@ export function DetailViewRenderer({
onRecordPress={onRelatedRecordPress}
/>
))}

{/* Extra content (e.g. lifecycle / state machine diagram) */}
{footer}
</ScrollView>
</View>
);
Expand Down
41 changes: 41 additions & 0 deletions components/workflow/RecordStateMachines.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View>
{machines.map((m) => (
<View
key={m.key}
className="mb-4 overflow-hidden rounded-xl border border-border bg-card"
>
<View className="border-b border-border px-4 py-3">
<Text className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{m.title}
</Text>
</View>
<StateMachineViewer
states={m.states}
transitions={m.transitions}
currentState={m.currentState}
scrollable={false}
/>
</View>
))}
</View>
);
}
84 changes: 52 additions & 32 deletions components/workflow/StateMachineViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -87,6 +94,7 @@ export function StateMachineViewer({
states,
transitions,
currentState,
scrollable = true,
}: StateMachineViewerProps) {
if (states.length === 0) {
return (
Expand All @@ -98,41 +106,53 @@ export function StateMachineViewer({
);
}

return (
<ScrollView className="flex-1" accessibilityRole="list" accessibilityLabel="State machine diagram">
<View className="gap-3 px-4 py-3">
{states.map((state) => {
const outgoing = getTransitionsFrom(state.name, transitions);
const body = (
<View className="gap-3 px-4 py-3">
{states.map((state) => {
const outgoing = getTransitionsFrom(state.name, transitions);

return (
<View key={state.name} className="items-center gap-1.5">
{/* State badge */}
<StateBadge
name={state.name}
type={state.type}
label={state.label}
isCurrent={state.name === currentState}
/>

return (
<View key={state.name} className="items-center gap-1.5">
{/* State badge */}
<StateBadge
name={state.name}
type={state.type}
label={state.label}
isCurrent={state.name === currentState}
/>
{/* Transitions from this state */}
{outgoing.map((t, idx) => (
<View
key={`${t.from}-${t.to}-${idx}`}
className="items-center gap-0.5"
>
<View className="h-3 w-px bg-muted-foreground/30" />
<Text className="text-[10px] text-muted-foreground">
{t.label ?? t.event}
</Text>
<Text className="text-xs text-muted-foreground">
↓ {formatStateName(t.to)}
</Text>
</View>
))}
</View>
);
})}
</View>
);

{/* Transitions from this state */}
{outgoing.map((t, idx) => (
<View
key={`${t.from}-${t.to}-${idx}`}
className="items-center gap-0.5"
>
<View className="h-3 w-px bg-muted-foreground/30" />
<Text className="text-[10px] text-muted-foreground">
{t.label ?? t.event}
</Text>
<Text className="text-xs text-muted-foreground">
↓ {formatStateName(t.to)}
</Text>
</View>
))}
</View>
);
})}
if (!scrollable) {
return (
<View accessibilityRole="list" accessibilityLabel="State machine diagram">
{body}
</View>
);
}

return (
<ScrollView className="flex-1" accessibilityRole="list" accessibilityLabel="State machine diagram">
{body}
</ScrollView>
);
}
Loading
Loading