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
53 changes: 53 additions & 0 deletions apps/web/components/watchlists/WatchlistCreationModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { WatchlistCreationModal } from './WatchlistCreationModal';

describe('WatchlistCreationModal', () => {
it('requires a name and at least one target', () => {
const onCreate = jest.fn();
render(<WatchlistCreationModal onClose={jest.fn()} onCreate={onCreate} />);

fireEvent.click(screen.getByRole('button', { name: /create watchlist/i }));

expect(screen.getByText('Enter a name for this watchlist.')).toBeTruthy();
expect(screen.getByText('Add a wallet address, contract address, or both.')).toBeTruthy();
expect(onCreate).not.toHaveBeenCalled();
});

it('submits trimmed values and closes after creation succeeds', async () => {
const onCreate = jest.fn().mockResolvedValue(undefined);
const onClose = jest.fn();
render(<WatchlistCreationModal onClose={onClose} onCreate={onCreate} />);

fireEvent.change(screen.getByLabelText('Watchlist name'), {
target: { value: ' Treasury ' },
});
fireEvent.change(screen.getByLabelText(/wallet address/i), {
target: { value: ' GABC123 ' },
});
fireEvent.click(screen.getByRole('button', { name: /create watchlist/i }));

await waitFor(() =>
expect(onCreate).toHaveBeenCalledWith({
label: 'Treasury',
walletAddress: 'GABC123',
contractAddress: undefined,
}),
);
expect(onClose).toHaveBeenCalledTimes(1);
});

it('keeps the dialog open and reports a submission error', async () => {
const onCreate = jest.fn().mockRejectedValue(new Error('Address could not be saved.'));
const onClose = jest.fn();
render(<WatchlistCreationModal onClose={onClose} onCreate={onCreate} />);

fireEvent.change(screen.getByLabelText('Watchlist name'), { target: { value: 'Treasury' } });
fireEvent.change(screen.getByLabelText(/contract address/i), {
target: { value: 'contract-1' },
});
fireEvent.click(screen.getByRole('button', { name: /create watchlist/i }));

expect((await screen.findByRole('alert')).textContent).toContain('Address could not be saved.');
expect(onClose).not.toHaveBeenCalled();
});
});
190 changes: 190 additions & 0 deletions apps/web/components/watchlists/WatchlistCreationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
'use client';

import { useState } from 'react';
import './watchlists.css';

export interface WatchlistCreationPayload {
label: string;
walletAddress?: string;
contractAddress?: string;
}

interface WatchlistCreationModalProps {
onClose: () => void;
onCreate: (payload: WatchlistCreationPayload) => Promise<void> | void;
}

type FormErrors = Partial<Record<keyof WatchlistCreationPayload | 'targets', string>>;

const MAX_LABEL_LENGTH = 100;
const MAX_ADDRESS_LENGTH = 256;

export function WatchlistCreationModal({ onClose, onCreate }: WatchlistCreationModalProps) {
const [label, setLabel] = useState('');
const [walletAddress, setWalletAddress] = useState('');
const [contractAddress, setContractAddress] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [submitError, setSubmitError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);

const validate = (): FormErrors => {
const nextErrors: FormErrors = {};
const trimmedLabel = label.trim();
const trimmedWallet = walletAddress.trim();
const trimmedContract = contractAddress.trim();

if (!trimmedLabel) nextErrors.label = 'Enter a name for this watchlist.';
else if (trimmedLabel.length > MAX_LABEL_LENGTH) {
nextErrors.label = `Name must be ${MAX_LABEL_LENGTH} characters or fewer.`;
}

if (!trimmedWallet && !trimmedContract) {
nextErrors.targets = 'Add a wallet address, contract address, or both.';
}
if (trimmedWallet.length > MAX_ADDRESS_LENGTH) {
nextErrors.walletAddress = 'Wallet address is too long.';
}
if (trimmedContract.length > MAX_ADDRESS_LENGTH) {
nextErrors.contractAddress = 'Contract address is too long.';
}
if (trimmedWallet && trimmedContract && trimmedWallet === trimmedContract) {
nextErrors.targets = 'Wallet and contract addresses must be different.';
}

return nextErrors;
};

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitError('');

const nextErrors = validate();
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;

setIsSubmitting(true);
try {
await onCreate({
label: label.trim(),
walletAddress: walletAddress.trim() || undefined,
contractAddress: contractAddress.trim() || undefined,
});
onClose();
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Unable to create watchlist.');
} finally {
setIsSubmitting(false);
}
};

