Skip to content
Merged
15 changes: 0 additions & 15 deletions __tests__/components/ui/actions/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
import type { AssignedActionWithContext } from "@/types/assigned-actions";
import {
STATUS_COLUMN_ORDER,
statusColor,
groupByStatus,
applyTimeFilter,
visibleStatuses,
Expand Down Expand Up @@ -89,20 +88,6 @@ describe("actionStatusToString", () => {
});
});

// ---------------------------------------------------------------------------
// statusColor
// ---------------------------------------------------------------------------

describe("statusColor", () => {
it("returns a Tailwind class for each status", () => {
expect(statusColor(ItemStatus.NotStarted)).toBe("bg-slate-400");
expect(statusColor(ItemStatus.InProgress)).toBe("bg-blue-500");
expect(statusColor(ItemStatus.OnHold)).toBe("bg-amber-400");
expect(statusColor(ItemStatus.Completed)).toBe("bg-green-500");
expect(statusColor(ItemStatus.WontDo)).toBe("bg-gray-400");
});
});

// ---------------------------------------------------------------------------
// groupByStatus
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ vi.mock("@/lib/api/coaching-session-views", () => ({
}));

vi.mock("@/lib/api/goal-progress", () => ({
useRevalidateGoalProgress: () => vi.fn(),
useGoalProgress: vi.fn(() => ({
progressMetrics: undefined,
isLoading: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ vi.mock("@/lib/api/coaching-session-views", () => ({
}));

vi.mock("@/lib/api/goal-progress", () => ({
useRevalidateGoalProgress: () => vi.fn(),
useGoalProgress: vi.fn(() => ({
progressMetrics: {
actions_completed: 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { None } from "@/types/option"

// Mock the goal progress hook so tests don't fire real API calls
vi.mock("@/lib/api/goal-progress", () => ({
useRevalidateGoalProgress: () => vi.fn(),
useGoalProgress: () => ({
progressMetrics: {
actions_completed: 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ vi.mock("@/lib/api/coaching-session-views", () => ({
}));

vi.mock("@/lib/api/goal-progress", () => ({
useRevalidateGoalProgress: () => vi.fn(),
useGoalProgress: vi.fn(() => ({
progressMetrics: undefined,
isLoading: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ vi.mock("@/lib/api/coaching-session-views", () => ({
}));

vi.mock("@/lib/api/goal-progress", () => ({
useRevalidateGoalProgress: () => vi.fn(),
useGoalProgress: vi.fn(() => ({
progressMetrics: undefined,
isLoading: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DateTime, Settings } from "ts-luxon";
import { SessionHoverDetail } from "@/components/ui/dashboard/coaching-sessions-hover-detail";
import { ItemStatus } from "@/types/general";
import { Some } from "@/types/option";
import { toast } from "@/lib/toast";
import { createMockAction, createMockEnrichedSession } from "../../../test-utils";

const update = vi.fn();
const revalidateGoalProgress = vi.fn();
vi.mock("@/lib/api/actions", () => ({ useActionMutation: () => ({ update }) }));
vi.mock("@/lib/api/goal-progress", () => ({ useRevalidateGoalProgress: () => revalidateGoalProgress }));
vi.mock("@/lib/toast", () => ({ toast: { error: vi.fn() } }));

const NOW = DateTime.fromISO("2026-08-25T12:00:00Z", { zone: "utc" });

function renderWith(actions: ReturnType<typeof createMockAction>[]) {
Expand All @@ -23,6 +31,8 @@ function renderWith(actions: ReturnType<typeof createMockAction>[]) {
describe("SessionHoverDetail action rows (compact layout)", () => {
const originalNow = Settings.now;
beforeEach(() => {
vi.clearAllMocks();
update.mockResolvedValue(undefined);
Settings.now = () => NOW.toMillis();
});
afterEach(() => {
Expand All @@ -42,11 +52,39 @@ describe("SessionHoverDetail action rows (compact layout)", () => {

const row = screen.getByRole("link", { name: /Send Ehab the notes/ });
expect(row).toHaveAttribute("href", "/coaching-sessions/sess-9?panel=actions");
expect(screen.getByRole("img", { name: "In Progress" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Status: In Progress" })).toBeInTheDocument();
expect(screen.getByText("Due Aug 20")).toHaveClass("text-warning-text");
expect(screen.getByText("In Progress", { selector: 'span[data-status="InProgress"]' })).toBeInTheDocument();
// Display-only here: no status buttons on the dashboard.
expect(screen.queryByRole("button", { name: /^Status:/ })).not.toBeInTheDocument();
});

it("saves a status chosen from the row", async () => {
const action = createMockAction({ id: "a1", body: "Send Ehab the notes", status: ItemStatus.InProgress });
renderWith([action]);

await userEvent.click(screen.getByRole("button", { name: "Status: In Progress" }));
await userEvent.click(screen.getByRole("menuitemradio", { name: /Completed/ }));

expect(update).toHaveBeenCalledWith("a1", { ...action, status: ItemStatus.Completed });
expect(revalidateGoalProgress).not.toHaveBeenCalled();
});

it("refreshes goal progress when the action is linked to a goal", async () => {
renderWith([createMockAction({ id: "a1", status: ItemStatus.InProgress, goal_id: Some("goal-1") })]);

await userEvent.click(screen.getByRole("button", { name: "Status: In Progress" }));
await userEvent.click(screen.getByRole("menuitemradio", { name: /Completed/ }));

await waitFor(() => expect(revalidateGoalProgress).toHaveBeenCalled());
});

it("says so when the status can't be saved", async () => {
update.mockRejectedValue(new Error("boom"));
renderWith([createMockAction({ id: "a1", status: ItemStatus.InProgress })]);

await userEvent.click(screen.getByRole("button", { name: "Status: In Progress" }));
await userEvent.click(screen.getByRole("menuitemradio", { name: /Completed/ }));

await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Couldn't update the status"));
});

it("mutes a completed action without striking it through", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function apiError(status: number): EntityApiError {
// ── Hook mocks ─────────────────────────────────────────────────────────
const mockUseGoalProgressList = vi.fn();
vi.mock("@/lib/api/goal-progress", () => ({
useRevalidateGoalProgress: () => vi.fn(),
useGoalProgressList: (...args: unknown[]) => mockUseGoalProgressList(...args),
}));

Expand Down
43 changes: 43 additions & 0 deletions __tests__/lib/api/goal-progress-revalidate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import useSWR, { SWRConfig } from "swr";
import { useRevalidateGoalProgress } from "@/lib/api/goal-progress";
import { siteConfig } from "@/site.config";

const base = siteConfig.env.backendServiceURL;

const wrapper = ({ children }: { children: ReactNode }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);

const fetchers = {
goal: vi.fn(() => Promise.resolve("goal")),
list: vi.fn(() => Promise.resolve("list")),
goalItself: vi.fn(() => Promise.resolve("goal-itself")),
};

/** Caches the same key shapes the progress hooks use, counting fetches. */
function useCaches() {
useSWR(`${base}/goals/goal-1/progress`, fetchers.goal);
useSWR([`${base}/organizations/org-1/coaching_relationships/rel-1/goal_progress?limit=3`, "rel-1|limit=3"], fetchers.list);
useSWR(`${base}/goals/goal-1`, fetchers.goalItself);
return useRevalidateGoalProgress();
}

describe("useRevalidateGoalProgress", () => {
it("refetches a goal's progress and a relationship's progress list, and nothing else", async () => {
const { result } = renderHook(() => useCaches(), { wrapper });
await waitFor(() => {
expect(fetchers.goal).toHaveBeenCalledTimes(1);
expect(fetchers.list).toHaveBeenCalledTimes(1);
expect(fetchers.goalItself).toHaveBeenCalledTimes(1);
});

await act(() => result.current());

expect(fetchers.goal).toHaveBeenCalledTimes(2);
expect(fetchers.list).toHaveBeenCalledTimes(2);
expect(fetchers.goalItself).toHaveBeenCalledTimes(1);
});
});
32 changes: 3 additions & 29 deletions __tests__/lib/hooks/use-panel-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe("handleGoalChange", () => {
await result.current.handleGoalChange("action-1", "goal-abc");
});

expect(mockGlobalMutate).toHaveBeenCalledOnce();
expect(mockGlobalMutate).toHaveBeenCalled();
});

it("revalidates goal progress cache after unlinking", async () => {
Expand All @@ -153,7 +153,7 @@ describe("handleGoalChange", () => {
await result.current.handleGoalChange("action-1", undefined);
});

expect(mockGlobalMutate).toHaveBeenCalledOnce();
expect(mockGlobalMutate).toHaveBeenCalled();
});

it("does nothing when action ID is not found", async () => {
Expand Down Expand Up @@ -221,7 +221,7 @@ describe("handleCreate — goal linking", () => {
await result.current.handleCreate("New action", [], "goal-abc");
});

expect(mockGlobalMutate).toHaveBeenCalledOnce();
expect(mockGlobalMutate).toHaveBeenCalled();
});

it("does NOT revalidate goal progress when goalId is omitted", async () => {
Expand Down Expand Up @@ -309,29 +309,3 @@ describe("handleStatusChange — goal progress revalidation", () => {
// ---------------------------------------------------------------------------
// revalidateGoalProgress — SWR key filter
// ---------------------------------------------------------------------------

describe("revalidateGoalProgress key filter", () => {
it("matches goal progress URLs", async () => {
mockSessionActions = [makeAction()];
const { result } = renderHook(() => usePanelActions(PARAMS));

await act(async () => {
await result.current.handleGoalChange("action-1", "goal-abc");
});

// Extract the predicate function passed to globalMutate
const predicate = mockGlobalMutate.mock.calls[0][0] as (key: unknown) => boolean;

// Should match goal progress URLs
expect(predicate("http://localhost/api/goals/goal-123/progress")).toBe(true);
expect(predicate("http://localhost/api/goals/abc-def/progress")).toBe(true);

// Should NOT match non-goal progress URLs
expect(predicate("http://localhost/api/users/user-1/progress")).toBe(false);
expect(predicate("http://localhost/api/goals/goal-123/actions")).toBe(false);
expect(predicate("http://localhost/api/progress")).toBe(false);
expect(predicate("/progress")).toBe(false);
expect(predicate(42)).toBe(false);
expect(predicate(null)).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function AdminLayout({
return (
<div className="flex min-h-screen w-full flex-col">
<SiteHeader />
<main className="flex-1 p-6 min-w-0">
<main className="flex-1 p-4 sm:p-6 min-w-0">
<PageContainer>{children}</PageContainer>
</main>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function DashboardLayout({
return (
<div className="flex min-h-screen w-full flex-col">
<SiteHeader />
<main className="flex-1 p-6 min-w-0">{children}</main>
<main className="flex-1 p-4 sm:p-6 min-w-0">{children}</main>
</div>
);
}
2 changes: 1 addition & 1 deletion src/app/members/[id]/profile/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function MembersLayout({
return (
<div className="flex min-h-screen w-full flex-col">
<SiteHeader />
<main className="flex-1 p-6">{children}</main>
<main className="flex-1 p-4 sm:p-6">{children}</main>
</div>
);
}
2 changes: 1 addition & 1 deletion src/app/organizations/[id]/members/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function MembersLayout({
return (
<div className="flex min-h-screen w-full flex-col">
<SiteHeader />
<main className="flex-1 p-6">{children}</main>
<main className="flex-1 p-4 sm:p-6">{children}</main>
</div>
);
}
2 changes: 1 addition & 1 deletion src/app/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function SettingsLayout({
return (
<div className="flex min-h-screen w-full flex-col">
<SiteHeader />
<main className="flex-1 p-6 min-w-0">
<main className="flex-1 p-4 sm:p-6 min-w-0">
<PageContainer>
<div className="lg:max-w-[70%]">
{/* SettingsNav renders both mobile (horizontal tabs above)
Expand Down
26 changes: 7 additions & 19 deletions src/components/ui/actions/actions-page-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
import { useReplaceSearchParams } from "@/lib/hooks/use-replace-search-params";
import { DateTime } from "ts-luxon";
import { toast } from "@/lib/toast";
import { actionErrorMessage } from "@/lib/utils/action-error-message";
import { useAuthStore } from "@/lib/providers/auth-store-provider";
import { useCurrentOrganization } from "@/lib/hooks/use-current-organization";
import { useCoachingRelationshipList } from "@/lib/api/coaching-relationships";
Expand All @@ -22,8 +23,6 @@ import type { Id, ItemStatus } from "@/types/general";
import { ActionsPageHeader } from "@/components/ui/actions/actions-page-header";
import { KanbanBoard } from "@/components/ui/actions/kanban-board";
import {
EntityApiError,
PERMISSION_DENIED_MESSAGE,
viewPermissionDeniedMessage,
isForbiddenError,
} from "@/lib/api/entity-api";
Expand All @@ -47,17 +46,6 @@ function parseEnum<T extends string>(
return valid.has(value as T) ? (value as T) : fallback;
}

/** Maps a failed action mutation to a user-facing message. `action` reads as
* the verb phrase in "Couldn't <action>" (e.g. "update the status"). */
function mutationErrorMessage(err: unknown, action: string): string {
if (err instanceof EntityApiError) {
if (err.isForbidden()) return PERMISSION_DENIED_MESSAGE;
if (err.isNetworkError())
return `Couldn't ${action} · Check your connection and try again`;
}
return `Couldn't ${action}`;
}

interface ActionsPageContainerProps {
locale: string;
}
Expand Down Expand Up @@ -214,7 +202,7 @@ export function ActionsPageContainer({ locale }: ActionsPageContainerProps) {
// No explicit refresh() — the optimistic update already moved the card
// visually, and SWR will revalidate on its own schedule.
} catch (err) {
toast.error(mutationErrorMessage(err, "update the status"));
toast.error(actionErrorMessage("update the status", err));
throw err; // Re-throw so the board can roll back optimistic updates
}
},
Expand All @@ -230,7 +218,7 @@ export function ActionsPageContainer({ locale }: ActionsPageContainerProps) {
await updateAction(id, { ...ctx.action, due_by: Some(newDueBy) });
refresh();
} catch (err) {
toast.error(mutationErrorMessage(err, "update the due date"));
toast.error(actionErrorMessage("update the due date", err));
}
},
[actionsWithContext, updateAction, refresh]
Expand All @@ -245,7 +233,7 @@ export function ActionsPageContainer({ locale }: ActionsPageContainerProps) {
await updateAction(id, { ...ctx.action, assignee_ids: assigneeIds });
refresh();
} catch (err) {
toast.error(mutationErrorMessage(err, "update the assignees"));
toast.error(actionErrorMessage("update the assignees", err));
}
},
[actionsWithContext, updateAction, refresh]
Expand All @@ -266,7 +254,7 @@ export function ActionsPageContainer({ locale }: ActionsPageContainerProps) {
await updateAction(id, updated);
refresh();
} catch (err) {
toast.error(mutationErrorMessage(err, "update the action"));
toast.error(actionErrorMessage("update the action", err));
}
},
[actionsWithContext, updateAction, refresh]
Expand All @@ -282,7 +270,7 @@ export function ActionsPageContainer({ locale }: ActionsPageContainerProps) {
await updateAction(id, updated);
refresh();
} catch (err) {
toast.error(mutationErrorMessage(err, "update the goal link"));
toast.error(actionErrorMessage("update the goal link", err));
}
},
[actionsWithContext, updateAction, refresh]
Expand All @@ -294,7 +282,7 @@ export function ActionsPageContainer({ locale }: ActionsPageContainerProps) {
await deleteAction(id);
refresh();
} catch (err) {
toast.error(mutationErrorMessage(err, "delete the action"));
toast.error(actionErrorMessage("delete the action", err));
}
},
[deleteAction, refresh]
Expand Down
Loading
Loading