Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
92 changes: 92 additions & 0 deletions web/src/components/ProposalList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ProposalList proposals={proposals} repoUrl={repoUrl} />);
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(<ProposalList proposals={proposals} repoUrl={repoUrl} />);
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(<ProposalList proposals={proposals} repoUrl={repoUrl} />);
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(<ProposalList proposals={proposals} repoUrl={repoUrl} />);
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(<ProposalList proposals={proposals} repoUrl={repoUrl} />);
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(<ProposalList proposals={proposals} repoUrl={repoUrl} />);
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();
});
});
111 changes: 110 additions & 1 deletion web/src/components/ProposalList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildDecisionSnapshot,
getProposalHash,
} from '../utils/decision-explorer';
import { filterProposals, type ProposalPhaseFilter } from '../utils/governance';

interface ProposalListProps {
proposals: Proposal[];
Expand All @@ -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[]
Expand All @@ -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 = [],
Expand All @@ -40,6 +85,12 @@ export function ProposalList({
return match ? getProposalIdentity(match) : null;
}
);
const [searchQuery, setSearchQuery] = useState<string>(
() => readSearchParams().query
);
const [phaseFilter, setPhaseFilter] = useState<ProposalPhaseFilter>(
() => readSearchParams().phaseFilter
);
const detailRef = useRef<HTMLElement>(null);

// Resolve hash-based deep link once proposals are available
Expand Down Expand Up @@ -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<HTMLInputElement>): 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 (
<p className="text-sm text-amber-600 dark:text-amber-400 italic">
Expand All @@ -152,8 +225,44 @@ export function ProposalList({

return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="search"
value={searchQuery}
onChange={handleQueryChange}
placeholder="Search proposals…"
aria-label="Search proposals"
className="flex-1 rounded-md border border-amber-200 dark:border-neutral-600 bg-white/60 dark:bg-neutral-800/60 px-3 py-1.5 text-sm text-amber-900 dark:text-amber-100 placeholder-amber-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-400 dark:focus:ring-amber-500"
/>
<div
role="group"
aria-label="Filter by phase"
className="flex gap-1 shrink-0"
>
{PHASE_FILTER_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => handlePhaseFilterChange(value)}
aria-pressed={phaseFilter === value}
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 ${
phaseFilter === value
? 'bg-amber-500 text-white border-amber-500 dark:bg-amber-600 dark:border-amber-600'
: 'bg-white/60 dark:bg-neutral-800/60 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-neutral-600 hover:bg-amber-50 dark:hover:bg-neutral-700'
}`}
>
{label}
</button>
))}
</div>
</div>
{visibleProposals.length === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400 italic">
No proposals match your search.
</p>
)}
<div className="grid gap-4 sm:grid-cols-2">
{proposals.map((proposal) => {
{visibleProposals.map((proposal) => {
const proposalId = getProposalIdentity(proposal);
const explorerId = getDecisionExplorerId(proposal);
const isSelected = proposalId === effectiveSelectedId;
Expand Down
87 changes: 87 additions & 0 deletions web/src/utils/governance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
computeAgentRoles,
computeTopProposers,
computeThroughput,
filterProposals,
} from './governance';

function makeProposal(overrides: Partial<Proposal> = {}): Proposal {
Expand Down Expand Up @@ -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);
});
});
48 changes: 48 additions & 0 deletions web/src/utils/governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}