From eea3c074d5b94014ce85136c0cebaf1b5252d298 Mon Sep 17 00:00:00 2001 From: dubemoyibe-star Date: Mon, 30 Mar 2026 12:13:34 +0100 Subject: [PATCH] implemeted the sort presetz "Add Sort Presets To The Campaign Board Fixes #3" --- frontend/package-lock.json | 31 ++- frontend/src/components/CampaignsTable.tsx | 20 +- frontend/src/components/SortDropdown.tsx | 38 ++++ .../components/campaignsTableUtils.test.ts | 180 +++++++++++++++++- .../src/components/campaignsTableUtils.ts | 53 ++++++ 5 files changed, 298 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/SortDropdown.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7982de5..0041db0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -144,7 +144,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -507,7 +506,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -556,7 +554,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1598,6 +1595,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -1612,7 +1610,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -1688,7 +1687,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1818,7 +1818,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1830,7 +1829,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -1968,6 +1966,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2209,7 +2208,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2751,7 +2749,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3455,7 +3454,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3627,6 +3625,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4023,7 +4022,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4233,7 +4231,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4246,7 +4243,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4259,15 +4255,13 @@ "version": "18.3.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4367,8 +4361,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4856,7 +4849,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5106,7 +5098,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/CampaignsTable.tsx b/frontend/src/components/CampaignsTable.tsx index 1a31c1c..d99131b 100644 --- a/frontend/src/components/CampaignsTable.tsx +++ b/frontend/src/components/CampaignsTable.tsx @@ -3,7 +3,9 @@ import { useMemo, useState } from "react"; import { Campaign } from "../types/campaign"; import { EmptyState } from "./EmptyState"; import { AssetFilterDropdown } from "./AssetFilterDropdown"; -import { applyFilters, getDistinctAssetCodes } from "./campaignsTableUtils"; +import { SortDropdown, SortOption } from "./SortDropdown"; +import { SearchInput } from "./SearchInput"; +import { applyFilters, getDistinctAssetCodes, sortCampaigns } from "./campaignsTableUtils"; import { useDebounce } from "../hooks/useDebounce"; interface CampaignsTableProps { @@ -28,11 +30,18 @@ export function CampaignsTable({ isLoading = false, }: CampaignsTableProps) { const [selectedAssetCode, setSelectedAssetCode] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [sortBy, setSortBy] = useState("newest"); + const debouncedSearchQuery = useDebounce(searchInput, 300); const distinctAssetCodes = useMemo(() => getDistinctAssetCodes(campaigns), [campaigns]); const filteredCampaigns = useMemo( () => applyFilters(campaigns, selectedAssetCode, "", debouncedSearchQuery), [campaigns, selectedAssetCode, debouncedSearchQuery], ); + const sortedCampaigns = useMemo( + () => sortCampaigns(filteredCampaigns, sortBy), + [filteredCampaigns, sortBy], + ); const isEmpty = campaigns.length === 0; if (isLoading && isEmpty) { @@ -86,9 +95,14 @@ export function CampaignsTable({ onChange={setSelectedAssetCode} disabled={false} /> + - {filteredCampaigns.length === 0 ? ( + {sortedCampaigns.length === 0 ? (

