Skip to content

Commit 2ade2db

Browse files
committed
feat: Add SortDropdown server-side integration with URL-synced sort params (closes #259)
1 parent 6d123f3 commit 2ade2db

5 files changed

Lines changed: 87 additions & 5 deletions

File tree

backend/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
calculateProgress,
1919
CampaignProgress,
2020
CampaignRecord,
21+
CampaignSortField,
2122
CampaignStatus,
2223
claimCampaign,
2324
createCampaign,
@@ -32,6 +33,7 @@ import {
3233
type ListCampaignsOptions,
3334
reconcileOnChainPledge,
3435
refundContributor,
36+
SortOrder,
3537
updateCampaign,
3638
} from './services/campaignStore';
3739
import { checkDbHealth } from './services/db';
@@ -201,18 +203,28 @@ export function parseCampaignListFilters(query: {
201203
q?: unknown;
202204
search?: unknown;
203205
includeDeleted?: unknown;
206+
sort?: unknown;
207+
order?: unknown;
204208
}): {
205209
asset?: string;
206210
status?: CampaignStatus;
207211
searchQuery?: string;
208212
includeDeleted?: boolean;
213+
sort?: CampaignSortField;
214+
order?: SortOrder;
209215
} {
216+
const VALID_SORT_FIELDS: CampaignSortField[] = ['newest', 'deadline', 'percentFunded', 'totalPledged'];
217+
const VALID_ORDERS: SortOrder[] = ['asc', 'desc'];
218+
const rawSort = normalizeQueryValue(query.sort);
219+
const rawOrder = normalizeQueryValue(query.order);
210220
return {
211221
asset: normalizeAssetFilter(query.asset),
212222
status: normalizeStatusFilter(query.status),
213223
searchQuery:
214224
normalizeQueryValue(query.search) || normalizeQueryValue(query.q),
215225
includeDeleted: query.includeDeleted === "true",
226+
sort: rawSort && VALID_SORT_FIELDS.includes(rawSort as CampaignSortField) ? (rawSort as CampaignSortField) : undefined,
227+
order: rawOrder && VALID_ORDERS.includes(rawOrder as SortOrder) ? (rawOrder as SortOrder) : undefined,
216228
};
217229
}
218230

@@ -261,13 +273,17 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
261273
q: req.query.q,
262274
search: req.query.search,
263275
includeDeleted: req.query.includeDeleted,
276+
sort: req.query.sort,
277+
order: req.query.order,
264278
});
265279

266280
const listOptions: ListCampaignsOptions = {
267281
searchQuery: filters.searchQuery,
268282
assetCode: filters.asset,
269283
status: filters.status,
270284
includeDeleted: filters.includeDeleted,
285+
sort: filters.sort,
286+
order: filters.order,
271287
};
272288
if (paginationResult.page !== undefined) {
273289
listOptions.page = paginationResult.page;

backend/src/services/campaignStore.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,18 @@ export function calculateProgress(campaign: CampaignRecord, at = nowInSeconds())
272272
};
273273
}
274274

275+
export type CampaignSortField = 'newest' | 'deadline' | 'percentFunded' | 'totalPledged';
276+
export type SortOrder = 'asc' | 'desc';
277+
275278
export interface ListCampaignsOptions {
276279
searchQuery?: string;
277280
assetCode?: string;
278281
status?: CampaignStatus;
279282
includeDeleted?: boolean;
280283
page?: number;
281284
limit?: number;
285+
sort?: CampaignSortField;
286+
order?: SortOrder;
282287
}
283288

