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

View File

@@ -182,6 +182,30 @@ describe("GoalsView", () => {
expect(await screen.findByText("Loaded Goal")).toBeInTheDocument(); 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 () => { it("renders inline load error when API request fails", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@@ -388,7 +412,7 @@ describe("GoalsView", () => {
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } }); fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
fireEvent.click(screen.getByTestId("goals-form-draft-ai")); 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( expect(screen.getByTestId("goals-form-description")).toHaveValue(
"Expand the extension ecosystem with better support and adoption goals." "Expand the extension ecosystem with better support and adoption goals."
); );

View File

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