FN-5649: wire GoalsView to Goals API workflows
Replace mock-only goals behavior with live API-backed create/edit/archive flows in GoalsView. - load goals from GET /api/goals when initial goals are not injected - add inline create form that POSTs goals and handles active-goal cap 409 errors - add per-goal edit form that PATCHes title/description changes - add archive/unarchive actions wired to archive endpoints with inline error handling - refresh GoalsView docs and expand component tests for loading, CRUD, cap errors, and status transitions Files changed: docs/dashboard-guide.md | 18 +- packages/dashboard/app/components/GoalsView.css | 52 ++- packages/dashboard/app/components/GoalsView.tsx | 402 +++++++++++++++++---- packages/dashboard/app/components/__tests__/GoalsView.test.tsx | 250 +++++++++++-- 4 files changed, 600 insertions(+), 122 deletions(-) Fusion-Task-Id: FN-5649 Fusion-Task-Lineage: b787c8e3-64c5-4243-ae84-0f20f12ea09e
This commit is contained in:
@@ -421,24 +421,26 @@ For mission planning context and handoff structure, see [Missions guide](./missi
|
||||
|
||||
## Goals View
|
||||
|
||||
Goals view is a minimal strategic-goals surface that shows the current goal list, active-goal count, and quick activation controls.
|
||||
Goals view is a strategic-goals surface backed by the Goals REST API.
|
||||
|
||||
> No feature flag required.
|
||||
> Current status: the `GoalsView` chunk is lazy-defined/prefetched in `App.tsx`, but it is not yet wired into the primary dashboard navigation.
|
||||
|
||||
What it currently shows:
|
||||
What it shows:
|
||||
- Header with active-goal count (`N active goals`) and an **Add Goal** action
|
||||
- Goal cards with title, `Status: active|inactive`, and a per-goal **Activate** button (disabled for already-active goals)
|
||||
- Goal cards with title, optional description, and `Status: active|archived`
|
||||
- Empty state when no goals exist: `No goals yet. Add one to begin tracking strategic outcomes.`
|
||||
|
||||
Current data behavior:
|
||||
- Uses in-component mock data (`defaultMockGoals`)
|
||||
- UI-only state; no backend persistence or server-side goal storage yet
|
||||
Data behavior:
|
||||
- Initial load: `GET /api/goals` (returns `{ goals }`)
|
||||
- Create: inline Add Goal form posts `title` (required) + `description` (optional) to `POST /api/goals`
|
||||
- Edit: per-card inline form patches title/description via `PATCH /api/goals/:id`
|
||||
- Archive/unarchive: `POST /api/goals/:id/archive` and `POST /api/goals/:id/unarchive`
|
||||
|
||||
Active-goal cap behavior:
|
||||
- Hard cap of 5 active goals
|
||||
- Hard cap of 5 active goals (server-enforced)
|
||||
- Warning banner appears when active goals are in the 3–5 range
|
||||
- Add/activate attempts beyond 5 are blocked and show an error message
|
||||
- Cap violations (for create or unarchive) return HTTP 409 with `code: ACTIVE_GOAL_LIMIT_EXCEEDED` and are surfaced as inline goal errors
|
||||
|
||||
Source file: `packages/dashboard/app/components/GoalsView.tsx`
|
||||
|
||||
|
||||
@@ -49,6 +49,26 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.goals-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.goals-form-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.goals-form textarea.input {
|
||||
min-height: calc(var(--space-2xl) * 2);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.goals-form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.goals-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -62,21 +82,42 @@
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.goals-card-archived {
|
||||
border-color: color-mix(in srgb, var(--text-muted) 25%, transparent);
|
||||
}
|
||||
|
||||
.goals-card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.goals-card-edit {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.goals-card-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: calc(var(--space-md) + var(--space-xs));
|
||||
}
|
||||
|
||||
.goals-card-description {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.goals-card-status {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.goals-card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.goals-activate-button {
|
||||
min-width: calc(var(--space-2xl) * 2);
|
||||
}
|
||||
@@ -91,14 +132,13 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.goals-add-button,
|
||||
.goals-card,
|
||||
.goals-activate-button {
|
||||
min-height: calc(var(--space-xl) + var(--space-md));
|
||||
}
|
||||
|
||||
.goals-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.goals-form-actions,
|
||||
.goals-card-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Goal } from "@fusion/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import "./GoalsView.css";
|
||||
|
||||
interface Goal {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "active" | "inactive";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UseGoalsResult {
|
||||
goals: Goal[];
|
||||
activeCount: number;
|
||||
errorMessage: string | null;
|
||||
addGoal: () => void;
|
||||
activateGoal: (goalId: string) => void;
|
||||
}
|
||||
|
||||
export interface GoalsViewProps {
|
||||
initialGoals?: Goal[];
|
||||
}
|
||||
@@ -24,64 +10,216 @@ export interface GoalsViewProps {
|
||||
const MAX_ACTIVE_GOALS = 5;
|
||||
const WARNING_THRESHOLD = 3;
|
||||
|
||||
const defaultMockGoals: Goal[] = [
|
||||
{ id: "goal-1", title: "Reduce mean review turnaround", status: "active", createdAt: "2026-05-14T09:30:00.000Z" },
|
||||
{ id: "goal-2", title: "Raise merge reliability coverage", status: "active", createdAt: "2026-05-15T12:00:00.000Z" },
|
||||
{ id: "goal-3", title: "Ship dashboard quality audit", status: "inactive", createdAt: "2026-05-16T08:15:00.000Z" },
|
||||
];
|
||||
const CAP_ERROR_MESSAGE = "Cannot activate more than 5 goals. Resolve an active goal before activating another.";
|
||||
|
||||
function useGoals(initialGoals?: Goal[]): UseGoalsResult {
|
||||
const [goals, setGoals] = useState<Goal[]>(() => initialGoals ?? defaultMockGoals);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const activeCount = useMemo(() => goals.filter((goal) => goal.status === "active").length, [goals]);
|
||||
|
||||
function addGoal() {
|
||||
if (activeCount >= MAX_ACTIVE_GOALS) {
|
||||
setErrorMessage("Cannot activate more than 5 goals. Resolve an active goal before adding another active goal.");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
setGoals((current) => [
|
||||
...current,
|
||||
{
|
||||
id: `goal-${current.length + 1}`,
|
||||
title: `New Goal ${current.length + 1}`,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function activateGoal(goalId: string) {
|
||||
const nextGoal = goals.find((goal) => goal.id === goalId);
|
||||
if (!nextGoal || nextGoal.status === "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeCount >= MAX_ACTIVE_GOALS) {
|
||||
setErrorMessage("Cannot activate more than 5 goals. Resolve an active goal before activating another.");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
setGoals((current) => current.map((goal) => (goal.id === goalId ? { ...goal, status: "active" } : goal)));
|
||||
}
|
||||
|
||||
return {
|
||||
goals,
|
||||
activeCount,
|
||||
errorMessage,
|
||||
addGoal,
|
||||
activateGoal,
|
||||
};
|
||||
function isCapError(payload: unknown): boolean {
|
||||
return Boolean(payload && typeof payload === "object" && "code" in payload && (payload as { code?: unknown }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED");
|
||||
}
|
||||
|
||||
export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
const { goals, activeCount, errorMessage, addGoal, activateGoal } = useGoals(initialGoals);
|
||||
const [goals, setGoals] = useState<Goal[]>(() => initialGoals ?? []);
|
||||
const [loading, setLoading] = useState<boolean>(initialGoals === undefined);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const [isAddFormOpen, setIsAddFormOpen] = useState(false);
|
||||
const [addTitle, setAddTitle] = useState("");
|
||||
const [addDescription, setAddDescription] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const [editGoalId, setEditGoalId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialGoals !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const loadGoals = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
const response = await fetch("/api/goals");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load goals (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { goals?: Goal[] };
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setGoals(Array.isArray(payload.goals) ? payload.goals : []);
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage("Unable to load goals right now. Please try again.");
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadGoals();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [initialGoals]);
|
||||
|
||||
const activeCount = useMemo(() => goals.filter((goal) => goal.status === "active").length, [goals]);
|
||||
const showWarning = activeCount >= WARNING_THRESHOLD && activeCount <= MAX_ACTIVE_GOALS;
|
||||
|
||||
function openAddForm() {
|
||||
setErrorMessage(null);
|
||||
setAddError(null);
|
||||
setIsAddFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(goal: Goal) {
|
||||
setEditGoalId(goal.id);
|
||||
setEditTitle(goal.title);
|
||||
setEditDescription(goal.description ?? "");
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditGoalId(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
function closeAddForm() {
|
||||
setIsAddFormOpen(false);
|
||||
setAddTitle("");
|
||||
setAddDescription("");
|
||||
setAddError(null);
|
||||
}
|
||||
|
||||
async function submitAddGoal() {
|
||||
const title = addTitle.trim();
|
||||
if (!title) {
|
||||
setAddError("Title is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setAddError(null);
|
||||
setErrorMessage(null);
|
||||
const response = await fetch("/api/goals", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: addDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const createdGoal = (await response.json()) as Goal;
|
||||
setGoals((current) => [...current, createdGoal]);
|
||||
closeAddForm();
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (response.status === 409 && isCapError(payload)) {
|
||||
setErrorMessage(CAP_ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
setAddError("Unable to create goal right now. Please try again.");
|
||||
} catch {
|
||||
setAddError("Unable to create goal right now. Please try again.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEditGoal() {
|
||||
if (!editGoalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = editTitle.trim();
|
||||
if (!title) {
|
||||
setEditError("Title is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSavingEdit(true);
|
||||
setEditError(null);
|
||||
const response = await fetch(`/api/goals/${editGoalId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ title, description: editDescription }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update goal (${response.status})`);
|
||||
}
|
||||
|
||||
const updatedGoal = (await response.json()) as Goal;
|
||||
setGoals((current) => current.map((goal) => (goal.id === updatedGoal.id ? updatedGoal : goal)));
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setEditError("Unable to save goal right now. Please try again.");
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGoalArchiveStatus(goal: Goal) {
|
||||
const endpoint = goal.status === "active" ? `/api/goals/${goal.id}/archive` : `/api/goals/${goal.id}/unarchive`;
|
||||
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedGoal = (await response.json()) as Goal;
|
||||
setGoals((current) => current.map((entry) => (entry.id === updatedGoal.id ? updatedGoal : entry)));
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (response.status === 409 && isCapError(payload)) {
|
||||
setErrorMessage(CAP_ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("Unable to update goal status right now. Please try again.");
|
||||
} catch {
|
||||
setErrorMessage("Unable to update goal status right now. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="goals-view" data-testid="goals-view">
|
||||
<header className="goals-header">
|
||||
@@ -91,12 +229,53 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
{activeCount} active goals
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary goals-add-button" onClick={addGoal} data-testid="goals-add-button">
|
||||
<button type="button" className="btn btn-primary goals-add-button" onClick={openAddForm} data-testid="goals-add-button">
|
||||
<Plus aria-hidden="true" />
|
||||
Add Goal
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isAddFormOpen ? (
|
||||
<div className="card goals-form" data-testid="goals-form">
|
||||
<label className="goals-form-label" htmlFor="goals-form-title">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="goals-form-title"
|
||||
className="input"
|
||||
type="text"
|
||||
value={addTitle}
|
||||
maxLength={200}
|
||||
onChange={(event) => setAddTitle(event.target.value)}
|
||||
data-testid="goals-form-title"
|
||||
/>
|
||||
<label className="goals-form-label" htmlFor="goals-form-description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="goals-form-description"
|
||||
className="input"
|
||||
value={addDescription}
|
||||
maxLength={5000}
|
||||
onChange={(event) => setAddDescription(event.target.value)}
|
||||
data-testid="goals-form-description"
|
||||
/>
|
||||
{addError ? (
|
||||
<p className="form-error goals-error" role="alert">
|
||||
{addError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="goals-form-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void submitAddGoal()} disabled={isCreating} data-testid="goals-form-submit">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={closeAddForm} disabled={isCreating} data-testid="goals-form-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showWarning ? (
|
||||
<p className="goals-warning" role="status">
|
||||
Approaching the 5-active goal cap. Keep active goals focused.
|
||||
@@ -104,36 +283,113 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="form-error goals-error" role="alert">
|
||||
<p className="form-error goals-error" role="alert" data-testid="goals-error">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{goals.length === 0 ? (
|
||||
{loading ? (
|
||||
<p className="goals-loading" role="status" data-testid="goals-loading">
|
||||
Loading goals…
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && goals.length === 0 ? (
|
||||
<div className="goals-empty card" data-testid="goals-empty-state">
|
||||
No goals yet. Add one to begin tracking strategic outcomes.
|
||||
</div>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{!loading && goals.length > 0 ? (
|
||||
<div className="goals-list" data-testid="goals-list">
|
||||
{goals.map((goal) => (
|
||||
<article key={goal.id} className="card goals-card" data-testid={`goal-card-${goal.id}`}>
|
||||
<div className="goals-card-main">
|
||||
<h3 className="goals-card-title">{goal.title}</h3>
|
||||
<p className="goals-card-status">Status: {goal.status}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn goals-activate-button"
|
||||
disabled={goal.status === "active"}
|
||||
onClick={() => activateGoal(goal.id)}
|
||||
data-testid={`goal-activate-${goal.id}`}
|
||||
>
|
||||
{goal.status === "active" ? "Active" : "Activate"}
|
||||
</button>
|
||||
<article
|
||||
key={goal.id}
|
||||
className={`card goals-card ${goal.status === "archived" ? "goals-card-archived" : ""}`.trim()}
|
||||
data-testid={`goal-card-${goal.id}`}
|
||||
>
|
||||
{editGoalId === goal.id ? (
|
||||
<div className="goals-card-main goals-card-edit">
|
||||
<label className="goals-form-label" htmlFor={`goal-edit-title-${goal.id}`}>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id={`goal-edit-title-${goal.id}`}
|
||||
className="input"
|
||||
type="text"
|
||||
value={editTitle}
|
||||
maxLength={200}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
data-testid={`goal-edit-title-${goal.id}`}
|
||||
/>
|
||||
<label className="goals-form-label" htmlFor={`goal-edit-description-${goal.id}`}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={`goal-edit-description-${goal.id}`}
|
||||
className="input"
|
||||
value={editDescription}
|
||||
maxLength={5000}
|
||||
onChange={(event) => setEditDescription(event.target.value)}
|
||||
data-testid={`goal-edit-description-${goal.id}`}
|
||||
/>
|
||||
{editError ? (
|
||||
<p className="form-error goals-error" role="alert">
|
||||
{editError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="goals-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void saveEditGoal()}
|
||||
disabled={isSavingEdit}
|
||||
data-testid={`goal-edit-save-${goal.id}`}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={cancelEdit} disabled={isSavingEdit} data-testid={`goal-edit-cancel-${goal.id}`}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="goals-card-main">
|
||||
<h3 className="goals-card-title">{goal.title}</h3>
|
||||
{goal.description ? <p className="goals-card-description">{goal.description}</p> : null}
|
||||
<p className="goals-card-status">Status: {goal.status}</p>
|
||||
</div>
|
||||
<div className="goals-card-actions">
|
||||
<button type="button" className="btn" onClick={() => openEdit(goal)} data-testid={`goal-edit-${goal.id}`}>
|
||||
Edit
|
||||
</button>
|
||||
{goal.status === "active" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn goals-activate-button"
|
||||
onClick={() => void updateGoalArchiveStatus(goal)}
|
||||
data-testid={`goal-archive-${goal.id}`}
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn goals-activate-button"
|
||||
onClick={() => void updateGoalArchiveStatus(goal)}
|
||||
data-testid={`goal-unarchive-${goal.id}`}
|
||||
>
|
||||
Unarchive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,244 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Goal } from "@fusion/core";
|
||||
import { GoalsView } from "../GoalsView";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Plus: () => <span data-testid="icon-plus" />,
|
||||
}));
|
||||
|
||||
function makeGoal(overrides: Partial<Goal> & Pick<Goal, "id" | "title">): Goal {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.title,
|
||||
status: overrides.status ?? "active",
|
||||
createdAt: overrides.createdAt ?? "2026-05-16T00:00:00.000Z",
|
||||
updatedAt: overrides.updatedAt ?? "2026-05-16T00:00:00.000Z",
|
||||
description: overrides.description,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GoalsView", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state", () => {
|
||||
render(<GoalsView initialGoals={[]} />);
|
||||
expect(screen.getByTestId("goals-empty-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show warning at 2 active goals", () => {
|
||||
render(
|
||||
<GoalsView
|
||||
initialGoals={[
|
||||
{ id: "g1", title: "One", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g2", title: "Two", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
]}
|
||||
/>,
|
||||
it("loads goals from API when initialGoals is not provided", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ goals: [makeGoal({ id: "g1", title: "Loaded Goal" })] }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<GoalsView />);
|
||||
|
||||
expect(screen.getByTestId("goals-loading")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/goals"));
|
||||
expect(await screen.findByText("Loaded Goal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders inline load error when API request fails", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
|
||||
render(<GoalsView />);
|
||||
|
||||
expect(screen.getByTestId("goals-loading")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("goals-error")).toHaveTextContent("Unable to load goals right now. Please try again.");
|
||||
});
|
||||
|
||||
it("does not show warning at 2 active goals", () => {
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One" }), makeGoal({ id: "g2", title: "Two" })]} />);
|
||||
expect(screen.queryByText(/approaching the 5-active goal cap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning at 3 active goals", () => {
|
||||
render(
|
||||
<GoalsView
|
||||
initialGoals={[
|
||||
{ id: "g1", title: "One", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g2", title: "Two", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g3", title: "Three", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
]}
|
||||
initialGoals={[makeGoal({ id: "g1", title: "One" }), makeGoal({ id: "g2", title: "Two" }), makeGoal({ id: "g3", title: "Three" })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("5-active goal cap");
|
||||
expect(screen.getByText(/approaching the 5-active goal cap/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows hard error and prevents 6th activation when 5 are active", () => {
|
||||
render(
|
||||
<GoalsView
|
||||
initialGoals={[
|
||||
{ id: "g1", title: "One", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g2", title: "Two", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g3", title: "Three", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g4", title: "Four", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g5", title: "Five", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
{ id: "g6", title: "Six", status: "inactive", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
it("archives goal via API", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => makeGoal({ id: "g1", title: "One", status: "archived" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goal-activate-g6"));
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One" })]} />);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("goal-activate-g6")).toHaveTextContent("Activate");
|
||||
fireEvent.click(screen.getByTestId("goal-archive-g1"));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/goals/g1/archive", { method: "POST" }));
|
||||
expect(await screen.findByText("Status: archived")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders add button with class for focus-visible style hook", () => {
|
||||
it("unarchives goal via API", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => makeGoal({ id: "g1", title: "One", status: "active" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", status: "archived" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goal-unarchive-g1"));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/goals/g1/unarchive", { method: "POST" }));
|
||||
expect(await screen.findByText("Status: active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows cap error for unarchive 409", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", status: "archived" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goal-unarchive-g1"));
|
||||
|
||||
expect(await screen.findByTestId("goals-error")).toHaveTextContent("Cannot activate more than 5 goals");
|
||||
});
|
||||
|
||||
it("shows form when add button is clicked", () => {
|
||||
render(<GoalsView initialGoals={[]} />);
|
||||
expect(screen.getByTestId("goals-add-button")).toHaveClass("goals-add-button");
|
||||
|
||||
fireEvent.click(screen.getByTestId("goals-add-button"));
|
||||
|
||||
expect(screen.getByTestId("goals-form-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("goals-form-description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates empty title on create", async () => {
|
||||
render(<GoalsView initialGoals={[]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goals-add-button"));
|
||||
fireEvent.click(screen.getByTestId("goals-form-submit"));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Title is required.");
|
||||
});
|
||||
|
||||
it("creates goal via API and closes form", async () => {
|
||||
const created = makeGoal({ id: "g3", title: "Created Goal", description: "new description" });
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => created,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goals-add-button"));
|
||||
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Created Goal" } });
|
||||
fireEvent.change(screen.getByTestId("goals-form-description"), { target: { value: "new description" } });
|
||||
fireEvent.click(screen.getByTestId("goals-form-submit"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/goals",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("Created Goal")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("goals-form-title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows cap error on 409 and keeps add form open", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goals-add-button"));
|
||||
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Overflow Goal" } });
|
||||
fireEvent.click(screen.getByTestId("goals-form-submit"));
|
||||
|
||||
expect(await screen.findByTestId("goals-error")).toHaveTextContent("Cannot activate more than 5 goals");
|
||||
expect(screen.getByTestId("goals-form-title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens edit form with prefilled values", () => {
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: "Desc" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goal-edit-g1"));
|
||||
|
||||
expect(screen.getByTestId("goal-edit-title-g1")).toHaveValue("One");
|
||||
expect(screen.getByTestId("goal-edit-description-g1")).toHaveValue("Desc");
|
||||
});
|
||||
|
||||
it("updates goal via PATCH", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => makeGoal({ id: "g1", title: "Updated", description: "Edited" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: "Desc" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goal-edit-g1"));
|
||||
fireEvent.change(screen.getByTestId("goal-edit-title-g1"), { target: { value: "Updated" } });
|
||||
fireEvent.change(screen.getByTestId("goal-edit-description-g1"), { target: { value: "Edited" } });
|
||||
fireEvent.click(screen.getByTestId("goal-edit-save-g1"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/goals/g1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("Updated")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("goal-edit-title-g1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates empty title when editing", async () => {
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: "Desc" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goal-edit-g1"));
|
||||
fireEvent.change(screen.getByTestId("goal-edit-title-g1"), { target: { value: " " } });
|
||||
fireEvent.click(screen.getByTestId("goal-edit-save-g1"));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Title is required.");
|
||||
});
|
||||
|
||||
it("shows edit error when PATCH fails", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: "Desc" })]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goal-edit-g1"));
|
||||
fireEvent.change(screen.getByTestId("goal-edit-title-g1"), { target: { value: "Updated" } });
|
||||
fireEvent.click(screen.getByTestId("goal-edit-save-g1"));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Unable to save goal right now. Please try again.");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user