Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions frontend/src/_test_utilities/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { ReactElement, ReactNode } from "react";
import { render as rtlRender, type RenderOptions } from "@testing-library/react";
import { HashRouter } from "react-router-dom";
import { AccessProvider } from "@/access/AccessContext";

// Wraps components under test in app-wide providers
export const AllTheProviders = ({ children }: { children: ReactNode }) => {
return <HashRouter>{children}</HashRouter>;
// Session-wide providers only. Filter tests mount their own FiltersProvider, with a fixed date.
export const AllTheProviders = ({ children }: Readonly<{ children: ReactNode }>) => {
return (
<AccessProvider>
<HashRouter>{children}</HashRouter>
</AccessProvider>
);
};

function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/access/AccessContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import { render as renderWithoutProviders } from "@testing-library/react";
import { render, screen } from "@/_test_utilities/test-utils";
import { MODULE_IDS, PERMISSIONS } from "@/access/AccessContext";
import { AccessProvider, useAccess } from "./AccessContext";

function AccessProbe() {
const access = useAccess();
return (
<div>
<span data-testid="has-institutions">{String(access.hasPermission("institutions:view"))}</span>
<span data-testid="has-access-management">{String(access.hasPermission("access-management:manage"))}</span>
<span data-testid="active-modules">{access.activeModules.join(",")}</span>
<span data-testid="is-multi-institution">{String(access.isMultiInstitution)}</span>
</div>
);
}

describe("AccessProvider", () => {
it("should serve the built-in grant when no access is passed", () => {
// GIVEN no explicit access
// WHEN rendered
render(
<AccessProvider>
<AccessProbe />
</AccessProvider>
);

// THEN the placeholder grant defined in code applies — every permission, every module
expect(screen.getByTestId("has-institutions")).toHaveTextContent("true");
expect(screen.getByTestId("active-modules")).toHaveTextContent(Object.values(MODULE_IDS).join(","));
});

it("should expose the permissions and active modules it is given", () => {
// GIVEN a grant with two active modules
const givenAccess = {
activeModules: [MODULE_IDS.BUILD_YOUR_PROFILE, MODULE_IDS.JOB_READINESS],
};

// WHEN rendered
render(
<AccessProvider access={givenAccess}>
<AccessProbe />
</AccessProvider>
);

// THEN only those modules are active
expect(screen.getByTestId("active-modules")).toHaveTextContent("build-your-profile,job-readiness");
});

it("should report false from hasPermission for a permission the grant excludes", () => {
// GIVEN a grant without access-management:manage
const givenAccess = { permissions: new Set([PERMISSIONS.DASHBOARD_VIEW]) };

// WHEN rendered
render(
<AccessProvider access={givenAccess}>
<AccessProbe />
</AccessProvider>
);

// THEN that permission is not granted
expect(screen.getByTestId("has-access-management")).toHaveTextContent("false");
});

it("should report isMultiInstitution for an 'all' scope", () => {
// GIVEN a grant covering every institution
render(
<AccessProvider access={{ scope: { type: "all" } }}>
<AccessProbe />
</AccessProvider>
);

// THEN drilling down is meaningful
expect(screen.getByTestId("is-multi-institution")).toHaveTextContent("true");
});

it("should not report isMultiInstitution for a single-institution scope", () => {
// GIVEN a grant covering exactly one institution
render(
<AccessProvider access={{ scope: { type: "institutions", institutionIds: ["inst-1"] } }}>
<AccessProbe />
</AccessProvider>
);

// THEN drilling down would be a no-op
expect(screen.getByTestId("is-multi-institution")).toHaveTextContent("false");
});
});

describe("useAccess", () => {
it("should throw when used outside an AccessProvider", () => {
// GIVEN a component using useAccess with no provider above it
// WHEN / THEN rendering it throws
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderWithoutProviders(<AccessProbe />)).toThrow("useAccess must be used within an AccessProvider.");
consoleError.mockRestore();
});
});
69 changes: 69 additions & 0 deletions frontend/src/access/AccessContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";

export const PERMISSIONS = {
DASHBOARD_VIEW: "dashboard:view",
INSTITUTIONS_VIEW: "institutions:view",
JOBSEEKERS_VIEW: "jobseekers:view",
ACCESS_MANAGEMENT_MANAGE: "access-management:manage",
ACCOUNT_VIEW: "account:view",
} as const;

export type PermissionKey = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];

export const MODULE_IDS = {
BUILD_YOUR_PROFILE: "build-your-profile",
JOB_READINESS: "job-readiness",
CAREER_EXPLORER: "career-explorer",
JOBS: "jobs",
} as const;

export type ModuleId = (typeof MODULE_IDS)[keyof typeof MODULE_IDS];

/** Which institutions a grant covers: every institution in the deployment, or a named list. */
export type AccessScope = { type: "all" } | { type: "institutions"; institutionIds: string[] };

/** True when the grant covers two or more institutions — either "all", or a list of several. */
export function coversMultipleInstitutions(scope: AccessScope): boolean {
return scope.type === "all" || scope.institutionIds.length > 1;
}

export interface AccessState {
permissions: ReadonlySet<PermissionKey>;
scope: AccessScope;
activeModules: readonly ModuleId[];
}

export interface AccessContextValue extends AccessState {
hasPermission: (permission: PermissionKey) => boolean;
isMultiInstitution: boolean; // true ⇒ institution drill-down is meaningful
}

const DEFAULT_ACCESS: AccessState = {
permissions: new Set(Object.values(PERMISSIONS)),
scope: { type: "institutions", institutionIds: ["inst-1"] },
activeModules: Object.values(MODULE_IDS),
};

