diff --git a/src/components/ContactCombobox.stories.tsx b/src/components/ContactCombobox.stories.tsx new file mode 100644 index 0000000..4a97e9c --- /dev/null +++ b/src/components/ContactCombobox.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { within, userEvent, expect } from '@storybook/test'; +import { ContactCombobox, type ContactOption } from './ContactCombobox'; + +const SAMPLE_OPTIONS: ContactOption[] = [ + { address: 'st:xlm:AAAAPAYROLLONE', name: 'Alice (Payroll)' }, + { address: 'st:xlm:BBBBPAYROLLTWO', name: 'Bob (Contractor)' }, + { address: 'st:xlm:CCCCVENDORONE', name: 'Acme Vendor' }, +]; + +function ControlledCombobox(props: Partial[0]>) { + const [value, setValue] = useState(props.value ?? ''); + return ( +
+ +
+ ); +} + +const meta = { + title: 'Stellar/ContactCombobox', + component: ControlledCombobox, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = {}; + +export const WithValue: Story = { + args: { value: 'st:xlm:AAAAPAYROLLONE' }, +}; + +export const Invalid: Story = { + args: { value: 'not-a-meta-address', invalid: true }, +}; + +export const SuggestionsOpen: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await userEvent.type(input, 'a'); + await expect(canvas.getByRole('listbox')).toBeInTheDocument(); + }, +}; diff --git a/src/components/ContactCombobox.tsx b/src/components/ContactCombobox.tsx new file mode 100644 index 0000000..23faafa --- /dev/null +++ b/src/components/ContactCombobox.tsx @@ -0,0 +1,175 @@ +import { useState, useRef, useId, useMemo, useEffect } from 'react'; + +export interface ContactOption { + address: string; + name: string; +} + +export interface ContactComboboxProps { + value: string; + onChange: (value: string) => void; + onSelectOption?: (option: ContactOption) => void; + options: ContactOption[]; + placeholder?: string; + ariaLabel: string; + disabled?: boolean; + invalid?: boolean; + className?: string; +} + +const MAX_SUGGESTIONS = 8; + +/** + * A text input that suggests matching entries from `options` as the person + * types, following the WAI-ARIA "combobox with list autocomplete" pattern: + * https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/ + * + * Free text is always allowed — selecting a suggestion is a shortcut, not a + * requirement, since recipients won't always be saved contacts. + */ +export function ContactCombobox({ + value, + onChange, + onSelectOption, + options, + placeholder, + ariaLabel, + disabled = false, + invalid = false, + className = '', +}: ContactComboboxProps) { + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const containerRef = useRef(null); + const baseId = useId(); + const listboxId = `${baseId}-listbox`; + + const filtered = useMemo(() => { + const query = value.trim().toLowerCase(); + const matches = query + ? options.filter( + (o) => o.name.toLowerCase().includes(query) || o.address.toLowerCase().includes(query), + ) + : options; + return matches.slice(0, MAX_SUGGESTIONS); + }, [options, value]); + + const showListbox = open && !disabled && filtered.length > 0; + + useEffect(() => { + if (!showListbox) setActiveIndex(-1); + }, [showListbox]); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + const selectOption = (option: ContactOption) => { + onChange(option.address); + onSelectOption?.(option); + setOpen(false); + setActiveIndex(-1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!open) { + setOpen(true); + return; + } + setActiveIndex((i) => (filtered.length === 0 ? -1 : (i + 1) % filtered.length)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!open) { + setOpen(true); + return; + } + setActiveIndex((i) => + filtered.length === 0 ? -1 : (i - 1 + filtered.length) % filtered.length, + ); + } else if (e.key === 'Enter') { + if (showListbox && activeIndex >= 0 && activeIndex < filtered.length) { + e.preventDefault(); + selectOption(filtered[activeIndex]); + } + } else if (e.key === 'Escape') { + if (open) { + e.preventDefault(); + setOpen(false); + setActiveIndex(-1); + } + } + }; + + const activeOptionId = + showListbox && activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined; + + return ( +
+ { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className={[ + 'h-9 w-full border bg-surface px-2.5 font-mono text-xs text-primary placeholder:text-outline focus:outline-none', + invalid ? 'border-error' : 'border-outline-variant focus:border-primary', + disabled ? 'opacity-50' : '', + className, + ].join(' ')} + /> + + {showListbox && ( +
+ {filtered.map((option, i) => ( +
{ + // preventDefault keeps focus on the input so the click doesn't + // fire a blur before the selection is applied. + e.preventDefault(); + selectOption(option); + }} + onMouseEnter={() => setActiveIndex(i)} + className={[ + 'flex flex-col gap-0.5 px-3 py-2 cursor-pointer', + i === activeIndex ? 'bg-surface-bright' : '', + ].join(' ')} + > + {option.name} + {option.address} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/TemplateImportConflictModal.tsx b/src/components/TemplateImportConflictModal.tsx new file mode 100644 index 0000000..eb0407b --- /dev/null +++ b/src/components/TemplateImportConflictModal.tsx @@ -0,0 +1,69 @@ +import { type TemplateImportConflict } from '@/store/splitTemplatesStore'; + +interface TemplateImportConflictModalProps { + conflicts: TemplateImportConflict[]; + onResolve: (action: 'keep-all' | 'overwrite-all') => void; + onClose: () => void; +} + +export function TemplateImportConflictModal({ + conflicts, + onResolve, + onClose, +}: TemplateImportConflictModalProps) { + return ( +
+
+

+ Import Conflicts +

+

+ {conflicts.length} template{conflicts.length !== 1 ? 's' : ''} already exist with + different content. +

+ +
+ {conflicts.map((c) => ( +
+
+
+ + Current + +

{c.existingName || '(empty)'}

+
+
+ + Incoming + +

{c.incomingName || '(empty)'}

+
+
+
+ ))} +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/lib/stellar/batchSend.test.ts b/src/lib/stellar/batchSend.test.ts index 098b327..575c910 100644 --- a/src/lib/stellar/batchSend.test.ts +++ b/src/lib/stellar/batchSend.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { parseCsvRows, validateRow, validateRows, MAX_BATCH_ROWS } from './batchSend'; +import { + parseCsvRows, + serializeRowsToCsv, + validateRow, + validateRows, + MAX_BATCH_ROWS, +} from './batchSend'; import type { BatchRow } from './batchSend'; // --------------------------------------------------------------------------- @@ -294,6 +300,63 @@ describe('validateRows', () => { }); }); +// --------------------------------------------------------------------------- +// serializeRowsToCsv +// --------------------------------------------------------------------------- + +describe('serializeRowsToCsv', () => { + it('returns empty string for empty input', () => { + expect(serializeRowsToCsv([])).toBe(''); + }); + + it('serializes a two-column row when memo is empty', () => { + const csv = serializeRowsToCsv([{ metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' }]); + expect(csv).toBe('st:xlm:AAA,10'); + }); + + it('serializes a three-column row when memo is present', () => { + const csv = serializeRowsToCsv([ + { metaAddress: 'st:xlm:AAA', amountRaw: '5.5', memo: 'payment-1' }, + ]); + expect(csv).toBe('st:xlm:AAA,5.5,payment-1'); + }); + + it('joins multiple rows with newlines', () => { + const csv = serializeRowsToCsv([ + { metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' }, + { metaAddress: 'st:xlm:BBB', amountRaw: '5.5', memo: 'payment-1' }, + ]); + expect(csv).toBe('st:xlm:AAA,10\nst:xlm:BBB,5.5,payment-1'); + }); + + it('quotes fields containing commas', () => { + const csv = serializeRowsToCsv([ + { metaAddress: 'st:xlm:AAA,extra', amountRaw: '10', memo: '' }, + ]); + expect(csv).toBe('"st:xlm:AAA,extra",10'); + }); + + it('escapes embedded double quotes', () => { + const csv = serializeRowsToCsv([{ metaAddress: 'st:xlm:AAA"B', amountRaw: '5', memo: '' }]); + expect(csv).toBe('"st:xlm:AAA""B",5'); + }); + + it('round-trips through parseCsvRows', () => { + const original = [ + { metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' }, + { metaAddress: 'st:xlm:BBB', amountRaw: '5.5', memo: 'payment-1' }, + { metaAddress: 'st:xlm:AAA,extra', amountRaw: '2', memo: 'has "quotes"' }, + ]; + const parsed = parseCsvRows(serializeRowsToCsv(original)); + expect(parsed).toHaveLength(original.length); + parsed.forEach((row, i) => { + expect(row.metaAddress).toBe(original[i].metaAddress); + expect(row.amountRaw).toBe(original[i].amountRaw); + expect(row.memo).toBe(original[i].memo); + }); + }); +}); + // --------------------------------------------------------------------------- // MAX_BATCH_ROWS constant // --------------------------------------------------------------------------- diff --git a/src/lib/stellar/batchSend.ts b/src/lib/stellar/batchSend.ts index 3231a5d..cf2f8f1 100644 --- a/src/lib/stellar/batchSend.ts +++ b/src/lib/stellar/batchSend.ts @@ -152,6 +152,34 @@ function isHeaderRow(cols: string[]): boolean { ); } +/** Quote a CSV field if it contains a comma, quote, or newline. */ +function escapeCsvField(value: string): string { + if (/[",\n]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** + * Serialize rows back into CSV text — the inverse of {@link parseCsvRows}. + * + * Used to keep the CSV textarea in sync when rows are edited through a + * structured UI (e.g. the per-row recipient combobox) instead of pasted + * directly, so the existing validate/submit pipeline keeps working unchanged. + */ +export function serializeRowsToCsv( + rows: Array>, +): string { + return rows + .map((row) => { + const cols = row.memo + ? [row.metaAddress, row.amountRaw, row.memo] + : [row.metaAddress, row.amountRaw]; + return cols.map(escapeCsvField).join(','); + }) + .join('\n'); +} + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- diff --git a/src/main.tsx b/src/main.tsx index 80a0bf6..792c0d2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -25,6 +25,7 @@ import { StellarWalletProvider } from '@/context/StellarWalletContext'; import { ThemeProvider, useTheme } from '@/context/ThemeContext'; import { ScanStrategyProvider } from '@/context/ScanStrategyContext'; import { ContactsProvider } from '@/store/contactsStore'; +import { SplitTemplatesProvider } from '@/store/splitTemplatesStore'; import { NameHistoryProvider } from '@/store/nameHistoryStore'; import { wagmiConfig } from '@/config'; import { App } from './App'; @@ -76,9 +77,11 @@ function Providers({ children }: { children: React.ReactNode }) { - - {children} - + + + {children} + + diff --git a/src/pages/StellarSplit.tsx b/src/pages/StellarSplit.tsx index 67576af..6ea249d 100644 --- a/src/pages/StellarSplit.tsx +++ b/src/pages/StellarSplit.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { StellarLink } from '@/components/StellarLink'; @@ -6,7 +6,22 @@ import { trackEvent } from '@/lib/telemetry'; import { STELLAR_NETWORK } from '@/config'; import type { StellarAssetKey } from '@/lib/stellar/assets'; import type { BatchRow, BatchSendResult } from '@/lib/stellar/batchSend'; -import { parseCsvRows, validateRows, sendBatch, MAX_BATCH_ROWS } from '@/lib/stellar/batchSend'; +import { + parseCsvRows, + serializeRowsToCsv, + validateRow, + validateRows, + sendBatch, + MAX_BATCH_ROWS, +} from '@/lib/stellar/batchSend'; +import { useContacts } from '@/store/contactsStore'; +import { + useSplitTemplates, + type SplitTemplate, + type TemplateImportConflict, +} from '@/store/splitTemplatesStore'; +import { ContactCombobox, type ContactOption } from '@/components/ContactCombobox'; +import { TemplateImportConflictModal } from '@/components/TemplateImportConflictModal'; // --------------------------------------------------------------------------- // CSV placeholder @@ -118,12 +133,28 @@ function StatusLabel({ status }: { status: BatchRow['status'] }) { // Preview table // --------------------------------------------------------------------------- +type RowField = 'metaAddress' | 'amountRaw' | 'memo'; + interface PreviewTableProps { rows: BatchRow[]; assetKey: StellarAssetKey; + /** When set, Meta-Address/Amount/Memo become editable and a Remove column is shown. */ + editable?: boolean; + contactOptions?: ContactOption[]; + onFieldInput?: (rowIndex: number, field: RowField, value: string) => void; + onFieldCommit?: (rowIndex: number, field: RowField) => void; + onRemoveRow?: (rowIndex: number) => void; } -function PreviewTable({ rows, assetKey }: PreviewTableProps) { +function PreviewTable({ + rows, + assetKey, + editable = false, + contactOptions = [], + onFieldInput, + onFieldCommit, + onRemoveRow, +}: PreviewTableProps) { if (rows.length === 0) return null; return ( @@ -143,9 +174,14 @@ function PreviewTable({ rows, assetKey }: PreviewTableProps) { Amount ({assetKey}) - - Error + + {editable ? 'Memo' : 'Error'} + {editable && ( + + Remove + + )} @@ -168,21 +204,75 @@ function PreviewTable({ rows, assetKey }: PreviewTableProps) { - - - {row.metaAddress || empty} - - - - {row.amountRaw || } - - {row.error || null} + + {editable ? ( + <> + + onFieldInput?.(row.index, 'metaAddress', value)} + onSelectOption={() => onFieldCommit?.(row.index, 'metaAddress')} + /> + + + onFieldInput?.(row.index, 'amountRaw', e.target.value)} + onBlur={() => onFieldCommit?.(row.index, 'amountRaw')} + className={[ + 'h-9 w-full border bg-surface px-2.5 font-mono text-xs text-primary placeholder:text-outline focus:outline-none', + row.status === 'invalid' + ? 'border-error' + : 'border-outline-variant focus:border-primary', + ].join(' ')} + /> + + + onFieldInput?.(row.index, 'memo', e.target.value)} + onBlur={() => onFieldCommit?.(row.index, 'memo')} + className="h-9 w-full border border-outline-variant bg-surface px-2.5 font-mono text-xs text-primary placeholder:text-outline focus:border-primary focus:outline-none" + /> + + + + + + ) : ( + <> + + + {row.metaAddress || empty} + + + + {row.amountRaw || } + + {row.error || null} + + )} ))} @@ -275,6 +365,40 @@ export default function StellarSplit() { const [error, setError] = useState(''); const [result, setResult] = useState(null); + // Set right before a programmatic setCsvText() so the csvText-changed effect + // below (which resets validation on manual textarea edits) skips this one — + // the rows already reflect the CSV we just wrote. + const skipNextCsvReset = useRef(false); + + // Contacts feeding the per-row recipient combobox + const { contacts } = useContacts(); + const contactOptions: ContactOption[] = useMemo( + () => + contacts + .filter((c) => c.address.startsWith('st:xlm:')) + .map((c) => ({ address: c.address, name: c.name })), + [contacts], + ); + + // Saved batch templates + const { + templates, + saveTemplate, + renameTemplate, + deleteTemplate, + duplicateTemplate, + exportTemplates, + importTemplates, + } = useSplitTemplates(); + const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false); + const [templateNameDraft, setTemplateNameDraft] = useState(''); + const [renamingTemplateId, setRenamingTemplateId] = useState(null); + const [renameDraft, setRenameDraft] = useState(''); + const [templateMessage, setTemplateMessage] = useState(''); + const [pendingImportJson, setPendingImportJson] = useState(null); + const [importConflicts, setImportConflicts] = useState(null); + const templateFileInputRef = useRef(null); + // --------------------------------------------------------------------------- // Derived state // --------------------------------------------------------------------------- @@ -368,6 +492,10 @@ export default function StellarSplit() { // Re-run validation whenever CSV text changes after a first validation useEffect(() => { + if (skipNextCsvReset.current) { + skipNextCsvReset.current = false; + return; + } if (phase === 'validated' || phase === 'done') { setPhase('idle'); setRows([]); @@ -377,6 +505,194 @@ export default function StellarSplit() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [csvText]); + // --------------------------------------------------------------------------- + // Per-row editing (combobox / amount / memo) + // --------------------------------------------------------------------------- + + /** Push an edited `rows` array back into the CSV textarea without triggering a re-validate. */ + const syncRowsToCsv = useCallback((nextRows: BatchRow[]) => { + skipNextCsvReset.current = true; + setCsvText(serializeRowsToCsv(nextRows)); + }, []); + + const handleRowFieldInput = useCallback( + (rowIndex: number, field: RowField, value: string) => { + const next = rows.map((r) => + r.index === rowIndex ? { ...r, [field]: value, status: 'idle' as const, error: '' } : r, + ); + setRows(next); + syncRowsToCsv(next); + }, + [rows, syncRowsToCsv], + ); + + const handleRowFieldCommit = useCallback( + (rowIndex: number) => { + const next = rows.map((r) => (r.index === rowIndex ? validateRow(r, ASSET_KEY) : r)); + setRows(next); + syncRowsToCsv(next); + }, + [rows, syncRowsToCsv], + ); + + const handleAddRow = useCallback(() => { + setResult(null); + setError(''); + const nextIndex = rows.length ? Math.max(...rows.map((r) => r.index)) + 1 : 1; + const blank = validateRow( + { index: nextIndex, metaAddress: '', amountRaw: '', memo: '', error: '', status: 'idle' }, + ASSET_KEY, + ); + const next = [...rows, blank]; + setRows(next); + syncRowsToCsv(next); + setPhase('validated'); + }, [rows, syncRowsToCsv]); + + const handleRemoveRow = useCallback( + (rowIndex: number) => { + const next = rows.filter((r) => r.index !== rowIndex); + setRows(next); + syncRowsToCsv(next); + }, + [rows, syncRowsToCsv], + ); + + // --------------------------------------------------------------------------- + // Templates + // --------------------------------------------------------------------------- + + const templateRows = useMemo( + () => rows.map((r) => ({ metaAddress: r.metaAddress, amountRaw: r.amountRaw, memo: r.memo })), + [rows], + ); + + const handleSaveTemplate = useCallback(() => { + if (!templateNameDraft.trim() || templateRows.length === 0) return; + saveTemplate(templateNameDraft, templateRows); + setTemplateNameDraft(''); + setShowSaveTemplateDialog(false); + setTemplateMessage('Template saved.'); + }, [templateNameDraft, templateRows, saveTemplate]); + + const handleLoadTemplate = useCallback( + (template: SplitTemplate) => { + const loaded = validateRows( + template.rows.map((r, i) => ({ + index: i + 1, + metaAddress: r.metaAddress, + amountRaw: r.amountRaw, + memo: r.memo ?? '', + error: '', + status: 'idle' as const, + })), + ASSET_KEY, + ); + setResult(null); + setError(''); + setRows(loaded); + syncRowsToCsv(loaded); + setPhase('validated'); + setTemplateMessage(`Loaded "${template.name}".`); + trackEvent('batch_template_loaded'); + }, + [syncRowsToCsv], + ); + + const handleStartRenameTemplate = useCallback((template: SplitTemplate) => { + setRenamingTemplateId(template.id); + setRenameDraft(template.name); + }, []); + + const handleConfirmRenameTemplate = useCallback(() => { + if (!renamingTemplateId || !renameDraft.trim()) return; + renameTemplate(renamingTemplateId, renameDraft); + setRenamingTemplateId(null); + setRenameDraft(''); + }, [renamingTemplateId, renameDraft, renameTemplate]); + + const handleDuplicateTemplate = useCallback( + (template: SplitTemplate) => { + duplicateTemplate(template.id); + setTemplateMessage(`Duplicated "${template.name}".`); + }, + [duplicateTemplate], + ); + + const handleDeleteTemplate = useCallback( + (template: SplitTemplate) => { + deleteTemplate(template.id); + setTemplateMessage(`Deleted "${template.name}".`); + }, + [deleteTemplate], + ); + + const handleExportTemplates = useCallback(() => { + const json = exportTemplates(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `wraith-split-templates-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }, [exportTemplates]); + + const runImport = useCallback( + (json: string, overwriteConflicts: boolean) => { + try { + const result = importTemplates(json, overwriteConflicts); + if (result.conflicts.length > 0) { + setPendingImportJson(json); + setImportConflicts(result.conflicts); + return; + } + setPendingImportJson(null); + setImportConflicts(null); + setTemplateMessage( + result.imported > 0 + ? `Imported ${result.imported} template${result.imported !== 1 ? 's' : ''}.` + : 'Templates already up to date.', + ); + } catch (err) { + setTemplateMessage(err instanceof Error ? err.message : 'Failed to import templates.'); + } + }, + [importTemplates], + ); + + const handleImportFileSelected = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result; + if (typeof text === 'string') runImport(text, false); + }; + reader.readAsText(file); + }, + [runImport], + ); + + const handleResolveImportConflicts = useCallback( + (action: 'keep-all' | 'overwrite-all') => { + if (pendingImportJson) { + if (action === 'overwrite-all') { + runImport(pendingImportJson, true); + } else { + // Non-conflicting templates from this import were already saved on + // the first pass — only the conflicting ones are left as-is. + setTemplateMessage('Kept your existing templates.'); + } + } + setImportConflicts(null); + setPendingImportJson(null); + }, + [pendingImportJson, runImport], + ); + // --------------------------------------------------------------------------- // Not connected // --------------------------------------------------------------------------- @@ -479,6 +795,133 @@ export default function StellarSplit() {

+ {/* Templates */} +
+
+ + Templates + +
+ + + +
+
+ + {templates.length === 0 ? ( +

+ No saved templates yet. Validate a batch below, then save it as a template to reuse next + time. +

+ ) : ( +
    + {templates.map((template) => ( +
  • + {renamingTemplateId === template.id ? ( +
    + + setRenameDraft(e.target.value)} + autoFocus + className="h-8 flex-1 min-w-0 border border-outline-variant bg-surface px-2 font-mono text-xs text-primary focus:border-primary focus:outline-none" + /> + + +
    + ) : ( + <> +
    + + {template.name} + + + {template.rows.length} recipient{template.rows.length !== 1 ? 's' : ''} + +
    +
    + + + + +
    + + )} +
  • + ))} +
+ )} + + {templateMessage && ( +

+ {templateMessage} +

+ )} +
+ {/* CSV input */}
@@ -516,14 +959,23 @@ export default function StellarSplit() { {/* Validate button */} {phase === 'idle' && ( - +
+ + +
)} {/* Validation error */} @@ -537,7 +989,71 @@ export default function StellarSplit() { {hasRows && (
- + + {phase === 'validated' && ( +
+ + · + +
+ )} + + {showSaveTemplateDialog && ( +
+ + setTemplateNameDraft(e.target.value)} + placeholder="e.g. Monthly payroll" + className="h-10 w-full border border-outline-variant bg-surface px-3 font-mono text-sm text-primary placeholder:text-outline focus:border-primary focus:outline-none" + autoFocus + /> +
+ + +
+
+ )}
)} @@ -617,6 +1133,17 @@ export default function StellarSplit() {
)} + + {importConflicts && importConflicts.length > 0 && ( + { + setImportConflicts(null); + setPendingImportJson(null); + }} + /> + )} ); } diff --git a/src/store/splitTemplatesStore.test.ts b/src/store/splitTemplatesStore.test.ts new file mode 100644 index 0000000..bae3ea6 --- /dev/null +++ b/src/store/splitTemplatesStore.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveTemplateImport, + templatesEqual, + isValidTemplate, + type SplitTemplate, +} from './splitTemplatesStore'; + +function makeTemplate(overrides: Partial = {}): SplitTemplate { + return { + id: 'tpl_1', + name: 'Payroll', + rows: [ + { metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' }, + { metaAddress: 'st:xlm:BBB', amountRaw: '5.5', memo: 'payment-1' }, + ], + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// isValidTemplate +// --------------------------------------------------------------------------- + +describe('isValidTemplate', () => { + it('accepts a well-formed template', () => { + expect(isValidTemplate(makeTemplate())).toBe(true); + }); + + it('accepts a template with rows that omit memo', () => { + expect( + isValidTemplate(makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '10' }] })), + ).toBe(true); + }); + + it.each([ + ['null', null], + ['not an object', 'nope'], + ['missing id', { name: 'x', rows: [], createdAt: 1, updatedAt: 1 }], + ['missing rows', { id: '1', name: 'x', createdAt: 1, updatedAt: 1 }], + ['non-array rows', { id: '1', name: 'x', rows: 'nope', createdAt: 1, updatedAt: 1 }], + [ + 'row missing amountRaw', + { id: '1', name: 'x', rows: [{ metaAddress: 'a' }], createdAt: 1, updatedAt: 1 }, + ], + ])('rejects %s', (_label, value) => { + expect(isValidTemplate(value)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// templatesEqual +// --------------------------------------------------------------------------- + +describe('templatesEqual', () => { + it('treats identical templates as equal', () => { + expect(templatesEqual(makeTemplate(), makeTemplate())).toBe(true); + }); + + it('treats an empty memo and an omitted memo as equal', () => { + const a = makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '10', memo: '' }] }); + const b = makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '10' }] }); + expect(templatesEqual(a, b)).toBe(true); + }); + + it('detects a name difference', () => { + expect(templatesEqual(makeTemplate(), makeTemplate({ name: 'Vendors' }))).toBe(false); + }); + + it('detects a row difference', () => { + const b = makeTemplate({ rows: [{ metaAddress: 'st:xlm:AAA', amountRaw: '999', memo: '' }] }); + expect(templatesEqual(makeTemplate(), b)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resolveTemplateImport — export/import round trip (issue #155 acceptance) +// --------------------------------------------------------------------------- + +describe('resolveTemplateImport', () => { + it('round-trips a bare-array export back through import with no duplicates or conflicts', () => { + const existing = [makeTemplate({ id: 'a' }), makeTemplate({ id: 'b', name: 'Vendors' })]; + const exported = JSON.stringify(existing); + + const result = resolveTemplateImport(existing, exported); + + expect(result.conflicts).toEqual([]); + expect(result.imported).toBe(0); + expect(result.skipped).toBe(2); + expect(result.next).toHaveLength(2); + }); + + it('round-trips the enveloped export format ({ type, version, templates })', () => { + const existing = [makeTemplate({ id: 'a' })]; + const exported = JSON.stringify({ + type: 'wraith-split-templates', + version: 1, + templates: existing, + }); + + const result = resolveTemplateImport(existing, exported); + + expect(result.imported).toBe(0); + expect(result.skipped).toBe(1); + expect(result.next).toHaveLength(1); + }); + + it('imports templates that do not exist locally yet', () => { + const remote = JSON.stringify([makeTemplate({ id: 'remote-1', name: 'From teammate' })]); + + const result = resolveTemplateImport([], remote); + + expect(result.imported).toBe(1); + expect(result.skipped).toBe(0); + expect(result.conflicts).toEqual([]); + expect(result.next[0].name).toBe('From teammate'); + }); + + it('reports a conflict when the same id exists locally with different content', () => { + const local = [makeTemplate({ id: 'a', name: 'Payroll' })]; + const remote = JSON.stringify([makeTemplate({ id: 'a', name: 'Payroll (edited elsewhere)' })]); + + const result = resolveTemplateImport(local, remote); + + expect(result.imported).toBe(0); + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toEqual({ + id: 'a', + existingName: 'Payroll', + incomingName: 'Payroll (edited elsewhere)', + }); + // Existing template is left untouched until the caller resolves the conflict. + expect(result.next[0].name).toBe('Payroll'); + }); + + it('overwrites conflicting templates when overwriteConflicts is true', () => { + const local = [makeTemplate({ id: 'a', name: 'Payroll' })]; + const remote = JSON.stringify([makeTemplate({ id: 'a', name: 'Payroll (edited elsewhere)' })]); + + const result = resolveTemplateImport(local, remote, true); + + expect(result.imported).toBe(1); + expect(result.conflicts).toEqual([]); + expect(result.next[0].name).toBe('Payroll (edited elsewhere)'); + }); + + it('skips malformed entries instead of throwing', () => { + const remote = JSON.stringify([{ not: 'a template' }, null, 42]); + const result = resolveTemplateImport([], remote); + expect(result.imported).toBe(0); + expect(result.skipped).toBe(3); + expect(result.next).toEqual([]); + }); + + it('throws a clear error for invalid JSON', () => { + expect(() => resolveTemplateImport([], 'not json')).toThrow(/valid JSON/i); + }); + + it('throws a clear error when the top level is neither an array nor an envelope', () => { + expect(() => resolveTemplateImport([], '{}')).toThrow(/array/i); + }); +}); diff --git a/src/store/splitTemplatesStore.tsx b/src/store/splitTemplatesStore.tsx new file mode 100644 index 0000000..1361b1d --- /dev/null +++ b/src/store/splitTemplatesStore.tsx @@ -0,0 +1,259 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +export interface TemplateRow { + metaAddress: string; + amountRaw: string; + memo?: string; +} + +export interface SplitTemplate { + id: string; + name: string; + rows: TemplateRow[]; + createdAt: number; + updatedAt: number; +} + +export interface TemplateImportConflict { + id: string; + existingName: string; + incomingName: string; +} + +export interface TemplateImportResult { + imported: number; + skipped: number; + conflicts: TemplateImportConflict[]; +} + +interface SplitTemplatesContextValue { + templates: SplitTemplate[]; + saveTemplate: (name: string, rows: TemplateRow[]) => SplitTemplate; + renameTemplate: (id: string, name: string) => void; + deleteTemplate: (id: string) => void; + duplicateTemplate: (id: string) => void; + getTemplate: (id: string) => SplitTemplate | undefined; + exportTemplates: () => string; + importTemplates: (json: string, overwriteConflicts?: boolean) => TemplateImportResult; +} + +const SplitTemplatesContext = createContext(null); +const STORAGE_KEY = 'wraith-split-templates'; + +function generateId() { + return `tpl_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +// --------------------------------------------------------------------------- +// Pure import resolution (unit-testable without rendering React) +// --------------------------------------------------------------------------- +// Split out from the hook below so the export -> import round trip (and +// conflict handling) can be tested directly, matching how the rest of the +// codebase tests pure logic (see batchSend.ts) separately from the +// React-context wiring around it. + +function isValidTemplateRow(value: unknown): value is TemplateRow { + if (typeof value !== 'object' || value === null) return false; + const row = value as Record; + return ( + typeof row.metaAddress === 'string' && + typeof row.amountRaw === 'string' && + (row.memo === undefined || typeof row.memo === 'string') + ); +} + +export function isValidTemplate(value: unknown): value is SplitTemplate { + if (typeof value !== 'object' || value === null) return false; + const t = value as Record; + return ( + typeof t.id === 'string' && + t.id.length > 0 && + typeof t.name === 'string' && + Array.isArray(t.rows) && + t.rows.every(isValidTemplateRow) && + typeof t.createdAt === 'number' && + typeof t.updatedAt === 'number' + ); +} + +export function templatesEqual(a: SplitTemplate, b: SplitTemplate): boolean { + return ( + a.name === b.name && + a.rows.length === b.rows.length && + a.rows.every( + (row, i) => + row.metaAddress === b.rows[i].metaAddress && + row.amountRaw === b.rows[i].amountRaw && + (row.memo ?? '') === (b.rows[i].memo ?? ''), + ) + ); +} + +/** + * Resolve an import against the current template list. + * + * Matching is by `id`, so exporting and immediately re-importing the same + * file is a no-op (entries come back `skipped`, not duplicated). Templates + * that don't exist locally yet are added directly. Templates that share an + * id with a local template but differ are reported as `conflicts` unless + * `overwriteConflicts` is set, in which case the incoming version wins. + * + * Accepts either a bare array export or the enveloped + * `{ type, version, templates }` shape produced by `exportTemplates`. + */ +export function resolveTemplateImport( + existing: SplitTemplate[], + json: string, + overwriteConflicts: boolean = false, +): { next: SplitTemplate[] } & TemplateImportResult { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new Error('That file is not valid JSON.'); + } + + const incoming = Array.isArray(parsed) ? parsed : (parsed as { templates?: unknown })?.templates; + if (!Array.isArray(incoming)) { + throw new Error('Expected a JSON array of templates.'); + } + + const next = [...existing]; + const conflicts: TemplateImportConflict[] = []; + let imported = 0; + let skipped = 0; + + for (const raw of incoming) { + if (!isValidTemplate(raw)) { + skipped++; + continue; + } + + const currentIndex = next.findIndex((t) => t.id === raw.id); + if (currentIndex === -1) { + next.push(raw); + imported++; + continue; + } + + if (templatesEqual(next[currentIndex], raw)) { + skipped++; + continue; + } + + if (overwriteConflicts) { + next[currentIndex] = raw; + imported++; + } else { + conflicts.push({ id: raw.id, existingName: next[currentIndex].name, incomingName: raw.name }); + } + } + + return { next, imported, skipped, conflicts }; +} + +// --------------------------------------------------------------------------- +// React context +// --------------------------------------------------------------------------- + +export function SplitTemplatesProvider({ children }: { children: ReactNode }) { + const [templates, setTemplates] = useState([]); + + // Load templates from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + setTemplates(JSON.parse(stored)); + } + } catch { + // Ignore parse errors + } + }, []); + + // Save templates to localStorage when they change + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(templates)); + }, [templates]); + + const saveTemplate = (name: string, rows: TemplateRow[]) => { + const now = Date.now(); + const newTemplate: SplitTemplate = { + id: generateId(), + name, + rows, + createdAt: now, + updatedAt: now, + }; + setTemplates((prev) => [...prev, newTemplate]); + return newTemplate; + }; + + const renameTemplate = (id: string, name: string) => { + setTemplates((prev) => + prev.map((t) => (t.id === id ? { ...t, name, updatedAt: Date.now() } : t)), + ); + }; + + const deleteTemplate = (id: string) => { + setTemplates((prev) => prev.filter((t) => t.id !== id)); + }; + + const duplicateTemplate = (id: string) => { + setTemplates((prev) => { + const original = prev.find((t) => t.id === id); + if (!original) return prev; + const now = Date.now(); + const copy: SplitTemplate = { + ...original, + id: generateId(), + name: `${original.name} (copy)`, + createdAt: now, + updatedAt: now, + }; + return [...prev, copy]; + }); + }; + + const getTemplate = (id: string) => templates.find((t) => t.id === id); + + // Same envelope shape as the address-book export so files round-trip + // through either tool. Update the `type` field to match theirs exactly + // once you paste that code in. + const exportTemplates = () => + JSON.stringify({ type: 'wraith-split-templates', version: 1, templates }, null, 2); + + const importTemplates = ( + json: string, + overwriteConflicts: boolean = false, + ): TemplateImportResult => { + // Throws on invalid JSON / shape (see resolveTemplateImport) so the + // caller can show a real error instead of a silent no-op. + const result = resolveTemplateImport(templates, json, overwriteConflicts); + setTemplates(result.next); + return { imported: result.imported, skipped: result.skipped, conflicts: result.conflicts }; + }; + + return ( + + {children} + + ); +} + +export function useSplitTemplates() { + const ctx = useContext(SplitTemplatesContext); + if (!ctx) throw new Error('useSplitTemplates must be used within SplitTemplatesProvider'); + return ctx; +}