Merge pull request #257 from Sea-Haven-Industries/feat/ab/sh-207-uplift-services-nav

feat(nav): add Uplift Approvals and Services to the sidebar (SH-207, SH-304)
This commit is contained in:
Alexandre Brandizzi 2026-09-25 00:50:46 +00:00 • committed by GitHub
commit 563f81e163
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 255 additions and 3 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 195 KiB

View file

@ -3,7 +3,8 @@ import { Link, useLocation } from "react-router";
import { Box, Drawer } from "@mui/material";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
import { useNavGroups } from "@/components/layout/use-nav-groups";
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
if (isActive) {
@ -129,6 +130,7 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
const width = getSidebarWidth(collapsed);
const { pathname } = useLocation();
const activePath = getActiveNavPath(pathname);
const navGroups = useNavGroups();
return (
<Drawer

View file

@ -0,0 +1,20 @@
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { getNavGroups, type NavGroup } from "@/config/menu";
/**
* Sidebar groups for the signed-in user. The APPROVALS group appears only
* when the backend's can-approve answer (UpliftService tier-role rule, the
* same authority behind uplift approve/reject) is true for either tier.
* Loading and error states stay closed so the entry never flashes for a
* user the backend would refuse, including when a refetch fails and the
* query still holds an earlier `true`.
*/
export function useNavGroups(): NavGroup[] {
const tier1 = useUpliftsCanApprove(1);
const tier2 = useUpliftsCanApprove(2);
const allows = (query: { data?: boolean; isError?: boolean }) =>
query.data === true && !query.isError;
const canReviewUplifts = allows(tier1) || allows(tier2);
return getNavGroups({ canReviewUplifts });
}

View file

@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
// BarChart2,
BadgeDollarSign,
BriefcaseBusiness,
Building2,
ClipboardList,
@ -11,6 +12,7 @@ import {
BarChart3,
Star,
Users,
Wrench,
} from "lucide-react";
export type NavItemDef = {
@ -59,6 +61,10 @@ export const navGroups: NavGroup[] = [
{ label: "Sites", icon: MapPin, path: "/locations" },
],
},
{
title: "DATA MANAGEMENT",
items: [{ label: "Services", icon: Wrench, path: "/settings/services" }],
},
{
title: "TEAM",
items: [{ label: "Team Members", icon: Users, path: "/team-members" }],
@ -69,8 +75,21 @@ export const navGroups: NavGroup[] = [
// },
];
// Approvals navigation is gated on the same server-derived authority the
// uplift approve/reject API enforces (UpliftService.CanApprove tier roles,
// exposed via GET uplifts/can-approve), so the sidebar never shows the queue
// to a user the backend would refuse.
const approvalsNavGroup: NavGroup = {
title: "APPROVALS",
items: [{ label: "Uplift Approvals", icon: BadgeDollarSign, path: "/uplifts" }],
};
export function getNavGroups({ canReviewUplifts }: { canReviewUplifts: boolean }): NavGroup[] {
return canReviewUplifts ? [...navGroups, approvalsNavGroup] : navGroups;
}
export function getActiveNavPath(pathname: string): string | undefined {
return navGroups
return [...navGroups, approvalsNavGroup]
.flatMap((group) => group.items)
.map((item) => item.path)
.filter((path) =>

View file

@ -0,0 +1,58 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { queryKeys } from "@/infra/query-key/query-key";
import { createTestQueryClient, renderWithProviders } from "@/test/test-utils";
const canApproveApiMock = vi.hoisted(() => vi.fn());
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
upliftsApi: { canApprove: canApproveApiMock },
}));
describe("AppSidebar Uplift Approvals gating through the can-approve query", () => {
beforeEach(() => {
canApproveApiMock.mockReset();
});
it("shows the entry when the API allows tier 2 and fails for tier 1", async () => {
canApproveApiMock.mockImplementation(async (tier: number) => {
if (tier === 1) throw new Error("503");
return true;
});
renderWithProviders(<AppSidebar collapsed={false} />, { route: "/", withAuth: false });
expect(await screen.findByRole("link", { name: "Uplift Approvals" })).toHaveAttribute(
"href",
"/uplifts",
);
expect(canApproveApiMock).toHaveBeenCalledWith(1);
expect(canApproveApiMock).toHaveBeenCalledWith(2);
});
it("keeps the entry hidden once the API has denied both tiers", async () => {
canApproveApiMock.mockResolvedValue(false);
const queryClient = createTestQueryClient();
renderWithProviders(<AppSidebar collapsed={false} />, {
route: "/",
withAuth: false,
queryClient,
});
await waitFor(() => {
for (const tier of [1, 2]) {
expect(queryClient.getQueryState(queryKeys.uplifts.canApprove(tier))?.status).toBe(
"success",
);
}
});
expect(screen.queryByRole("link", { name: "Uplift Approvals" })).toBeNull();
expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute(
"href",
"/settings/services",
);
});
});

View file

@ -0,0 +1,115 @@
import { screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { renderWithProviders } from "@/test/test-utils";
const canApproveMock = vi.hoisted(() => vi.fn());
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: canApproveMock,
}));
function renderSidebar(route: string) {
return renderWithProviders(<AppSidebar collapsed={false} />, {
route,
withAuth: false,
});
}
function sidebarLinks(): Record<string, string> {
const nav = document.getElementById("primary-navigation");
const links = Array.from(within(nav as HTMLElement).getAllByRole("link"));
return Object.fromEntries(
links.map((link) => [link.textContent ?? "", link.getAttribute("href") ?? ""]),
);
}
describe("AppSidebar navigation entries", () => {
it("shows Uplift Approvals for a user the backend says can approve uplifts", () => {
canApproveMock.mockReturnValue({ data: true });
renderSidebar("/");
const links = sidebarLinks();
expect(links["Uplift Approvals"]).toBe("/uplifts");
});
it("hides Uplift Approvals when the backend says the user cannot approve uplifts", () => {
canApproveMock.mockReturnValue({ data: false });
renderSidebar("/");
const links = sidebarLinks();
expect(links["Uplift Approvals"]).toBeUndefined();
});
it("hides Uplift Approvals while the backend answer is still loading", () => {
canApproveMock.mockReturnValue({ data: undefined });
renderSidebar("/");
const links = sidebarLinks();
expect(links["Uplift Approvals"]).toBeUndefined();
});
it.each([
[1, "tier 1"],
[2, "tier 2"],
])("shows Uplift Approvals when only %s approval is allowed (%s)", (allowedTier) => {
canApproveMock.mockImplementation((tier: number) => ({ data: tier === allowedTier }));
renderSidebar("/");
const links = sidebarLinks();
expect(links["Uplift Approvals"]).toBe("/uplifts");
expect(canApproveMock).toHaveBeenCalledWith(1);
expect(canApproveMock).toHaveBeenCalledWith(2);
});
it("hides Uplift Approvals when the can-approve request fails", () => {
canApproveMock.mockReturnValue({ data: undefined, isError: true, error: new Error("403") });
renderSidebar("/");
const links = sidebarLinks();
expect(links["Uplift Approvals"]).toBeUndefined();
});
it("hides Uplift Approvals when a refetch fails after an earlier approval", () => {
canApproveMock.mockReturnValue({ data: true, isError: true, error: new Error("403") });
renderSidebar("/");
const links = sidebarLinks();
expect(links["Uplift Approvals"]).toBeUndefined();
});
it("offers Services to every user", () => {
canApproveMock.mockReturnValue({ data: false });
renderSidebar("/");
const links = sidebarLinks();
expect(links["Services"]).toBe("/settings/services");
});
it("highlights the Uplift Approvals entry on /uplifts", () => {
canApproveMock.mockReturnValue({ data: true });
renderSidebar("/uplifts");
expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute(
"aria-current",
"page",
);
});
it("highlights the Services entry on /settings/services", () => {
canApproveMock.mockReturnValue({ data: false });
renderSidebar("/settings/services");
expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute("aria-current", "page");
});
});