284289
export interface ListCampaignsResult {
@@ -383,9 +388,30 @@ export function listCampaigns(options?: ListCampaignsOptions): ListCampaignsResu
383388
const countQuery = `SELECT COUNT(DISTINCT campaigns.id) as total FROM campaigns LEFT JOIN pledges ON campaigns.id = pledges.campaign_id AND pledges.refunded_at IS NULL${whereClause}`;
384389
const totalCount = (db.prepare(countQuery).get(...params) as { total: number }).total;
385390

391+
// Build ORDER BY clause from sort options
392+
const sortField = options?.sort ?? 'newest';
393+
const sortOrder = options?.order ?? 'desc';
394+
const orderDir = sortOrder === 'asc' ? 'ASC' : 'DESC';
395+
let orderByClause: string;
396+
switch (sortField) {
397+
case 'deadline':
398+
orderByClause = `campaigns.deadline ${sortOrder === 'desc' ? 'DESC' : 'ASC'}`;
399+
break;
400+
case 'percentFunded':
401+
orderByClause = `(CAST(campaigns.pledged_amount AS REAL) / CAST(campaigns.target_amount AS REAL)) ${orderDir}`;
402+
break;
403+
case 'totalPledged':
404+
orderByClause = `campaigns.pledged_amount ${orderDir}`;
405+
break;
406+
case 'newest':
407+
default:
408+
orderByClause = `campaigns.created_at ${orderDir}`;
409+
break;
410+
}
411+
386412
const dataQuery = paginate
387-
? `SELECT campaigns.*, COUNT(pledges.id) as pledge_count FROM campaigns LEFT JOIN pledges ON campaigns.id = pledges.campaign_id AND pledges.refunded_at IS NULL${whereClause} GROUP BY campaigns.id ORDER BY campaigns.created_at DESC LIMIT ? OFFSET ?`
388-
: `SELECT campaigns.*, COUNT(pledges.id) as pledge_count FROM campaigns LEFT JOIN pledges ON campaigns.id = pledges.campaign_id AND pledges.refunded_at IS NULL${whereClause} GROUP BY campaigns.id ORDER BY campaigns.created_at DESC`;
413+
? `SELECT campaigns.*, COUNT(pledges.id) as pledge_count FROM campaigns LEFT JOIN pledges ON campaigns.id = pledges.campaign_id AND pledges.refunded_at IS NULL${whereClause} GROUP BY campaigns.id ORDER BY ${orderByClause} LIMIT ? OFFSET ?`
414+
: `SELECT campaigns.*, COUNT(pledges.id) as pledge_count FROM campaigns LEFT JOIN pledges ON campaigns.id = pledges.campaign_id AND pledges.refunded_at IS NULL${whereClause} GROUP BY campaigns.id ORDER BY ${orderByClause}`;
389415

390416
const rows = (
391417
paginate

frontend/src/App.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
2-
import { useNavigate, useParams } from "react-router-dom";
2+
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
33
import { CampaignDetailPanel } from "./components/CampaignDetailPanel";
44
import { ErrorBoundary } from "./components/ErrorBoundary";
55
import { FundedConfetti } from "./components/FundedConfetti";
@@ -123,6 +123,9 @@ function App() {
123123
const [hasMoreCampaigns, setHasMoreCampaigns] = useState(false);
124124
const [isLoadingMoreCampaigns, setIsLoadingMoreCampaigns] = useState(false);
125125
const activeSearchRef = useRef("");
126+
const activeSortRef = useRef<string>('newest');
127+
const activeOrderRef = useRef<string>('desc');
128+
const [searchParams] = useSearchParams();
126129
const [issues, setIssues] = useState<OpenIssue[]>([]);
127130
const [history, setHistory] = useState<CampaignEvent[]>([]);
128131
const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
@@ -169,11 +172,15 @@ function App() {
169172
page: number,
170173
searchQuery = "",
171174
append = false,
175+
sort = activeSortRef.current,
176+
order = activeOrderRef.current,
172177
): Promise<Awaited<ReturnType<typeof listCampaigns>>> {
173178
const response = await listCampaigns({
174179
search: searchQuery,
175180
page,
176181
limit: CAMPAIGN_PAGE_SIZE,
182+
sort,
183+
order,
177184
});
178185

179186
setCampaigns((current) => (append ? [...current, ...response.data] : response.data));
@@ -729,6 +736,11 @@ function App() {
729736
onSearchChange={(query) => {
730737
void refreshCampaigns(query);
731738
}}
739+
onSortChange={(sort, order) => {
740+
activeSortRef.current = sort;
741+
activeOrderRef.current = order;
742+
void refreshCampaigns(activeSearchRef.current);
743+
}}
732744
onLoadMore={() => {
733745
void loadMoreCampaigns();
734746
}}

frontend/src/components/CampaignsTable.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { LayoutGrid } from "lucide-react";
22
import { useEffect, useMemo, useRef, useState } from "react";
33
import { useDebounce } from "../hooks/useDebounce";
4+
import { useSearchParams } from "react-router-dom";
45
import { Campaign, CampaignStatus } from "../types/campaign";
56
import { EmptyState } from "./EmptyState";
67
import { AssetFilterDropdown } from "./AssetFilterDropdown";
@@ -30,6 +31,7 @@ interface CampaignsTableProps {
3031
selectedCampaignId: string | null;
3132
onSelect: (campaignId: string) => void;
3233
onSearchChange?: (query: string) => void;
34+
onSortChange?: (sort: SortOption, order: 'asc' | 'desc') => void;
3335
onLoadMore?: () => void;
3436
hasMore?: boolean;
3537
isLoadingMore?: boolean;
@@ -64,19 +66,36 @@ export function CampaignsTable({
6466
selectedCampaignId,
6567
onSelect,
6668
onSearchChange,
69+
onSortChange,
6770
onLoadMore,
6871
hasMore = false,
6972
isLoadingMore = false,
7073
isLoading = false,
7174
invalidUrlCampaignId = null,
7275
}: CampaignsTableProps) {
76+
const [searchParams, setSearchParams] = useSearchParams();
77+
const urlSort = (searchParams.get('sort') as SortOption | null) ?? 'newest';
78+
const urlOrder = (searchParams.get('order') as 'asc' | 'desc' | null) ?? 'desc';
79+
const VALID_SORTS: SortOption[] = ['newest', 'deadline', 'percentFunded', 'totalPledged'];
80+
const sortBy: SortOption = VALID_SORTS.includes(urlSort) ? urlSort : 'newest';
7381
const [assetCode, setAssetCode] = useState("");
7482
const [statusFilter, setStatusFilter] = useState<StatusFilterValue>("");
75-
const [sortBy, setSortBy] = useState<SortOption>("newest");
7683
const [searchQuery, setSearchQuery] = useState("");
7784
const loadMoreRef = useRef<HTMLDivElement | null>(null);
7885
const debouncedSearchQuery = useDebounce(searchQuery, 300);
7986

87+
function handleSortChange(newSort: SortOption) {
88+
// Toggle order if clicking same field, else default to desc
89+
const newOrder = newSort === sortBy && urlOrder === 'desc' ? 'asc' : 'desc';
90+
setSearchParams((prev) => {
91+
const next = new URLSearchParams(prev);
92+
next.set('sort', newSort);
93+
next.set('order', newOrder);
94+
return next;
95+
}, { replace: true });
96+
onSortChange?.(newSort, newOrder);
97+
}
98+
8099
useEffect(() => {
81100
onSearchChange?.(debouncedSearchQuery);
82101
}, [debouncedSearchQuery, onSearchChange]);
@@ -131,6 +150,7 @@ export function CampaignsTable({
131150
statusFilter,
132151
"", // server-side search, no client search
133152
);
153+
// Server already sorted; only apply client-side sort as a tie-break fallback
134154
return sortCampaigns(filtered, sortBy);
135155
}, [campaigns, assetCode, statusFilter, sortBy]);
136156

@@ -229,7 +249,7 @@ export function CampaignsTable({
229249
<span>Sort:</span>
230250
<SortDropdown
231251
value={sortBy}
232-
onChange={setSortBy}
252+
onChange={handleSortChange}
233253
disabled={isLoading}
234254
/>
235255
</label>

frontend/src/services/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export async function listCampaigns(filters?: {
2727
status?: string;
2828
page?: number;
2929
limit?: number;
30+
sort?: string;
31+
order?: string;
3032
}): Promise<CampaignListResponse> {
3133
const params = new URLSearchParams();
3234
if (filters?.includeDeleted) {
@@ -47,6 +49,12 @@ export async function listCampaigns(filters?: {
4749
if (filters?.limit !== undefined) {
4850
params.set('limit', String(filters.limit));
4951
}
52+
if (filters?.sort) {
53+
params.set('sort', filters.sort);
54+
}
55+
if (filters?.order) {
56+
params.set('order', filters.order);
57+
}
5058

5159
const query = params.toString();
5260
return apiRequest<CampaignListResponse>({

0 commit comments

Comments
 (0)