diff --git a/web/src/components/ProposalList.test.tsx b/web/src/components/ProposalList.test.tsx index d26087fe..1df6274b 100644 --- a/web/src/components/ProposalList.test.tsx +++ b/web/src/components/ProposalList.test.tsx @@ -943,3 +943,95 @@ describe('ProposalList', () => { ).toBe('proposal-hivemoot-hivemoot-3'); }); }); + +describe('ProposalList search and filter', () => { + const repoUrl = 'https://github.com/hivemoot/colony'; + + const proposals: Proposal[] = [ + { + number: 1, + title: 'Add benchmarking panel', + phase: 'implemented', + author: 'builder', + createdAt: '2026-01-01T00:00:00Z', + commentCount: 5, + }, + { + number: 2, + title: 'Proposal detail view', + phase: 'discussion', + author: 'nurse', + createdAt: '2026-01-02T00:00:00Z', + commentCount: 3, + }, + { + number: 3, + title: 'External outreach', + phase: 'voting', + author: 'scout', + createdAt: '2026-01-03T00:00:00Z', + commentCount: 2, + }, + ]; + + beforeEach(() => { + window.location.hash = ''; + window.history.replaceState(null, '', window.location.pathname); + }); + + it('renders search input and phase filter buttons', () => { + render(); + expect( + screen.getByRole('searchbox', { name: /search proposals/i }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /^active$/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /^decided$/i }) + ).toBeInTheDocument(); + }); + + it('filters proposals by search query', () => { + render(); + const input = screen.getByRole('searchbox', { name: /search proposals/i }); + fireEvent.change(input, { target: { value: 'benchmarking' } }); + expect(screen.getByText('Add benchmarking panel')).toBeInTheDocument(); + expect(screen.queryByText('Proposal detail view')).not.toBeInTheDocument(); + }); + + it('shows no-match message when search yields nothing', () => { + render(); + const input = screen.getByRole('searchbox', { name: /search proposals/i }); + fireEvent.change(input, { target: { value: 'xyzzy' } }); + expect(screen.getByText(/no proposals match/i)).toBeInTheDocument(); + }); + + it('filters by Active phase', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /^active$/i })); + // discussion and voting are active; implemented is decided + expect(screen.getByText('Proposal detail view')).toBeInTheDocument(); + expect(screen.getByText('External outreach')).toBeInTheDocument(); + expect( + screen.queryByText('Add benchmarking panel') + ).not.toBeInTheDocument(); + }); + + it('filters by Decided phase', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /^decided$/i })); + expect(screen.getByText('Add benchmarking panel')).toBeInTheDocument(); + expect(screen.queryByText('Proposal detail view')).not.toBeInTheDocument(); + expect(screen.queryByText('External outreach')).not.toBeInTheDocument(); + }); + + it('All button resets phase filter', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /^decided$/i })); + fireEvent.click(screen.getByRole('button', { name: /^all$/i })); + expect(screen.getByText('Proposal detail view')).toBeInTheDocument(); + expect(screen.getByText('Add benchmarking panel')).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/ProposalList.tsx b/web/src/components/ProposalList.tsx index 89744a89..419cf980 100644 --- a/web/src/components/ProposalList.tsx +++ b/web/src/components/ProposalList.tsx @@ -6,6 +6,7 @@ import { buildDecisionSnapshot, getProposalHash, } from '../utils/decision-explorer'; +import { filterProposals, type ProposalPhaseFilter } from '../utils/governance'; interface ProposalListProps { proposals: Proposal[]; @@ -15,6 +16,41 @@ interface ProposalListProps { filteredAgent?: string | null; } +function readSearchParams(): { + query: string; + phaseFilter: ProposalPhaseFilter; +} { + const params = new URLSearchParams(window.location.search); + const q = params.get('q') ?? ''; + const filter = params.get('filter') ?? 'all'; + const phaseFilter: ProposalPhaseFilter = + filter === 'active' || filter === 'decided' ? filter : 'all'; + return { query: q, phaseFilter }; +} + +function updateSearchParams( + query: string, + phaseFilter: ProposalPhaseFilter +): void { + const params = new URLSearchParams(window.location.search); + if (query) { + params.set('q', query); + } else { + params.delete('q'); + } + if (phaseFilter !== 'all') { + params.set('filter', phaseFilter); + } else { + params.delete('filter'); + } + const search = params.toString(); + const newUrl = + window.location.pathname + + (search ? `?${search}` : '') + + window.location.hash; + window.history.replaceState(null, '', newUrl); +} + function findProposalByHash( hash: string, proposals: Proposal[] @@ -24,6 +60,15 @@ function findProposalByHash( return proposals.find((p) => getProposalHash(p) === fragment) ?? null; } +const PHASE_FILTER_OPTIONS: Array<{ + value: ProposalPhaseFilter; + label: string; +}> = [ + { value: 'all', label: 'All' }, + { value: 'active', label: 'Active' }, + { value: 'decided', label: 'Decided' }, +]; + export function ProposalList({ proposals, pullRequests = [], @@ -40,6 +85,12 @@ export function ProposalList({ return match ? getProposalIdentity(match) : null; } ); + const [searchQuery, setSearchQuery] = useState( + () => readSearchParams().query + ); + const [phaseFilter, setPhaseFilter] = useState( + () => readSearchParams().phaseFilter + ); const detailRef = useRef(null); // Resolve hash-based deep link once proposals are available @@ -140,6 +191,28 @@ export function ProposalList({ return [...unique.entries()].sort((a, b) => b[1].count - a[1].count); }, [proposalComments]); + const visibleProposals = useMemo( + () => filterProposals(proposals, searchQuery, phaseFilter), + [proposals, searchQuery, phaseFilter] + ); + + const handleQueryChange = useCallback( + (e: React.ChangeEvent): void => { + const q = e.target.value; + setSearchQuery(q); + updateSearchParams(q, phaseFilter); + }, + [phaseFilter] + ); + + const handlePhaseFilterChange = useCallback( + (next: ProposalPhaseFilter): void => { + setPhaseFilter(next); + updateSearchParams(searchQuery, next); + }, + [searchQuery] + ); + if (proposals.length === 0) { return (

