diff --git a/__tests__/components/ui/actions/utils.test.ts b/__tests__/components/ui/actions/utils.test.ts index 65edff160..e942d8fc9 100644 --- a/__tests__/components/ui/actions/utils.test.ts +++ b/__tests__/components/ui/actions/utils.test.ts @@ -9,7 +9,6 @@ import { import type { AssignedActionWithContext } from "@/types/assigned-actions"; import { STATUS_COLUMN_ORDER, - statusColor, groupByStatus, applyTimeFilter, visibleStatuses, @@ -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 // --------------------------------------------------------------------------- diff --git a/__tests__/components/ui/coaching-sessions/coaching-session-panel-topics.test.tsx b/__tests__/components/ui/coaching-sessions/coaching-session-panel-topics.test.tsx index 62cef584b..a156901a7 100644 --- a/__tests__/components/ui/coaching-sessions/coaching-session-panel-topics.test.tsx +++ b/__tests__/components/ui/coaching-sessions/coaching-session-panel-topics.test.tsx @@ -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, diff --git a/__tests__/components/ui/coaching-sessions/coaching-session-panel.test.tsx b/__tests__/components/ui/coaching-sessions/coaching-session-panel.test.tsx index f49249810..b9a5c614f 100644 --- a/__tests__/components/ui/coaching-sessions/coaching-session-panel.test.tsx +++ b/__tests__/components/ui/coaching-sessions/coaching-session-panel.test.tsx @@ -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, diff --git a/__tests__/components/ui/coaching-sessions/goal-card-compact.test.tsx b/__tests__/components/ui/coaching-sessions/goal-card-compact.test.tsx index 57a76330b..4f58cb3c5 100644 --- a/__tests__/components/ui/coaching-sessions/goal-card-compact.test.tsx +++ b/__tests__/components/ui/coaching-sessions/goal-card-compact.test.tsx @@ -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, diff --git a/__tests__/components/ui/coaching-sessions/topic-provenance-wiring.test.tsx b/__tests__/components/ui/coaching-sessions/topic-provenance-wiring.test.tsx index 20a669b09..ddd09e46f 100644 --- a/__tests__/components/ui/coaching-sessions/topic-provenance-wiring.test.tsx +++ b/__tests__/components/ui/coaching-sessions/topic-provenance-wiring.test.tsx @@ -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, diff --git a/__tests__/components/ui/coaching-sessions/topic-rating-wiring.test.tsx b/__tests__/components/ui/coaching-sessions/topic-rating-wiring.test.tsx index be6f01acd..1ff816fe3 100644 --- a/__tests__/components/ui/coaching-sessions/topic-rating-wiring.test.tsx +++ b/__tests__/components/ui/coaching-sessions/topic-rating-wiring.test.tsx @@ -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, diff --git a/__tests__/components/ui/dashboard/coaching-sessions-hover-detail.test.tsx b/__tests__/components/ui/dashboard/coaching-sessions-hover-detail.test.tsx index 2a311071e..07d003eab 100644 --- a/__tests__/components/ui/dashboard/coaching-sessions-hover-detail.test.tsx +++ b/__tests__/components/ui/dashboard/coaching-sessions-hover-detail.test.tsx @@ -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[]) { @@ -23,6 +31,8 @@ function renderWith(actions: ReturnType[]) { describe("SessionHoverDetail action rows (compact layout)", () => { const originalNow = Settings.now; beforeEach(() => { + vi.clearAllMocks(); + update.mockResolvedValue(undefined); Settings.now = () => NOW.toMillis(); }); afterEach(() => { @@ -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", () => { diff --git a/__tests__/components/ui/dashboard/goals-overview-card.test.tsx b/__tests__/components/ui/dashboard/goals-overview-card.test.tsx index b6b176867..062a135d6 100644 --- a/__tests__/components/ui/dashboard/goals-overview-card.test.tsx +++ b/__tests__/components/ui/dashboard/goals-overview-card.test.tsx @@ -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), })); diff --git a/__tests__/lib/api/goal-progress-revalidate.test.tsx b/__tests__/lib/api/goal-progress-revalidate.test.tsx new file mode 100644 index 000000000..053c834c0 --- /dev/null +++ b/__tests__/lib/api/goal-progress-revalidate.test.tsx @@ -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 }) => ( + new Map(), dedupingInterval: 0 }}>{children} +); + +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); + }); +}); diff --git a/__tests__/lib/hooks/use-panel-actions.test.ts b/__tests__/lib/hooks/use-panel-actions.test.ts index da1fae301..c23e36fbc 100644 --- a/__tests__/lib/hooks/use-panel-actions.test.ts +++ b/__tests__/lib/hooks/use-panel-actions.test.ts @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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); - }); -}); diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 467944bd4..92821be12 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -18,7 +18,7 @@ export default function AdminLayout({ return (
-
+
{children}
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 0b072cf37..ea75ae06d 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -17,7 +17,7 @@ export default function DashboardLayout({ return (
-
{children}
+
{children}
); } diff --git a/src/app/members/[id]/profile/layout.tsx b/src/app/members/[id]/profile/layout.tsx index 175ee9e20..80cbc06ad 100644 --- a/src/app/members/[id]/profile/layout.tsx +++ b/src/app/members/[id]/profile/layout.tsx @@ -17,7 +17,7 @@ export default function MembersLayout({ return (
-
{children}
+
{children}
); } diff --git a/src/app/organizations/[id]/members/layout.tsx b/src/app/organizations/[id]/members/layout.tsx index 04aad707f..3d1c7eda0 100644 --- a/src/app/organizations/[id]/members/layout.tsx +++ b/src/app/organizations/[id]/members/layout.tsx @@ -17,7 +17,7 @@ export default function MembersLayout({ return (
-
{children}
+
{children}
); } diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx index 0a340aca3..68a0c7d08 100644 --- a/src/app/settings/layout.tsx +++ b/src/app/settings/layout.tsx @@ -19,7 +19,7 @@ export default function SettingsLayout({ return (
-
+
{/* SettingsNav renders both mobile (horizontal tabs above) diff --git a/src/components/ui/actions/actions-page-container.tsx b/src/components/ui/actions/actions-page-container.tsx index 856a9f8a0..9885eb6cd 100644 --- a/src/components/ui/actions/actions-page-container.tsx +++ b/src/components/ui/actions/actions-page-container.tsx @@ -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"; @@ -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"; @@ -47,17 +46,6 @@ function parseEnum( 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 " (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; } @@ -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 } }, @@ -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] @@ -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] @@ -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] @@ -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] @@ -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] diff --git a/src/components/ui/actions/kanban-column.tsx b/src/components/ui/actions/kanban-column.tsx index 57486c595..896c3a5f9 100644 --- a/src/components/ui/actions/kanban-column.tsx +++ b/src/components/ui/actions/kanban-column.tsx @@ -3,7 +3,7 @@ import { memo } from "react"; import { useDroppable } from "@dnd-kit/core"; import { cn } from "@/components/lib/utils"; -import { statusColor } from "@/components/ui/actions/utils"; +import { StatusGlyph } from "@/components/ui/status/status-glyph"; import { actionStatusToString } from "@/types/general"; import { KanbanActionCard } from "@/components/ui/actions/kanban-action-card"; import type { KanbanCardCallbacks } from "@/components/ui/actions/kanban-action-card"; @@ -47,10 +47,7 @@ export const KanbanColumn = memo(function KanbanColumn({ > {/* Column header */}
- +

{actionStatusToString(status)}

{visibleCount} diff --git a/src/components/ui/actions/utils.ts b/src/components/ui/actions/utils.ts index 68fe10d3e..f650121b8 100644 --- a/src/components/ui/actions/utils.ts +++ b/src/components/ui/actions/utils.ts @@ -23,26 +23,6 @@ export const STATUS_COLUMN_ORDER: ItemStatus[] = [ ItemStatus.WontDo, ]; -/** Tailwind background color class for each status column indicator */ -export function statusColor(status: ItemStatus): string { - switch (status) { - case ItemStatus.NotStarted: - return "bg-slate-400"; - case ItemStatus.InProgress: - return "bg-blue-500"; - case ItemStatus.OnHold: - return "bg-amber-400"; - case ItemStatus.Completed: - return "bg-green-500"; - case ItemStatus.WontDo: - return "bg-gray-400"; - default: { - const _exhaustive: never = status; - throw new Error(`Unhandled status: ${_exhaustive}`); - } - } -} - /** Group items into status buckets, preserving order within each bucket */ export function groupByStatus( items: T[], diff --git a/src/components/ui/coaching-sessions/action-card-compact.tsx b/src/components/ui/coaching-sessions/action-card-compact.tsx index 179cd34ce..cf17d17bc 100644 --- a/src/components/ui/coaching-sessions/action-card-compact.tsx +++ b/src/components/ui/coaching-sessions/action-card-compact.tsx @@ -650,9 +650,9 @@ export function ActionEditForm({ {saveLabel}