Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ddfb0a8
Add ending-soon to SortOption type definition
Mosas2000 Apr 23, 2026
0dfcf6b
Register ending-soon in VALID_SORTS validation set
Mosas2000 Apr 23, 2026
06e12a8
Widen sortProposals signature to accept ending-soon
Mosas2000 Apr 23, 2026
799f037
Implement ending-soon sort logic in sortProposals
Mosas2000 Apr 23, 2026
208f76c
Import SortOption from proposal-params in SortDropdown
Mosas2000 Apr 23, 2026
f927ff5
Add ending-soon option to SortDropdown options list
Mosas2000 Apr 23, 2026
7bae97e
Add sort description map to SortDropdown
Mosas2000 Apr 23, 2026
431d12e
Show active sort description below the dropdown trigger
Mosas2000 Apr 23, 2026
2b3471e
Rewrite SortDropdown with full sorting controls support
Mosas2000 Apr 23, 2026
3fa3f1c
Add ending-soon to ProposalSortbar default options
Mosas2000 Apr 23, 2026
0b5e6e1
Add ending-soon case to ProposalList sort switch
Mosas2000 Apr 23, 2026
767ba84
Show active sort description below the toolbar in ProposalList
Mosas2000 Apr 23, 2026
4113ba1
Test parseSort accepts ending-soon as valid sort value
Mosas2000 Apr 23, 2026
b452866
Test serializeParams serializes ending-soon to query string
Mosas2000 Apr 23, 2026
4cf5a15
Test countActiveFilters counts ending-soon as active filter
Mosas2000 Apr 23, 2026
5b19a87
Test ending-soon sort round-trips through serialize and parse
Mosas2000 Apr 23, 2026
f651375
Test ending-soon places active proposals before executed ones
Mosas2000 Apr 23, 2026
6bedcd0
Test ending-soon orders active proposals by ascending createdAt
Mosas2000 Apr 23, 2026
4caceb9
Add test coverage for highest and lowest amount sort modes
Mosas2000 Apr 23, 2026
d4ac764
Test isDefaultParams returns false when sort is ending-soon
Mosas2000 Apr 23, 2026
ba23eec
Test ending-soon sorts all-executed proposals by ascending createdAt
Mosas2000 Apr 23, 2026
5c457d2
Add keyboard arrow-key navigation to SortDropdown
Mosas2000 Apr 23, 2026
db86fe0
Add aria-live region to ProposalList sort description for screen readers
Mosas2000 Apr 23, 2026
acef050
Add aria-live region to SortDropdown description for screen readers
Mosas2000 Apr 23, 2026
b95fe7c
Add aria-controls and listbox ID to SortDropdown for a11y
Mosas2000 Apr 23, 2026
77886ed
Add aria-activedescendant to SortDropdown for keyboard a11y
Mosas2000 Apr 23, 2026
4d518a6
Test sortProposals highest amount sort handles equal amounts gracefully
Mosas2000 Apr 23, 2026
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
15 changes: 15 additions & 0 deletions frontend/components/ProposalList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,9 @@ export default function ProposalList({ userAddress }: ProposalListProps) {
const totalVotesB = b.votesFor + b.votesAgainst;
return totalVotesB - totalVotesA;
}
case 'ending-soon':
if (a.executed !== b.executed) return a.executed ? 1 : -1;
return a.createdAt - b.createdAt;
default:
return 0;
}
Expand Down Expand Up @@ -511,6 +514,18 @@ export default function ProposalList({ userAddress }: ProposalListProps) {
</div>
</div>

{filterParams.sort !== 'newest' && (
<p className="mb-4 text-xs text-purple-300/70" aria-live="polite" role="status">
{{
'oldest': 'Sorted by earliest created',
'ending-soon': 'Sorted by active proposals closest to their deadline',
'highest': 'Sorted by largest funding request',
'lowest': 'Sorted by smallest funding request',
'most-votes': 'Sorted by highest total vote count',
}[filterParams.sort]}
</p>
)}

<div className="space-y-4">
{sortedProposals.length === 0 && filterParams.q ? (
<div className="text-center py-12">
Expand Down
1 change: 1 addition & 0 deletions frontend/components/ProposalSortbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface ProposalSortbarProps {

const DEFAULT_SORT_OPTIONS: SortOption[] = [
{ label: 'Date Created', value: 'createdAt' },
{ label: 'Ending Soon', value: 'endingSoon' },
{ label: 'Title', value: 'title' },
{ label: 'Funding Requested', value: 'requestedAmount' },
{ label: 'Vote Count', value: 'votes' },
Expand Down
159 changes: 125 additions & 34 deletions frontend/components/SortDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,89 +1,180 @@
'use client';

import { useState, useRef, useEffect } from 'react';

export type SortOption = 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes';
import { useState, useRef, useEffect, useCallback } from 'react';
import type { SortOption } from '../src/lib/proposal-params';

interface SortDropdownProps {
onSortChange: (sort: SortOption) => void;
sort?: SortOption;
}

const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'newest', label: 'Newest First' },
{ value: 'oldest', label: 'Oldest First' },
{ value: 'ending-soon', label: 'Ending Soon' },
{ value: 'highest', label: 'Highest Amount' },
{ value: 'lowest', label: 'Lowest Amount' },
{ value: 'most-votes', label: 'Most Votes' },
];

const SORT_DESCRIPTIONS: Record<SortOption, string> = {
newest: 'Most recently created proposals first',
oldest: 'Earliest created proposals first',
'ending-soon': 'Active proposals closest to their deadline',
highest: 'Largest funding requests first',
lowest: 'Smallest funding requests first',
'most-votes': 'Proposals with the most total votes',
};

export default function SortDropdown({ onSortChange, sort }: SortDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [internalSort, setInternalSort] = useState<SortOption>('newest');
const [activeIndex, setActiveIndex] = useState(-1);

const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);

const selectedSort = sort ?? internalSort;

const sortOptions = [
{ value: 'newest' as const, label: 'Newest First' },
{ value: 'oldest' as const, label: 'Oldest First' },
{ value: 'highest' as const, label: 'Highest Amount' },
{ value: 'lowest' as const, label: 'Lowest Amount' },
{ value: 'most-votes' as const, label: 'Most Votes' },
];
const close = useCallback(() => {
setIsOpen(false);
setActiveIndex(-1);
}, []);

useEffect(() => {
if (isOpen) {
const index = SORT_OPTIONS.findIndex(o => o.value === selectedSort);
setActiveIndex(index >= 0 ? index : 0);
}
}, [isOpen, selectedSort]);

// Click outside to close
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
close();
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}, [close]);

useEffect(() => {
if (!isOpen) return;

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
triggerRef.current?.focus();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
setActiveIndex(prev => (prev < SORT_OPTIONS.length - 1 ? prev + 1 : 0));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActiveIndex(prev => (prev > 0 ? prev - 1 : SORT_OPTIONS.length - 1));
} else if (event.key === 'Enter') {
event.preventDefault();
setActiveIndex(prev => {
const opt = SORT_OPTIONS[prev];
if (opt) {
if (sort === undefined) setInternalSort(opt.value);
onSortChange(opt.value);
close();
triggerRef.current?.focus();
}
return prev;
});
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, close, onSortChange, sort]);

const handleSortSelect = (s: SortOption) => {
if (sort === undefined) setInternalSort(s);
onSortChange(s);
setIsOpen(false);
close();
};

const selectedLabel = sortOptions.find(s => s.value === selectedSort)?.label || 'Newest First';
const selectedLabel = SORT_OPTIONS.find(s => s.value === selectedSort)?.label ?? 'Newest First';

return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-white/20 bg-white/10 px-4 py-3 text-white shadow-md transition-all hover:bg-white/20 sm:w-auto sm:justify-start sm:py-2"
ref={triggerRef}
onClick={() => setIsOpen(prev => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? 'sort-options-list' : undefined}
aria-activedescendant={isOpen && activeIndex >= 0 ? `sort-option-${SORT_OPTIONS[activeIndex].value}` : undefined}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-white/20 bg-white/10 px-4 py-3 text-white shadow-md transition-all hover:bg-white/20 sm:w-auto sm:justify-start sm:py-2 focus:outline-none focus:ring-2 focus:ring-purple-500/50"
>
<svg
className="w-4 h-4"
className="w-4 h-4 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
<span className="text-sm font-medium">{selectedLabel}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
className={`w-4 h-4 shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>

{selectedSort !== 'newest' && (
<p className="mt-1 text-xs text-purple-300/70" aria-live="polite" role="status">
{SORT_DESCRIPTIONS[selectedSort]}
</p>
)}

{isOpen && (
<div className="absolute left-0 right-0 top-full z-10 mt-2 overflow-hidden rounded-lg border border-white/20 bg-gray-800 shadow-md sm:left-auto sm:right-0 sm:w-48">
{sortOptions.map((option) => (
<button
key={option.value}
onClick={() => handleSortSelect(option.value)}
className={`w-full text-left px-4 py-3 text-sm transition-colors ${selectedSort === option.value
? 'bg-purple-500/20 text-purple-200 font-semibold'
: 'text-white hover:bg-white/10'
}`}
>
{option.label}
</button>
))}
</div>
<ul
id="sort-options-list"
role="listbox"
aria-label="Sort proposals by"
className="absolute left-0 right-0 top-full z-10 mt-2 overflow-hidden rounded-lg border border-white/20 bg-gray-800 shadow-md sm:left-auto sm:right-0 sm:w-52"
>
{SORT_OPTIONS.map((option, index) => {
const isSelected = selectedSort === option.value;
const isActive = activeIndex === index;

return (
<li
key={option.value}
id={`sort-option-${option.value}`}
role="option"
aria-selected={isSelected}
>
<button
onClick={() => handleSortSelect(option.value)}
tabIndex={-1}
onMouseEnter={() => setActiveIndex(index)}
className={`w-full text-left px-4 py-3 text-sm transition-colors ${
isActive ? 'bg-white/10' : ''
} ${
isSelected
? 'text-purple-200 font-semibold'
: 'text-white'
}`}
>
<span className="block">{option.label}</span>
<span className={`block text-xs mt-0.5 ${isSelected ? 'text-purple-300/80' : 'text-purple-300/60'}`}>
{SORT_DESCRIPTIONS[option.value]}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
);
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/lib/proposal-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ describe('parseSort', () => {
});

it('accepts all valid sort options', () => {
const valid = ['newest', 'oldest', 'highest', 'lowest', 'most-votes'] as const;
const valid = ['newest', 'oldest', 'highest', 'lowest', 'most-votes', 'ending-soon'] as const;
valid.forEach((s) => expect(parseSort(s)).toBe(s));
});

it('accepts ending-soon as a valid sort value', () => {
expect(parseSort('ending-soon')).toBe('ending-soon');
});

it('returns default for unknown value', () => {
expect(parseSort('alphabetical')).toBe('newest');
expect(parseSort('')).toBe('newest');
Expand Down Expand Up @@ -139,6 +143,11 @@ describe('serializeParams', () => {
expect(result.get('sort')).toBe('most-votes');
});

it('serializes ending-soon sort to query string', () => {
const result = serializeParams({ ...DEFAULT_PARAMS, sort: 'ending-soon' });
expect(result.get('sort')).toBe('ending-soon');
});

it('includes non-default search query', () => {
const result = serializeParams({ ...DEFAULT_PARAMS, q: 'treasury' });
expect(result.get('q')).toBe('treasury');
Expand Down Expand Up @@ -171,6 +180,20 @@ describe('serializeParams', () => {
const parsed = parseSearchParams(serialized);
expect(parsed).toEqual(original);
});

it('round-trips ending-soon sort through parse and serialize', () => {
const original: ProposalFilterParams = {
status: 'active',
category: 'all',
sort: 'ending-soon',
q: '',
page: 1,
};
const serialized = serializeParams(original);
const parsed = parseSearchParams(serialized);
expect(parsed.sort).toBe('ending-soon');
expect(parsed.status).toBe('active');
});
});

describe('buildProposalUrl', () => {
Expand Down Expand Up @@ -213,6 +236,10 @@ describe('countActiveFilters', () => {
expect(countActiveFilters({ ...DEFAULT_PARAMS, sort: 'oldest' })).toBe(1);
});

it('counts ending-soon as an active sort filter', () => {
expect(countActiveFilters({ ...DEFAULT_PARAMS, sort: 'ending-soon' })).toBe(1);
});

it('counts search query', () => {
expect(countActiveFilters({ ...DEFAULT_PARAMS, q: 'hello' })).toBe(1);
});
Expand Down Expand Up @@ -254,6 +281,10 @@ describe('isDefaultParams', () => {
expect(isDefaultParams({ ...DEFAULT_PARAMS, sort: 'oldest' })).toBe(false);
});

it('returns false when sort is ending-soon', () => {
expect(isDefaultParams({ ...DEFAULT_PARAMS, sort: 'ending-soon' })).toBe(false);
});

it('returns false when q is set', () => {
expect(isDefaultParams({ ...DEFAULT_PARAMS, q: 'governance' })).toBe(false);
});
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/proposal-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type CategoryFilter =
| 'community'
| 'research'
| 'other';
export type SortOption = 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes';
export type SortOption = 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes' | 'ending-soon';

export interface ProposalFilterParams {
status: StatusFilter;
Expand Down Expand Up @@ -45,6 +45,7 @@ const VALID_SORTS = new Set<SortOption>([
'highest',
'lowest',
'most-votes',
'ending-soon',
]);

export function parseStatus(value: string | null): StatusFilter {
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/lib/proposal-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,55 @@ describe('Proposal utilities', () => {
expect(sorted[0].createdAt).toBeLessThanOrEqual(sorted[1].createdAt);
});

it('sorts by highest amount', () => {
const low = { ...mockProposal, id: 1, amount: 500 };
const high = { ...mockProposal, id: 2, amount: 5000 };
const sorted = sortProposals([low, high], 'highest');
expect(sorted[0].amount).toBeGreaterThan(sorted[1].amount);
});

it('sorts by lowest amount', () => {
const low = { ...mockProposal, id: 1, amount: 500 };
const high = { ...mockProposal, id: 2, amount: 5000 };
const sorted = sortProposals([high, low], 'lowest');
expect(sorted[0].amount).toBeLessThan(sorted[1].amount);
});

it('sorts by highest amount with equal amounts', () => {
const p1 = { ...mockProposal, id: 1, amount: 1000 };
const p2 = { ...mockProposal, id: 2, amount: 1000 };
const sorted = sortProposals([p1, p2], 'highest');
expect(sorted[0].amount).toEqual(sorted[1].amount);
});

it('sorts by most votes', () => {
const p1 = { ...mockProposal, votesFor: 10, votesAgainst: 5 };
const p2 = { ...mockProposal, id: 2, votesFor: 50, votesAgainst: 20 };
const sorted = sortProposals([p1, p2], 'most-votes');
expect(calculateTotalVotes(sorted[0])).toBeGreaterThan(calculateTotalVotes(sorted[1]));
});

it('ending-soon: places active proposals before executed ones', () => {
const active = { ...mockProposal, id: 1, executed: false, createdAt: 1000 };
const executed = { ...mockProposal, id: 2, executed: true, createdAt: 500 };
const sorted = sortProposals([executed, active], 'ending-soon');
expect(sorted[0].executed).toBe(false);
expect(sorted[1].executed).toBe(true);
});

it('ending-soon: orders active proposals by ascending createdAt', () => {
const older = { ...mockProposal, id: 1, executed: false, createdAt: 100 };
const newer = { ...mockProposal, id: 2, executed: false, createdAt: 500 };
const sorted = sortProposals([newer, older], 'ending-soon');
expect(sorted[0].createdAt).toBeLessThan(sorted[1].createdAt);
});

it('ending-soon: sorts executed proposals by ascending createdAt when all are executed', () => {
const early = { ...mockProposal, id: 1, executed: true, createdAt: 200 };
const late = { ...mockProposal, id: 2, executed: true, createdAt: 800 };
const sorted = sortProposals([late, early], 'ending-soon');
expect(sorted[0].createdAt).toBeLessThan(sorted[1].createdAt);
});
});

describe('findProposal', () => {
Expand Down
Loading
Loading