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