Files
xo-cli/src/tui/screens/action-wizard/hooks/useActionWizard.ts

535 lines
18 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect } from "react";
import { useNavigation } from "../../../hooks/useNavigation.js";
import { useAppContext, useStatus } from "../../../hooks/useAppContext.js";
import { copyToClipboard } from "../../../utils/clipboard.js";
import { roleRequiresInputs } from "../../../../utils/invitation-flow.js";
import type { XOTemplate } from "@xo-cash/types";
import type { StepConfig, FlowContext, DataResult } from "../types.js";
import {
createWizardFlow,
type WizardFlow,
DataWizardFlow,
} from "../flows/index.js";
import { useRoleSelection } from "./useRoleSelection.js";
import { useVariableInputs } from "./useVariableInputs.js";
import { useUtxoSelection } from "./useUtxoSelection.js";
import { useInvitationManager } from "./useInvitationManager.js";
import { useWizardFocus } from "./useWizardFocus.js";
import { useWizardSteps } from "./useWizardSteps.js";
/**
* Thin orchestrator that composes domain hooks and wires them
* to step configs produced by the WizardFlow strategy.
*
* This replaces the original 861-line god-hook.
*/
export function useActionWizard() {
const { goBack, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
if (!appService) {
throw new Error("AppService not initialized");
}
// ── Navigation data ───────────────────────────────────────────
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
// ── Derived template data ─────────────────────────────────────
const action = template?.actions?.[actionIdentifier ?? ""];
const actionName = action?.name || actionIdentifier || "Unknown";
// ── Flow strategy ─────────────────────────────────────────────
const flow = useMemo<WizardFlow>(() => {
// Create a default action if no action is found
if (!action) {
return createWizardFlow({ name: "", description: "" });
}
// Create the flow from the action
return createWizardFlow(action);
}, [action]);
// ── Domain hooks ──────────────────────────────────────────────
const roleSelection = useRoleSelection(
template,
actionIdentifier,
actionRolesFromNavigation,
);
const variableInputs = useVariableInputs();
const utxoSelection = useUtxoSelection();
const invitationManager = useInvitationManager({
appService,
showError,
showInfo,
setStatus,
});
const focus = useWizardFocus();
// ── Data results (data-only flows) ────────────────────────────
const [dataResults, setDataResults] = useState<DataResult[]>([]);
// ── Initialize variables when role becomes available ──────────
useEffect(() => {
if (template && actionIdentifier && roleSelection.effectiveRole) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleSelection.effectiveRole];
const varIds = role?.requirements?.variables;
if (varIds && varIds.length > 0) {
variableInputs.initFromTemplate(
template,
actionIdentifier,
roleSelection.effectiveRole,
);
}
}
}, [
template,
actionIdentifier,
roleSelection.effectiveRole,
variableInputs.initFromTemplate,
]);
// ── Determine whether creator should provide inputs ───────────
const shouldCollectInputs = useMemo(() => {
if (flow.type !== "transaction") return false;
if (!template || !actionIdentifier || !roleSelection.effectiveRole)
return false;
const act = template.actions?.[actionIdentifier];
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const isSingleRoleAction = totalActionRoles <= 1;
return (
isSingleRoleAction &&
roleRequiresInputs(
template,
actionIdentifier,
roleSelection.effectiveRole,
)
);
}, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]);
// ── Build flow context for strategy methods ───────────────────
const flowContext = useMemo<FlowContext>(
() => ({
availableRoles: roleSelection.availableRoles,
hasVariables: variableInputs.variables.length > 0,
shouldCollectInputs,
requirementsComplete: invitationManager.requirementsComplete,
wizardCollectedInputs: shouldCollectInputs,
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
}),
[
roleSelection.availableRoles,
variableInputs.variables.length,
shouldCollectInputs,
invitationManager.requirementsComplete,
invitationManager.hasSignedAndBroadcasted,
],
);
// ── Handle Enter inside a TextInput ───────────────────────────
const handleTextInputSubmit = useCallback(() => {
if (focus.focusedInput < variableInputs.variables.length - 1) {
focus.setFocusedInput((prev) => prev + 1);
} else {
focus.moveToButtons();
}
}, [
focus.focusedInput,
variableInputs.variables.length,
focus.setFocusedInput,
focus.moveToButtons,
]);
// ── Copy invitation ID to clipboard ───────────────────────────
const copyId = useCallback(async () => {
if (!invitationManager.invitationId) return;
try {
await copyToClipboard(invitationManager.invitationId);
showInfo(`Copied to clipboard!\n\n${invitationManager.invitationId}`);
} catch (error) {
showError(
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [invitationManager.invitationId, showInfo, showError]);
// ── Helper: create invitation if it doesn't exist yet ─────────
const ensureInvitation = useCallback(
async (roleId?: string): Promise<string | null> => {
if (invitationManager.invitationId) return invitationManager.invitationId;
const role = roleId ?? roleSelection.effectiveRole;
if (!templateIdentifier || !actionIdentifier || !role || !template)
return null;
return invitationManager.createWithVariables(
templateIdentifier,
actionIdentifier,
role,
template,
variableInputs.variables,
);
},
[
invitationManager.invitationId,
invitationManager.createWithVariables,
roleSelection.effectiveRole,
templateIdentifier,
actionIdentifier,
template,
variableInputs.variables,
],
);
// ── Helper: load UTXOs after invitation is created ────────────
const loadUtxosForInvitation = useCallback(
async (invId: string) => {
if (!appService || !templateIdentifier) return;
const instance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invId,
);
if (instance) {
invitationManager.setIsProcessing(true);
try {
await utxoSelection.loadUtxos(
instance,
templateIdentifier,
variableInputs.variables,
setStatus,
);
} finally {
invitationManager.setIsProcessing(false);
}
}
},
[
appService,
templateIdentifier,
variableInputs.variables,
utxoSelection.loadUtxos,
invitationManager.setIsProcessing,
setStatus,
],
);
// ── Build step configs from flow strategy ─────────────────────
const stepConfigs = useMemo<StepConfig[]>(() => {
const stepTypes = flow.getStepTypes(flowContext);
return stepTypes.map((type): StepConfig => {
switch (type) {
case "role-select":
return {
type,
name: "Select Role",
validate: () => {
const selectedRole =
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
return selectedRole ? null : "Please select a role";
},
onNext: async () => {
const selectedRole =
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
if (!selectedRole) return false;
// Initialize variables for this role immediately
if (template && actionIdentifier) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[selectedRole];
const hasVars =
(role?.requirements?.variables?.length ?? 0) > 0;
if (hasVars) {
variableInputs.initFromTemplate(
template,
actionIdentifier,
selectedRole,
);
}
// If no variables step follows, create the invitation now (transaction flows only)
if (!hasVars && flow.type === "transaction") {
if (templateIdentifier && template) {
const invId = await invitationManager.createWithVariables(
templateIdentifier,
actionIdentifier,
selectedRole,
template,
[],
);
if (!invId) return false;
// Pre-load UTXOs if the inputs step follows
const totalRoles = Object.keys(act?.roles ?? {}).length;
const needsInputs =
totalRoles <= 1 &&
roleRequiresInputs(
template,
actionIdentifier,
selectedRole,
);
if (needsInputs) {
await loadUtxosForInvitation(invId);
}
}
}
}
roleSelection.setRoleIdentifier(selectedRole);
focus.resetToContent();
return true;
},
};
case "variables":
return {
type,
name: "Variables",
validate: () => variableInputs.validate(),
onNext: async () => {
if (flow.type === "transaction") {
if (
!templateIdentifier ||
!actionIdentifier ||
!template ||
!roleSelection.effectiveRole
)
return false;
const invId = await invitationManager.createWithVariables(
templateIdentifier,
actionIdentifier,
roleSelection.effectiveRole,
template,
variableInputs.variables,
);
if (!invId) return false;
// Pre-load UTXOs if the inputs step follows
if (shouldCollectInputs) {
await loadUtxosForInvitation(invId);
}
}
// For data flows, just advance — variables are used in the result step
focus.resetToContent();
return true;
},
};
case "inputs":
return {
type,
name: "Select UTXOs",
validate: () => utxoSelection.validate(),
onNext: async () => {
const selectedUtxos = utxoSelection.availableUtxos.filter(
(u) => u.selected,
);
const success = await invitationManager.addInputsAndOutputs(
selectedUtxos,
utxoSelection.changeAmount,
);
if (success) focus.resetToContent();
return success;
},
};
case "review":
return {
type,
name: "Review",
validate: () => null,
onNext: async () => {
// Ensure invitation exists (covers the case where no prior step created it)
const invId = await ensureInvitation();
if (!invId) return false;
await invitationManager.refreshRequirements(invId);
focus.resetToContent();
return true;
},
};
case "publish":
return {
type,
name: "Publish",
validate: () => null,
onNext: async () => {
if (flow.canFinalize(flowContext)) {
await invitationManager.signAndBroadcast();
// Stay on publish step (it's the last step, stepper won't advance)
return true;
}
goBack();
return true;
},
};
case "result":
return {
type,
name: "Result",
validate: () => null,
onNext: async () => {
// Data-only flows: populate stubbed results, then exit
if (flow instanceof DataWizardFlow) {
const results: DataResult[] = flow.dataOutputs.map((dataId) => {
const dataDef = template?.data?.[dataId];
return {
id: dataId,
name: dataDef?.hint ?? dataId,
type: dataDef?.type ?? "unknown",
hint: dataDef?.hint,
value: null, // Engine-level data execution not yet implemented
};
});
setDataResults(results);
}
goBack();
return true;
},
};
default:
return {
type,
name: type,
validate: () => null,
onNext: async () => true,
};
}
});
}, [
flow,
flowContext,
roleSelection,
variableInputs,
utxoSelection,
invitationManager,
focus,
template,
templateIdentifier,
actionIdentifier,
shouldCollectInputs,
ensureInvitation,
loadUtxosForInvitation,
goBack,
setStatus,
]);
// ── Step navigation ───────────────────────────────────────────
const stepper = useWizardSteps(stepConfigs, goBack, showError);
// ── Set initial status ────────────────────────────────────────
useEffect(() => {
if (!template || !actionIdentifier) {
showError("Missing wizard data");
goBack();
return;
}
setStatus(
roleSelection.effectiveRole
? `${actionIdentifier}/${roleSelection.effectiveRole}`
: actionIdentifier,
);
}, [
template,
actionIdentifier,
roleSelection.effectiveRole,
showError,
goBack,
setStatus,
]);
// ── Convenience derived values ────────────────────────────────
const textInputHasFocus =
stepper.currentStepData?.type === "variables" &&
focus.focusArea === "content";
const canSignAndBroadcast = flow.canFinalize(flowContext);
const isLastStep = stepper.currentStep >= stepper.steps.length - 1;
const lastStepType = stepper.currentStepData?.type;
const nextButtonLabel =
lastStepType === "publish"
? flow.getFinalActionLabel(flowContext)
: lastStepType === "result"
? "Done"
: "Next";
// ── Public API ────────────────────────────────────────────────
return {
// Meta
template,
templateIdentifier,
actionIdentifier,
roleIdentifier: roleSelection.effectiveRole,
action,
actionName,
flow,
flowContext,
// Role selection
availableRoles: roleSelection.availableRoles,
selectedRoleIndex: roleSelection.selectedRoleIndex,
setSelectedRoleIndex: roleSelection.setSelectedRoleIndex,
// Steps
steps: stepper.steps,
currentStep: stepper.currentStep,
currentStepData: stepper.currentStepData,
// Variables
variables: variableInputs.variables,
updateVariable: variableInputs.updateVariable,
handleTextInputSubmit,
// UTXOs
availableUtxos: utxoSelection.availableUtxos,
selectedUtxoIndex: utxoSelection.selectedUtxoIndex,
setSelectedUtxoIndex: utxoSelection.setSelectedUtxoIndex,
requiredAmount: utxoSelection.requiredAmount,
fee: utxoSelection.fee,
selectedAmount: utxoSelection.selectedAmount,
changeAmount: utxoSelection.changeAmount,
toggleUtxoSelection: utxoSelection.toggleSelection,
selectAll: utxoSelection.selectAll,
deselectAll: utxoSelection.deselectAll,
// Invitation
invitation: invitationManager.invitation,
invitationId: invitationManager.invitationId,
requirementsComplete: invitationManager.requirementsComplete,
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
canSignAndBroadcast,
// Data results
dataResults,
// UI focus
focusedInput: focus.focusedInput,
setFocusedInput: focus.setFocusedInput,
focusedButton: focus.focusedButton,
setFocusedButton: focus.setFocusedButton,
focusArea: focus.focusArea,
setFocusArea: focus.setFocusArea,
isProcessing: invitationManager.isProcessing,
textInputHasFocus,
nextButtonLabel,
isLastStep,
// Actions
nextStep: stepper.nextStep,
previousStep: stepper.previousStep,
cancel: stepper.cancel,
copyId,
} as const;
}
/** Convenience type so other files can type the return value. */
export type ActionWizardState = ReturnType<typeof useActionWizard>;