From 34e13e54390ef672ba6c23c0f2e344776fe98ff6 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 18 Sep 2026 18:16:52 -0700 Subject: [PATCH] fix(plugins): honor route onboarding and complete overlay context Co-Authored-By: Paperclip --- .../PluginAppShellOverlays.test.tsx | 20 +++++++++++++++---- ui/src/components/PluginAppShellOverlays.tsx | 17 +++++++++++++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/ui/src/components/PluginAppShellOverlays.test.tsx b/ui/src/components/PluginAppShellOverlays.test.tsx index 10ba863da68..3174a30606f 100644 --- a/ui/src/components/PluginAppShellOverlays.test.tsx +++ b/ui/src/components/PluginAppShellOverlays.test.tsx @@ -5,13 +5,15 @@ import { flushSync } from "react-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { PluginAppShellOverlays } from "./PluginAppShellOverlays"; -const state = vi.hoisted(() => ({ userId: "alice" as string | null, settled: true, company: "first", onboarding: false, failed: false, mounts: 0, disposals: 0 })); +const state = vi.hoisted(() => ({ userId: "alice" as string | null, settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {} as Record, failed: false, mounts: 0, disposals: 0 })); vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: state.userId, settled: state.settled }) })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.company, selectedCompany: { issuePrefix: "ACME" }, loading: false }) })); -vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: state.onboarding }) })); +vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: state.onboarding, onboardingRouteDismissed: state.dismissed }) })); +vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: state.pathname }) })); vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => ({ errorMessage: state.failed ? "unavailable" : null, slots: [{ pluginId: "fixture", pluginVersion: "1.0.0", id: "overlay" }] }), - PluginSlotMount: () => { + PluginSlotMount: ({ context }: { context: Record }) => { + state.context = context; const [draft, setDraft] = useState(""); useEffect(() => { state.mounts++; return () => { state.disposals++; }; }, []); return ; @@ -26,9 +28,19 @@ function render(localTrusted = false) { afterEach(() => { if (root) flushSync(() => root!.unmount()); root = undefined; container?.remove(); - Object.assign(state, { userId: "alice", settled: true, company: "first", onboarding: false, failed: false, mounts: 0, disposals: 0 }); + Object.assign(state, { userId: "alice", settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {}, failed: false, mounts: 0, disposals: 0 }); }); describe("persistent app-shell plugin lifecycle", () => { + it("passes the complete host context promised by PluginWidgetProps", () => { + render(); + expect(state.context).toEqual({ companyId: "first", companyPrefix: "ACME", projectId: null, entityId: null, entityType: null, parentEntityId: null, userId: "alice" }); + }); + it("disposes drafts on route-driven onboarding and remounts after dismissal", () => { + render(); flushSync(() => container.querySelector("button")!.click()); + state.pathname = "/ACME/onboarding"; render(); + expect(state.onboarding).toBe(false); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1); + state.dismissed = true; render(); expect(container.textContent).toBe("empty"); + }); it("keeps a draft during shell rerenders and clears it on account/company transitions", () => { render(); flushSync(() => container.querySelector("button")!.click()); render(); expect(container.textContent).toBe("private draft"); expect(state.mounts).toBe(1); diff --git a/ui/src/components/PluginAppShellOverlays.tsx b/ui/src/components/PluginAppShellOverlays.tsx index eb7b31555fe..b53a529adbf 100644 --- a/ui/src/components/PluginAppShellOverlays.tsx +++ b/ui/src/components/PluginAppShellOverlays.tsx @@ -1,6 +1,9 @@ import { useAccountIdentity } from "@/api/companies-query"; import { useCompany } from "@/context/CompanyContext"; import { useDialogState } from "@/context/DialogContext"; +import { useLocation } from "@/lib/router"; +import { isOnboardingPath } from "@/lib/onboarding-route"; +import type { PluginHostContext } from "@/plugins/bridge"; import { PluginSlotMount, usePluginSlots, type PluginSlotContext } from "@/plugins/slots"; function AppShellEntries({ context }: { context: PluginSlotContext }) { @@ -33,16 +36,24 @@ function AppShellEntries({ context }: { context: PluginSlotContext }) { export function PluginAppShellOverlays({ localTrusted = false }: { localTrusted?: boolean }) { const { userId, settled } = useAccountIdentity(); const { selectedCompanyId, selectedCompany, loading } = useCompany(); - const { onboardingOpen } = useDialogState(); + const { onboardingOpen, onboardingRouteDismissed } = useDialogState(); + const { pathname } = useLocation(); // Local-trusted instances intentionally have no login requirement. Still // prefer any real account and wait for identity resolution so account changes // cannot reuse the prior account's in-memory plugin state. const identity = settled ? userId ?? (localTrusted ? "local-board" : null) : null; - if (!identity || loading || onboardingOpen) return null; + const onboardingVisible = onboardingOpen || (!onboardingRouteDismissed && isOnboardingPath(pathname)); + if (!identity || loading || onboardingVisible) return null; + const context: PluginSlotContext & PluginHostContext = { + companyId: selectedCompanyId, + companyPrefix: selectedCompany?.issuePrefix ?? null, + projectId: null, entityId: null, entityType: null, parentEntityId: null, + userId, + }; return ( ); }