No campaigns match the current filters.

) : (
@@ -104,7 +118,7 @@ export function CampaignsTable({ - {filteredCampaigns.map((campaign) => ( + {sortedCampaigns.map((campaign) => (
diff --git a/frontend/src/components/SortDropdown.tsx b/frontend/src/components/SortDropdown.tsx new file mode 100644 index 0000000..c50bcb9 --- /dev/null +++ b/frontend/src/components/SortDropdown.tsx @@ -0,0 +1,38 @@ +export type SortOption = "newest" | "deadline" | "percentFunded" | "totalPledged"; + +export interface SortDropdownProps { + value: SortOption; + onChange: (value: SortOption) => void; + disabled?: boolean; +} + +export function SortDropdown({ + value, + onChange, + disabled = false, +}: SortDropdownProps) { + return ( + + ); +} diff --git a/frontend/src/components/campaignsTableUtils.test.ts b/frontend/src/components/campaignsTableUtils.test.ts index 754c553..0f2e1b2 100644 --- a/frontend/src/components/campaignsTableUtils.test.ts +++ b/frontend/src/components/campaignsTableUtils.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { searchCampaigns } from "./campaignsTableUtils"; +import { searchCampaigns, sortCampaigns } from "./campaignsTableUtils"; import type { Campaign } from "../types/campaign"; +import type { SortOption } from "./SortDropdown"; // Mock campaign data const mockCampaigns: Campaign[] = [ @@ -210,3 +211,180 @@ describe("searchCampaigns", () => { }); }); }); + +describe("sortCampaigns", () => { + const mockCampaigns: Campaign[] = [ + { + id: "1", + creator: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + title: "Campaign A", + description: "First campaign", + assetCode: "USDC", + targetAmount: 10000, + pledgedAmount: 5000, + deadline: 1710086400, + createdAt: 1710000000, + progress: { + status: "open", + percentFunded: 50, + remainingAmount: 5000, + pledgeCount: 3, + hoursLeft: 24, + canPledge: true, + canClaim: false, + canRefund: false, + }, + }, + { + id: "2", + creator: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + title: "Campaign B", + description: "Second campaign", + assetCode: "XLM", + targetAmount: 5000, + pledgedAmount: 2500, + deadline: 1710172800, + createdAt: 1710000100, + progress: { + status: "open", + percentFunded: 50, + remainingAmount: 2500, + pledgeCount: 5, + hoursLeft: 48, + canPledge: true, + canClaim: false, + canRefund: false, + }, + }, + { + id: "3", + creator: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + title: "Campaign C", + description: "Third campaign", + assetCode: "USDC", + targetAmount: 20000, + pledgedAmount: 15000, + deadline: 1710259200, + createdAt: 1710000200, + progress: { + status: "funded", + percentFunded: 75, + remainingAmount: 5000, + pledgeCount: 10, + hoursLeft: 72, + canPledge: false, + canClaim: true, + canRefund: false, + }, + }, + ]; + + describe("Sort by newest", () => { + it("should sort campaigns by createdAt descending (newest first)", () => { + const sorted = sortCampaigns(mockCampaigns, "newest"); + expect(sorted[0].id).toBe("3"); // createdAt: 1710000200 + expect(sorted[1].id).toBe("2"); // createdAt: 1710000100 + expect(sorted[2].id).toBe("1"); // createdAt: 1710000000 + }); + + it("should not mutate the original array", () => { + const original = [...mockCampaigns]; + sortCampaigns(mockCampaigns, "newest"); + expect(mockCampaigns).toEqual(original); + }); + }); + + describe("Sort by deadline", () => { + it("should sort campaigns by deadline ascending (nearest deadline first)", () => { + const sorted = sortCampaigns(mockCampaigns, "deadline"); + expect(sorted[0].id).toBe("1"); // deadline: 1710086400 + expect(sorted[1].id).toBe("2"); // deadline: 1710172800 + expect(sorted[2].id).toBe("3"); // deadline: 1710259200 + }); + }); + + describe("Sort by percentFunded", () => { + it("should sort campaigns by percentFunded descending (highest first)", () => { + const sorted = sortCampaigns(mockCampaigns, "percentFunded"); + expect(sorted[0].id).toBe("3"); // percentFunded: 75 + expect(sorted[1].id).toBe("1"); // percentFunded: 50 + expect(sorted[2].id).toBe("2"); // percentFunded: 50 + }); + + it("should maintain stable sort for equal percentFunded values", () => { + const sorted = sortCampaigns(mockCampaigns, "percentFunded"); + // Campaigns 1 and 2 both have 50% funded + // They should maintain their original relative order + const campaign1Index = sorted.findIndex((c) => c.id === "1"); + const campaign2Index = sorted.findIndex((c) => c.id === "2"); + expect(campaign1Index).toBeLessThan(campaign2Index); + }); + }); + + describe("Sort by totalPledged", () => { + it("should sort campaigns by pledgedAmount descending (largest first)", () => { + const sorted = sortCampaigns(mockCampaigns, "totalPledged"); + expect(sorted[0].id).toBe("3"); // pledgedAmount: 15000 + expect(sorted[1].id).toBe("1"); // pledgedAmount: 5000 + expect(sorted[2].id).toBe("2"); // pledgedAmount: 2500 + }); + }); + + describe("Edge cases", () => { + it("should handle empty campaign array", () => { + const sorted = sortCampaigns([], "newest"); + expect(sorted).toHaveLength(0); + }); + + it("should handle single campaign", () => { + const singleCampaign = [mockCampaigns[0]]; + const sorted = sortCampaigns(singleCampaign, "newest"); + expect(sorted).toHaveLength(1); + expect(sorted[0].id).toBe("1"); + }); + + it("should return a new array instance", () => { + const sorted = sortCampaigns(mockCampaigns, "newest"); + expect(sorted).not.toBe(mockCampaigns); + }); + + it("should preserve all campaign properties", () => { + const sorted = sortCampaigns(mockCampaigns, "newest"); + sorted.forEach((campaign) => { + const original = mockCampaigns.find((c) => c.id === campaign.id); + expect(campaign).toEqual(original); + }); + }); + }); + + describe("Stability", () => { + it("should maintain stable sort order for campaigns with equal sort values", () => { + // Create campaigns with same createdAt + const equalCreatedAt: Campaign[] = [ + { ...mockCampaigns[0], id: "A", createdAt: 1000 }, + { ...mockCampaigns[1], id: "B", createdAt: 1000 }, + { ...mockCampaigns[2], id: "C", createdAt: 1000 }, + ]; + + const sorted = sortCampaigns(equalCreatedAt, "newest"); + // All have same createdAt, so order should be preserved + expect(sorted[0].id).toBe("A"); + expect(sorted[1].id).toBe("B"); + expect(sorted[2].id).toBe("C"); + }); + + it("should maintain stable sort order for campaigns with equal deadlines", () => { + const equalDeadline: Campaign[] = [ + { ...mockCampaigns[0], id: "A", deadline: 1000 }, + { ...mockCampaigns[1], id: "B", deadline: 1000 }, + { ...mockCampaigns[2], id: "C", deadline: 1000 }, + ]; + + const sorted = sortCampaigns(equalDeadline, "deadline"); + // All have same deadline, so order should be preserved + expect(sorted[0].id).toBe("A"); + expect(sorted[1].id).toBe("B"); + expect(sorted[2].id).toBe("C"); + }); + }); +}); diff --git a/frontend/src/components/campaignsTableUtils.ts b/frontend/src/components/campaignsTableUtils.ts index 81c8b4c..5c6cceb 100644 --- a/frontend/src/components/campaignsTableUtils.ts +++ b/frontend/src/components/campaignsTableUtils.ts @@ -1,4 +1,5 @@ import type { Campaign } from "../types/campaign"; +import type { SortOption } from "./SortDropdown"; /** * Returns sorted, deduplicated assetCode values from the given campaigns. @@ -73,3 +74,55 @@ export function applyFilters( return filtered; } + +/** + * Sorts campaigns by the specified sort option. + * + * Sorting is stable - campaigns with equal sort values maintain their original order. + * This ensures the selected campaign state is preserved during sorting. + * + * @param campaigns - Array of campaigns to sort + * @param sortBy - Sort option (newest, deadline, percentFunded, totalPledged) + * @returns Sorted array of campaigns + */ +export function sortCampaigns( + campaigns: Campaign[], + sortBy: SortOption, +): Campaign[] { + // Create a copy to avoid mutating the original array + const sorted = [...campaigns]; + + sorted.sort((a, b) => { + let comparison = 0; + + switch (sortBy) { + case "newest": + // Sort by createdAt descending (newest first) + comparison = b.createdAt - a.createdAt; + break; + + case "deadline": + // Sort by deadline ascending (nearest deadline first) + comparison = a.deadline - b.deadline; + break; + + case "percentFunded": + // Sort by percentFunded descending (highest first) + comparison = b.progress.percentFunded - a.progress.percentFunded; + break; + + case "totalPledged": + // Sort by pledgedAmount descending (largest first) + comparison = b.pledgedAmount - a.pledgedAmount; + break; + + default: + // No sorting for unknown options + comparison = 0; + } + + return comparison; + }); + + return sorted; +}