Gate the quick-nav Processor entry on the Processor flag

This commit is contained in:
Anthony Stirling
2026-08-28 09:40:10 +01:00
parent b0e1384de2
commit 4ffc21cbca
6 changed files with 47 additions and 14 deletions
@@ -16,6 +16,7 @@ import type { ToolId } from "@app/types/toolId";
export interface QuickNavHostBridgeProps {
portalAccess?: boolean;
processorEnabled?: boolean;
readerMode?: boolean;
onSetReaderMode?: (on: boolean) => void;
onOpenSettings: () => void;
@@ -29,6 +30,7 @@ export interface QuickNavHostBridgeProps {
/** Registers with the rail what only the app can see, and owns the notifications panel. */
export function QuickNavHostBridge({
portalAccess = false,
processorEnabled = true,
readerMode = false,
onSetReaderMode,
onOpenSettings,
@@ -58,6 +60,7 @@ export function QuickNavHostBridge({
identity: { displayName, profilePictureUrl },
signingBadge,
portalAccess,
processorEnabled,
readerMode,
notificationsOpen,
toolReasons: mergedToolReasons,
@@ -159,7 +159,13 @@ export function QuickNavRailHost() {
return (
<QuickNavRailContainer
groups={HAS_PORTAL ? [apps, within] : [within]}
// Bundling the portal says nothing about the server: an editor-only one has
// no Processor to offer, so drop the group rather than show it disabled.
groups={
HAS_PORTAL && (inPortal || host?.processorEnabled !== false)
? [apps, within]
: [within]
}
onReturnHome={returnHome}
identity={host?.identity ?? null}
onOpenSettings={host?.hasSettings ? openSettings : undefined}
@@ -23,6 +23,8 @@ export interface QuickNavHostData {
identity: QuickNavIdentity | null;
signingBadge: number;
portalAccess: boolean;
/** False only on an editor-only server, where there is no Processor to offer. */
processorEnabled: boolean;
readerMode: boolean;
/** The app owns the panel; the rail's bell only reports its state. */
notificationsOpen: boolean;
@@ -60,6 +62,7 @@ const EMPTY_DATA: QuickNavHostData = {
identity: null,
signingBadge: 0,
portalAccess: false,
processorEnabled: true,
readerMode: false,
notificationsOpen: false,
hasSettings: false,
@@ -89,6 +92,7 @@ export function QuickNavHostProvider({ children }: { children: ReactNode }) {
merged.appMounted === prev.appMounted &&
merged.signingBadge === prev.signingBadge &&
merged.portalAccess === prev.portalAccess &&
merged.processorEnabled === prev.processorEnabled &&
merged.readerMode === prev.readerMode &&
merged.notificationsOpen === prev.notificationsOpen &&
merged.hasSettings === prev.hasSettings &&
@@ -142,6 +146,7 @@ export function useRegisterQuickNavHost(
identity,
signingBadge,
portalAccess,
processorEnabled,
readerMode,
notificationsOpen,
toolReasons,
@@ -154,6 +159,7 @@ export function useRegisterQuickNavHost(
identity: identity ?? null,
signingBadge: signingBadge ?? 0,
portalAccess: portalAccess ?? false,
processorEnabled: processorEnabled ?? true,
readerMode: readerMode ?? false,
notificationsOpen: notificationsOpen ?? false,
// Omitted when unknown, so the last answer survives a re-fetch.
@@ -50,6 +50,7 @@ import {
getDisabledLabel,
} from "@app/components/tools/fullscreen/shared";
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
import { useProcessorEnabled } from "@app/hooks/useProcessorEnabled";
import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode";
import {
FilesPageProvider,
@@ -126,6 +127,7 @@ export default function HomePage() {
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const otherApp = useOtherAppSwitch();
const processorEnabled = useProcessorEnabled();
const location = useLocation();
// Persisted user preference for the FileSidebar collapsed state. Auto-
// collapse on /files is layered on top in the transition effect below and
@@ -519,6 +521,7 @@ export default function HomePage() {
<HomePageExtensions />
<QuickNavHostBridge
portalAccess={Boolean(otherApp)}
processorEnabled={processorEnabled}
onOpenSettings={() => setConfigModalOpen(true)}
requestNavigation={requestNavigation}
readerMode={readerMode}
@@ -30,7 +30,10 @@ const ADMIN_WITH_PORTAL = {
portalAccess: true,
};
const SWITCH_APP = "Switch app";
// The quick-nav rail's Processor entry, and the sidebar footer row. Both are
// named: an assertion on a control that no longer exists passes vacuously,
// which is how #7695 silently defanged this file.
const RAIL_PROCESSOR = "Processor";
const OPEN_PROCESSOR = "Open PDF Processor";
// Copy that only a Processor server can honour: a link tool pointing at the
@@ -114,8 +117,10 @@ test.describe("editor-only server (processor.enabled=false)", () => {
await page.goto("/editor", { waitUntil: "domcontentloaded" });
await expect(page.locator(INPUT)).toBeVisible();
// The logo stays a plain logo: no editor -> processor switcher...
await expect(page.getByLabel(SWITCH_APP)).toHaveCount(0);
// The quick-nav rail offers no Processor entry...
await expect(
page.getByRole("button", { name: RAIL_PROCESSOR, exact: true }),
).toHaveCount(0);
// ...and the sidebar footer offers no "Open PDF Processor" row.
await expect(page.getByLabel(OPEN_PROCESSOR)).toHaveCount(0);
@@ -168,15 +173,10 @@ test.describe("editor-only server (processor.enabled=false)", () => {
}
});
test("bounces a hand-typed /processor URL back to the editor", async ({
page,
}) => {
// The last hole: hiding the entry points doesn't stop someone typing the URL.
// The editor shell rendering here is the assertion - the portal has its own
// shell and no #super-search-input, as the control below shows.
await page.goto("/processor", { waitUntil: "domcontentloaded" });
await expect(page.locator(INPUT)).toBeVisible();
});
// Not asserted here: whether /processor is routable depends on BUILD_PORTAL,
// which the stubbed dev suite (portal always bundled) cannot vary. The
// editor-only image is built with BUILD_PORTAL=false and its dist carries no
// portal chunk - that is where the absence is verified.
});
test.describe("same user on a server with the Processor on", () => {
@@ -192,7 +192,9 @@ test.describe("same user on a server with the Processor on", () => {
test("is offered the switch to the Processor", async ({ page }) => {
await expect(page.locator(INPUT)).toBeVisible();
await expect(page.getByLabel(SWITCH_APP)).toBeVisible();
await expect(
page.getByRole("button", { name: RAIL_PROCESSOR, exact: true }),
).toBeVisible();
});
test("is offered Processor lanes in super search", async ({ page }) => {
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
requestNavigation: vi.fn(),
portalAccess: true,
processorEnabled: true,
}));
vi.mock("react-router-dom", () => ({
@@ -14,6 +15,11 @@ vi.mock("react-router-dom", () => ({
vi.mock("@app/auth/context", () => ({
useAuth: () => ({ portalAccess: mocks.portalAccess }),
}));
// Stubbed rather than provider-wrapped: the real hook reads app-config, which
// this suite never fetches.
vi.mock("@app/hooks/useProcessorEnabled", () => ({
useProcessorEnabled: () => mocks.processorEnabled,
}));
vi.mock("@app/contexts/NavigationContext", () => ({
useNavigationActions: () => ({
actions: { requestNavigation: mocks.requestNavigation },
@@ -27,6 +33,7 @@ beforeEach(() => {
sessionStorage.clear();
vi.clearAllMocks();
mocks.portalAccess = true;
mocks.processorEnabled = true;
});
describe("useOtherAppSwitch", () => {
@@ -36,6 +43,12 @@ describe("useOtherAppSwitch", () => {
expect(result.current).toBeNull();
});
it("offers no switch on an editor-only server", () => {
mocks.processorEnabled = false;
const { result } = renderHook(() => useOtherAppSwitch());
expect(result.current).toBeNull();
});
it("routes the switch through the unsaved-changes guard", () => {
const { result } = renderHook(() => useOtherAppSwitch());
result.current?.onOpen();