fix(agent-world): confirm bounty creation with a success toast (#3837)

This commit is contained in:
Cyrus Gray
2026-06-19 09:08:50 -07:00
committed by GitHub
parent a05be91e24
commit 8c8180378c
2 changed files with 166 additions and 0 deletions
@@ -708,6 +708,148 @@ describe('Council section', () => {
});
});
// ── Create success confirmation ───────────────────────────────────────────────
/**
* Helper: open Create Bounty modal, fill the minimum required fields, and
* click the submit button. Returns after the submit click so callers can
* assert on the result.
*/
async function openAndSubmitCreateForm(user: ReturnType<typeof userEvent.setup>) {
await waitFor(() => {
expect(screen.getByRole('button', { name: /create bounty/i })).toBeInTheDocument();
});
const createBtn = screen
.getAllByRole('button')
.find(
btn => btn.textContent?.includes('Create Bounty') && btn.getAttribute('type') === 'button'
);
expect(createBtn).toBeDefined();
await user.click(createBtn!);
await waitFor(() => {
expect(document.querySelector('input[placeholder="Bounty title"]')).not.toBeNull();
});
await user.type(screen.getByPlaceholderText(/bounty title/i), 'My new bounty');
await user.type(screen.getByPlaceholderText(/describe the bounty/i), 'Some description');
// Amount field is required and must be positive
await user.clear(screen.getByPlaceholderText('5'));
await user.type(screen.getByPlaceholderText('5'), '10');
const submitBtn = screen
.getAllByRole('button')
.find(btn => btn.getAttribute('type') === 'submit');
expect(submitBtn).toBeDefined();
await user.click(submitBtn!);
}
describe('Create success confirmation', () => {
test('shows success toast with title after bounty creation (free/open path)', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.bounties.create).mockResolvedValue({
bounty: { ...sampleOwnBounty, status: 'open', title: 'My new bounty' },
} as never);
vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty);
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never);
render(<BountiesSection />);
await openAndSubmitCreateForm(user);
await waitFor(() => {
expect(screen.getByText('Bounty created')).toBeInTheDocument();
expect(screen.getByText('My new bounty')).toBeInTheDocument();
expect(screen.getByText('View')).toBeInTheDocument();
});
});
test('View action in toast expands the created bounty row', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.bounties.create).mockResolvedValue({
bounty: { ...sampleOwnBounty, status: 'open', title: 'My new bounty' },
} as never);
// List returns the created bounty so the row exists to expand
vi.mocked(apiClient.bounties.list).mockResolvedValue({
bounties: [{ ...sampleOwnBounty, status: 'open', title: 'My new bounty' }],
});
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never);
render(<BountiesSection />);
await openAndSubmitCreateForm(user);
// Wait for the toast to appear
await waitFor(() => {
expect(screen.getByText('View')).toBeInTheDocument();
});
// Click "View" — should expand the bounty row (description becomes visible)
await user.click(screen.getByText('View'));
await waitFor(() => {
expect(
screen.getByText('Create a TypeScript plugin that connects to our API.')
).toBeInTheDocument();
});
});
test('list refetches after successful creation', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.bounties.create).mockResolvedValue({
bounty: { ...sampleOwnBounty, status: 'open', title: 'My new bounty' },
} as never);
vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty);
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never);
render(<BountiesSection />);
// list is called once on mount
await waitFor(() => {
expect(apiClient.bounties.list).toHaveBeenCalledTimes(1);
});
await openAndSubmitCreateForm(user);
// list should be called a second time after create succeeds
await waitFor(() => {
expect(apiClient.bounties.list).toHaveBeenCalledTimes(2);
});
});
test('toast can be dismissed', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.bounties.create).mockResolvedValue({
bounty: { ...sampleOwnBounty, status: 'open', title: 'My new bounty' },
} as never);
vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty);
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as never);
render(<BountiesSection />);
await openAndSubmitCreateForm(user);
await waitFor(() => {
expect(screen.getByText('Bounty created')).toBeInTheDocument();
});
// Find the close button inside the toast (svg close button, aria unlabelled)
// The Toast component renders a close <button> after the action button.
// We locate it by finding all buttons visible and clicking the one without
// meaningful text content that follows the "View" action button.
const closeBtn = screen.getAllByRole('button').find(btn => btn.textContent?.trim() === '');
expect(closeBtn).toBeDefined();
await user.click(closeBtn!);
// After dismiss animation (200ms) the toast is removed; but in jsdom
// setTimeout fires synchronously via fake timers — use waitFor instead.
await waitFor(
() => {
expect(screen.queryByText('Bounty created')).not.toBeInTheDocument();
},
{ timeout: 1000 }
);
});
});
// ── Submissions section ───────────────────────────────────────────────────────
describe('Submissions section', () => {
@@ -14,6 +14,7 @@
*/
import { useCallback, useEffect, useState } from 'react';
import { ToastContainer } from '../../components/intelligence/Toast';
import PanelScaffold from '../../components/layout/PanelScaffold';
import Button from '../../components/ui/Button';
import { ModalShell } from '../../components/ui/ModalShell';
@@ -27,6 +28,7 @@ import {
type RegistryWalletBalance,
} from '../../lib/agentworld/invokeApiClient';
import { fetchWalletStatus } from '../../services/walletApi';
import type { ToastNotification } from '../../types/intelligence';
import { apiClient } from '../AgentWorldShell';
import X402ConfirmDialog, { formatUnits } from '../components/X402ConfirmDialog';
import { useX402Buy } from '../hooks/useX402Buy';
@@ -867,6 +869,12 @@ export default function BountiesSection() {
const [expandedBountyId, setExpandedBountyId] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
// Toast state
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = (toast: Omit<ToastNotification, 'id'>) =>
setToasts(prev => [...prev, { ...toast, id: crypto.randomUUID() }]);
const removeToast = (id: string) => setToasts(prev => prev.filter(t => t.id !== id));
// Modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const [submitWorkBountyId, setSubmitWorkBountyId] = useState<string | null>(null);
@@ -997,6 +1005,20 @@ export default function BountiesSection() {
onClose={() => setShowCreateModal(false)}
onCreated={bounty => {
setShowCreateModal(false);
// Show success toast with a "View" action that expands the new row.
// The expand state is pre-set here; once the list refetch completes
// the row auto-expands. No i18n yet — the entire BountiesSection
// uses hardcoded English strings (TODO: i18n when section is
// internationalised).
addToast({
type: 'success',
title: 'Bounty created',
message: (bounty as Bounty).title,
action: {
label: 'View',
handler: () => setExpandedBountyId((bounty as Bounty).bountyId),
},
});
fetchBounties();
// If the new bounty is a draft, offer to fund it
if ((bounty as Bounty).status === 'draft') {
@@ -1130,6 +1152,8 @@ export default function BountiesSection() {
</div>
</ModalShell>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</PanelScaffold>
);
}