fix(dashboard): scope goals view to selected project

This commit is contained in:
Phil Larson
2026-06-30 21:59:03 -07:00
parent d58ba268c5
commit 7ea9aee98f
4 changed files with 52 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep the Goals dashboard view scoped to the selected project.
category: fix
dev: Threads projectId through Goals view reads, mutations, mission links, and AI description drafting.

View File

@@ -11,6 +11,7 @@ import "./GoalsView.css";
export interface GoalsViewProps {
initialGoals?: Goal[];
anchorGoalId?: string;
projectId?: string;
onNavigateToMission?: (missionId: string) => void;
}
@@ -25,11 +26,17 @@ const WARNING_THRESHOLD = 3;
const GOAL_DESCRIPTION_TOGGLE_LENGTH = 280;
function withProjectId(path: string, projectId?: string): string {
if (!projectId) return path;
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}projectId=${encodeURIComponent(projectId)}`;
}
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, anchorGoalId, onNavigateToMission }: GoalsViewProps) {
export function GoalsView({ initialGoals, anchorGoalId, projectId, onNavigateToMission }: GoalsViewProps) {
const { t } = useTranslation("app");
const [goals, setGoals] = useState<Goal[]>(() => initialGoals ?? []);
const [highlightedGoalId, setHighlightedGoalId] = useState<string | null>(null);
@@ -66,7 +73,7 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
try {
setLoading(true);
setErrorMessage(null);
const response = await fetch("/api/goals");
const response = await fetch(withProjectId("/api/goals", projectId));
if (!response.ok) {
throw new Error(`Failed to load goals (${response.status})`);
}
@@ -93,13 +100,13 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
return () => {
active = false;
};
}, [initialGoals]);
}, [initialGoals, projectId]);
useEffect(() => {
let active = true;
const loadMissions = async () => {
try {
const response = await fetch("/api/missions");
const response = await fetch(withProjectId("/api/missions", projectId));
if (!response.ok) {
throw new Error(`Failed to load missions (${response.status})`);
}
@@ -124,10 +131,10 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
return () => {
active = false;
};
}, [t]);
}, [projectId, t]);
const loadLinkedMissionsForGoal = async (goalId: string): Promise<LinkedMission[]> => {
const response = await fetch(`/api/goals/${encodeURIComponent(goalId)}/missions`);
const response = await fetch(withProjectId(`/api/goals/${encodeURIComponent(goalId)}/missions`, projectId));
if (!response.ok) {
throw new Error(`Failed to load linked missions (${response.status})`);
}
@@ -235,7 +242,7 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
try {
setIsDraftingDescription(true);
setAddError(null);
const description = await draftGoalDescription(title);
const description = await draftGoalDescription(title, projectId);
setAddDescription(description);
} catch (error) {
setAddError(getRefineErrorMessage(error));
@@ -255,7 +262,7 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
setIsCreating(true);
setAddError(null);
setErrorMessage(null);
const response = await fetch("/api/goals", {
const response = await fetch(withProjectId("/api/goals", projectId), {
method: "POST",
headers: {
"content-type": "application/json",
@@ -307,7 +314,7 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
try {
setIsSavingEdit(true);
setEditError(null);
const response = await fetch(`/api/goals/${editGoalId}`, {
const response = await fetch(withProjectId(`/api/goals/${editGoalId}`, projectId), {
method: "PATCH",
headers: {
"content-type": "application/json",
@@ -368,7 +375,7 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
try {
setLinkingMissionGoalId(goalId);
setErrorMessage(null);
const response = await fetch(`/api/missions/${encodeURIComponent(missionId)}/goals/${encodeURIComponent(goalId)}`, { method: "POST" });
const response = await fetch(withProjectId(`/api/missions/${encodeURIComponent(missionId)}/goals/${encodeURIComponent(goalId)}`, projectId), { method: "POST" });
if (!response.ok) {
throw new Error(`Failed to link mission (${response.status})`);
}
@@ -384,7 +391,7 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
try {
setUnlinkingMissionKey(`${goalId}:${missionId}`);
setErrorMessage(null);
const response = await fetch(`/api/missions/${encodeURIComponent(missionId)}/goals/${encodeURIComponent(goalId)}`, { method: "DELETE" });
const response = await fetch(withProjectId(`/api/missions/${encodeURIComponent(missionId)}/goals/${encodeURIComponent(goalId)}`, projectId), { method: "DELETE" });
if (!response.ok) {
throw new Error(`Failed to unlink mission (${response.status})`);
}
@@ -401,7 +408,7 @@ export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: G
try {
setErrorMessage(null);
const response = await fetch(endpoint, {
const response = await fetch(withProjectId(endpoint, projectId), {
method: "POST",
});

View File

@@ -182,6 +182,30 @@ describe("GoalsView", () => {
expect(await screen.findByText("Loaded Goal")).toBeInTheDocument();
});
it("threads projectId through goals and mission reads", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const path = String(input);
if (path === "/api/goals?projectId=proj-fusion") {
return { ok: true, json: async () => ({ goals: [makeGoal({ id: "g1", title: "Fusion Goal" })] }) };
}
if (path === "/api/missions?projectId=proj-fusion") {
return { ok: true, json: async () => ({ missions: [] }) };
}
if (path === "/api/goals/g1/missions?projectId=proj-fusion") {
return { ok: true, json: async () => ({ missions: [] }) };
}
return { ok: false, status: 404, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetchMock);
render(<GoalsView projectId="proj-fusion" />);
expect(await screen.findByText("Fusion Goal")).toBeInTheDocument();
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/goals?projectId=proj-fusion"));
expect(fetchMock).toHaveBeenCalledWith("/api/missions?projectId=proj-fusion");
expect(fetchMock).toHaveBeenCalledWith("/api/goals/g1/missions?projectId=proj-fusion");
});
it("renders inline load error when API request fails", async () => {
vi.stubGlobal(
"fetch",
@@ -388,7 +412,7 @@ describe("GoalsView", () => {
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
fireEvent.click(screen.getByTestId("goals-form-draft-ai"));
await waitFor(() => expect(mockDraftGoalDescription).toHaveBeenCalledWith("Grow ecosystem"));
await waitFor(() => expect(mockDraftGoalDescription).toHaveBeenCalledWith("Grow ecosystem", undefined));
expect(screen.getByTestId("goals-form-description")).toHaveValue(
"Expand the extension ecosystem with better support and adoption goals."
);

View File

@@ -531,7 +531,7 @@ export function MainContent({
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<GoalsView anchorGoalId={goalAnchorId} onNavigateToMission={handleOpenMission} />
<GoalsView anchorGoalId={goalAnchorId} projectId={currentProject?.id} onNavigateToMission={handleOpenMission} />
</Suspense>
</PageErrorBoundary>
);