feat(FN-3424): add unassigned branch filter options to dashboard header

Adds unassigned branch filter options to the dashboard header, allowing users to filter tasks by unassigned status. Updates the Header component with new filter controls and includes corresponding tests for the App and Header components.

Fusion-Task-Id: FN-3424
This commit is contained in:
Fusion
2026-05-06 21:47:09 -07:00
committed by gsxdsm
parent 2ed722ecbd
commit 5caaabcdba
6 changed files with 386 additions and 1 deletions

View File

@@ -116,6 +116,7 @@ function prefetchLazyViews() {
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
const NO_BRANCH_FILTER_VALUE = "__none__";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl);
@@ -206,6 +207,8 @@ function AppInner() {
// Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState("");
const [branchFilter, setBranchFilter] = useState<string | null>(null);
const [baseBranchFilter, setBaseBranchFilter] = useState<string | null>(null);
// Remote node data and events when in remote mode (pass searchQuery for server-side filtering)
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined });
@@ -279,6 +282,8 @@ function AppInner() {
}
);
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
const mountTimeRef = useRef(performance.now());
@@ -413,6 +418,48 @@ function AppInner() {
});
}, [currentProject?.id, taskView]);
const branchOptions = useMemo(() => {
return Array.from(
new Set(
boardSourceTasks
.map((task) => task.branch?.trim())
.filter((branch): branch is string => Boolean(branch && branch.length > 0)),
),
).sort((a, b) => a.localeCompare(b));
}, [boardSourceTasks]);
const baseBranchOptions = useMemo(() => {
return Array.from(
new Set(
boardSourceTasks
.map((task) => task.baseBranch?.trim())
.filter((baseBranch): baseBranch is string => Boolean(baseBranch && baseBranch.length > 0)),
),
).sort((a, b) => a.localeCompare(b));
}, [boardSourceTasks]);
const filteredBoardTasks = useMemo(() => {
return boardSourceTasks.filter((task) => {
const taskBranch = task.branch?.trim() ?? null;
const taskBaseBranch = task.baseBranch?.trim() ?? null;
if (branchFilter === NO_BRANCH_FILTER_VALUE) {
if (taskBranch) {
return false;
}
} else if (branchFilter && taskBranch !== branchFilter) {
return false;
}
if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) {
if (taskBaseBranch) {
return false;
}
} else if (baseBranchFilter && taskBaseBranch !== baseBranchFilter) {
return false;
}
return true;
});
}, [boardSourceTasks, branchFilter, baseBranchFilter]);
// Nodes management is an overlay view (not a modal), so it stays local to App.
const [nodesOpen, setNodesOpen] = useState(false);
const [retryingProjects, setRetryingProjects] = useState(false);
@@ -1129,7 +1176,7 @@ function AppInner() {
return (
<PageErrorBoundary>
<Board
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
tasks={filteredBoardTasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
@@ -1252,6 +1299,12 @@ function AppInner() {
showAgentsTab={agentsEnabled}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
branchFilter={branchFilter}
baseBranchFilter={baseBranchFilter}
branchOptions={branchOptions}
baseBranchOptions={baseBranchOptions}
onBranchFilterChange={setBranchFilter}
onBaseBranchFilterChange={setBaseBranchFilter}
projects={effectiveProjects}
currentProject={currentProject}
onSelectProject={handleSelectProject}

View File

@@ -27,6 +27,38 @@
max-width: none;
}
.header-branch-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.header-branch-filter-label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.header-branch-filter-select {
width: 100%;
min-height: calc(var(--space-xl) + var(--space-sm) / 2);
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0 var(--space-sm);
font-size: calc(var(--space-sm) + var(--space-xs) + var(--space-xs) / 4);
}
.header-branch-filter-select:focus-visible {
border-color: var(--todo);
box-shadow: var(--focus-ring);
outline: none;
}
.header-left {
display: flex;
align-items: center;
@@ -956,6 +988,14 @@
width: 100%;
}
.header-branch-filters {
grid-template-columns: 1fr;
}
.header-branch-filter-select {
min-height: calc(var(--space-xl) + var(--space-md));
}
/* Mobile: compact node label, no dropdown */
.header-node-selector--mobile {
margin-right: var(--space-xs);

View File

@@ -18,6 +18,8 @@ import { getPluginNavIcon } from "./pluginNavIcon";
export { useViewportMode };
const NO_BRANCH_FILTER_VALUE = "__none__";
// Status icon config for project selector dropdown
const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
active: { color: "var(--success)" },
@@ -210,6 +212,12 @@ export interface HeaderProps {
showAgentsTab?: boolean;
searchQuery?: string;
onSearchChange?: (query: string) => void;
branchFilter?: string | null;
baseBranchFilter?: string | null;
branchOptions?: string[];
baseBranchOptions?: string[];
onBranchFilterChange?: (value: string | null) => void;
onBaseBranchFilterChange?: (value: string | null) => void;
/** Multi-project props */
projects?: ProjectInfo[];
currentProject?: ProjectInfo | null;
@@ -268,6 +276,12 @@ export function Header({
showAgentsTab,
searchQuery = "",
onSearchChange,
branchFilter = null,
baseBranchFilter = null,
branchOptions = [],
baseBranchOptions = [],
onBranchFilterChange,
onBaseBranchFilterChange,
projects = [],
currentProject,
onSelectProject,
@@ -647,6 +661,7 @@ export function Header({
// Show toggle when search is available, NOT currently shown, NOT explicitly closed, AND query is empty
const canShowNonMobileSearchToggle = (view === "board" || view === "list") && !isMobile && onSearchChange && !isNonMobileSearchExplicitlyClosed && searchQuery.length === 0;
const canShowNonMobileSearch = (view === "board" || view === "list") && !isMobile && onSearchChange;
const showBoardBranchFilters = view === "board";
// Reset explicit close flag when query becomes empty (so toggle reappears)
useEffect(() => {
@@ -1881,6 +1896,44 @@ export function Header({
<X size={14} />
</button>
</div>
{showBoardBranchFilters && (
<div className="header-branch-filters" data-testid="header-branch-filters-desktop">
<label className="header-branch-filter-label">
<span>Working branch</span>
<select
className="header-branch-filter-select"
value={branchFilter ?? ""}
onChange={(event) => onBranchFilterChange?.(event.target.value || null)}
data-testid="working-branch-filter"
>
<option value="">All working branches</option>
<option value={NO_BRANCH_FILTER_VALUE}>No working branch</option>
{branchOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="header-branch-filter-label">
<span>Target branch</span>
<select
className="header-branch-filter-select"
value={baseBranchFilter ?? ""}
onChange={(event) => onBaseBranchFilterChange?.(event.target.value || null)}
data-testid="target-branch-filter"
>
<option value="">All target branches</option>
<option value={NO_BRANCH_FILTER_VALUE}>No target branch</option>
{baseBranchOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
)}
</div>
)}
@@ -1909,6 +1962,44 @@ export function Header({
<X size={14} />
</button>
</div>
{showBoardBranchFilters && (
<div className="header-branch-filters" data-testid="header-branch-filters-mobile">
<label className="header-branch-filter-label">
<span>Working branch</span>
<select
className="header-branch-filter-select"
value={branchFilter ?? ""}
onChange={(event) => onBranchFilterChange?.(event.target.value || null)}
data-testid="working-branch-filter-mobile"
>
<option value="">All working branches</option>
<option value={NO_BRANCH_FILTER_VALUE}>No working branch</option>
{branchOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="header-branch-filter-label">
<span>Target branch</span>
<select
className="header-branch-filter-select"
value={baseBranchFilter ?? ""}
onChange={(event) => onBaseBranchFilterChange?.(event.target.value || null)}
data-testid="target-branch-filter-mobile"
>
<option value="">All target branches</option>
<option value={NO_BRANCH_FILTER_VALUE}>No target branch</option>
{baseBranchOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
)}
</div>
)}
</div>

View File

@@ -3258,3 +3258,142 @@ describe("FN-3290: modal keyboard isolation for mobile dashboard layout", () =>
});
});
});
describe("App board branch filters", () => {
function makeTask(id: string, title: string, branch?: string, baseBranch?: string) {
return {
id,
title,
description: title,
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...(branch ? { branch } : {}),
...(baseBranch ? { baseBranch } : {}),
};
}
it("filters board tasks by working and target branch in local mode", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [
makeTask("FN-1", "Task Alpha", "feature/a", "main"),
makeTask("FN-2", "Task Beta", "feature/b", "release"),
],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } });
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "main" } });
await waitFor(() => {
expect(screen.getByText("Task Alpha")).toBeTruthy();
expect(screen.queryByText("Task Beta")).toBeNull();
});
});
it("supports filtering for tasks without branch values", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [
makeTask("FN-1", "Unassigned Task"),
makeTask("FN-2", "Assigned Task", "feature/a", "main"),
],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__none__" } });
await waitFor(() => {
expect(screen.getByText("Unassigned Task")).toBeTruthy();
expect(screen.queryByText("Assigned Task")).toBeNull();
});
});
it("derives branch filter options from remote task data in remote mode", async () => {
mockNodeContextValue.isRemote = true;
mockNodeContextValue.currentNodeId = "node-1";
const remoteSpy = vi.spyOn(apiNodeModule, "useRemoteNodeData").mockReturnValue({
projects: [],
tasks: [makeTask("FN-3", "Remote Task", "feature/remote", "develop")],
health: null,
loading: false,
error: null,
refresh: vi.fn(),
});
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByRole("option", { name: "feature/remote" })).toBeTruthy();
expect(screen.getByRole("option", { name: "develop" })).toBeTruthy();
remoteSpy.mockRestore();
});
it("composes with search and does not affect list view tasks", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [
makeTask("FN-4", "Alpha Search", "feature/a", "main"),
makeTask("FN-5", "Beta Search", "feature/b", "main"),
],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.change(screen.getByPlaceholderText("Search tasks..."), { target: { value: "Search" } });
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } });
await waitFor(() => {
expect(screen.getByText("Alpha Search")).toBeTruthy();
expect(screen.queryByText("Beta Search")).toBeNull();
});
fireEvent.click(screen.getByTitle("List view"));
await waitFor(() => {
expect(screen.getByText("Alpha Search")).toBeTruthy();
expect(screen.getByText("Beta Search")).toBeTruthy();
});
});
});

