diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx index cc6434a7..f3eda7d8 100644 --- a/src/pages/OnBoardingPage.tsx +++ b/src/pages/OnBoardingPage.tsx @@ -25,7 +25,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { PageHeader } from "../components/layout/PageHeader"; import { PageShell } from "../components/layout/PageShell"; -import { AlertDialog } from "../components/ui/AlertDialog"; import { Badge } from "../components/ui/Badge"; import { Button } from "../components/ui/Button"; import { SegmentedTabs } from "../components/ui/SegmentedTabs"; @@ -221,7 +220,6 @@ export function OnBoardingPage() { SLIDING_PANEL_EXIT_MS, ); }, []); - const [confirmRegenerate, setConfirmRegenerate] = useState(false); const [isDinoActiveInGeneration, setIsDinoActiveInGeneration] = useState(false); /** * The last running generation, kept so the generation screen can stay up while the dino game @@ -690,7 +688,6 @@ export function OnBoardingPage() { const requestGeneration = () => { if (!selectedProjectId) return; - setConfirmRegenerate(false); startGeneration(selectedProjectId); }; @@ -799,26 +796,37 @@ export function OnBoardingPage() { {/* Retrying a phase that was skipped for lack of material changes nothing, so when that is all there is, the button says so rather than inviting the same answer. */} -

- {retryCouldHelp(generationIssues) - ? "Trying again re-runs assembly for every phase." - : "Another run will produce the same result until the project has more material."} -

