mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
feat(nav): add Uplift Approvals and Services to the sidebar (SH-207, SH-304)
Uplift Approvals appears in a new APPROVALS group only when GET uplifts/can-approve is true for tier 1 or tier 2, the same rule the approve/reject API enforces. Services is linked from a DATA MANAGEMENT group for every user. Both entries highlight on their routes.
This commit is contained in:
parent
6367dc2a7a
commit
ec56bd0d7d
5 changed files with 162 additions and 3 deletions
|
|
@ -3,7 +3,8 @@ import { Link, useLocation } from "react-router";
|
||||||
import { Box, Drawer } from "@mui/material";
|
import { Box, Drawer } from "@mui/material";
|
||||||
|
|
||||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
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) {
|
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
|
@ -129,6 +130,7 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
||||||
const width = getSidebarWidth(collapsed);
|
const width = getSidebarWidth(collapsed);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const activePath = getActiveNavPath(pathname);
|
const activePath = getActiveNavPath(pathname);
|
||||||
|
const navGroups = useNavGroups();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
|
||||||
17
src/components/layout/use-nav-groups.ts
Normal file
17
src/components/layout/use-nav-groups.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function useNavGroups(): NavGroup[] {
|
||||||
|
const tier1 = useUpliftsCanApprove(1);
|
||||||
|
const tier2 = useUpliftsCanApprove(2);
|
||||||
|
const canReviewUplifts = tier1.data === true || tier2.data === true;
|
||||||
|
|
||||||
|
return getNavGroups({ canReviewUplifts });
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
// BarChart2,
|
// BarChart2,
|
||||||
|
BadgeDollarSign,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
Building2,
|
Building2,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Star,
|
Star,
|
||||||
Users,
|
Users,
|
||||||
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type NavItemDef = {
|
export type NavItemDef = {
|
||||||
|
|
@ -59,6 +61,10 @@ export const navGroups: NavGroup[] = [
|
||||||
{ label: "Sites", icon: MapPin, path: "/locations" },
|
{ label: "Sites", icon: MapPin, path: "/locations" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "DATA MANAGEMENT",
|
||||||
|
items: [{ label: "Services", icon: Wrench, path: "/settings/services" }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "TEAM",
|
title: "TEAM",
|
||||||
items: [{ label: "Team Members", icon: Users, path: "/team-members" }],
|
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 {
|
export function getActiveNavPath(pathname: string): string | undefined {
|
||||||
return navGroups
|
return [...navGroups, approvalsNavGroup]
|
||||||
.flatMap((group) => group.items)
|
.flatMap((group) => group.items)
|
||||||
.map((item) => item.path)
|
.map((item) => item.path)
|
||||||
.filter((path) =>
|
.filter((path) =>
|
||||||
|
|
|
||||||
83
src/test/components/layout/app-sidebar-nav.test.tsx
Normal file
83
src/test/components/layout/app-sidebar-nav.test.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getActiveNavPath } from "@/config/menu";
|
import { getActiveNavPath, getNavGroups, navGroups } from "@/config/menu";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("getActiveNavPath", () => {
|
describe("getActiveNavPath", () => {
|
||||||
|
|
@ -10,6 +10,9 @@ describe("getActiveNavPath", () => {
|
||||||
["/vendors/insights", "/vendors/insights"],
|
["/vendors/insights", "/vendors/insights"],
|
||||||
["/vendors/preferences", "/vendors/preferences"],
|
["/vendors/preferences", "/vendors/preferences"],
|
||||||
["/vendor-pos/42", "/vendor-pos"],
|
["/vendor-pos/42", "/vendor-pos"],
|
||||||
|
["/settings/services", "/settings/services"],
|
||||||
|
["/uplifts", "/uplifts"],
|
||||||
|
["/uplifts/12", "/uplifts"],
|
||||||
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
|
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
|
||||||
expect(getActiveNavPath(pathname)).toBe(expectedPath);
|
expect(getActiveNavPath(pathname)).toBe(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
@ -17,4 +20,39 @@ describe("getActiveNavPath", () => {
|
||||||
it("does not match partial route segments", () => {
|
it("does not match partial route segments", () => {
|
||||||
expect(getActiveNavPath("/vendor-posts")).toBeUndefined();
|
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"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue