diff --git a/.changeset/fix-goals-project-scope.md b/.changeset/fix-goals-project-scope.md new file mode 100644 index 0000000000..5547b8d3d4 --- /dev/null +++ b/.changeset/fix-goals-project-scope.md @@ -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. diff --git a/packages/dashboard/app/components/GoalsView.tsx b/packages/dashboard/app/components/GoalsView.tsx index 72b222dd55..89a4b96cf7 100644 --- a/packages/dashboard/app/components/GoalsView.tsx +++ b/packages/dashboard/app/components/GoalsView.tsx @@ -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(() => initialGoals ?? []); const [highlightedGoalId, setHighlightedGoalId] = useState(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 => { - 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", }); diff --git a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx index a52fd7586f..ae1c12bcc3 100644 --- a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx @@ -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(); + + 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." ); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 56690bf837..152cf8e4ff 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -531,7 +531,7 @@ export function MainContent({ return ( - + );