+ {canManageSelected && ( +

+ {retryCouldHelp(generationIssues) + ? "Trying again re-runs assembly for every phase." + : "Another run will produce the same result until the project has more material."} +

+ )} ) : null} - + {/* Building an existing path again replaces it, which is the project manager's call: the + backend refuses it to members, so they are pointed at the PM instead of a button that + can only fail. */} + {canManageSelected ? ( + + ) : ( +

+ Your project manager can rebuild your path from your page in the team area. +

+ )} ); } @@ -839,30 +847,20 @@ export function OnBoardingPage() { ? "You made it through every phase. Everything stays here to look back on." : "Your path into the project. Phases that are open can be done in any order." } + // Rebuilding a path throws the member's progress away, so it is the PM's call: it lives + // on the member's page in the PM area, not here. actions={ - <> - {generationIssues.length > 0 && ( - - - - - )} - - + + + + ) } /> @@ -978,21 +976,6 @@ export function OnBoardingPage() { )} - - setConfirmRegenerate(false)} - onConfirm={() => { - requestGeneration(); - toast.info("Rebuilding your onboarding path", { - description: "This runs in the background; you can keep using the app.", - }); - }} - /> ); } diff --git a/src/pages/TeamMemberDetailPage.tsx b/src/pages/TeamMemberDetailPage.tsx index 495d89ed..c7dad9af 100644 --- a/src/pages/TeamMemberDetailPage.tsx +++ b/src/pages/TeamMemberDetailPage.tsx @@ -1,4 +1,13 @@ -import { MessageSquareText, Pencil, Plus, ThumbsDown, ThumbsUp, Users, X } from "lucide-react"; +import { + MessageSquareText, + Pencil, + Plus, + RefreshCw, + ThumbsDown, + ThumbsUp, + Users, + X, +} from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useToast } from "../context/useToast"; @@ -42,6 +51,23 @@ type DetailOnboardingStep = OnboardingStepEndpoint & { } | null; }; +/** A rebuild failure, in the PM's words rather than the member's. */ +function describeRebuildError(message: string, reason?: string): string { + if (reason === "not-enough-knowledge") { + return "The project's knowledge base does not cover any phase yet. The current path is unchanged."; + } + if (/no active blueprint/i.test(message)) { + return "This project has no published onboarding blueprint yet. Publish one, then try again."; + } + if (/multiple active blueprints/i.test(message)) { + return "This project has more than one published onboarding blueprint. Archive all but one, then try again."; + } + if (/status: 403/.test(message)) { + return "Only the project's manager can rebuild paths, and only for members of the project."; + } + return `${message || "The generation failed."} The current path is unchanged.`; +} + function getElapsedDays(startedAt: string): number { const started = new Date(startedAt).getTime(); @@ -56,6 +82,8 @@ import { MemberDetailDialogs } from "../features/team-management/components/deta import { MemberGapsPanel } from "../features/team-management/components/detail/MemberGapsPanel"; import { MemberJourneySection } from "../features/team-management/components/detail/MemberJourneySection"; import { AlertDialog } from "../components/ui/AlertDialog"; +import { Button } from "../components/ui/Button"; +import { onboardingService } from "../services/onboardingService"; import { PhaseCheckAdminModal, type PhaseCheckAdminTab, @@ -158,6 +186,13 @@ export function TeamMemberDetailPage() { const [feedbackError, setFeedbackError] = useState(""); const [loadError, setLoadError] = useState(""); const toast = useToast(); + // Rebuilding a member's path is the PM's call alone (members can only build their first one), + // so the control lives here rather than on the member's onboarding page. + const [confirmRebuild, setConfirmRebuild] = useState(false); + const [rebuildingUserId, setRebuildingUserId] = useState(null); + // Leaving the page only stops watching; the generation carries on on the backend. + const rebuildWatch = useRef(null); + useEffect(() => () => rebuildWatch.current?.abort(), []); useEffect(() => { // A response for the member (or project) this page has since moved away from is dropped: it @@ -592,6 +627,60 @@ export function TeamMemberDetailPage() { } } + function handleRebuildPath() { + if (!user || !selectedProjectId || rebuildingUserId) return; + const memberId = user.userId; + const firstName = user.firstname; + const controller = new AbortController(); + rebuildWatch.current?.abort(); + rebuildWatch.current = controller; + setConfirmRebuild(false); + setRebuildingUserId(memberId); + toast.info(`Rebuilding ${firstName}'s onboarding path`, { + description: "This runs in the background and takes a few minutes.", + }); + + const finish = () => { + if (rebuildWatch.current === controller) rebuildWatch.current = null; + setRebuildingUserId((current) => (current === memberId ? null : current)); + }; + + void onboardingService + .rebuildMemberPath( + selectedProjectId, + memberId, + { + onPath: () => {}, + onDone: () => { + finish(); + if (shownUserId.current !== memberId) return; + toast.success(`${firstName}'s onboarding path was rebuilt`); + void Promise.all([refreshOnboardingPath(), refreshMember()]); + }, + onError: (message, reason) => { + finish(); + toast.error(`${firstName}'s path could not be rebuilt`, { + description: describeRebuildError(message, reason), + }); + }, + onInterrupted: () => { + finish(); + toast.info("Lost track of the rebuild", { + description: "It keeps running on the server. Reload the page in a few minutes.", + }); + }, + }, + controller.signal, + ) + .catch((error: unknown) => { + finish(); + if (controller.signal.aborted) return; + toast.error(`${firstName}'s path could not be rebuilt`, { + description: error instanceof Error ? error.message : undefined, + }); + }); + } + function goBack() { if (typeof window !== "undefined" && window.history.length > 1) { void navigate(-1); @@ -706,6 +795,22 @@ export function TeamMemberDetailPage() { title={`${user.firstname} ${user.lastname}`} subtitle={user.currentStep?.title || "Onboarding completed"} back={{ label: "Back", onClick: goBack }} + actions={ + + } mainClassName="pt-6 pb-24 lg:pt-8" bandExtra={
@@ -1050,6 +1155,15 @@ export function TeamMemberDetailPage() { onClose={() => setCheckModal(null)} /> )} + setConfirmRebuild(false)} + onConfirm={handleRebuildPath} + /> { + try { + if (keycloak.authenticated) { + await keycloak.updateToken(30); + } + } catch (error) { + console.error("Failed to refresh Keycloak token for onboarding personalize", error); + void keycloak.login(); + return; + } + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${keycloak.token}`, + }, + signal, + }); + + if (!res.ok) { + handlers.onError?.(`HTTP error! status: ${res.status}`); + return; + } + + const stream = res.body; + if (!stream) { + throw new Error("No response stream"); + } + + for await (const event of parseSSEStream(stream)) { + switch (event.type) { + case "stage": + handlers.onStage?.(event.name ?? "", event.detail); + break; + case "path": + if (event.path) { + handlers.onPath(event.path); + } + break; + case "done": + handlers.onDone(); + return; + case "error": + handlers.onError?.(event.message ?? "Unknown error", event.reason ?? undefined); + return; + } + } + + // Falling out of the loop means the body ended without `done` or `error`, which the backend + // never does on purpose. Reporting a finished path here told members their path was ready + // while it was still being built. + handlers.onInterrupted?.(); +} + /** * Onboarding path, step, question and task CRUD. * Streams AI path generation over SSE; falls back to mock data on fetch @@ -50,57 +112,32 @@ export const onboardingService = { handlers: OnboardingPersonalizeHandlers, signal?: AbortSignal, ): Promise { - try { - if (keycloak.authenticated) { - await keycloak.updateToken(30); - } - } catch (error) { - console.error("Failed to refresh Keycloak token for onboarding personalize", error); - void keycloak.login(); - return; - } - - const res = await fetch(`/api/v1/projects/${projectId}/onboarding/me/path/personalize`, { - method: "POST", - headers: { - Authorization: `Bearer ${keycloak.token}`, - }, + await streamPathGeneration( + `/api/v1/projects/${projectId}/onboarding/me/path/personalize`, + handlers, signal, - }); - - if (!res.ok) { - handlers.onError?.(`HTTP error! status: ${res.status}`); - return; - } - - const stream = res.body; - if (!stream) { - throw new Error("No response stream"); - } - - for await (const event of parseSSEStream(stream)) { - switch (event.type) { - case "stage": - handlers.onStage?.(event.name ?? "", event.detail); - break; - case "path": - if (event.path) { - handlers.onPath(event.path); - } - break; - case "done": - handlers.onDone(); - return; - case "error": - handlers.onError?.(event.message ?? "Unknown error", event.reason ?? undefined); - return; - } - } + ); + }, - // Falling out of the loop means the body ended without `done` or `error`, which the backend - // never does on purpose. Reporting a finished path here told members their path was ready - // while it was still being built. - handlers.onInterrupted?.(); + /** + * Rebuilds a member's onboarding path from the project's active blueprint -- the PM's side of + * `personalizePath`, which members may only use for their first path. Replaces the member's + * path, and their progress with it, once the generation finishes. + * + * Like `personalizePath`, the generation runs on the backend independently of this stream: the + * member's own onboarding page attaches to it, and aborting here only stops watching. + */ + async rebuildMemberPath( + projectId: string, + userId: string, + handlers: OnboardingPersonalizeHandlers, + signal?: AbortSignal, + ): Promise { + await streamPathGeneration( + `/api/v1/projects/${projectId}/onboarding/users/${userId}/path/personalize`, + handlers, + signal, + ); }, /** diff --git a/tests/unit/pages/OnBoardingPage.test.tsx b/tests/unit/pages/OnBoardingPage.test.tsx index 1483a5f0..40e90839 100644 --- a/tests/unit/pages/OnBoardingPage.test.tsx +++ b/tests/unit/pages/OnBoardingPage.test.tsx @@ -18,6 +18,7 @@ const { projectContextState } = vi.hoisted(() => ({ selectedProjectId: "proj1", isLoading: false, isSwitcherEnabled: true, + canManageSelected: false, }, })); @@ -73,6 +74,7 @@ vi.mock("../../../src/features/projects/useProjectContext", async () => { : null, isLoading: projectContextState.isLoading, isSwitcherEnabled: projectContextState.isSwitcherEnabled, + canManageSelected: projectContextState.canManageSelected, }), }; }); @@ -162,6 +164,7 @@ describe("OnBoardingPage", () => { projectContextState.selectedProjectId = "proj1"; projectContextState.isLoading = false; projectContextState.isSwitcherEnabled = true; + projectContextState.canManageSelected = false; }); it("renders loading state initially", () => { @@ -294,6 +297,8 @@ describe("OnBoardingPage", () => { expect(screen.getAllByText("Phase 1").length).toBeGreaterThan(0); expect(screen.getAllByText("Step 1").length).toBeGreaterThan(0); + // Rebuilding throws the member's progress away: that is the PM's call, from the PM area. + expect(screen.queryByRole("button", { name: /rebuild|regenerate/i })).not.toBeInTheDocument(); }); it("computes progress correctly", async () => { @@ -421,7 +426,8 @@ describe("OnBoardingPage", () => { expect(screen.getByTitle("Role-specific tasks — Could not be reached")).toBeInTheDocument(); }); - it("offers regeneration when every generated phase is hidden", async () => { + it("offers the project's manager regeneration when every generated phase is hidden", async () => { + projectContextState.canManageSelected = true; server.use( http.get("/api/v1/onboarding/me/path", () => HttpResponse.json({ @@ -482,7 +488,33 @@ describe("OnBoardingPage", () => { expect(screen.getByTitle("Architecture — Took too long")).toBeInTheDocument(); }); + it("points a member at their PM instead of rebuilding a path whose phases are all hidden", async () => { + server.use( + http.get("/api/v1/onboarding/me/path", () => + HttpResponse.json({ + id: "path1", + userId: "user1", + createdAt: new Date().toISOString(), + phases: [], + generationIssues: [{ phaseId: "phase1", title: "Role-specific tasks", status: "EMPTY" }], + }), + ), + ); + + render( + + + , + ); + + expect(await screen.findByText("No onboarding phases were generated")).toBeInTheDocument(); + // Replacing an existing path is the PM's call; the backend refuses it to members. + expect(screen.queryByRole("button", { name: "Try generation again" })).not.toBeInTheDocument(); + expect(screen.getByText(/Your project manager can rebuild your path/)).toBeInTheDocument(); + }); + it("warns that every phase timed out and offers regeneration", async () => { + projectContextState.canManageSelected = true; server.use( http.get("/api/v1/onboarding/me/path", () => HttpResponse.json({ diff --git a/tests/unit/pages/TeamMemberDetailPage.test.tsx b/tests/unit/pages/TeamMemberDetailPage.test.tsx index da759019..6570bb91 100644 --- a/tests/unit/pages/TeamMemberDetailPage.test.tsx +++ b/tests/unit/pages/TeamMemberDetailPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router-dom"; @@ -6,6 +6,11 @@ import { TeamMemberDetailPage } from "../../../src/pages/TeamMemberDetailPage"; import type { TeamOverviewUser, ProjectRole } from "../../../src/features/team-management/types"; import type { KnowledgeGap } from "../../../src/features/knowledge-gaps/types"; import { knowledgeGapService } from "../../../src/services/knowledgeGapService"; +import { onboardingService } from "../../../src/services/onboardingService"; + +vi.mock("../../../src/services/onboardingService", () => ({ + onboardingService: { rebuildMemberPath: vi.fn() }, +})); vi.mock("../../../src/context/useAuth", () => ({ useAuth: () => ({ profile: { id: "pm1", firstName: "PM", lastName: "User" } }), @@ -335,6 +340,39 @@ describe("TeamMemberDetailPage", () => { // The knowledge-gaps overview is the project's full component roster now, but // this panel is headed "Knowledge gaps" -- listing repositories that are // missing nothing would overstate what the member has to answer for. + it("lets the PM rebuild the member's path after confirming", async () => { + vi.mocked(onboardingService.rebuildMemberPath).mockImplementation( + (_projectId, _userId, handlers) => { + handlers.onDone(); + return Promise.resolve(); + }, + ); + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(await screen.findByRole("button", { name: /Rebuild path/ })); + expect(onboardingService.rebuildMemberPath).not.toHaveBeenCalled(); + + await user.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Rebuild path" }), + ); + + await waitFor(() => { + expect(onboardingService.rebuildMemberPath).toHaveBeenCalledWith( + "proj1", + "user1", + expect.any(Object), + expect.any(AbortSignal), + ); + }); + // The finished rebuild is read back, so the journey shows the new path. + await waitFor(() => expect(mockGetUserOnboardingPath).toHaveBeenCalledTimes(2)); + }); + it("keeps covered components out of the member's gaps panel", async () => { const gap = (component: string, severity: KnowledgeGap["severity"]): KnowledgeGap => ({ id: component,