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

@@ -116,7 +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__";
const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl);
@@ -207,8 +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);
const [branchFilter, setBranchFilter] = useState("");
const [baseBranchFilter, setBaseBranchFilter] = useState("");
// 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 });
@@ -446,20 +446,20 @@ function AppInner() {
const filteredBoardTasks = useMemo(() => {
return boardSourceTasks.filter((task) => {
const taskBranch = task.branch?.trim() ?? null;
const taskBaseBranch = task.baseBranch?.trim() ?? null;
const taskBranch = task.branch?.trim() ?? "";
const taskBaseBranch = task.baseBranch?.trim() ?? "";
if (branchFilter === NO_BRANCH_FILTER_VALUE) {
if (taskBranch) {
if (taskBranch.length > 0) {
return false;
}
} else if (branchFilter && taskBranch !== branchFilter) {
} else if (branchFilter.length > 0 && taskBranch !== branchFilter) {
return false;
}
if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) {
if (taskBaseBranch) {
if (taskBaseBranch.length > 0) {
return false;
}
} else if (baseBranchFilter && taskBaseBranch !== baseBranchFilter) {
} else if (baseBranchFilter.length > 0 && taskBaseBranch !== baseBranchFilter) {
return false;
}
return true;

View File

@@ -29,7 +29,7 @@
.header-branch-filters {
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);
margin-top: var(--space-sm);
}

View File

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

View File

@@ -1067,6 +1067,10 @@ describe("Header", () => {
expect(screen.getByTestId("header-branch-filters-desktop")).toBeInTheDocument();
expect(screen.getByTestId("working-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", () => {
@@ -1088,15 +1092,15 @@ describe("Header", () => {
});
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("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: "__none__" } });
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "__fusion:no-branch__" } });
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "" } });
expect(onBranchFilterChange).toHaveBeenCalledWith("feature/a");
expect(onBranchFilterChange).toHaveBeenCalledWith("__none__");
expect(onBranchFilterChange).toHaveBeenCalledWith(null);
expect(onBranchFilterChange).toHaveBeenCalledWith("__fusion:no-branch__");
expect(onBranchFilterChange).toHaveBeenCalledWith("");
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", () => {