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
64 changes: 64 additions & 0 deletions __tests__/components/RecordStateMachines.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Tests for RecordStateMachines — validates that available transitions render
* for the current state and fire onTransition, and stay hidden when read-only.
*/
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";

jest.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));

import { RecordStateMachines } from "~/components/workflow/RecordStateMachines";
import type { RecordStateMachine } from "~/hooks/useStateMachines";

const machine: RecordStateMachine = {
key: "lifecycle",
title: "Lifecycle",
field: "stage",
currentState: "proposal",
states: [
{ name: "proposal", type: "normal", label: "Proposal" },
{ name: "negotiation", type: "normal", label: "Negotiation" },
{ name: "closed_lost", type: "final", label: "Lost" },
],
transitions: [
{ from: "proposal", to: "negotiation", event: "NEGOTIATE", label: "Enter negotiation" },
{ from: "proposal", to: "closed_lost", event: "LOSE", label: "Proposal rejected" },
{ from: "negotiation", to: "closed_lost", event: "LOSE", label: "Lost late" },
],
};

describe("RecordStateMachines", () => {
it("renders available transitions for the current state and fires onTransition", () => {
const onTransition = jest.fn();
// Buttons carry a unique a11y label "<label> → <target>"; the diagram
// reuses the transition text, so query the buttons by accessibility label.
const { getByLabelText, queryByLabelText } = render(
<RecordStateMachines machines={[machine]} onTransition={onTransition} />,
);

// Only the current state's (proposal) outgoing transitions are actionable.
expect(getByLabelText("Enter negotiation → Negotiation")).toBeTruthy();
expect(getByLabelText("Proposal rejected → Lost")).toBeTruthy();
// A transition from another state is not offered as an action button.
expect(queryByLabelText("Lost late → Lost")).toBeNull();

fireEvent.press(getByLabelText("Enter negotiation → Negotiation"));
expect(onTransition).toHaveBeenCalledTimes(1);
expect(onTransition.mock.calls[0][1]).toMatchObject({
to: "negotiation",
event: "NEGOTIATE",
});
});

it("renders read-only (no action buttons) when onTransition is omitted", () => {
const { queryByLabelText } = render(<RecordStateMachines machines={[machine]} />);
expect(queryByLabelText("Enter negotiation → Negotiation")).toBeNull();
});

it("renders nothing when there are no machines", () => {
const { toJSON } = render(<RecordStateMachines machines={[]} />);
expect(toJSON()).toBeNull();
});
});
48 changes: 46 additions & 2 deletions app/(app)/[appName]/[objectName]/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ 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 {
useRecordStateMachines,
type RecordStateMachine,
type SMTransition,
} from "~/hooks/useStateMachines";
import { RecordStateMachines } from "~/components/workflow/RecordStateMachines";
import { useToast } from "~/components/ui/Toast";
import { isActionVisible } from "~/lib/record-actions";
import { renderRecordTitle } from "~/lib/record-title";

Expand Down Expand Up @@ -135,6 +140,41 @@ export default function ObjectDetailScreen() {

/* ---- Lifecycle / state machine diagram(s) ---- */
const stateMachines = useRecordStateMachines(meta, fields, record);
const { toastSuccess, toastError } = useToast();
const [pendingEvent, setPendingEvent] = useState<string | null>(null);

const handleTransition = useCallback(
(machine: RecordStateMachine, transition: SMTransition) => {
if (!machine.field || !objectName || !id) return;
const toLabel =
machine.states.find((s) => s.name === transition.to)?.label ?? transition.to;
Alert.alert(
t("workflow.updateStatus"),
t("workflow.moveToConfirm", { state: toLabel }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.confirm"),
onPress: async () => {
setPendingEvent(`${machine.key}:${transition.event}`);
try {
await client.data.update(objectName, id, {
[machine.field!]: transition.to,
});
await fetchRecord();
toastSuccess(t("workflow.statusUpdated"));
} catch {
toastError(t("workflow.statusUpdateFailed"));
} finally {
setPendingEvent(null);
}
},
},
],
);
},
[client, objectName, id, t, fetchRecord, toastSuccess, toastError],
);

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
Expand Down Expand Up @@ -162,7 +202,11 @@ export default function ObjectDetailScreen() {
positionLabel={positionLabel}
footer={
stateMachines.length > 0 ? (
<RecordStateMachines machines={stateMachines} />
<RecordStateMachines
machines={stateMachines}
onTransition={handleTransition}
pendingEvent={pendingEvent}
/>
) : undefined
}
/>
Expand Down
114 changes: 90 additions & 24 deletions components/workflow/RecordStateMachines.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,107 @@
import React from "react";
import { View, Text } from "react-native";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { ArrowRight } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StateMachineViewer } from "./StateMachineViewer";
import type { RecordStateMachine } from "~/hooks/useStateMachines";
import type { RecordStateMachine, SMTransition } from "~/hooks/useStateMachines";

