From 2f82a23b0abad855b2a367928a68cb845496892a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:07:33 +0800 Subject: [PATCH] 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 --- __tests__/hooks/useStateMachines.test.ts | 87 ++++++++++++ app/(app)/[appName]/[objectName]/[id].tsx | 10 ++ components/renderers/DetailViewRenderer.tsx | 6 + components/workflow/RecordStateMachines.tsx | 41 ++++++ components/workflow/StateMachineViewer.tsx | 84 ++++++----- hooks/useStateMachines.ts | 146 ++++++++++++++++++++ 6 files changed, 342 insertions(+), 32 deletions(-) create mode 100644 __tests__/hooks/useStateMachines.test.ts create mode 100644 components/workflow/RecordStateMachines.tsx create mode 100644 hooks/useStateMachines.ts 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], + ); +}