const AccessContext = createContext<AccessContextValue | null>(null);

export function useAccess(): AccessContextValue {
const context = useContext(AccessContext);
if (!context) {
throw new Error("useAccess must be used within an AccessProvider.");
}
return context;
}

/** Fields passed in `access` win; the rest fall back to DEFAULT_ACCESS. */
export function AccessProvider({ children, access }: Readonly<{ children: ReactNode; access?: Partial<AccessState> }>) {
const value = useMemo<AccessContextValue>(() => {
const state: AccessState = { ...DEFAULT_ACCESS, ...access };
return {
...state,
hasPermission: (permission) => state.permissions.has(permission),
isMultiInstitution: coversMultipleInstitutions(state.scope),
};
}, [access]);

return <AccessContext.Provider value={value}>{children}</AccessContext.Provider>;
}
66 changes: 66 additions & 0 deletions frontend/src/app/Layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { useAccess } from "@/access/AccessContext";
import { useFilters } from "@/filters/FiltersContext";
import { Layout } from "./Layout";

/** Reads both shared contexts, proving the shell supplies them to the outlet. */
function Screen() {
const { hasPermission } = useAccess();
const { filters } = useFilters();
return (
<div>
<span data-testid="screen">Screen</span>
<span data-testid="can-view-dashboard">{String(hasPermission("dashboard:view"))}</span>
<span data-testid="granularity">{filters.granularity}</span>
</div>
);
}

// Raw RouterProvider: Layout renders an <Outlet/>, and two nested Routers aren't supported.
function renderLayout() {
const router = createMemoryRouter([
{ path: "/", element: <Layout />, children: [{ index: true, element: <Screen /> }] },
]);
return render(<RouterProvider router={router} />);
}

describe("Layout", () => {
it("should render the routed screen alongside the sidebar", () => {
// GIVEN the app shell
// WHEN it mounts
renderLayout();

// THEN the outlet content and the sidebar brand mark are both present
expect(screen.getByTestId("screen")).toBeInTheDocument();
expect(screen.getByAltText("Compass Analytics")).toBeInTheDocument();
});

it("should provide the access context to routed screens", () => {
// GIVEN the app shell
// WHEN it mounts
renderLayout();

// THEN a screen can read permissions without wiring its own provider
expect(screen.getByTestId("can-view-dashboard")).toHaveTextContent("true");
});

it("should provide the shared filter state to routed screens", () => {
// GIVEN the app shell
// WHEN it mounts
renderLayout();

// THEN a screen reads the shared filters without mounting its own provider
expect(screen.getByTestId("granularity")).toHaveTextContent("day");
});

it("should render the sidebar navigation with the current route marked active", () => {
// GIVEN the app shell at the root path
// WHEN it mounts
renderLayout();

// THEN the nav is present and Overview is the active item
expect(screen.getByRole("link", { name: /^Overview$/ })).toHaveAttribute("data-active", "true");
});
});
21 changes: 14 additions & 7 deletions frontend/src/app/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { Outlet } from "react-router-dom";
import { AppSidebar } from "@/components/app-sidebar";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { AccessProvider } from "@/access/AccessContext";
import { FiltersProvider } from "@/filters/FiltersContext";

/** The shell every screen renders inside. Filters wrap the outlet only — the sidebar doesn't use them. */
export const Layout = () => {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<Outlet />
</SidebarInset>
</SidebarProvider>
<AccessProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<FiltersProvider>
<Outlet />
</FiltersProvider>
</SidebarInset>
</SidebarProvider>
</AccessProvider>
);
};
25 changes: 18 additions & 7 deletions frontend/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createHashRouter, Navigate, RouterProvider } from "react-router-dom";
import ProtectedRoute from "@/app/ProtectedRoute/ProtectedRoute";
import { routerPaths } from "@/app/routerPaths";
import { Layout } from "@/app/Layout";
import { HomePage } from "@/pages/HomePage";
import { routerPaths } from "@/app/routerPaths";
import { Login } from "@/pages/Login/Login";
import { Register } from "@/pages/Register/Register";

Expand All @@ -29,11 +28,23 @@ const router = createHashRouter([
children: [
{
index: true,
element: (
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
),
element: <ProtectedRoute>Overview</ProtectedRoute>,
},
{
path: routerPaths.JOBSEEKERS,
element: <ProtectedRoute>Jobseekers</ProtectedRoute>,
},
{
path: routerPaths.MODULES,
element: <ProtectedRoute>Modules</ProtectedRoute>,
},
{
path: routerPaths.MODULE,
element: <ProtectedRoute>Module</ProtectedRoute>,
},
{
path: routerPaths.SETTINGS,
element: <ProtectedRoute>Settings</ProtectedRoute>,
},
],
},
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/app/routerPaths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import type { ModuleId } from "@/access/AccessContext";

export const routerPaths = {
ROOT: "/",
LOGIN: "/login",
REGISTER: "/register",
};
JOBSEEKERS: "/jobseekers",
MODULES: "/modules",
MODULE: "/modules/:moduleId",
SETTINGS: "/settings",
} as const;

/** Link target for a specific module, e.g. modulePath("jobs") === "/modules/jobs". */
export function modulePath(moduleId: ModuleId): string {
return `${routerPaths.MODULES}/${moduleId}`;
}
36 changes: 0 additions & 36 deletions frontend/src/components/app-sidebar.stories.tsx

This file was deleted.

20 changes: 0 additions & 20 deletions frontend/src/components/app-sidebar.tsx

This file was deleted.

Loading
Loading