feat(FN-3425): add board branch filter with no-branch semantics and respons

Restructures the dependency graph plugin into a modular architecture with separate edges, filters, layout, types, and interaction hooks, adding comprehensive tests for the new modules. Implements a board branch filter with "no-branch" semantics and responsive layout refinement, while removing legacy

Fusion-Task-Id: FN-3425
This commit is contained in:
Fusion
2026-05-07 01:46:27 -07:00
committed by gsxdsm
parent 12326eeb00
commit ccddba4a67
6 changed files with 73 additions and 39 deletions

View File

@@ -17,7 +17,7 @@ Board view is the kanban surface for day-to-day operation.
Features: Features:
- Drag-and-drop between lifecycle columns - Drag-and-drop between lifecycle columns
- Search/filter tasks - Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
- Column visibility controls - Column visibility controls
- Inline quick entry creation - Inline quick entry creation
- PR/issue badges with live updates - PR/issue badges with live updates

View File

@@ -116,7 +116,7 @@ function prefetchLazyViews() {
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session"; const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
const NO_BRANCH_FILTER_VALUE = "__none__"; const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl); const url = new URL(serverUrl);
@@ -207,8 +207,8 @@ function AppInner() {
// Search query state - must be defined before useTasks // Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [branchFilter, setBranchFilter] = useState<string | null>(null); const [branchFilter, setBranchFilter] = useState("");
const [baseBranchFilter, setBaseBranchFilter] = useState<string | null>(null); const [baseBranchFilter, setBaseBranchFilter] = useState("");
// Remote node data and events when in remote mode (pass searchQuery for server-side filtering) // 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 }); const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined });
@@ -446,20 +446,20 @@ function AppInner() {
const filteredBoardTasks = useMemo(() => { const filteredBoardTasks = useMemo(() => {
return boardSourceTasks.filter((task) => { return boardSourceTasks.filter((task) => {
const taskBranch = task.branch?.trim() ?? null; const taskBranch = task.branch?.trim() ?? "";
const taskBaseBranch = task.baseBranch?.trim() ?? null; const taskBaseBranch = task.baseBranch?.trim() ?? "";
if (branchFilter === NO_BRANCH_FILTER_VALUE) { if (branchFilter === NO_BRANCH_FILTER_VALUE) {
if (taskBranch) { if (taskBranch.length > 0) {
return false; return false;
} }
} else if (branchFilter && taskBranch !== branchFilter) { } else if (branchFilter.length > 0 && taskBranch !== branchFilter) {
return false; return false;
} }
if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) { if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) {
if (taskBaseBranch) { if (taskBaseBranch.length > 0) {
return false; return false;
} }
} else if (baseBranchFilter && taskBaseBranch !== baseBranchFilter) { } else if (baseBranchFilter.length > 0 && taskBaseBranch !== baseBranchFilter) {
return false; return false;
} }
return true; return true;

View File

@@ -29,7 +29,7 @@
.header-branch-filters { .header-branch-filters {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(calc(var(--space-2xl) * 5), 100%), 1fr));
gap: var(--space-sm); gap: var(--space-sm);
margin-top: var(--space-sm); margin-top: var(--space-sm);
} }

View File

@@ -18,7 +18,7 @@ import { getPluginNavIcon } from "./pluginNavIcon";
export { useViewportMode }; export { useViewportMode };
const NO_BRANCH_FILTER_VALUE = "__none__"; const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
// Status icon config for project selector dropdown // Status icon config for project selector dropdown
const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = { const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
@@ -212,12 +212,12 @@ export interface HeaderProps {
showAgentsTab?: boolean; showAgentsTab?: boolean;
searchQuery?: string; searchQuery?: string;
onSearchChange?: (query: string) => void; onSearchChange?: (query: string) => void;
branchFilter?: string | null; branchFilter?: string;
baseBranchFilter?: string | null; baseBranchFilter?: string;
branchOptions?: string[]; branchOptions?: string[];
baseBranchOptions?: string[]; baseBranchOptions?: string[];
onBranchFilterChange?: (value: string | null) => void; onBranchFilterChange?: (value: string) => void;
onBaseBranchFilterChange?: (value: string | null) => void; onBaseBranchFilterChange?: (value: string) => void;
/** Multi-project props */ /** Multi-project props */
projects?: ProjectInfo[]; projects?: ProjectInfo[];
currentProject?: ProjectInfo | null; currentProject?: ProjectInfo | null;
@@ -276,8 +276,8 @@ export function Header({
showAgentsTab, showAgentsTab,
searchQuery = "", searchQuery = "",
onSearchChange, onSearchChange,
branchFilter = null, branchFilter = "",
baseBranchFilter = null, baseBranchFilter = "",
branchOptions = [], branchOptions = [],
baseBranchOptions = [], baseBranchOptions = [],
onBranchFilterChange, onBranchFilterChange,
@@ -1902,8 +1902,8 @@ export function Header({
<span>Working branch</span> <span>Working branch</span>
<select <select
className="header-branch-filter-select" className="header-branch-filter-select"
value={branchFilter ?? ""} value={branchFilter}
onChange={(event) => onBranchFilterChange?.(event.target.value || null)} onChange={(event) => onBranchFilterChange?.(event.target.value)}
data-testid="working-branch-filter" data-testid="working-branch-filter"
> >
<option value="">All working branches</option> <option value="">All working branches</option>
@@ -1916,15 +1916,15 @@ export function Header({
</select> </select>
</label> </label>
<label className="header-branch-filter-label"> <label className="header-branch-filter-label">
<span>Target branch</span> <span>Base branch</span>
<select <select
className="header-branch-filter-select" className="header-branch-filter-select"
value={baseBranchFilter ?? ""} value={baseBranchFilter}
onChange={(event) => onBaseBranchFilterChange?.(event.target.value || null)} onChange={(event) => onBaseBranchFilterChange?.(event.target.value)}
data-testid="target-branch-filter" data-testid="target-branch-filter"
> >
<option value="">All target branches</option> <option value="">All base branches</option>
<option value={NO_BRANCH_FILTER_VALUE}>No target branch</option> <option value={NO_BRANCH_FILTER_VALUE}>No base branch</option>
{baseBranchOptions.map((option) => ( {baseBranchOptions.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
@@ -1968,8 +1968,8 @@ export function Header({
<span>Working branch</span> <span>Working branch</span>
<select <select
className="header-branch-filter-select" className="header-branch-filter-select"
value={branchFilter ?? ""} value={branchFilter}
onChange={(event) => onBranchFilterChange?.(event.target.value || null)} onChange={(event) => onBranchFilterChange?.(event.target.value)}
data-testid="working-branch-filter-mobile" data-testid="working-branch-filter-mobile"
> >
<option value="">All working branches</option> <option value="">All working branches</option>
@@ -1982,15 +1982,15 @@ export function Header({
</select> </select>
</label> </label>
<label className="header-branch-filter-label"> <label className="header-branch-filter-label">
<span>Target branch</span> <span>Base branch</span>
<select <select
className="header-branch-filter-select" className="header-branch-filter-select"
value={baseBranchFilter ?? ""} value={baseBranchFilter}
onChange={(event) => onBaseBranchFilterChange?.(event.target.value || null)} onChange={(event) => onBaseBranchFilterChange?.(event.target.value)}
data-testid="target-branch-filter-mobile" data-testid="target-branch-filter-mobile"
> >
<option value="">All target branches</option> <option value="">All base branches</option>
<option value={NO_BRANCH_FILTER_VALUE}>No target branch</option> <option value={NO_BRANCH_FILTER_VALUE}>No base branch</option>
{baseBranchOptions.map((option) => ( {baseBranchOptions.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}

View File

@@ -3308,7 +3308,7 @@ describe("App board branch filters", () => {
}); });
}); });
it("supports filtering for tasks without branch values", async () => { it("supports filtering for tasks without working branch values", async () => {
mockUseTasks.mockImplementation(() => ({ mockUseTasks.mockImplementation(() => ({
tasks: [ tasks: [
makeTask("FN-1", "Unassigned Task"), makeTask("FN-1", "Unassigned Task"),
@@ -3330,7 +3330,7 @@ describe("App board branch filters", () => {
await waitForAppShell(); await waitForAppShell();
fireEvent.click(screen.getByTestId("desktop-header-search-btn")); fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__none__" } }); fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__fusion:no-branch__" } });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Unassigned Task")).toBeTruthy(); expect(screen.getByText("Unassigned Task")).toBeTruthy();
@@ -3338,6 +3338,36 @@ describe("App board branch filters", () => {
}); });
}); });
it("supports filtering for tasks without base branch values", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [
makeTask("FN-1", "No Base Branch", "feature/a"),
makeTask("FN-2", "Has Base Branch", "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("target-branch-filter"), { target: { value: "__fusion:no-branch__" } });
await waitFor(() => {
expect(screen.getByText("No Base Branch")).toBeTruthy();
expect(screen.queryByText("Has Base Branch")).toBeNull();
});
});
it("derives branch filter options from remote task data in remote mode", async () => { it("derives branch filter options from remote task data in remote mode", async () => {
mockNodeContextValue.isRemote = true; mockNodeContextValue.isRemote = true;
mockNodeContextValue.currentNodeId = "node-1"; mockNodeContextValue.currentNodeId = "node-1";

View File

@@ -1067,6 +1067,10 @@ describe("Header", () => {
expect(screen.getByTestId("header-branch-filters-desktop")).toBeInTheDocument(); expect(screen.getByTestId("header-branch-filters-desktop")).toBeInTheDocument();
expect(screen.getByTestId("working-branch-filter")).toBeInTheDocument(); expect(screen.getByTestId("working-branch-filter")).toBeInTheDocument();
expect(screen.getByTestId("target-branch-filter")).toBeInTheDocument(); expect(screen.getByTestId("target-branch-filter")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "All working branches" })).toHaveValue("");
expect(screen.getByRole("option", { name: "No working branch" })).toHaveValue("__fusion:no-branch__");
expect(screen.getByRole("option", { name: "All base branches" })).toHaveValue("");
expect(screen.getByRole("option", { name: "No base branch" })).toHaveValue("__fusion:no-branch__");
}); });
it("does not render branch filters in list view", () => { it("does not render branch filters in list view", () => {
@@ -1088,15 +1092,15 @@ describe("Header", () => {
}); });
fireEvent.click(screen.getByTestId("desktop-header-search-btn")); 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: "feature/a" } });
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__none__" } }); fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__fusion:no-branch__" } });
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "release" } }); fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "release" } });
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "__none__" } }); fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "__fusion:no-branch__" } });
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "" } }); fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "" } });
expect(onBranchFilterChange).toHaveBeenCalledWith("feature/a"); expect(onBranchFilterChange).toHaveBeenCalledWith("feature/a");
expect(onBranchFilterChange).toHaveBeenCalledWith("__none__"); expect(onBranchFilterChange).toHaveBeenCalledWith("__fusion:no-branch__");
expect(onBranchFilterChange).toHaveBeenCalledWith(null); expect(onBranchFilterChange).toHaveBeenCalledWith("");
expect(onBaseBranchFilterChange).toHaveBeenCalledWith("release"); expect(onBaseBranchFilterChange).toHaveBeenCalledWith("release");
expect(onBaseBranchFilterChange).toHaveBeenCalledWith("__none__"); expect(onBaseBranchFilterChange).toHaveBeenCalledWith("__fusion:no-branch__");
}); });
it("renders branch filters in mobile expanded search for board view", () => { it("renders branch filters in mobile expanded search for board view", () => {