Massive changes. I dont know what happens. Rewrote the action wizard again. Fixed several bugs related to the utxo selection. QR codes were added for address. Add support for data results. Experiment with other methods of role extraction

This commit is contained in:
2026-03-22 13:20:46 +00:00
parent be52f73e64
commit a28d43a68b
35 changed files with 2226 additions and 1169 deletions

View File

@@ -0,0 +1,417 @@
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>;