fix(dashboard): scope goals view to selected project
This commit is contained in:
7
.changeset/fix-goals-project-scope.md
Normal file
7
.changeset/fix-goals-project-scope.md
Normal 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.
|
||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user