Compare commits
4 Commits
df57f1b9ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66e9918e04 | |||
| 38a0ac436b | |||
| 2c547f766a | |||
| ef169e76db |
@@ -118,16 +118,10 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
const invitationsDb = this.storage.child('invitations');
|
||||
|
||||
// Load invitations from storage
|
||||
console.time('loadInvitations');
|
||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
||||
console.timeEnd('loadInvitations');
|
||||
|
||||
console.time('createInvitations');
|
||||
|
||||
await Promise.all(invitations.map(async ({ key }) => {
|
||||
await this.createInvitation(key);
|
||||
}));
|
||||
|
||||
console.timeEnd('createInvitations');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||
import { hasInvitationExpired } from '@xo-cash/engine';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
|
||||
@@ -31,12 +32,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// Try to get the invitation from the storage
|
||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||
if (invitationFromStorage) {
|
||||
console.log(`Invitation found in storage: ${invitation}`);
|
||||
return this.create(invitationFromStorage, dependencies);
|
||||
}
|
||||
|
||||
// Try to get the invitation from the sync server
|
||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
||||
if (invitationFromSyncServer) {
|
||||
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
||||
console.log(`Invitation found in sync server: ${invitation}`);
|
||||
return this.create(invitationFromSyncServer, dependencies);
|
||||
}
|
||||
|
||||
@@ -86,6 +89,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
*/
|
||||
private storage: Storage;
|
||||
|
||||
/**
|
||||
* True after we have successfully called sign() on this invitation (session-only, not persisted).
|
||||
*/
|
||||
private _weHaveSigned = false;
|
||||
|
||||
/**
|
||||
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
|
||||
*/
|
||||
private _broadcasted = false;
|
||||
|
||||
/**
|
||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||
*/
|
||||
public status: string = 'unknown';
|
||||
|
||||
/**
|
||||
* Create an invitation and start the SSE Session required for it.
|
||||
*/
|
||||
@@ -108,28 +126,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Connect to the sync server and get the invitation (in parallel)
|
||||
console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
|
||||
const [_, invitation] = await Promise.all([
|
||||
this.syncServer.connect(),
|
||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
||||
]);
|
||||
console.timeEnd(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
|
||||
|
||||
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
||||
const sseCommits = this.data.commits;
|
||||
|
||||
console.time(`mergeCommits-${this.data.invitationIdentifier}`);
|
||||
// Merge the commits
|
||||
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
|
||||
console.timeEnd(`mergeCommits-${this.data.invitationIdentifier}`);
|
||||
|
||||
console.time(`setInvitationData-${this.data.invitationIdentifier}`);
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
console.timeEnd(`setInvitationData-${this.data.invitationIdentifier}`);
|
||||
|
||||
// Publish the invitation to the sync server
|
||||
this.syncServer.publishInvitation(this.data);
|
||||
|
||||
// Compute and emit initial status
|
||||
await this.updateStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,8 +173,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// Set the new commits
|
||||
this.data = { ...this.data, commits: newCommits };
|
||||
|
||||
// Calculate the new status of the invitation
|
||||
this.updateStatus();
|
||||
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
|
||||
this.updateStatus().catch(() => {});
|
||||
|
||||
// Emit the updated event
|
||||
this.emit('invitation-updated', this.data);
|
||||
@@ -185,26 +203,79 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// Return the merged commits
|
||||
return Array.from(initialMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of the invitation based on the filled in information
|
||||
* Compute the invitation status as a single word: expired | complete | ready | signed | actionable | unknown.
|
||||
*/
|
||||
private updateStatus(): void {
|
||||
// Calculate the status of the invitation
|
||||
this.emit('invitation-status-changed', 'pending - this is temporary');
|
||||
private async computeStatus(): Promise<string> {
|
||||
try {
|
||||
return await this.computeStatusInternal();
|
||||
} catch (err) {
|
||||
return `error (${err instanceof Error ? err.message : String(err)})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal status computation: returns a single word.
|
||||
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
||||
* - expired: any commit has expired
|
||||
* - complete: we have broadcast this invitation
|
||||
* - ready: no missing requirements and we have signed (ready to broadcast)
|
||||
* - signed: we have signed but there are still missing parts (waiting for others)
|
||||
* - actionable: you can provide data (missing requirements and/or you can sign)
|
||||
* - unknown: template/action not found or error
|
||||
*/
|
||||
private async computeStatusInternal(): Promise<string> {
|
||||
if (hasInvitationExpired(this.data)) {
|
||||
return 'expired';
|
||||
}
|
||||
if (this._broadcasted) {
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
let missingReqs;
|
||||
try {
|
||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const hasMissing =
|
||||
(missingReqs.variables?.length ?? 0) > 0 ||
|
||||
(missingReqs.inputs?.length ?? 0) > 0 ||
|
||||
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
||||
|
||||
if (!hasMissing && this._weHaveSigned) {
|
||||
return 'ready';
|
||||
}
|
||||
if (hasMissing && this._weHaveSigned) {
|
||||
return 'signed';
|
||||
}
|
||||
return 'actionable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of the invitation and emit the new single-word status.
|
||||
*/
|
||||
private async updateStatus(): Promise<void> {
|
||||
const status = await this.computeStatus();
|
||||
this.status = status;
|
||||
this.emit('invitation-status-changed', status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the invitation
|
||||
*/
|
||||
async accept(): Promise<void> {
|
||||
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
||||
// Accept the invitation
|
||||
this.data = await this.engine.acceptInvitation(this.data);
|
||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
this.syncServer.publishInvitation(this.data);
|
||||
|
||||
// Update the status of the invitation
|
||||
this.updateStatus();
|
||||
await this.updateStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,26 +291,26 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// Store the signed invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||
|
||||
this.data = signedInvitation;
|
||||
this._weHaveSigned = true;
|
||||
|
||||
// Update the status of the invitation
|
||||
this.updateStatus();
|
||||
await this.updateStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the invitation
|
||||
*/
|
||||
async broadcast(): Promise<void> {
|
||||
// Broadcast the invitation
|
||||
const broadcastedInvitation = await this.engine.executeAction(this.data, {
|
||||
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
|
||||
await this.engine.executeAction(this.data, {
|
||||
broadcastTransaction: true,
|
||||
});
|
||||
|
||||
// Store the broadcasted invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, broadcastedInvitation);
|
||||
|
||||
// TODO: Am I supposed to do something here?
|
||||
this._broadcasted = true;
|
||||
|
||||
// Update the status of the invitation
|
||||
this.updateStatus();
|
||||
await this.updateStatus();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -260,7 +331,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
|
||||
// Update the status of the invitation
|
||||
this.updateStatus();
|
||||
await this.updateStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,7 +366,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
||||
|
||||
// Update the status of the invitation
|
||||
this.updateStatus();
|
||||
await this.updateStatus();
|
||||
|
||||
// Return the suitable resources
|
||||
return unspentOutputs;
|
||||
|
||||
@@ -18,6 +18,8 @@ import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.j
|
||||
import { InvitationScreen } from './screens/Invitation.js';
|
||||
import { TransactionScreen } from './screens/Transaction.js';
|
||||
|
||||
import { MessageDialog } from './components/Dialog.js';
|
||||
|
||||
/**
|
||||
* Props for the App component.
|
||||
*/
|
||||
@@ -107,28 +109,14 @@ function DialogOverlay(): React.ReactElement | null {
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="double"
|
||||
borderColor={borderColor}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={60}
|
||||
>
|
||||
<Text color={borderColor} bold>
|
||||
{dialog.type === 'error' ? '✗ Error' :
|
||||
<MessageDialog
|
||||
title={dialog.type === 'error' ? '✗ Error' :
|
||||
dialog.type === 'confirm' ? '? Confirm' :
|
||||
'ℹ Info'}
|
||||
</Text>
|
||||
<Box marginY={1}>
|
||||
<Text wrap="wrap">{dialog.message}</Text>
|
||||
</Box>
|
||||
<Text color={colors.textMuted}>
|
||||
{dialog.type === 'confirm'
|
||||
? 'Press Y to confirm, N or ESC to cancel'
|
||||
: 'Press Enter or ESC to close'}
|
||||
</Text>
|
||||
</Box>
|
||||
message={dialog.message}
|
||||
onClose={dialog.onCancel ?? (() => {})}
|
||||
type={dialog.type as 'error' | 'info' | 'success'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Dialog components for modals, confirmations, and input dialogs.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Box, Text, useInput, measureElement } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import { colors } from '../theme.js';
|
||||
|
||||
@@ -23,32 +23,61 @@ interface DialogWrapperProps {
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base dialog wrapper component.
|
||||
*/
|
||||
|
||||
function DialogWrapper({
|
||||
title,
|
||||
borderColor = colors.primary,
|
||||
children,
|
||||
width = 60,
|
||||
backgroundColor = colors.bg,
|
||||
}: DialogWrapperProps): React.ReactElement {
|
||||
const ref = useRef<any>(null);
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
|
||||
// measure after render
|
||||
React.useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
const { height } = measureElement(ref.current);
|
||||
setHeight(height);
|
||||
}
|
||||
}, [children, title, width]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
||||
{/* Opaque backing layer */}
|
||||
{height !== null && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
{Array.from({ length: height }).map((_, i) => (
|
||||
<Text key={i}>{' '.repeat(width)}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Actual dialog */}
|
||||
<Box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
borderStyle="double"
|
||||
borderColor={borderColor}
|
||||
// backgroundColor={backgroundColor || 'white'}
|
||||
backgroundColor="white"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={width}
|
||||
>
|
||||
<Text color={borderColor} bold>{title}</Text>
|
||||
<Text color={borderColor} bold>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Box marginY={1} flexDirection="column">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,462 @@
|
||||
/**
|
||||
* Selectable list component with keyboard navigation.
|
||||
* List components with keyboard navigation.
|
||||
*
|
||||
* Provides:
|
||||
* - ScrollableList: Full-featured list with grouping, filtering, and custom rendering
|
||||
* - List: Basic selectable list (legacy, kept for backward compatibility)
|
||||
* - SimpleList: Non-selectable list for display only
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import { colors } from '../theme.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* List item type.
|
||||
* Base list item data interface.
|
||||
* Used by ScrollableList for item data.
|
||||
*/
|
||||
export interface ListItemData<T = unknown> {
|
||||
/** Unique key for the item */
|
||||
key: string;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Optional secondary text/description */
|
||||
description?: string;
|
||||
/** Optional value associated with item */
|
||||
value?: T;
|
||||
/** Whether item is disabled (can't be activated) */
|
||||
disabled?: boolean;
|
||||
/** Whether item should be hidden (not rendered, skipped in navigation) */
|
||||
hidden?: boolean;
|
||||
/** Custom color name for the item (semantic: 'info', 'warning', 'success', 'error', 'muted') */
|
||||
color?: string;
|
||||
/** Group identifier for grouping items */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group definition for organizing list items.
|
||||
*/
|
||||
export interface ListGroup {
|
||||
/** Unique group identifier */
|
||||
id: string;
|
||||
/** Optional header text to display above group */
|
||||
label?: string;
|
||||
/** Whether to show a separator after this group */
|
||||
separator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ScrollableList component.
|
||||
*/
|
||||
export interface ScrollableListProps<T> {
|
||||
/** Array of list items */
|
||||
items: ListItemData<T>[];
|
||||
/** Currently selected index */
|
||||
selectedIndex: number;
|
||||
/** Handler called when selection changes */
|
||||
onSelect: (index: number) => void;
|
||||
/** Handler called when item is activated (Enter key) */
|
||||
onActivate?: (item: ListItemData<T>, index: number) => void;
|
||||
/** Whether the list is focused for keyboard input */
|
||||
focus?: boolean;
|
||||
/** Maximum number of visible items (enables scrolling). Default: 10 */
|
||||
maxVisible?: number;
|
||||
/** Whether to show a border around the list */
|
||||
border?: boolean;
|
||||
/** Optional label/title for the list */
|
||||
label?: string;
|
||||
/** Message to show when list is empty */
|
||||
emptyMessage?: string;
|
||||
/** Group definitions for organizing items */
|
||||
groups?: ListGroup[];
|
||||
/** Whether to enable filtering/search */
|
||||
filterable?: boolean;
|
||||
/** Placeholder text for filter input */
|
||||
filterPlaceholder?: string;
|
||||
/** Handler called when filter text changes */
|
||||
onFilterChange?: (filter: string) => void;
|
||||
/** Custom render function for items */
|
||||
renderItem?: (item: ListItemData<T>, isSelected: boolean, isFocused: boolean) => React.ReactNode;
|
||||
/** Whether to wrap around when navigating past ends */
|
||||
wrapNavigation?: boolean;
|
||||
/** Whether to show the scroll position indicator (e.g., "1-5 of 10"). Default: true */
|
||||
showScrollIndicator?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Map semantic color names to theme colors.
|
||||
*/
|
||||
function getColorFromName(colorName: string | undefined): string {
|
||||
switch (colorName) {
|
||||
case 'info':
|
||||
return colors.info as string;
|
||||
case 'warning':
|
||||
return colors.warning as string;
|
||||
case 'success':
|
||||
return colors.success as string;
|
||||
case 'error':
|
||||
return colors.error as string;
|
||||
case 'muted':
|
||||
return colors.textMuted as string;
|
||||
case 'accent':
|
||||
return colors.accent as string;
|
||||
case 'primary':
|
||||
return colors.primary as string;
|
||||
default:
|
||||
return colors.text as string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next valid (non-hidden) index in a direction.
|
||||
*
|
||||
* @param items - Array of items
|
||||
* @param currentIndex - Current index
|
||||
* @param direction - Direction to search (1 for down, -1 for up)
|
||||
* @param wrap - Whether to wrap around at ends
|
||||
* @returns Next valid index, or current if none found
|
||||
*/
|
||||
function findNextValidIndex<T>(
|
||||
items: ListItemData<T>[],
|
||||
currentIndex: number,
|
||||
direction: 1 | -1,
|
||||
wrap: boolean = false
|
||||
): number {
|
||||
if (items.length === 0) return 0;
|
||||
|
||||
// Count visible items
|
||||
const visibleIndices = items
|
||||
.map((item, idx) => ({ item, idx }))
|
||||
.filter(({ item }) => !item.hidden)
|
||||
.map(({ idx }) => idx);
|
||||
|
||||
if (visibleIndices.length === 0) return currentIndex;
|
||||
|
||||
// Find current position in visible indices
|
||||
const currentVisiblePos = visibleIndices.indexOf(currentIndex);
|
||||
|
||||
if (currentVisiblePos === -1) {
|
||||
// Current index is hidden, find nearest visible
|
||||
return visibleIndices[0] ?? 0;
|
||||
}
|
||||
|
||||
// Calculate next position
|
||||
let nextVisiblePos = currentVisiblePos + direction;
|
||||
|
||||
if (wrap) {
|
||||
if (nextVisiblePos < 0) nextVisiblePos = visibleIndices.length - 1;
|
||||
if (nextVisiblePos >= visibleIndices.length) nextVisiblePos = 0;
|
||||
} else {
|
||||
nextVisiblePos = Math.max(0, Math.min(visibleIndices.length - 1, nextVisiblePos));
|
||||
}
|
||||
|
||||
return visibleIndices[nextVisiblePos] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scroll window for visible items.
|
||||
*/
|
||||
function calculateScrollWindow(
|
||||
selectedIndex: number,
|
||||
totalItems: number,
|
||||
maxVisible: number
|
||||
): { startIndex: number; endIndex: number } {
|
||||
const halfWindow = Math.floor(maxVisible / 2);
|
||||
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
||||
let endIndex = Math.min(totalItems, startIndex + maxVisible);
|
||||
|
||||
// Adjust start if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible);
|
||||
}
|
||||
|
||||
return { startIndex, endIndex };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ScrollableList Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Full-featured scrollable list with grouping, filtering, and custom rendering.
|
||||
*/
|
||||
export function ScrollableList<T>({
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onActivate,
|
||||
focus = true,
|
||||
maxVisible = 10,
|
||||
border = false,
|
||||
label,
|
||||
emptyMessage = 'No items',
|
||||
groups,
|
||||
filterable = false,
|
||||
filterPlaceholder = 'Filter...',
|
||||
onFilterChange,
|
||||
renderItem,
|
||||
wrapNavigation = false,
|
||||
showScrollIndicator = true,
|
||||
}: ScrollableListProps<T>): React.ReactElement {
|
||||
// Filter state
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [isFiltering, setIsFiltering] = useState(false);
|
||||
|
||||
// Filter items based on filter text
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!filterText.trim()) return items;
|
||||
const lowerFilter = filterText.toLowerCase();
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
hidden: item.hidden || !item.label.toLowerCase().includes(lowerFilter),
|
||||
}));
|
||||
}, [items, filterText]);
|
||||
|
||||
// Get visible (non-hidden) items count
|
||||
const visibleCount = useMemo(() =>
|
||||
filteredItems.filter(item => !item.hidden).length,
|
||||
[filteredItems]
|
||||
);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((input, key) => {
|
||||
if (!focus) return;
|
||||
|
||||
// Toggle filter mode with '/'
|
||||
if (filterable && input === '/' && !isFiltering) {
|
||||
setIsFiltering(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit filter mode with Escape
|
||||
if (isFiltering && key.escape) {
|
||||
setIsFiltering(false);
|
||||
setFilterText('');
|
||||
onFilterChange?.('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't process navigation when filtering
|
||||
if (isFiltering) return;
|
||||
|
||||
// Navigation
|
||||
if (key.upArrow || input === 'k') {
|
||||
const newIndex = findNextValidIndex(filteredItems, selectedIndex, -1, wrapNavigation);
|
||||
onSelect(newIndex);
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
const newIndex = findNextValidIndex(filteredItems, selectedIndex, 1, wrapNavigation);
|
||||
onSelect(newIndex);
|
||||
} else if (key.return && onActivate) {
|
||||
const item = filteredItems[selectedIndex];
|
||||
if (item && !item.disabled && !item.hidden) {
|
||||
onActivate(item, selectedIndex);
|
||||
}
|
||||
}
|
||||
}, { isActive: focus && !isFiltering });
|
||||
|
||||
// Handle filter text change
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
setFilterText(value);
|
||||
onFilterChange?.(value);
|
||||
}, [onFilterChange]);
|
||||
|
||||
// Handle filter submit (Enter in filter mode)
|
||||
const handleFilterSubmit = useCallback(() => {
|
||||
setIsFiltering(false);
|
||||
}, []);
|
||||
|
||||
// Render a single item
|
||||
const renderListItem = (item: ListItemData<T>, index: number) => {
|
||||
if (item.hidden) return null;
|
||||
|
||||
const isSelected = index === selectedIndex;
|
||||
const isFocused = focus && isSelected;
|
||||
|
||||
// Use custom render if provided
|
||||
if (renderItem) {
|
||||
return (
|
||||
<Box key={item.key}>
|
||||
{renderItem(item, isSelected, isFocused)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering
|
||||
const itemColor = isFocused
|
||||
? colors.focus
|
||||
: item.disabled
|
||||
? colors.textMuted
|
||||
: getColorFromName(item.color);
|
||||
|
||||
return (
|
||||
<Box key={item.key}>
|
||||
<Text
|
||||
color={itemColor as string}
|
||||
bold={isSelected}
|
||||
dimColor={item.disabled}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text color={colors.textMuted} dimColor> {item.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Get all visible (non-hidden) items with their original indices
|
||||
const visibleItemsWithIndices = useMemo(() => {
|
||||
return filteredItems
|
||||
.map((item, idx) => ({ item, idx }))
|
||||
.filter(({ item }) => !item.hidden);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Calculate scroll window based on visible items only
|
||||
const scrollWindow = useMemo(() => {
|
||||
// Find position of selected index in visible items
|
||||
const selectedVisiblePos = visibleItemsWithIndices.findIndex(({ idx }) => idx === selectedIndex);
|
||||
const effectivePos = selectedVisiblePos >= 0 ? selectedVisiblePos : 0;
|
||||
|
||||
const halfWindow = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(0, effectivePos - halfWindow);
|
||||
let end = Math.min(visibleItemsWithIndices.length, start + maxVisible);
|
||||
|
||||
// Adjust start if we're near the end
|
||||
if (end - start < maxVisible) {
|
||||
start = Math.max(0, end - maxVisible);
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}, [visibleItemsWithIndices, selectedIndex, maxVisible]);
|
||||
|
||||
// Get the slice of visible items to display
|
||||
const displayItems = useMemo(() => {
|
||||
return visibleItemsWithIndices.slice(scrollWindow.start, scrollWindow.end);
|
||||
}, [visibleItemsWithIndices, scrollWindow]);
|
||||
|
||||
// Render content based on grouping
|
||||
const renderContent = () => {
|
||||
// Show empty message if no visible items
|
||||
if (visibleCount === 0) {
|
||||
return <Text color={colors.textMuted} dimColor>{emptyMessage}</Text>;
|
||||
}
|
||||
|
||||
// If groups are defined, render grouped (but still respect maxVisible)
|
||||
if (groups && groups.length > 0) {
|
||||
// Get display item indices for quick lookup
|
||||
const displayIndices = new Set(displayItems.map(({ idx }) => idx));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{groups.map((group, groupIndex) => {
|
||||
// Filter to only items that are in this group AND in the display window
|
||||
const groupItems = displayItems.filter(({ item }) => item.group === group.id);
|
||||
|
||||
if (groupItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box key={group.id} flexDirection="column">
|
||||
{/* Group label */}
|
||||
{group.label && (
|
||||
<Text color={colors.textMuted} bold>{group.label}</Text>
|
||||
)}
|
||||
|
||||
{/* Group items */}
|
||||
{groupItems.map(({ item, idx }) => renderListItem(item, idx))}
|
||||
|
||||
{/* Separator - only show if there are more groups with items after this */}
|
||||
{group.separator && groupIndex < groups.length - 1 && (
|
||||
<Box marginY={1}>
|
||||
<Text color={colors.textMuted}>────────────────────────</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// No grouping - render with scroll window
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{displayItems.map(({ item, idx }) => renderListItem(item, idx))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const borderColor = focus ? colors.focus : colors.border;
|
||||
|
||||
const content = (
|
||||
<Box flexDirection="column">
|
||||
{/* Filter input */}
|
||||
{filterable && isFiltering && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.info}>Filter: </Text>
|
||||
<TextInput
|
||||
value={filterText}
|
||||
onChange={handleFilterChange}
|
||||
onSubmit={handleFilterSubmit}
|
||||
placeholder={filterPlaceholder}
|
||||
focus={isFiltering}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* List content */}
|
||||
{renderContent()}
|
||||
|
||||
{/* Scroll indicator */}
|
||||
{showScrollIndicator && visibleCount > maxVisible && (
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
{scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Filter hint */}
|
||||
{filterable && !isFiltering && (
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Press '/' to filter
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{label && <Text color={colors.primary} bold>{label}</Text>}
|
||||
{border ? (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
) : content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Legacy List Component (kept for backward compatibility)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Legacy list item type.
|
||||
* @deprecated Use ListItemData instead
|
||||
*/
|
||||
export interface ListItem<T = unknown> {
|
||||
/** Unique key for the item */
|
||||
@@ -23,7 +472,8 @@ export interface ListItem<T = unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the List component.
|
||||
* Props for the legacy List component.
|
||||
* @deprecated Use ScrollableListProps instead
|
||||
*/
|
||||
interface ListProps<T> {
|
||||
/** List items */
|
||||
@@ -46,6 +496,7 @@ interface ListProps<T> {
|
||||
|
||||
/**
|
||||
* Selectable list with keyboard navigation.
|
||||
* @deprecated Use ScrollableList instead
|
||||
*/
|
||||
export function List<T>({
|
||||
items,
|
||||
@@ -132,8 +583,12 @@ export function List<T>({
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SimpleList Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Simple inline list for displaying items without selection.
|
||||
* Props for SimpleList component.
|
||||
*/
|
||||
interface SimpleListProps {
|
||||
items: string[];
|
||||
@@ -141,6 +596,9 @@ interface SimpleListProps {
|
||||
bullet?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple inline list for displaying items without selection.
|
||||
*/
|
||||
export function SimpleList({
|
||||
items,
|
||||
label,
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
export { Screen } from './Screen.js';
|
||||
export { Input, TextDisplay } from './Input.js';
|
||||
export { Button, ButtonRow } from './Button.js';
|
||||
export { List, SimpleList, type ListItem } from './List.js';
|
||||
export {
|
||||
List,
|
||||
SimpleList,
|
||||
ScrollableList,
|
||||
type ListItem,
|
||||
type ListItemData,
|
||||
type ListGroup,
|
||||
type ScrollableListProps,
|
||||
} from './List.js';
|
||||
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
||||
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<Box flexDirection='column' flexGrow={1}>
|
||||
<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}>
|
||||
{/* 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>
|
||||
));
|
||||
})()}
|
||||
|
||||
{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>
|
||||
));
|
||||
})()}
|
||||
|
||||
{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}` : ''}
|
||||
{availableRoles.map((role) => (
|
||||
<Text key={role.roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name}
|
||||
{role.description ? `: ${role.description}` : ''}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -7,25 +7,59 @@
|
||||
* - Navigation to other actions
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||
import type { HistoryItem } from '../../services/history.js';
|
||||
|
||||
// Import utility functions
|
||||
import {
|
||||
formatHistoryListItem,
|
||||
getHistoryItemColorName,
|
||||
formatHistoryDate,
|
||||
type HistoryColorName,
|
||||
} from '../../utils/history-utils.js';
|
||||
|
||||
/**
|
||||
* Map history color name to theme color.
|
||||
*/
|
||||
function getHistoryColor(colorName: HistoryColorName): string {
|
||||
switch (colorName) {
|
||||
case 'info':
|
||||
return colors.info as string;
|
||||
case 'warning':
|
||||
return colors.warning as string;
|
||||
case 'success':
|
||||
return colors.success as string;
|
||||
case 'error':
|
||||
return colors.error as string;
|
||||
case 'muted':
|
||||
return colors.textMuted as string;
|
||||
case 'text':
|
||||
default:
|
||||
return colors.text as string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu action items.
|
||||
*/
|
||||
const menuItems = [
|
||||
{ label: 'New Transaction (from template)', value: 'new-tx' },
|
||||
{ label: 'Import Invitation', value: 'import' },
|
||||
{ label: 'View Invitations', value: 'invitations' },
|
||||
{ label: 'Generate New Address', value: 'new-address' },
|
||||
{ label: 'Refresh', value: 'refresh' },
|
||||
const menuItems: ListItemData<string>[] = [
|
||||
{ key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' },
|
||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
* History list item with HistoryItem value.
|
||||
*/
|
||||
type HistoryListItem = ListItemData<HistoryItem>;
|
||||
|
||||
/**
|
||||
* Wallet State Screen Component.
|
||||
* Displays wallet balance, history, and action menu.
|
||||
@@ -39,6 +73,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
|
||||
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
|
||||
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -124,10 +159,10 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
}, [appService, setStatus, showInfo, showError, refresh]);
|
||||
|
||||
/**
|
||||
* Handles menu selection.
|
||||
* Handles menu action.
|
||||
*/
|
||||
const handleMenuSelect = useCallback((item: { value: string }) => {
|
||||
switch (item.value) {
|
||||
const handleMenuAction = useCallback((action: string) => {
|
||||
switch (action) {
|
||||
case 'new-tx':
|
||||
navigate('templates');
|
||||
break;
|
||||
@@ -146,46 +181,131 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
}
|
||||
}, [navigate, generateNewAddress, refresh]);
|
||||
|
||||
/**
|
||||
* Handle menu item activation.
|
||||
*/
|
||||
const handleMenuItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
||||
if (item.value) {
|
||||
handleMenuAction(item.value);
|
||||
}
|
||||
}, [handleMenuAction]);
|
||||
|
||||
/**
|
||||
* Build history list items for ScrollableList.
|
||||
*/
|
||||
const historyListItems = useMemo((): HistoryListItem[] => {
|
||||
return history.map(item => {
|
||||
const formatted = formatHistoryListItem(item, false);
|
||||
return {
|
||||
key: item.id,
|
||||
label: formatted.label,
|
||||
description: formatted.description,
|
||||
value: item,
|
||||
color: formatted.color,
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
}, [history]);
|
||||
|
||||
// Handle keyboard navigation between panels
|
||||
useInput((input, key) => {
|
||||
if (key.tab) {
|
||||
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
||||
}
|
||||
|
||||
// Navigate history items when focused
|
||||
if (focusedPanel === 'history' && history.length > 0) {
|
||||
if (key.upArrow) {
|
||||
setSelectedHistoryIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedHistoryIndex(prev => Math.min(history.length - 1, prev + 1));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Render custom history list item.
|
||||
*/
|
||||
const renderHistoryItem = useCallback((
|
||||
item: HistoryListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
const historyItem = item.value;
|
||||
if (!historyItem) return null;
|
||||
|
||||
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
|
||||
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
||||
const dateStr = formatHistoryDate(historyItem.timestamp);
|
||||
const indicator = isFocused ? '▸ ' : ' ';
|
||||
|
||||
// Format based on type
|
||||
if (historyItem.type === 'invitation_created') {
|
||||
return (
|
||||
<Box flexDirection='column' flexGrow={1}>
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={itemColor}>
|
||||
{indicator}[Invitation] {historyItem.description}
|
||||
</Text>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
} else if (historyItem.type === 'utxo_reserved') {
|
||||
const sats = historyItem.valueSatoshis ?? 0n;
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={itemColor}>
|
||||
{indicator}[Reserved] {formatSatoshis(sats)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> {historyItem.description}</Text>
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
} else if (historyItem.type === 'utxo_received') {
|
||||
const sats = historyItem.valueSatoshis ?? 0n;
|
||||
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
<Text color={itemColor}>
|
||||
{indicator}{formatSatoshis(sats)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}{historyItem.description}{reservedTag}
|
||||
</Text>
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for other types
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={itemColor}>
|
||||
{indicator}{historyItem.type}: {historyItem.description}
|
||||
</Text>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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} - Wallet Overview</Text>
|
||||
</Box>
|
||||
|
||||
{/* Main content */}
|
||||
<Box flexDirection='row' marginTop={1} flexGrow={1}>
|
||||
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
||||
{/* Left column: Balance */}
|
||||
<Box
|
||||
flexDirection='column'
|
||||
width='50%'
|
||||
flexDirection="column"
|
||||
width="50%"
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={colors.primary}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Balance </Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Total Balance:</Text>
|
||||
{balance ? (
|
||||
<>
|
||||
@@ -205,135 +325,58 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
|
||||
{/* Right column: Actions menu */}
|
||||
<Box
|
||||
flexDirection='column'
|
||||
width='50%'
|
||||
flexDirection="column"
|
||||
width="50%"
|
||||
paddingLeft={1}
|
||||
>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Actions </Text>
|
||||
<Box marginTop={1}>
|
||||
<SelectInput
|
||||
<ScrollableList
|
||||
items={menuItems}
|
||||
onSelect={handleMenuSelect}
|
||||
isFocused={focusedPanel === 'menu'}
|
||||
indicatorComponent={({ isSelected }) => (
|
||||
<Text color={isSelected ? colors.focus : colors.text}>
|
||||
{isSelected ? '▸ ' : ' '}
|
||||
</Text>
|
||||
)}
|
||||
itemComponent={({ isSelected, label }) => (
|
||||
<Text
|
||||
color={isSelected ? colors.text : colors.textMuted}
|
||||
bold={isSelected}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
selectedIndex={selectedMenuIndex}
|
||||
onSelect={setSelectedMenuIndex}
|
||||
onActivate={handleMenuItemActivate}
|
||||
focus={focusedPanel === 'menu'}
|
||||
emptyMessage="No actions"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Wallet History */}
|
||||
<Box marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
width='100%'
|
||||
width="100%"
|
||||
height={14}
|
||||
overflow='hidden'
|
||||
overflow="hidden"
|
||||
>
|
||||
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{isLoading ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
) : history.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No history found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
// Show a scrolling window of items
|
||||
(() => {
|
||||
const maxVisible = 10;
|
||||
const halfWindow = Math.floor(maxVisible / 2);
|
||||
let startIndex = Math.max(0, selectedHistoryIndex - halfWindow);
|
||||
const endIndex = Math.min(history.length, startIndex + maxVisible);
|
||||
// Adjust start if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible);
|
||||
}
|
||||
const visibleItems = history.slice(startIndex, endIndex);
|
||||
|
||||
return visibleItems.map((item, idx) => {
|
||||
const actualIndex = startIndex + idx;
|
||||
const isSelected = actualIndex === selectedHistoryIndex && focusedPanel === 'history';
|
||||
const indicator = isSelected ? '▸ ' : ' ';
|
||||
const dateStr = item.timestamp
|
||||
? new Date(item.timestamp).toLocaleDateString()
|
||||
: '';
|
||||
|
||||
// Format the history item based on type
|
||||
if (item.type === 'invitation_created') {
|
||||
return (
|
||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||
<Text color={isSelected ? colors.focus : colors.text}>
|
||||
{indicator}[Invitation] {item.description}
|
||||
</Text>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
} else if (item.type === 'utxo_reserved') {
|
||||
const sats = item.valueSatoshis ?? 0n;
|
||||
return (
|
||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||
<Box>
|
||||
<Text color={isSelected ? colors.focus : colors.warning}>
|
||||
{indicator}[Reserved] {formatSatoshis(sats)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> {item.description}</Text>
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
} else if (item.type === 'utxo_received') {
|
||||
const sats = item.valueSatoshis ?? 0n;
|
||||
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
||||
return (
|
||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||
<Box flexDirection='row'>
|
||||
<Text color={isSelected ? colors.focus : colors.success}>
|
||||
{indicator}{formatSatoshis(sats)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}{item.description}{reservedTag}
|
||||
</Text>
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for other types
|
||||
return (
|
||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||
<Text color={isSelected ? colors.focus : colors.text}>
|
||||
{indicator}{item.type}: {item.description}
|
||||
</Text>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
})()
|
||||
<ScrollableList
|
||||
items={historyListItems}
|
||||
selectedIndex={selectedHistoryIndex}
|
||||
onSelect={setSelectedHistoryIndex}
|
||||
focus={focusedPanel === 'history'}
|
||||
maxVisible={10}
|
||||
emptyMessage="No history found"
|
||||
renderItem={renderHistoryItem}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||
import { formatSatoshis } from '../../theme.js';
|
||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
|
||||
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||
import type {
|
||||
WizardStep,
|
||||
VariableInput,
|
||||
@@ -323,6 +323,8 @@ export function useActionWizard() {
|
||||
actionIdentifier,
|
||||
});
|
||||
|
||||
console.log(xoInvitation)
|
||||
|
||||
// Wrap and track
|
||||
const invitationInstance =
|
||||
await appService.createInvitation(xoInvitation);
|
||||
@@ -358,8 +360,9 @@ export function useActionWizard() {
|
||||
setStatus('Adding required outputs...');
|
||||
|
||||
const outputsToAdd = transaction.outputs.map(
|
||||
(outputId: string) => ({
|
||||
outputIdentifier: outputId,
|
||||
(output: XOTemplateTransactionOutput) => ({
|
||||
outputIdentifier: output.output,
|
||||
roleIdentifier: roleIdentifier,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
259
src/utils/history-utils.ts
Normal file
259
src/utils/history-utils.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* History utility functions.
|
||||
*
|
||||
* Pure functions for parsing and formatting wallet history data.
|
||||
* These functions have no React dependencies and can be used
|
||||
* in both TUI and CLI contexts.
|
||||
*/
|
||||
|
||||
import type { HistoryItem, HistoryItemType } from '../services/history.js';
|
||||
|
||||
/**
|
||||
* Color names for history item types.
|
||||
* These are semantic color names that can be mapped to actual colors
|
||||
* by the consuming application (TUI or CLI).
|
||||
*/
|
||||
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
|
||||
|
||||
/**
|
||||
* Formatted history list item data.
|
||||
*/
|
||||
export interface FormattedHistoryItem {
|
||||
/** The display label for the history item */
|
||||
label: string;
|
||||
/** Optional secondary description */
|
||||
description?: string;
|
||||
/** The formatted date string */
|
||||
dateStr?: string;
|
||||
/** The semantic color name for this item type */
|
||||
color: HistoryColorName;
|
||||
/** The history item type */
|
||||
type: HistoryItemType;
|
||||
/** Whether the item data is valid */
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the semantic color name for a history item type.
|
||||
*
|
||||
* @param type - The history item type
|
||||
* @param isSelected - Whether the item is currently selected
|
||||
* @returns A semantic color name
|
||||
*/
|
||||
export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName {
|
||||
if (isSelected) return 'info'; // Use focus color when selected
|
||||
|
||||
switch (type) {
|
||||
case 'invitation_created':
|
||||
return 'text';
|
||||
case 'utxo_reserved':
|
||||
return 'warning';
|
||||
case 'utxo_received':
|
||||
return 'success';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a satoshi value for display.
|
||||
*
|
||||
* @param satoshis - The value in satoshis
|
||||
* @returns Formatted string with BCH amount
|
||||
*/
|
||||
export function formatSatoshisValue(satoshis: bigint | number): string {
|
||||
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
||||
const bch = Number(value) / 100_000_000;
|
||||
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display.
|
||||
*
|
||||
* @param timestamp - Unix timestamp in milliseconds
|
||||
* @returns Formatted date string or undefined
|
||||
*/
|
||||
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||
if (!timestamp) return undefined;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a history item for display in a list.
|
||||
*
|
||||
* @param item - The history item to format
|
||||
* @param isSelected - Whether the item is currently selected
|
||||
* @returns Formatted item data for display
|
||||
*/
|
||||
export function formatHistoryListItem(
|
||||
item: HistoryItem | null | undefined,
|
||||
isSelected: boolean = false
|
||||
): FormattedHistoryItem {
|
||||
if (!item) {
|
||||
return {
|
||||
label: '',
|
||||
description: undefined,
|
||||
dateStr: undefined,
|
||||
color: 'muted',
|
||||
type: 'utxo_received',
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const dateStr = formatHistoryDate(item.timestamp);
|
||||
const color = getHistoryItemColorName(item.type, isSelected);
|
||||
|
||||
switch (item.type) {
|
||||
case 'invitation_created':
|
||||
return {
|
||||
label: `[Invitation] ${item.description}`,
|
||||
description: undefined,
|
||||
dateStr,
|
||||
color,
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
case 'utxo_reserved': {
|
||||
const satsStr = item.valueSatoshis !== undefined
|
||||
? formatSatoshisValue(item.valueSatoshis)
|
||||
: 'Unknown amount';
|
||||
return {
|
||||
label: `[Reserved] ${satsStr}`,
|
||||
description: item.description,
|
||||
dateStr,
|
||||
color,
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
case 'utxo_received': {
|
||||
const satsStr = item.valueSatoshis !== undefined
|
||||
? formatSatoshisValue(item.valueSatoshis)
|
||||
: 'Unknown amount';
|
||||
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
||||
return {
|
||||
label: satsStr,
|
||||
description: `${item.description}${reservedTag}`,
|
||||
dateStr,
|
||||
color,
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
label: `${item.type}: ${item.description}`,
|
||||
description: undefined,
|
||||
dateStr,
|
||||
color: 'text',
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a type label for display.
|
||||
*
|
||||
* @param type - The history item type
|
||||
* @returns Human-readable type label
|
||||
*/
|
||||
export function getHistoryTypeLabel(type: HistoryItemType): string {
|
||||
switch (type) {
|
||||
case 'invitation_created':
|
||||
return 'Invitation';
|
||||
case 'utxo_reserved':
|
||||
return 'Reserved';
|
||||
case 'utxo_received':
|
||||
return 'Received';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scrolling window indices for a list.
|
||||
*
|
||||
* @param selectedIndex - Currently selected index
|
||||
* @param totalItems - Total number of items
|
||||
* @param maxVisible - Maximum visible items
|
||||
* @returns Start and end indices for the visible window
|
||||
*/
|
||||
export function calculateScrollWindow(
|
||||
selectedIndex: number,
|
||||
totalItems: number,
|
||||
maxVisible: number
|
||||
): { startIndex: number; endIndex: number } {
|
||||
const halfWindow = Math.floor(maxVisible / 2);
|
||||
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
||||
const endIndex = Math.min(totalItems, startIndex + maxVisible);
|
||||
|
||||
// Adjust start if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible);
|
||||
}
|
||||
|
||||
return { startIndex, endIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a history item is a UTXO-related event.
|
||||
*
|
||||
* @param item - The history item to check
|
||||
* @returns True if the item is UTXO-related
|
||||
*/
|
||||
export function isUtxoEvent(item: HistoryItem): boolean {
|
||||
return item.type === 'utxo_received' || item.type === 'utxo_reserved';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter history items by type.
|
||||
*
|
||||
* @param items - Array of history items
|
||||
* @param types - Types to include
|
||||
* @returns Filtered array
|
||||
*/
|
||||
export function filterHistoryByType(
|
||||
items: HistoryItem[],
|
||||
types: HistoryItemType[]
|
||||
): HistoryItem[] {
|
||||
return items.filter(item => types.includes(item.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics for history items.
|
||||
*
|
||||
* @param items - Array of history items
|
||||
* @returns Summary statistics
|
||||
*/
|
||||
export function getHistorySummary(items: HistoryItem[]): {
|
||||
totalReceived: bigint;
|
||||
totalReserved: bigint;
|
||||
invitationCount: number;
|
||||
utxoCount: number;
|
||||
} {
|
||||
let totalReceived = 0n;
|
||||
let totalReserved = 0n;
|
||||
let invitationCount = 0;
|
||||
let utxoCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'invitation_created':
|
||||
invitationCount++;
|
||||
break;
|
||||
case 'utxo_reserved':
|
||||
totalReserved += item.valueSatoshis ?? 0n;
|
||||
break;
|
||||
case 'utxo_received':
|
||||
totalReceived += item.valueSatoshis ?? 0n;
|
||||
utxoCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalReceived, totalReserved, invitationCount, utxoCount };
|
||||
}
|
||||
264
src/utils/invitation-utils.ts
Normal file
264
src/utils/invitation-utils.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Invitation utility functions.
|
||||
*
|
||||
* Pure functions for parsing and formatting invitation data.
|
||||
* These functions have no React dependencies and can be used
|
||||
* in both TUI and CLI contexts.
|
||||
*/
|
||||
|
||||
import type { Invitation } from '../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Color names for invitation states.
|
||||
* These are semantic color names that can be mapped to actual colors
|
||||
* by the consuming application (TUI or CLI).
|
||||
*/
|
||||
export type StateColorName = 'info' | 'warning' | 'success' | 'error' | 'muted';
|
||||
|
||||
/**
|
||||
* Input data extracted from invitation commits.
|
||||
*/
|
||||
export interface InvitationInput {
|
||||
inputIdentifier?: string;
|
||||
roleIdentifier?: string;
|
||||
entityIdentifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output data extracted from invitation commits.
|
||||
*/
|
||||
export interface InvitationOutput {
|
||||
outputIdentifier?: string;
|
||||
roleIdentifier?: string;
|
||||
valueSatoshis?: bigint;
|
||||
entityIdentifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable data extracted from invitation commits.
|
||||
*/
|
||||
export interface InvitationVariable {
|
||||
variableIdentifier: string;
|
||||
value: unknown;
|
||||
roleIdentifier?: string;
|
||||
entityIdentifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted invitation list item data.
|
||||
*/
|
||||
export interface FormattedInvitationItem {
|
||||
/** The display label for the invitation */
|
||||
label: string;
|
||||
/** The current status of the invitation */
|
||||
status: string;
|
||||
/** The semantic color name for the status */
|
||||
statusColor: StateColorName;
|
||||
/** Whether the invitation data is valid */
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state/status of an invitation.
|
||||
*
|
||||
* @param invitation - The invitation to get state for
|
||||
* @returns The status string
|
||||
*/
|
||||
export function getInvitationState(invitation: Invitation): string {
|
||||
return invitation.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the semantic color name for an invitation state.
|
||||
*
|
||||
* @param state - The invitation state string
|
||||
* @returns A semantic color name
|
||||
*/
|
||||
export function getStateColorName(state: string): StateColorName {
|
||||
switch (state) {
|
||||
case 'created':
|
||||
case 'published':
|
||||
return 'info';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'ready':
|
||||
case 'signed':
|
||||
case 'broadcast':
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'expired':
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all inputs from invitation commits.
|
||||
*
|
||||
* @param invitation - The invitation to extract inputs from
|
||||
* @returns Array of input data
|
||||
*/
|
||||
export function getInvitationInputs(invitation: Invitation): InvitationInput[] {
|
||||
const inputs: InvitationInput[] = [];
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
for (const input of commit.data?.inputs || []) {
|
||||
inputs.push({
|
||||
inputIdentifier: input.inputIdentifier,
|
||||
roleIdentifier: input.roleIdentifier,
|
||||
entityIdentifier: commit.entityIdentifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all outputs from invitation commits.
|
||||
*
|
||||
* @param invitation - The invitation to extract outputs from
|
||||
* @returns Array of output data
|
||||
*/
|
||||
export function getInvitationOutputs(invitation: Invitation): InvitationOutput[] {
|
||||
const outputs: InvitationOutput[] = [];
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
for (const output of commit.data?.outputs || []) {
|
||||
outputs.push({
|
||||
outputIdentifier: output.outputIdentifier,
|
||||
roleIdentifier: output.roleIdentifier,
|
||||
valueSatoshis: output.valueSatoshis,
|
||||
entityIdentifier: commit.entityIdentifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all variables from invitation commits.
|
||||
*
|
||||
* @param invitation - The invitation to extract variables from
|
||||
* @returns Array of variable data
|
||||
*/
|
||||
export function getInvitationVariables(invitation: Invitation): InvitationVariable[] {
|
||||
const variables: InvitationVariable[] = [];
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
for (const variable of commit.data?.variables || []) {
|
||||
variables.push({
|
||||
variableIdentifier: variable.variableIdentifier,
|
||||
value: variable.value,
|
||||
roleIdentifier: variable.roleIdentifier,
|
||||
entityIdentifier: commit.entityIdentifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's role from commits (the role they have accepted).
|
||||
*
|
||||
* @param invitation - The invitation to check
|
||||
* @param userEntityId - The user's entity identifier
|
||||
* @returns The role identifier if found, null otherwise
|
||||
*/
|
||||
export function getUserRole(invitation: Invitation, userEntityId: string | null): string | null {
|
||||
if (!userEntityId) return null;
|
||||
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
if (commit.entityIdentifier === userEntityId) {
|
||||
// Check inputs for role
|
||||
for (const input of commit.data?.inputs || []) {
|
||||
if (input.roleIdentifier) return input.roleIdentifier;
|
||||
}
|
||||
// Check outputs for role
|
||||
for (const output of commit.data?.outputs || []) {
|
||||
if (output.roleIdentifier) return output.roleIdentifier;
|
||||
}
|
||||
// Check variables for role
|
||||
for (const variable of commit.data?.variables || []) {
|
||||
if (variable.roleIdentifier) return variable.roleIdentifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an invitation for display in a list.
|
||||
*
|
||||
* @param invitation - The invitation to format
|
||||
* @param template - Optional template for additional info (name)
|
||||
* @returns Formatted item data for display
|
||||
*/
|
||||
export function formatInvitationListItem(
|
||||
invitation: Invitation,
|
||||
template?: XOTemplate | null
|
||||
): FormattedInvitationItem {
|
||||
// Validate that we have the minimum required data
|
||||
const invitationId = invitation?.data?.invitationIdentifier;
|
||||
const actionId = invitation?.data?.actionIdentifier;
|
||||
|
||||
if (!invitationId || !actionId) {
|
||||
return {
|
||||
label: '',
|
||||
status: 'error',
|
||||
statusColor: 'error',
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const state = getInvitationState(invitation);
|
||||
const templateName = template?.name ?? 'Unknown';
|
||||
const shortId = formatInvitationId(invitationId, 8);
|
||||
|
||||
return {
|
||||
label: `[${state}] ${templateName}-${actionId} (${shortId})`,
|
||||
status: state,
|
||||
statusColor: getStateColorName(state),
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an invitation ID for display (truncated).
|
||||
*
|
||||
* @param id - The full invitation ID
|
||||
* @param maxLength - Maximum length for display
|
||||
* @returns Truncated ID string
|
||||
*/
|
||||
export function formatInvitationId(id: string, maxLength: number = 16): string {
|
||||
if (id.length <= maxLength) return id;
|
||||
const half = Math.floor((maxLength - 3) / 2);
|
||||
return `${id.slice(0, half)}...${id.slice(-half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique entity identifiers from an invitation's commits.
|
||||
*
|
||||
* @param invitation - The invitation to check
|
||||
* @returns Array of unique entity identifiers
|
||||
*/
|
||||
export function getInvitationParticipants(invitation: Invitation): string[] {
|
||||
const participants = new Set<string>();
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
if (commit.entityIdentifier) {
|
||||
participants.add(commit.entityIdentifier);
|
||||
}
|
||||
}
|
||||
return Array.from(participants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a participant in an invitation.
|
||||
*
|
||||
* @param invitation - The invitation to check
|
||||
* @param userEntityId - The user's entity identifier
|
||||
* @returns True if the user has made at least one commit
|
||||
*/
|
||||
export function isUserParticipant(invitation: Invitation, userEntityId: string | null): boolean {
|
||||
if (!userEntityId) return false;
|
||||
return getInvitationParticipants(invitation).includes(userEntityId);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
import { EventEmitter } from "./event-emitter.js";
|
||||
import { SSESession, type SSEvent } from "./sse-client.js";
|
||||
import { decodeExtendedJsonObject, encodeExtendedJson } from "./ext-json.js";
|
||||
import { decodeExtendedJson, decodeExtendedJsonObject, encodeExtendedJson, encodeExtendedJsonObject } from "./ext-json.js";
|
||||
|
||||
export type SyncServerEventMap = {
|
||||
'connected': void;
|
||||
@@ -66,7 +66,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const invitation = decodeExtendedJsonObject(await response.text()) as XOInvitation | undefined;
|
||||
const invitation = decodeExtendedJson(await response.text()) as XOInvitation | undefined;
|
||||
return invitation;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
|
||||
// Read the returned JSON
|
||||
// TODO: This should use zod to verify the response
|
||||
const data = decodeExtendedJsonObject(await response.text()) as XOInvitation;
|
||||
const data = decodeExtendedJson(await response.text()) as XOInvitation;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
246
src/utils/template-utils.ts
Normal file
246
src/utils/template-utils.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Template utility functions.
|
||||
*
|
||||
* Pure functions for parsing and formatting template data.
|
||||
* These functions have no React dependencies and can be used
|
||||
* in both TUI and CLI contexts.
|
||||
*/
|
||||
|
||||
import type { XOTemplate, XOTemplateAction } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Formatted template list item data.
|
||||
*/
|
||||
export interface FormattedTemplateItem {
|
||||
/** The display label for the template */
|
||||
label: string;
|
||||
/** The template description */
|
||||
description?: string;
|
||||
/** Whether the template data is valid */
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted action list item data.
|
||||
*/
|
||||
export interface FormattedActionItem {
|
||||
/** The display label for the action */
|
||||
label: string;
|
||||
/** The action description */
|
||||
description?: string;
|
||||
/** Number of roles that can start this action */
|
||||
roleCount: number;
|
||||
/** Whether the action data is valid */
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique starting action (deduplicated by action identifier).
|
||||
* Multiple roles that can start the same action are counted
|
||||
* but not shown as separate entries.
|
||||
*/
|
||||
export interface UniqueStartingAction {
|
||||
actionIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
roleCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role information from a template.
|
||||
*/
|
||||
export interface TemplateRole {
|
||||
roleId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a template for display in a list.
|
||||
*
|
||||
* @param template - The template to format
|
||||
* @param index - Optional index for numbered display
|
||||
* @returns Formatted item data for display
|
||||
*/
|
||||
export function formatTemplateListItem(
|
||||
template: XOTemplate | null | undefined,
|
||||
index?: number
|
||||
): FormattedTemplateItem {
|
||||
if (!template) {
|
||||
return {
|
||||
label: '',
|
||||
description: undefined,
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const name = template.name || 'Unnamed Template';
|
||||
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
||||
|
||||
return {
|
||||
label: `${prefix}${name}`,
|
||||
description: template.description,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an action for display in a list.
|
||||
*
|
||||
* @param actionId - The action identifier
|
||||
* @param action - The action definition from the template
|
||||
* @param roleCount - Number of roles that can start this action
|
||||
* @param index - Optional index for numbered display
|
||||
* @returns Formatted item data for display
|
||||
*/
|
||||
export function formatActionListItem(
|
||||
actionId: string,
|
||||
action: XOTemplateAction | null | undefined,
|
||||
roleCount: number = 1,
|
||||
index?: number
|
||||
): FormattedActionItem {
|
||||
if (!actionId) {
|
||||
return {
|
||||
label: '',
|
||||
description: undefined,
|
||||
roleCount: 0,
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const name = action?.name || actionId;
|
||||
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
||||
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : '';
|
||||
|
||||
return {
|
||||
label: `${prefix}${name}${roleSuffix}`,
|
||||
description: action?.description,
|
||||
roleCount,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate starting actions from a template.
|
||||
* Multiple roles that can start the same action are counted
|
||||
* but returned as a single entry.
|
||||
*
|
||||
* @param template - The template to process
|
||||
* @param startingActions - Array of { action, role } pairs
|
||||
* @returns Array of unique starting actions with role counts
|
||||
*/
|
||||
export function deduplicateStartingActions(
|
||||
template: XOTemplate,
|
||||
startingActions: Array<{ action: string; role: string }>
|
||||
): UniqueStartingAction[] {
|
||||
const actionMap = new Map<string, UniqueStartingAction>();
|
||||
|
||||
for (const sa of startingActions) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(actionMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles from a template.
|
||||
*
|
||||
* @param template - The template to process
|
||||
* @returns Array of role information
|
||||
*/
|
||||
export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
|
||||
if (!template.roles) return [];
|
||||
|
||||
return Object.entries(template.roles).map(([roleId, role]) => {
|
||||
// Handle case where role might be a string instead of object
|
||||
const roleObj = typeof role === 'object' ? role : null;
|
||||
return {
|
||||
roleId,
|
||||
name: roleObj?.name || roleId,
|
||||
description: roleObj?.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roles that can start a specific action.
|
||||
*
|
||||
* @param template - The template to check
|
||||
* @param actionIdentifier - The action to check
|
||||
* @returns Array of role information for roles that can start this action
|
||||
*/
|
||||
export function getRolesForAction(
|
||||
template: XOTemplate,
|
||||
actionIdentifier: string
|
||||
): TemplateRole[] {
|
||||
const startEntries = (template.start ?? [])
|
||||
.filter((s) => s.action === actionIdentifier);
|
||||
|
||||
return startEntries.map((entry) => {
|
||||
const roleDef = template.roles?.[entry.role];
|
||||
const roleObj = typeof roleDef === 'object' ? roleDef : null;
|
||||
return {
|
||||
roleId: entry.role,
|
||||
name: roleObj?.name || entry.role,
|
||||
description: roleObj?.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template name safely.
|
||||
*
|
||||
* @param template - The template
|
||||
* @returns The template name or a default
|
||||
*/
|
||||
export function getTemplateName(template: XOTemplate | null | undefined): string {
|
||||
return template?.name || 'Unknown Template';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template description safely.
|
||||
*
|
||||
* @param template - The template
|
||||
* @returns The template description or undefined
|
||||
*/
|
||||
export function getTemplateDescription(template: XOTemplate | null | undefined): string | undefined {
|
||||
return template?.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action name safely.
|
||||
*
|
||||
* @param template - The template containing the action
|
||||
* @param actionId - The action identifier
|
||||
* @returns The action name or the action ID as fallback
|
||||
*/
|
||||
export function getActionName(
|
||||
template: XOTemplate | null | undefined,
|
||||
actionId: string
|
||||
): string {
|
||||
return template?.actions?.[actionId]?.name || actionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action description safely.
|
||||
*
|
||||
* @param template - The template containing the action
|
||||
* @param actionId - The action identifier
|
||||
* @returns The action description or undefined
|
||||
*/
|
||||
export function getActionDescription(
|
||||
template: XOTemplate | null | undefined,
|
||||
actionId: string
|
||||
): string | undefined {
|
||||
return template?.actions?.[actionId]?.description;
|
||||
}
|
||||
Reference in New Issue
Block a user