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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user