Add epic filter to kanban board

- Add epicId filter to KanbanFilterOptions
- Filter issues by parent epic when epicId is set
- Add epic dropdown to kanban controls with title-first format
- Pass epics list from kanban page to controls
This commit is contained in:
zenchantlive 2026-02-13 12:35:17 -08:00
parent 2cfaa9b406
commit 74871545c7
3 changed files with 28 additions and 0 deletions

View file

@ -3,12 +3,14 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban'; import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { StatPill } from '../shared/stat-pill'; import { StatPill } from '../shared/stat-pill';
interface KanbanControlsProps { interface KanbanControlsProps {
filters: KanbanFilterOptions; filters: KanbanFilterOptions;
stats: KanbanStats; stats: KanbanStats;
epics: BeadIssue[];
onFiltersChange: (filters: KanbanFilterOptions) => void; onFiltersChange: (filters: KanbanFilterOptions) => void;
onNextActionable: () => void; onNextActionable: () => void;
nextActionableFeedback?: string | null; nextActionableFeedback?: string | null;
@ -17,6 +19,7 @@ interface KanbanControlsProps {
export function KanbanControls({ export function KanbanControls({
filters, filters,
stats, stats,
epics,
onFiltersChange, onFiltersChange,
onNextActionable, onNextActionable,
nextActionableFeedback = null, nextActionableFeedback = null,
@ -34,6 +37,19 @@ export function KanbanControls({
placeholder="Search by id/title/labels" placeholder="Search by id/title/labels"
className={`${inputClass} w-full sm:min-w-[18rem] sm:flex-1`} className={`${inputClass} w-full sm:min-w-[18rem] sm:flex-1`}
/> />
<select
value={filters.epicId ?? ''}
onChange={(event) => onFiltersChange({ ...filters, epicId: event.target.value || undefined })}
className={`${inputClass} ui-select w-full sm:w-52`}
aria-label="Epic filter"
>
<option className="ui-option" value="">All epics</option>
{(epics ?? []).map((epic) => (
<option className="ui-option" key={epic.id} value={epic.id}>
{epic.title.slice(0, 40)}{epic.title.length > 40 ? '…' : ''} {epic.id}
</option>
))}
</select>
<select <select
value={filters.type ?? ''} value={filters.type ?? ''}
onChange={(event) => onFiltersChange({ ...filters, type: event.target.value })} onChange={(event) => onFiltersChange({ ...filters, type: event.target.value })}

View file

@ -282,6 +282,7 @@ export function KanbanPage({
<KanbanControls <KanbanControls
filters={filters} filters={filters}
stats={stats} stats={stats}
epics={localIssues.filter((issue) => issue.issue_type === 'epic')}
onFiltersChange={setFilters} onFiltersChange={setFilters}
onNextActionable={handleNextActionable} onNextActionable={handleNextActionable}
nextActionableFeedback={nextActionableFeedback} nextActionableFeedback={nextActionableFeedback}

View file

@ -11,6 +11,7 @@ export interface KanbanFilterOptions {
type?: string; type?: string;
priority?: string; priority?: string;
showClosed?: boolean; showClosed?: boolean;
epicId?: string;
} }
export interface KanbanStats { export interface KanbanStats {
@ -123,6 +124,7 @@ export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOpt
const type = (filters.type ?? '').trim().toLowerCase(); const type = (filters.type ?? '').trim().toLowerCase();
const priority = (filters.priority ?? '').trim(); const priority = (filters.priority ?? '').trim();
const showClosed = filters.showClosed ?? false; const showClosed = filters.showClosed ?? false;
const epicId = filters.epicId?.trim();
return issues.filter((issue) => { return issues.filter((issue) => {
if (!showClosed && issue.status === 'closed') { if (!showClosed && issue.status === 'closed') {
@ -144,6 +146,15 @@ export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOpt
return false; return false;
} }
if (epicId) {
// Filter to show only tasks belonging to this epic
const parentDep = issue.dependencies.find((dep) => dep.type === 'parent');
const issueEpicId = parentDep?.target ?? (issue.id.includes('.') ? issue.id.split('.')[0] : null);
if (issueEpicId !== epicId) {
return false;
}
}
return true; return true;
}); });
} }