@@ -152,8 +225,44 @@ export function ProposalList({ return (

+
+ +
+ {PHASE_FILTER_OPTIONS.map(({ value, label }) => ( + + ))} +
+
+ {visibleProposals.length === 0 && ( +

+ No proposals match your search. +

+ )}
- {proposals.map((proposal) => { + {visibleProposals.map((proposal) => { const proposalId = getProposalIdentity(proposal); const explorerId = getDecisionExplorerId(proposal); const isSelected = proposalId === effectiveSelectedId; diff --git a/web/src/utils/governance.test.ts b/web/src/utils/governance.test.ts index 7d3eace6..df50833b 100644 --- a/web/src/utils/governance.test.ts +++ b/web/src/utils/governance.test.ts @@ -6,6 +6,7 @@ import { computeAgentRoles, computeTopProposers, computeThroughput, + filterProposals, } from './governance'; function makeProposal(overrides: Partial = {}): Proposal { @@ -604,3 +605,89 @@ describe('computeThroughput', () => { expect(result.resolvedCount).toBe(2); }); }); + +describe('filterProposals', () => { + function p( + number: number, + title: string, + phase: Proposal['phase'], + body?: string + ): Proposal { + return makeProposal({ number, title, phase, body }); + } + + const proposals = [ + p(1, 'Add benchmarking panel', 'implemented'), + p(2, 'Proposal detail view', 'discussion'), + p(3, 'Searchable archive', 'ready-to-implement', 'Improve discoverability'), + p(4, 'External outreach', 'voting'), + p(5, 'Dark mode', 'rejected'), + p(6, 'Heatmap feature', 'inconclusive'), + ]; + + it('returns all proposals when query is empty and filter is all', () => { + expect(filterProposals(proposals, '', 'all')).toHaveLength(6); + }); + + it('filters by text query (case-insensitive title match)', () => { + const result = filterProposals(proposals, 'benchmarking', 'all'); + expect(result).toHaveLength(1); + expect(result[0].number).toBe(1); + }); + + it('filters by text query matching body', () => { + const result = filterProposals(proposals, 'discoverability', 'all'); + expect(result).toHaveLength(1); + expect(result[0].number).toBe(3); + }); + + it('is case-insensitive', () => { + const result = filterProposals(proposals, 'BENCHMARKING', 'all'); + expect(result).toHaveLength(1); + }); + + it('returns empty when query matches nothing', () => { + expect(filterProposals(proposals, 'xyzzy', 'all')).toHaveLength(0); + }); + + it('filters active phases (discussion, voting, extended-voting, ready-to-implement)', () => { + const result = filterProposals(proposals, '', 'active'); + const phases = result.map((p) => p.phase); + expect(phases).toContain('discussion'); + expect(phases).toContain('voting'); + expect(phases).toContain('ready-to-implement'); + expect(phases).not.toContain('implemented'); + expect(phases).not.toContain('rejected'); + expect(phases).not.toContain('inconclusive'); + }); + + it('filters decided phases (implemented, rejected, inconclusive)', () => { + const result = filterProposals(proposals, '', 'decided'); + const phases = result.map((p) => p.phase); + expect(phases).toContain('implemented'); + expect(phases).toContain('rejected'); + expect(phases).toContain('inconclusive'); + expect(phases).not.toContain('discussion'); + expect(phases).not.toContain('voting'); + expect(phases).not.toContain('ready-to-implement'); + }); + + it('combines text query and phase filter', () => { + // 'archive' matches proposal #3 (ready-to-implement = active) + const result = filterProposals(proposals, 'archive', 'active'); + expect(result).toHaveLength(1); + expect(result[0].number).toBe(3); + }); + + it('returns empty when text matches but phase filter excludes', () => { + // 'benchmarking' matches #1 (implemented = decided), but active filter excludes it + const result = filterProposals(proposals, 'benchmarking', 'active'); + expect(result).toHaveLength(0); + }); + + it('treats undefined body as empty string (no crash)', () => { + const noBody = [p(10, 'Titled only', 'discussion')]; + const result = filterProposals(noBody, 'bodytext', 'all'); + expect(result).toHaveLength(0); + }); +}); diff --git a/web/src/utils/governance.ts b/web/src/utils/governance.ts index 9e9deca0..872d49e3 100644 --- a/web/src/utils/governance.ts +++ b/web/src/utils/governance.ts @@ -284,3 +284,51 @@ function median(values: number[]): number | null { ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } + +/** + * Phase filter values for the governance archive search. + * - 'all': no phase filter applied + * - 'active': open governance phases (discussion, voting, extended-voting, ready-to-implement) + * - 'decided': terminal phases (implemented, rejected, inconclusive) + */ +export type ProposalPhaseFilter = 'all' | 'active' | 'decided'; + +const ACTIVE_FILTER_PHASES = new Set([ + 'discussion', + 'voting', + 'extended-voting', + 'ready-to-implement', +]); + +const DECIDED_FILTER_PHASES = new Set([ + 'implemented', + 'rejected', + 'inconclusive', +]); + +/** + * Filter proposals by a text query and phase bucket. + * + * Text matching is case-insensitive and matches against title and body. + * An empty query matches all proposals. + */ +export function filterProposals( + proposals: Proposal[], + query: string, + phaseFilter: ProposalPhaseFilter +): Proposal[] { + const trimmed = query.trim().toLowerCase(); + + return proposals.filter((p) => { + if (phaseFilter === 'active' && !ACTIVE_FILTER_PHASES.has(p.phase)) { + return false; + } + if (phaseFilter === 'decided' && !DECIDED_FILTER_PHASES.has(p.phase)) { + return false; + } + if (!trimmed) return true; + const titleMatch = p.title.toLowerCase().includes(trimmed); + const bodyMatch = (p.body ?? '').toLowerCase().includes(trimmed); + return titleMatch || bodyMatch; + }); +}