Skip to content

Commit d4d9c6f

Browse files
authored
Merge pull request #349 from daniella-techie/feature/209-57-200-206-order-filtering-swap-expiration-amount-input-error-recovery
feat: Implement order filtering, swap expiration, amount input, and error recovery
2 parents 0cfdf20 + 0573477 commit d4d9c6f

3 files changed

Lines changed: 444 additions & 12 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Order Filtering Feature
2+
3+
## Overview
4+
This document describes the order filtering implementation for the ChainBridge marketplace, addressing issue #209.
5+
6+
## Features Implemented
7+
8+
### 1. Combined Filters
9+
Users can apply multiple filters simultaneously to refine the order book:
10+
- **Search**: Text search across pair names, token symbols, and maker addresses
11+
- **Side Filter**: Filter by order side (All, Buys, Sells)
12+
- **Chain Filter**: Filter by source and destination chain pairs (e.g., "Ethereum → Stellar")
13+
- **Asset Filter**: Filter by token assets (e.g., BTC, ETH, USDC)
14+
15+
All filters work together - applying multiple filters shows only orders that match ALL criteria.
16+
17+
### 2. URL Query Parameters
18+
All filter states are synchronized with URL query parameters:
19+
- `search` - Search query string
20+
- `side` - Order side filter (buy/sell/all)
21+
- `chain` - Chain pair filter
22+
- `asset` - Asset filter
23+
24+
**Benefits:**
25+
- Shareable URLs with pre-applied filters
26+
- Browser back/forward navigation works correctly
27+
- Bookmarkable filter combinations
28+
- Deep linking support
29+
30+
**Example URLs:**
31+
```
32+
/marketplace?search=BTC&side=buy
33+
/marketplace?chain=Ethereum%20%E2%86%92%20Stellar&asset=USDC
34+
/marketplace?search=ETH&side=sell&asset=USDC
35+
```
36+
37+
### 3. Reset Filters
38+
A "Reset" button appears when any filters are active:
39+
- Clears all filter states
40+
- Returns to showing the full order list
41+
- Updates URL to remove all query parameters
42+
- Provides clear visual feedback
43+
44+
The reset button is conditionally rendered - it only appears when at least one filter is active, keeping the UI clean when not needed.
45+
46+
## Technical Implementation
47+
48+
### State Management
49+
- Filter states are managed with React `useState` hooks
50+
- Initial state is derived from URL query parameters
51+
- Two-way synchronization between state and URL
52+
53+
### URL Synchronization
54+
```typescript
55+
// Sync filters to URL
56+
useEffect(() => {
57+
const params = new URLSearchParams();
58+
// Update params based on filter state
59+
router.replace(targetUrl, { scroll: false });
60+
}, [search, sideFilter, chainPairFilter, assetFilter]);
61+
62+
// Sync URL to filters
63+
useEffect(() => {
64+
setSearch(searchParams.get("search") ?? "");
65+
setSideFilter(searchParams.get("side") ?? "all");
66+
// ... other filters
67+
}, [searchParams]);
68+
```
69+
70+
### Filter Logic
71+
Orders are filtered using a `useMemo` hook for performance:
72+
```typescript
73+
const filteredOrders = useMemo(() => {
74+
return orders.filter((order) => {
75+
const matchesSearch = /* search logic */;
76+
const matchesSide = /* side logic */;
77+
const matchesChain = /* chain logic */;
78+
const matchesAsset = /* asset logic */;
79+
return matchesSearch && matchesSide && matchesChain && matchesAsset;
80+
});
81+
}, [orders, search, sideFilter, chainPairFilter, assetFilter]);
82+
```
83+
84+
## User Experience
85+
86+
### Filter Controls Layout
87+
```
88+
[Search Input] [All] [Buys] [Sells] [Chain Dropdown] [Asset Dropdown] [Reset]
89+
```
90+
91+
### Empty State
92+
When no orders match the current filters:
93+
- Clear message: "No active orders matching filters."
94+
- "Clear Filters" button to reset
95+
- Helpful icon for visual clarity
96+
97+
### Visual Feedback
98+
- Active filter buttons have distinct styling
99+
- Reset button only appears when needed
100+
- Dropdown menus show current selection
101+
- Search input shows current query
102+
103+
## Testing
104+
105+
Comprehensive test suite in `frontend/src/__tests__/orderFiltering.test.tsx`:
106+
- ✅ Renders all orders by default
107+
- ✅ Filters by search query
108+
- ✅ Filters by side (buy/sell)
109+
- ✅ Filters by chain pair
110+
- ✅ Filters by asset
111+
- ✅ Combines multiple filters
112+
- ✅ Shows reset button when filters active
113+
- ✅ Resets all filters correctly
114+
- ✅ Updates URL params on filter change
115+
- ✅ Reads filters from URL on mount
116+
- ✅ Shows empty state appropriately
117+
118+
## Acceptance Criteria Status
119+
120+
**Filters can be combined** - All filters work together seamlessly
121+
122+
**URL query params reflect current filters** - Full bidirectional sync implemented
123+
124+
**Reset filters returns full list** - Reset button clears all filters and shows all orders
125+
126+
## Files Modified
127+
128+
1. `frontend/src/components/marketplace/OrderBookList.tsx`
129+
- Added URL synchronization with `useRouter`, `usePathname`, `useSearchParams`
130+
- Implemented bidirectional filter state sync
131+
- Added reset functionality
132+
- Added conditional reset button rendering
133+
134+
2. `frontend/src/__tests__/orderFiltering.test.tsx` (new)
135+
- Comprehensive test coverage for all filtering scenarios
136+
137+
## Future Enhancements
138+
139+
Potential improvements for future iterations:
140+
- Save filter presets (similar to orders page)
141+
- Advanced filter drawer for mobile
142+
- Filter by price range
143+
- Filter by expiration time
144+
- Sort persistence in URL
145+
- Filter analytics/tracking
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
2+
import { OrderBookList } from "@/components/marketplace/OrderBookList";
3+
import { Order, OrderSide, OrderStatus } from "@/types";
4+
import { useRouter, usePathname, useSearchParams } from "next/navigation";
5+
6+
// Mock Next.js navigation hooks
7+
jest.mock("next/navigation", () => ({
8+
useRouter: jest.fn(),
9+
usePathname: jest.fn(),
10+
useSearchParams: jest.fn(),
11+
}));
12+
13+
// Mock UnifiedWalletProvider
14+
jest.mock("@/components/wallet/UnifiedWalletProvider", () => ({
15+
useUnifiedWallet: () => ({ activeAddress: "0x123" }),
16+
}));
17+
18+
const mockOrders: Order[] = [
19+
{
20+
id: "1",
21+
pair: "BTC/ETH",
22+
side: OrderSide.BUY,
23+
amount: "1.0",
24+
price: "15.5",
25+
total: "15.5",
26+
tokenIn: "BTC",
27+
tokenOut: "ETH",
28+
chainIn: "Bitcoin",
29+
chainOut: "Ethereum",
30+
maker: "0xabc",
31+
status: OrderStatus.OPEN,
32+
timestamp: new Date().toISOString(),
33+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
34+
allowPartialFills: false,
35+
},
36+
{
37+
id: "2",
38+
pair: "ETH/USDC",
39+
side: OrderSide.SELL,
40+
amount: "10.0",
41+
price: "2000",
42+
total: "20000",
43+
tokenIn: "ETH",
44+
tokenOut: "USDC",
45+
chainIn: "Ethereum",
46+
chainOut: "Stellar",
47+
maker: "0xdef",
48+
status: OrderStatus.OPEN,
49+
timestamp: new Date().toISOString(),
50+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
51+
allowPartialFills: true,
52+
},
53+
{
54+
id: "3",
55+
pair: "SOL/USDC",
56+
side: OrderSide.BUY,
57+
amount: "100.0",
58+
price: "50",
59+
total: "5000",
60+
tokenIn: "SOL",
61+
tokenOut: "USDC",
62+
chainIn: "Solana",
63+
chainOut: "Stellar",
64+
maker: "0xghi",
65+
status: OrderStatus.OPEN,
66+
timestamp: new Date().toISOString(),
67+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
68+
allowPartialFills: false,
69+
},
70+
];
71+
72+
describe("OrderBookList Filtering", () => {
73+
const mockReplace = jest.fn();
74+
const mockSearchParams = new URLSearchParams();
75+
76+
beforeEach(() => {
77+
jest.clearAllMocks();
78+
(useRouter as jest.Mock).mockReturnValue({ replace: mockReplace });
79+
(usePathname as jest.Mock).mockReturnValue("/marketplace");
80+
(useSearchParams as jest.Mock).mockReturnValue(mockSearchParams);
81+
});
82+
83+
it("renders all orders by default", () => {
84+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
85+
expect(screen.getByText("BTC/ETH")).toBeInTheDocument();
86+
expect(screen.getByText("ETH/USDC")).toBeInTheDocument();
87+
expect(screen.getByText("SOL/USDC")).toBeInTheDocument();
88+
});
89+
90+
it("filters orders by search query", async () => {
91+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
92+
93+
const searchInput = screen.getByPlaceholderText("Search pair or address...");
94+
fireEvent.change(searchInput, { target: { value: "BTC" } });
95+
96+
await waitFor(() => {
97+
expect(screen.getByText("BTC/ETH")).toBeInTheDocument();
98+
expect(screen.queryByText("SOL/USDC")).not.toBeInTheDocument();
99+
});
100+
});
101+
102+
it("filters orders by side (buy/sell)", async () => {
103+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
104+
105+
const buysButton = screen.getByText("Buys");
106+
fireEvent.click(buysButton);
107+
108+
await waitFor(() => {
109+
expect(screen.getByText("BTC/ETH")).toBeInTheDocument();
110+
expect(screen.getByText("SOL/USDC")).toBeInTheDocument();
111+
expect(screen.queryByText("ETH/USDC")).not.toBeInTheDocument();
112+
});
113+
});
114+
115+
it("filters orders by chain pair", async () => {
116+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
117+
118+
const chainSelect = screen.getByDisplayValue("All routes");
119+
fireEvent.change(chainSelect, { target: { value: "Ethereum → Stellar" } });
120+
121+
await waitFor(() => {
122+
expect(screen.getByText("ETH/USDC")).toBeInTheDocument();
123+
expect(screen.queryByText("BTC/ETH")).not.toBeInTheDocument();
124+
});
125+
});
126+
127+
it("filters orders by asset", async () => {
128+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
129+
130+
const assetSelect = screen.getByDisplayValue("All assets");
131+
fireEvent.change(assetSelect, { target: { value: "USDC" } });
132+
133+
await waitFor(() => {
134+
expect(screen.getByText("ETH/USDC")).toBeInTheDocument();
135+
expect(screen.getByText("SOL/USDC")).toBeInTheDocument();
136+
expect(screen.queryByText("BTC/ETH")).not.toBeInTheDocument();
137+
});
138+
});
139+
140+
it("combines multiple filters", async () => {
141+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
142+
143+
// Filter by asset USDC
144+
const assetSelect = screen.getByDisplayValue("All assets");
145+
fireEvent.change(assetSelect, { target: { value: "USDC" } });
146+
147+
// Filter by side BUY
148+
const buysButton = screen.getByText("Buys");
149+
fireEvent.click(buysButton);
150+
151+
await waitFor(() => {
152+
expect(screen.getByText("SOL/USDC")).toBeInTheDocument();
153+
expect(screen.queryByText("ETH/USDC")).not.toBeInTheDocument();
154+
expect(screen.queryByText("BTC/ETH")).not.toBeInTheDocument();
155+
});
156+
});
157+
158+
it("shows reset button when filters are active", async () => {
159+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
160+
161+
// Initially no reset button
162+
expect(screen.queryByText("Reset")).not.toBeInTheDocument();
163+
164+
// Apply a filter
165+
const searchInput = screen.getByPlaceholderText("Search pair or address...");
166+
fireEvent.change(searchInput, { target: { value: "BTC" } });
167+
168+
// Reset button should appear
169+
await waitFor(() => {
170+
expect(screen.getByText("Reset")).toBeInTheDocument();
171+
});
172+
});
173+
174+
it("resets all filters when reset button is clicked", async () => {
175+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
176+
177+
// Apply multiple filters
178+
const searchInput = screen.getByPlaceholderText("Search pair or address...");
179+
fireEvent.change(searchInput, { target: { value: "BTC" } });
180+
181+
const buysButton = screen.getByText("Buys");
182+
fireEvent.click(buysButton);
183+
184+
// Click reset
185+
const resetButton = await screen.findByText("Reset");
186+
fireEvent.click(resetButton);
187+
188+
// All orders should be visible again
189+
await waitFor(() => {
190+
expect(screen.getByText("BTC/ETH")).toBeInTheDocument();
191+
expect(screen.getByText("ETH/USDC")).toBeInTheDocument();
192+
expect(screen.getByText("SOL/USDC")).toBeInTheDocument();
193+
});
194+
});
195+
196+
it("updates URL params when filters change", async () => {
197+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
198+
199+
const searchInput = screen.getByPlaceholderText("Search pair or address...");
200+
fireEvent.change(searchInput, { target: { value: "BTC" } });
201+
202+
await waitFor(() => {
203+
expect(mockReplace).toHaveBeenCalledWith(
204+
expect.stringContaining("search=BTC"),
205+
expect.any(Object)
206+
);
207+
});
208+
});
209+
210+
it("reads filters from URL params on mount", () => {
211+
const searchParams = new URLSearchParams("search=ETH&side=sell");
212+
(useSearchParams as jest.Mock).mockReturnValue(searchParams);
213+
214+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
215+
216+
const searchInput = screen.getByPlaceholderText("Search pair or address...") as HTMLInputElement;
217+
expect(searchInput.value).toBe("ETH");
218+
});
219+
220+
it("shows empty state when no orders match filters", async () => {
221+
render(<OrderBookList orders={mockOrders} onTakeOrder={jest.fn()} />);
222+
223+
const searchInput = screen.getByPlaceholderText("Search pair or address...");
224+
fireEvent.change(searchInput, { target: { value: "NONEXISTENT" } });
225+
226+
await waitFor(() => {
227+
expect(screen.getByText("No active orders matching filters.")).toBeInTheDocument();
228+
expect(screen.getByText("Clear Filters")).toBeInTheDocument();
229+
});
230+
});
231+
});

0 commit comments

Comments
 (0)