return (
<div className="watchlist-modal-backdrop" role="presentation" onMouseDown={onClose}>
<section
className="watchlist-modal"
role="dialog"
aria-modal="true"
aria-labelledby="watchlist-modal-title"
onMouseDown={event => event.stopPropagation()}
>
<header className="watchlist-modal-header">
<div>
<p className="watchlist-modal-eyebrow">Monitoring</p>
<h2 id="watchlist-modal-title">Create watchlist</h2>
<p>Choose the addresses Sentinel should keep an eye on.</p>
</div>
<button
type="button"
className="watchlist-modal-close"
onClick={onClose}
aria-label="Close"
>
×
</button>
</header>

<form onSubmit={handleSubmit} noValidate>
<div className="watchlist-field">
<label htmlFor="watchlist-label">Watchlist name</label>
<input
id="watchlist-label"
value={label}
onChange={event => setLabel(event.target.value)}
aria-invalid={Boolean(errors.label)}
aria-describedby={errors.label ? 'watchlist-label-error' : undefined}
placeholder="e.g. Treasury movement"
maxLength={MAX_LABEL_LENGTH}
disabled={isSubmitting}
/>
{errors.label && (
<p id="watchlist-label-error" className="watchlist-error">
{errors.label}
</p>
)}
</div>

<div className="watchlist-field">
<label htmlFor="watchlist-wallet">
Wallet address <span>(optional)</span>
</label>
<input
id="watchlist-wallet"
value={walletAddress}
onChange={event => setWalletAddress(event.target.value)}
aria-invalid={Boolean(errors.walletAddress || errors.targets)}
aria-describedby={errors.walletAddress ? 'watchlist-wallet-error' : undefined}
placeholder="Paste a wallet address"
maxLength={MAX_ADDRESS_LENGTH}
disabled={isSubmitting}
/>
{errors.walletAddress && (
<p id="watchlist-wallet-error" className="watchlist-error">
{errors.walletAddress}
</p>
)}
</div>

<div className="watchlist-field">
<label htmlFor="watchlist-contract">
Contract address <span>(optional)</span>
</label>
<input
id="watchlist-contract"
value={contractAddress}
onChange={event => setContractAddress(event.target.value)}
aria-invalid={Boolean(errors.contractAddress || errors.targets)}
aria-describedby={errors.contractAddress ? 'watchlist-contract-error' : undefined}
placeholder="Paste a contract address"
maxLength={MAX_ADDRESS_LENGTH}
disabled={isSubmitting}
/>
{errors.contractAddress && (
<p id="watchlist-contract-error" className="watchlist-error">
{errors.contractAddress}
</p>
)}
</div>

{errors.targets && (
<p className="watchlist-error" role="alert">
{errors.targets}
</p>
)}
{submitError && (
<p className="watchlist-error" role="alert">
{submitError}
</p>
)}

<footer className="watchlist-modal-actions">
<button type="button" onClick={onClose} disabled={isSubmitting}>
Cancel
</button>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create watchlist'}
</button>
</footer>
</form>
</section>
</div>
);
}
151 changes: 151 additions & 0 deletions apps/web/components/watchlists/watchlists.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
.watchlist-modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(8 15 29 / 72%);
}

.watchlist-modal {
width: min(100%, 32rem);
max-height: calc(100dvh - 2rem);
overflow-y: auto;
border: 1px solid #c9d5e6;
border-radius: 0.75rem;
background: #f8fafc;
color: #142238;
box-shadow: 0 1.5rem 4rem rgb(5 15 35 / 28%);
}

.watchlist-modal-header {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid #dbe4ef;
}

.watchlist-modal-eyebrow {
margin: 0 0 0.35rem;
color: #147d75;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}

.watchlist-modal h2 {
margin: 0;
font-size: 1.45rem;
line-height: 1.2;
}

.watchlist-modal-header p:last-child {
margin: 0.45rem 0 0;
color: #52647c;
font-size: 0.9rem;
}

.watchlist-modal-close {
align-self: flex-start;
border: 0;
background: transparent;
color: #52647c;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
}

.watchlist-modal form {
padding: 1.25rem 1.5rem 1.5rem;
}

.watchlist-field {
display: grid;
gap: 0.4rem;
margin-bottom: 1rem;
}

.watchlist-field label {
color: #263b56;
font-size: 0.85rem;
font-weight: 700;
}

.watchlist-field label span {
color: #718198;
font-weight: 400;
}

.watchlist-field input {
width: 100%;
box-sizing: border-box;
border: 1px solid #b8c7da;
border-radius: 0.4rem;
padding: 0.7rem 0.75rem;
background: #fff;
color: #142238;
font: inherit;
}

.watchlist-field input:focus {
outline: 3px solid rgb(20 125 117 / 18%);
border-color: #147d75;
}

.watchlist-field input[aria-invalid='true'] {
border-color: #c24146;
}

.watchlist-error {
margin: 0.35rem 0 0;
color: #a52d37;
font-size: 0.8rem;
}

.watchlist-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}

.watchlist-modal-actions button {
border: 1px solid #b8c7da;
border-radius: 0.4rem;
padding: 0.65rem 1rem;
cursor: pointer;
font: inherit;
font-weight: 700;
}

.watchlist-modal-actions button:first-child {
background: #fff;
color: #314661;
}

.watchlist-modal-actions button:last-child {
border-color: #147d75;
background: #147d75;
color: #fff;
}

.watchlist-modal-actions button:disabled {
cursor: wait;
opacity: 0.6;
}

@media (max-width: 32rem) {
.watchlist-modal-header,
.watchlist-modal form {
padding-left: 1rem;
padding-right: 1rem;
}

.watchlist-modal-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
Loading