export interface RecordStateMachinesProps {
machines: RecordStateMachine[];
/**
* Advance the record's state. Called with the machine and the chosen
* outgoing transition. When omitted, the diagram is purely read-only.
*/
onTransition?: (machine: RecordStateMachine, transition: SMTransition) => void;
/** `"<machineKey>:<event>"` currently being applied — drives the spinner. */
pendingEvent?: string | null;
}

function stateLabel(machine: RecordStateMachine, name: string): string {
return machine.states.find((s) => s.name === name)?.label ?? name;
}

/**
* 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.
* Renders a record's state machines as titled cards: the `StateMachineViewer`
* lifecycle diagram with the current state highlighted, plus — when
* `onTransition` is provided and the record isn't in a final state — a row of
* buttons to advance to each reachable next state.
*/
export function RecordStateMachines({ machines }: RecordStateMachinesProps) {
export function RecordStateMachines({
machines,
onTransition,
pendingEvent,
}: RecordStateMachinesProps) {
const { t } = useTranslation();
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>
{machines.map((m) => {
const available: SMTransition[] =
onTransition && m.currentState
? m.transitions.filter((tr) => tr.from === m.currentState)
: [];

return (
<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}
/>

{available.length > 0 && (
<View className="border-t border-border px-4 py-3">
<Text className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("workflow.availableActions")}
</Text>
<View className="gap-2">
{available.map((tr) => {
const isBusy = pendingEvent === `${m.key}:${tr.event}`;
const anyBusy = !!pendingEvent;
return (
<Pressable
key={tr.event}
onPress={() => onTransition?.(m, tr)}
disabled={anyBusy}
accessibilityRole="button"
accessibilityLabel={`${tr.label ?? tr.event} → ${stateLabel(m, tr.to)}`}
className={`flex-row items-center justify-between rounded-lg border border-primary/30 bg-primary/5 px-3 py-2.5 active:bg-primary/10 ${
anyBusy ? "opacity-50" : ""
}`}
>
<View className="flex-1 pr-2">
<Text className="text-sm font-medium text-foreground">
{tr.label ?? tr.event}
</Text>
<Text className="text-xs text-muted-foreground">
→ {stateLabel(m, tr.to)}
</Text>
</View>
{isBusy ? (
<ActivityIndicator size="small" />
) : (
<ArrowRight size={16} color="#1e40af" />
)}
</Pressable>
);
})}
</View>
</View>
)}
</View>
<StateMachineViewer
states={m.states}
transitions={m.transitions}
currentState={m.currentState}
scrollable={false}
/>
</View>
))}
);
})}
</View>
);
}
7 changes: 7 additions & 0 deletions locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@
"previous": "السابق",
"next": "التالي"
},
"workflow": {
"availableActions": "الإجراءات المتاحة",
"updateStatus": "تحديث الحالة",
"moveToConfirm": "نقل هذا السجل إلى \"{{state}}\"؟",
"statusUpdated": "تم تحديث الحالة.",
"statusUpdateFailed": "فشل تحديث الحالة."
},
"actions": {
"title": "إجراءات",
"moreActions": "المزيد من الإجراءات",
Expand Down
7 changes: 7 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
"previous": "Previous",
"next": "Next"
},
"workflow": {
"availableActions": "Available Actions",
"updateStatus": "Update Status",
"moveToConfirm": "Move this record to \"{{state}}\"?",
"statusUpdated": "Status updated.",
"statusUpdateFailed": "Failed to update status."
},
"actions": {
"title": "Actions",
"moreActions": "More actions",
Expand Down
7 changes: 7 additions & 0 deletions locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@
"previous": "上一条",
"next": "下一条"
},
"workflow": {
"availableActions": "可用操作",
"updateStatus": "更新状态",
"moveToConfirm": "将此记录移动到“{{state}}”?",
"statusUpdated": "状态已更新。",
"statusUpdateFailed": "更新状态失败。"
},
"actions": {
"title": "操作",
"moreActions": "更多操作",
Expand Down
Loading