535 lines
18 KiB
TypeScript
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>;
|