Invitations screen changes. Scrollable list. Details. And role selection on import
This commit is contained in:
@@ -6,8 +6,9 @@
|
||||
* - Select a template and action to start a new transaction
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { colors, logoSmall } from '../theme.js';
|
||||
@@ -16,18 +17,15 @@ import { colors, logoSmall } from '../theme.js';
|
||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* A unique starting action (deduplicated by action identifier).
|
||||
* Multiple roles that can start the same action are counted
|
||||
* but not shown as separate entries — role selection happens
|
||||
* inside the Action Wizard.
|
||||
*/
|
||||
interface UniqueStartingAction {
|
||||
actionIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
roleCount: number;
|
||||
}
|
||||
// Import utility functions
|
||||
import {
|
||||
formatTemplateListItem,
|
||||
formatActionListItem,
|
||||
deduplicateStartingActions,
|
||||
getTemplateRoles,
|
||||
getRolesForAction,
|
||||
type UniqueStartingAction,
|
||||
} from '../../utils/template-utils.js';
|
||||
|
||||
/**
|
||||
* Template item with metadata.
|
||||
@@ -38,6 +36,16 @@ interface TemplateItem {
|
||||
startingActions: UniqueStartingAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Template list item with TemplateItem value.
|
||||
*/
|
||||
type TemplateListItem = ListItemData<TemplateItem>;
|
||||
|
||||
/**
|
||||
* Action list item with UniqueStartingAction value.
|
||||
*/
|
||||
type ActionListItem = ListItemData<UniqueStartingAction>;
|
||||
|
||||
/**
|
||||
* Template List Screen Component.
|
||||
* Displays templates and their starting actions.
|
||||
@@ -74,27 +82,13 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||
|
||||
// Deduplicate by action identifier — role selection
|
||||
// is handled inside the Action Wizard, not here.
|
||||
const actionMap = new Map<string, UniqueStartingAction>();
|
||||
for (const sa of rawStartingActions) {
|
||||
if (actionMap.has(sa.action)) {
|
||||
actionMap.get(sa.action)!.roleCount++;
|
||||
} else {
|
||||
const actionDef = template.actions?.[sa.action];
|
||||
actionMap.set(sa.action, {
|
||||
actionIdentifier: sa.action,
|
||||
name: actionDef?.name || sa.action,
|
||||
description: actionDef?.description,
|
||||
roleCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Use utility function to deduplicate actions
|
||||
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
||||
|
||||
return {
|
||||
template,
|
||||
templateIdentifier,
|
||||
startingActions: Array.from(actionMap.values()),
|
||||
startingActions,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -119,15 +113,59 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const currentTemplate = templates[selectedTemplateIndex];
|
||||
const currentActions = currentTemplate?.startingActions ?? [];
|
||||
|
||||
/**
|
||||
* Build template list items for ScrollableList.
|
||||
*/
|
||||
const templateListItems = useMemo((): TemplateListItem[] => {
|
||||
return templates.map((item, index) => {
|
||||
const formatted = formatTemplateListItem(item.template, index);
|
||||
return {
|
||||
key: item.templateIdentifier,
|
||||
label: formatted.label,
|
||||
description: formatted.description,
|
||||
value: item,
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
}, [templates]);
|
||||
|
||||
/**
|
||||
* Build action list items for ScrollableList.
|
||||
*/
|
||||
const actionListItems = useMemo((): ActionListItem[] => {
|
||||
return currentActions.map((action, index) => {
|
||||
const formatted = formatActionListItem(
|
||||
action.actionIdentifier,
|
||||
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
||||
action.roleCount,
|
||||
index
|
||||
);
|
||||
return {
|
||||
key: action.actionIdentifier,
|
||||
label: formatted.label,
|
||||
description: formatted.description,
|
||||
value: action,
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
}, [currentActions, currentTemplate]);
|
||||
|
||||
/**
|
||||
* Handle template selection change.
|
||||
*/
|
||||
const handleTemplateSelect = useCallback((index: number) => {
|
||||
setSelectedTemplateIndex(index);
|
||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles action selection.
|
||||
* Navigates to the Action Wizard where the user will choose their role.
|
||||
*/
|
||||
const handleActionSelect = useCallback(() => {
|
||||
if (!currentTemplate || currentActions.length === 0) return;
|
||||
const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
|
||||
if (!currentTemplate || !item.value) return;
|
||||
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return;
|
||||
const action = item.value;
|
||||
|
||||
// Navigate to the Action Wizard — role selection happens there
|
||||
navigate('wizard', {
|
||||
@@ -135,7 +173,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
actionIdentifier: action.actionIdentifier,
|
||||
template: currentTemplate.template,
|
||||
});
|
||||
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
|
||||
}, [currentTemplate, navigate]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((input, key) => {
|
||||
@@ -144,124 +182,111 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down navigation
|
||||
if (key.upArrow || input === 'k') {
|
||||
if (focusedPanel === 'templates') {
|
||||
setSelectedTemplateIndex(prev => Math.max(0, prev - 1));
|
||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||
} else {
|
||||
setSelectedActionIndex(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
if (focusedPanel === 'templates') {
|
||||
setSelectedTemplateIndex(prev => Math.min(templates.length - 1, prev + 1));
|
||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||
} else {
|
||||
setSelectedActionIndex(prev => Math.min(currentActions.length - 1, prev + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Enter to select action
|
||||
if (key.return && focusedPanel === 'actions') {
|
||||
handleActionSelect();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Render custom template list item.
|
||||
*/
|
||||
const renderTemplateItem = useCallback((
|
||||
item: TemplateListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.text}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Render custom action list item.
|
||||
*/
|
||||
const renderActionItem = useCallback((
|
||||
item: ActionListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.text}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box flexDirection='column' flexGrow={1}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Header */}
|
||||
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
||||
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
|
||||
</Box>
|
||||
|
||||
{/* Main content - two columns */}
|
||||
<Box flexDirection='row' marginTop={1} flexGrow={1}>
|
||||
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
||||
{/* Left column: Template list */}
|
||||
<Box flexDirection='column' width='40%' paddingRight={1}>
|
||||
<Box flexDirection="column" width="40%" paddingRight={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Templates </Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
|
||||
{(() => {
|
||||
// Loading State
|
||||
if (isLoading) {
|
||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
||||
}
|
||||
|
||||
// No templates state
|
||||
if (templates.length === 0) {
|
||||
return <Text color={colors.textMuted}>No templates imported</Text>;
|
||||
}
|
||||
|
||||
// Templates state
|
||||
return templates.map((item, index) => (
|
||||
<Text
|
||||
key={item.templateIdentifier}
|
||||
color={index === selectedTemplateIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedTemplateIndex}
|
||||
>
|
||||
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
|
||||
{index + 1}. {item.template.name || 'Unnamed Template'}
|
||||
</Text>
|
||||
));
|
||||
})()}
|
||||
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<ScrollableList
|
||||
items={templateListItems}
|
||||
selectedIndex={selectedTemplateIndex}
|
||||
onSelect={handleTemplateSelect}
|
||||
focus={focusedPanel === 'templates'}
|
||||
emptyMessage="No templates imported"
|
||||
renderItem={renderTemplateItem}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right column: Actions list */}
|
||||
<Box flexDirection='column' width='60%' paddingLeft={1}>
|
||||
<Box flexDirection="column" width="60%" paddingLeft={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
|
||||
{(() => {
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
||||
}
|
||||
|
||||
// No template selected state
|
||||
if (!currentTemplate) {
|
||||
return <Text color={colors.textMuted}>Select a template...</Text>;
|
||||
}
|
||||
|
||||
// No starting actions state
|
||||
if (currentActions.length === 0) {
|
||||
return <Text color={colors.textMuted}>No starting actions available</Text>;
|
||||
}
|
||||
|
||||
// Starting actions state
|
||||
return currentActions.map((action, index) => (
|
||||
<Text
|
||||
key={action.actionIdentifier}
|
||||
color={index === selectedActionIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedActionIndex}
|
||||
>
|
||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
||||
{index + 1}. {action.name}
|
||||
{action.roleCount > 1
|
||||
? ` (${action.roleCount} roles)`
|
||||
: ''}
|
||||
</Text>
|
||||
));
|
||||
})()}
|
||||
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
</Box>
|
||||
) : !currentTemplate ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Select a template...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<ScrollableList
|
||||
items={actionListItems}
|
||||
selectedIndex={selectedActionIndex}
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionActivate}
|
||||
focus={focusedPanel === 'actions'}
|
||||
emptyMessage="No starting actions available"
|
||||
renderItem={renderActionItem}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -269,18 +294,18 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
{/* Description box */}
|
||||
<Box marginTop={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={colors.border}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
width='100%'
|
||||
width="100%"
|
||||
>
|
||||
<Text color={colors.primary} bold> Description </Text>
|
||||
|
||||
{/* Show template description when templates panel is focused */}
|
||||
{focusedPanel === 'templates' && currentTemplate ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text} bold>
|
||||
{currentTemplate.template.name || 'Unnamed Template'}
|
||||
</Text>
|
||||
@@ -293,11 +318,11 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
</Text>
|
||||
)}
|
||||
{currentTemplate.template.roles && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Roles:</Text>
|
||||
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
|
||||
<Text key={roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name || roleId}: {role.description || 'No description'}
|
||||
{getTemplateRoles(currentTemplate.template).map((role) => (
|
||||
<Text key={role.roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name}: {role.description || 'No description'}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -309,14 +334,13 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
|
||||
{/* Show action description when actions panel is focused */}
|
||||
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{(() => {
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return null;
|
||||
|
||||
// Collect all roles that can start this action
|
||||
const startEntries = (currentTemplate.template.start ?? [])
|
||||
.filter((s) => s.action === action.actionIdentifier);
|
||||
// Get roles that can start this action using utility function
|
||||
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -328,18 +352,15 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
</Text>
|
||||
|
||||
{/* List available roles for this action */}
|
||||
{startEntries.length > 0 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{availableRoles.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Available Roles:</Text>
|
||||
{startEntries.map((entry) => {
|
||||
const roleDef = currentTemplate.template.roles?.[entry.role];
|
||||
return (
|
||||
<Text key={entry.role} color={colors.textMuted}>
|
||||
{' '}- {roleDef?.name || entry.role}
|
||||
{roleDef?.description ? `: ${roleDef.description}` : ''}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{availableRoles.map((role) => (
|
||||
<Text key={role.roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name}
|
||||
{role.description ? `: ${role.description}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user