Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/components/ContactCombobox.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof ContactCombobox>[0]>) {
const [value, setValue] = useState(props.value ?? '');
return (
<div className="w-72 bg-surface p-4">
<ContactCombobox
ariaLabel="Recipient meta-address"
options={SAMPLE_OPTIONS}
{...props}
value={value}
onChange={setValue}
/>
</div>
);
}

const meta = {
title: 'Stellar/ContactCombobox',
component: ControlledCombobox,
} satisfies Meta<typeof ControlledCombobox>;

export default meta;
type Story = StoryObj<typeof meta>;

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();
},
};
175 changes: 175 additions & 0 deletions src/components/ContactCombobox.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div ref={containerRef} className="relative">
<input
type="text"
role="combobox"
aria-label={ariaLabel}
aria-expanded={showListbox}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
aria-invalid={invalid || undefined}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => {
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 && (
<div
id={listboxId}
role="listbox"
aria-label={`${ariaLabel} suggestions`}
className="absolute left-0 top-full z-50 mt-1 w-full max-h-56 overflow-y-auto border border-outline-variant bg-surface shadow-xl"
>
{filtered.map((option, i) => (
<div
key={option.address}
id={`${listboxId}-option-${i}`}
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => {
// 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(' ')}
>
<span className="font-heading text-xs font-semibold text-primary">{option.name}</span>
<span className="truncate font-mono text-[10px] text-outline">{option.address}</span>
</div>
))}
</div>
)}
</div>
);
}
69 changes: 69 additions & 0 deletions src/components/TemplateImportConflictModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="mx-4 w-full max-w-lg border border-outline-variant bg-surface-container p-6">
<h2 className="mb-1 font-heading text-lg font-bold uppercase tracking-tight text-on-surface">
Import Conflicts
</h2>
<p className="mb-4 font-body text-xs text-on-surface-variant">
{conflicts.length} template{conflicts.length !== 1 ? 's' : ''} already exist with
different content.
</p>

<div className="mb-6 max-h-60 overflow-y-auto">
{conflicts.map((c) => (
<div key={c.id} className="border-b border-outline-variant/30 py-3 last:border-0">
<div className="flex gap-4">
<div className="flex-1">
<span className="font-mono text-[9px] uppercase tracking-widest text-outline">
Current
</span>
<p className="text-xs text-on-surface">{c.existingName || '(empty)'}</p>
</div>
<div className="flex-1">
<span className="font-mono text-[9px] uppercase tracking-widest text-outline">
Incoming
</span>
<p className="text-xs text-on-surface">{c.incomingName || '(empty)'}</p>
</div>
</div>
</div>
))}
</div>

<div className="flex gap-2">
<button
onClick={() => onResolve('keep-all')}
className="flex-1 border border-outline-variant py-2 font-heading text-[10px] uppercase tracking-widest text-primary transition-colors hover:bg-surface-bright"
>
Keep Existing
</button>
<button
onClick={() => onResolve('overwrite-all')}
className="flex-1 bg-primary py-2 font-heading text-[10px] uppercase tracking-widest text-surface transition-colors hover:brightness-110"
>
Overwrite All
</button>
<button
onClick={onClose}
className="border border-outline-variant px-4 py-2 font-heading text-[10px] uppercase tracking-widest text-outline transition-colors hover:bg-surface-bright"
>
Cancel
</button>
</div>
</div>
</div>
);
}
65 changes: 64 additions & 1 deletion src/lib/stellar/batchSend.test.ts
Original file line number Diff line number Diff line change
@@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading