Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ function renderBoard(
hasMoreCancelled,
loadingMoreCancelled,
onLoadMoreCancelled,
hasMoreActive,
loadingMoreActive,
onLoadMoreActive,
applyEngineChange,
reconcile,
}: {
Expand All @@ -240,6 +243,9 @@ function renderBoard(
hasMoreCancelled?: boolean;
loadingMoreCancelled?: boolean;
onLoadMoreCancelled?: () => void;
hasMoreActive?: boolean;
loadingMoreActive?: boolean;
onLoadMoreActive?: () => void;
applyEngineChange?: (change: { goalId: string; action?: "created" | "updated" | "deleted" }) => void;
reconcile?: () => void;
} = {},
Expand All @@ -258,6 +264,9 @@ function renderBoard(
hasMoreCancelled={hasMoreCancelled}
loadingMoreCancelled={loadingMoreCancelled}
onLoadMoreCancelled={onLoadMoreCancelled}
hasMoreActive={hasMoreActive}
loadingMoreActive={loadingMoreActive}
onLoadMoreActive={onLoadMoreActive}
applyEngineChange={applyEngineChange}
reconcile={reconcile}
/>,
Expand All @@ -277,6 +286,9 @@ function renderBoard(
hasMoreCancelled={hasMoreCancelled}
loadingMoreCancelled={loadingMoreCancelled}
onLoadMoreCancelled={onLoadMoreCancelled}
hasMoreActive={hasMoreActive}
loadingMoreActive={loadingMoreActive}
onLoadMoreActive={onLoadMoreActive}
applyEngineChange={applyEngineChange}
reconcile={reconcile}
/>,
Expand Down Expand Up @@ -2411,6 +2423,30 @@ describe("GoalsKanbanView — Ready column count-driven auto-collapse", () => {
expect(ready.querySelector("[aria-label='Expand Ready column']")).toBeNull();
expect(within(ready).getByTestId("ready-collapsed-bar")).not.toHaveAttribute("role", "button");
});

it("stays EXPANDED at a 0 count while the active set is still loading under a filter (hasActiveFilters + hasMoreActive)", () => {
// Under a board filter the store eager-loads every active page; a filter-matching Ready
// goal can sit beyond the loaded window. A transient 0 count mid-load must NOT collapse
// Ready to a strip and hide it.
renderBoard([], { hasActiveFilters: true, hasMoreActive: true });
const ready = column("Ready");
expect(within(ready).queryByTestId("ready-collapsed-bar")).not.toBeInTheDocument();
});

it("collapses at a 0 count once the filtered active set is fully loaded (hasMoreActive false)", () => {
// Eager-load finished (no more active pages) and Ready genuinely has 0 matches → collapse.
renderBoard([], { hasActiveFilters: true, hasMoreActive: false });
const ready = column("Ready");
expect(within(ready).getByTestId("ready-collapsed-bar")).toBeInTheDocument();
});

it("with NO filter active, a still-paginating active set does NOT keep Ready expanded (desktop byte-identical)", () => {
// hasMoreActive can be true on the unfiltered manual-pagination path; without a filter the
// guard is inert, so a 0-count Ready collapses exactly as before.
renderBoard([], { hasActiveFilters: false, hasMoreActive: true });
const ready = column("Ready");
expect(within(ready).getByTestId("ready-collapsed-bar")).toBeInTheDocument();
});
});

describe("GoalsKanbanView — T#93 column variants", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -877,8 +877,15 @@ function KanbanColumn({
// against trapping the user: keep Ready expanded while its own per-column search box is open,
// so filtering it to 0 there never hides the search input with no way back. For Ready,
// forceExpand is moot since it is never a valid drop target.
// Also keep Ready expanded while the active set is STILL LOADING under a board filter
// (`hasActiveFilters && hasMore` — for Ready, `hasMore` tracks the active set): under a
// filter the store eager-loads every active page, and a filter-matching Ready goal can sit
// beyond the loaded window, so a transient 0 count mid-load must not collapse Ready to a
// strip and hide it. Once the active set is fully loaded (hasMore=false) a genuine 0 count
// collapses as before. With no filter active this term is false, so desktop is byte-identical.
const readyLoadingUnderFilter = hasActiveFilters && hasMore;
const effectiveCollapsed = autoCollapse
? displayCount === 0 && !isSearchOpen
? displayCount === 0 && !isSearchOpen && !readyLoadingUnderFilter
: collapsed && !forceExpand;

// Below lg only the selected lane renders — everything else (including the other two
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright (c) 2026 Interactor, Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useGoalsStore } from "./use-goals-view";
import type { EngineGoalRow } from "./goals-view";

Expand Down Expand Up @@ -489,6 +489,71 @@ describe("useGoalsStore — SSR re-seed merges (never resets loaded pages)", ()
});
});

describe("useGoalsStore — eager-load under an active filter (board authoritative over the whole active set)", () => {
// A filter-matching goal that sorts BEYOND page 1 (a brand-new goal lands at the end of its
// priority band) — the bug this behaviour exists to fix: invisible in every column, counts 0.
const g1: EngineGoalRow = { ...BASE_GOAL, id: "g1", title: "One", ownerId: "user-1", ownerName: "User One", status: "open" };
const gBeyond: EngineGoalRow = { ...BASE_GOAL, id: "g-beyond", title: "Beyond", ownerId: "user-1", ownerName: "User One", status: "open" };

// Identity-stable initial props (SSR arrays are stable in production) — an inline array
// literal re-fires the re-seed effect every render and loops. See the reconcile note above.
const PAGE1: EngineGoalRow[] = [g1];

/** Serves the active set's page 2 (after=c1) containing the beyond-page-1 goal, cursor null. */
function mockPage2() {
return vi.spyOn(global, "fetch").mockImplementation((input: RequestInfo | URL) => {
const after = new URL(String(input), "http://localhost").searchParams.get("after");
if (after === "c1") {
return Promise.resolve({
ok: true,
json: async () => ({ data: [gBeyond], meta: { hasMore: false, nextCursor: null } }),
} as Response);
}
return Promise.resolve({ ok: false, status: 500 } as Response);
});
}

it("pages through the whole active set while a facet filter is active, making a beyond-page-1 match visible with a correct count", async () => {
const spy = mockPage2();
// owner filter active via initialOwnerId → hasActiveFilters true; page 1 has only g1, the
// matching goal sits on page 2 (cursor c1).
const { result } = renderHook(() => useGoalsStore("proj-1", PAGE1, "c1", null, "user-1"));

// The mount effect fires and pages until the active cursor is exhausted.
await waitFor(() => expect(result.current.active.nextCursor).toBeNull());

// The beyond-page-1 goal is now loaded and visible to the Kanban filter — no longer 0.
expect(result.current.active.goals.map((g) => g.id)).toEqual(["g1", "g-beyond"]);
expect(result.current.kanbanGoals.map((g) => g.id)).toContain("g-beyond");
spy.mockRestore();
});

it("does NOT eager-load when no filter is active (unfiltered path stays paginated — no extra fetches)", async () => {
const spy = vi.spyOn(global, "fetch");
// No initialOwnerId → owner 'all', no search → filterActive false; a real next cursor exists.
const { result } = renderHook(() => useGoalsStore("proj-1", PAGE1, "c1", null));
// Flush any pending microtasks the effects might have queued.
await act(async () => { await Promise.resolve(); });
expect(spy).not.toHaveBeenCalled();
// The cursor is untouched — pagination is still manual via "Load more".
expect(result.current.active.nextCursor).toBe("c1");
spy.mockRestore();
});

it("also eager-loads when only a search query is active (fires when search is set, not before)", async () => {
const spy = mockPage2();
const { result } = renderHook(() => useGoalsStore("proj-1", PAGE1, "c1", null));
// No filter yet → no eager-load fetch.
expect(spy).not.toHaveBeenCalled();

act(() => result.current.setFilter("search", "beyond"));

await waitFor(() => expect(result.current.active.nextCursor).toBeNull());
expect(result.current.active.goals.map((g) => g.id)).toEqual(["g1", "g-beyond"]);
spy.mockRestore();
});
});

describe("useGoalsStore — reconcile (atomic in-store refetch of the loaded window)", () => {
const g1: EngineGoalRow = { ...BASE_GOAL, id: "g1", title: "One" };
const g2: EngineGoalRow = { ...BASE_GOAL, id: "g2", title: "Two" };
Expand Down
24 changes: 24 additions & 0 deletions src/app/(app)/o/[orgSlug]/p/[projectSlug]/goals/use-goals-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,30 @@ export function useGoalsStore(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.status, done.nextCursor, done.loading, cancelled.nextCursor, cancelled.loading]);

// While ANY facet filter or a search is active, the Kanban board must be authoritative
// over the ENTIRE non-terminal active set — not just the initially-loaded page(s). The
// filter + fallback counts run purely over `active.goals` (the loaded window), so a
// filter-matching goal that sorts beyond page 1 would be invisible in every column and the
// counts would under-report (a brand-new goal lands at the END of its priority band —
// createdAt ASC — so it's the most likely active goal to fall past page 1). Eagerly page
// through the bounded active set until its cursor is exhausted, one page per pass:
// completing a load advances `active.nextCursor` (and flips `loading` back to false),
// re-running this effect until the cursor hits null. Terminates for that reason; the
// `loading` guard here plus loadMore's own in-flight guard prevent concurrent/duplicate
// fetches. The UNFILTERED path is untouched — with no filter this never fires, so the
// paginated initial load, DB-accurate counts, and manual "Load more" all stay as-is.
// (A future server-side facet-filter + filter-aware counts is the scale follow-up; here we
// only eager-load the bounded active set, and only under an explicit filter.)
const filterActive = computeHasActiveFilters(filters) || Boolean(filters.search);
useEffect(() => {
if (!filterActive) return;
if (active.nextCursor === null || active.nextCursor === "__first__") return;
if (active.loading) return;
void loadMore("active");
// loadMore is stable for the life of the component (projectId doesn't change mid-session).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterActive, active.nextCursor, active.loading]);

// filteredGoals is used by List, Calendar, and Text views.
// "complete" → merge accepted goals from active+done sets, sorted updatedAt DESC.
// "cancelled" → cancelled goals from cancelled set, sorted updatedAt DESC.
Expand Down
Loading