View file

@ -1,4 +1,4 @@
import { getActiveNavPath } from "@/config/menu";
import { getActiveNavPath, getNavGroups, navGroups } from "@/config/menu";
import { describe, expect, it } from "vitest";
describe("getActiveNavPath", () => {
@ -10,6 +10,9 @@ describe("getActiveNavPath", () => {
["/vendors/insights", "/vendors/insights"],
["/vendors/preferences", "/vendors/preferences"],
["/vendor-pos/42", "/vendor-pos"],
["/settings/services", "/settings/services"],
["/uplifts", "/uplifts"],
["/uplifts/12", "/uplifts"],
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
expect(getActiveNavPath(pathname)).toBe(expectedPath);
});
@ -17,4 +20,39 @@ describe("getActiveNavPath", () => {
it("does not match partial route segments", () => {
expect(getActiveNavPath("/vendor-posts")).toBeUndefined();
});
it("does not highlight the Services entry from other settings tabs", () => {
expect(getActiveNavPath("/settings/dropdowns")).toBeUndefined();
});
});
describe("navGroups", () => {
it("always offers Services in a DATA MANAGEMENT group", () => {
const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT");
expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services");
});
it("does not include the approvals group by default", () => {
expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false);
});
});
describe("getNavGroups", () => {
it("adds the APPROVALS group with Uplift Approvals for uplift reviewers", () => {
const groups = getNavGroups({ canReviewUplifts: true });
const approvals = groups.find((group) => group.title === "APPROVALS");
expect(approvals?.items.map((item) => item.path)).toEqual(["/uplifts"]);
expect(approvals?.items[0]?.label).toBe("Uplift Approvals");
});
it("omits the APPROVALS group for users who cannot review uplifts", () => {
const groups = getNavGroups({ canReviewUplifts: false });
expect(groups.some((group) => group.title === "APPROVALS")).toBe(false);
});
it("keeps the approvals group after TEAM", () => {
const groups = getNavGroups({ canReviewUplifts: true });
const titles = groups.map((group) => group.title);
expect(titles.indexOf("TEAM")).toBeLessThan(titles.indexOf("APPROVALS"));
});
});