View File

@@ -1052,6 +1052,62 @@ describe("Header", () => {
fireEvent.click(screen.getByLabelText("Close search"));
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("renders branch filters in desktop board search panel only", () => {
renderHeader({
onSearchChange: vi.fn(),
view: "board",
branchOptions: ["feature/a"],
baseBranchOptions: ["main"],
});
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByTestId("header-branch-filters-desktop")).toBeInTheDocument();
expect(screen.getByTestId("working-branch-filter")).toBeInTheDocument();
expect(screen.getByTestId("target-branch-filter")).toBeInTheDocument();
});
it("does not render branch filters in list view", () => {
renderHeader({ onSearchChange: vi.fn(), view: "list" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.queryByTestId("header-branch-filters-desktop")).toBeNull();
});
it("calls branch filter callbacks with selected values, unassigned sentinel, and reset", () => {
const onBranchFilterChange = vi.fn();
const onBaseBranchFilterChange = vi.fn();
renderHeader({
onSearchChange: vi.fn(),
view: "board",
branchOptions: ["feature/a"],
baseBranchOptions: ["release"],
onBranchFilterChange,
onBaseBranchFilterChange,
});
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } });
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__none__" } });
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "release" } });
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "__none__" } });
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "" } });
expect(onBranchFilterChange).toHaveBeenCalledWith("feature/a");
expect(onBranchFilterChange).toHaveBeenCalledWith("__none__");
expect(onBranchFilterChange).toHaveBeenCalledWith(null);
expect(onBaseBranchFilterChange).toHaveBeenCalledWith("release");
expect(onBaseBranchFilterChange).toHaveBeenCalledWith("__none__");
});
it("renders branch filters in mobile expanded search for board view", () => {
renderHeader({
onSearchChange: vi.fn(),
view: "board",
branchOptions: ["feature/mobile"],
baseBranchOptions: ["main"],
}, "mobile");
fireEvent.click(screen.getByTestId("mobile-header-search-btn"));
expect(screen.getByTestId("header-branch-filters-mobile")).toBeInTheDocument();
expect(screen.getByTestId("working-branch-filter-mobile")).toBeInTheDocument();
expect(screen.getByTestId("target-branch-filter-mobile")).toBeInTheDocument();
});
});
describe("automation button", () => {