From c571307ac48a3a8d3a1aab0ebb0f65d262ad39d1 Mon Sep 17 00:00:00 2001 From: Eric Pastor <38719351+ericpastorm@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:38:51 +0200 Subject: [PATCH] Link legislator tabs to URL hashes --- .../LegislatorProfile/LegislatorTabs.test.tsx | 141 ++++++++++++++++++ .../LegislatorProfile/LegislatorTabs.tsx | 70 ++++++--- 2 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 components/LegislatorProfile/LegislatorTabs.test.tsx diff --git a/components/LegislatorProfile/LegislatorTabs.test.tsx b/components/LegislatorProfile/LegislatorTabs.test.tsx new file mode 100644 index 000000000..39cb90de6 --- /dev/null +++ b/components/LegislatorProfile/LegislatorTabs.test.tsx @@ -0,0 +1,141 @@ +import "@testing-library/jest-dom" +import { fireEvent, render, screen } from "@testing-library/react" + +import { LegislatorTabs } from "./LegislatorTabs" + +const push = jest.fn().mockResolvedValue(true) +let mockRouter = { + asPath: "/legislators/194/ABC1", + isReady: true, + push +} + +jest.mock("next/router", () => ({ + useRouter: () => mockRouter +})) + +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + "tabs.priorities": "Priorities", + "tabs.bills": "Bills", + "tabs.elections": "Elections", + "tabs.finance": "Campaign Finance", + "tabs.district": "District", + "tabs.testimony": "Testimony", + "tabs.votes": "Votes" + }[key] ?? key) + }) +})) + +jest.mock("./TabComponents/PrioritiesTab", () => ({ + PrioritiesTab: () => null +})) +jest.mock("./TabComponents/BillsTab", () => ({ + BillsTab: () => null +})) +jest.mock("./TabComponents/ElectionsTab", () => ({ + ElectionsTab: () => null +})) +jest.mock("./TabComponents/FinanceTab", () => ({ + FinanceTab: () => null +})) +jest.mock("./TabComponents/DistrictTab", () => ({ + DistrictTab: () => null +})) +jest.mock("./TabComponents/TestimonyTab", () => ({ + TestimonyTab: () => null +})) +jest.mock("./TabComponents/VotesTab", () => ({ + VotesTab: () => null +})) + +const renderTabs = () => + render() + +describe("LegislatorTabs", () => { + beforeEach(() => { + push.mockClear() + mockRouter = { + asPath: "/legislators/194/ABC1", + isReady: true, + push + } + }) + + it("selects the tab from the URL hash after the router is ready", () => { + mockRouter = { + ...mockRouter, + asPath: "/legislators/194/ABC1#elections", + isReady: false + } + const { rerender } = renderTabs() + + expect(screen.getByRole("tab", { name: "Priorities" })).toHaveAttribute( + "aria-selected", + "true" + ) + + mockRouter = { ...mockRouter, isReady: true } + rerender( + + ) + + expect(screen.getByRole("tab", { name: "Elections" })).toHaveAttribute( + "aria-selected", + "true" + ) + }) + + it.each([ + "/legislators/194/ABC1", + "/legislators/194/ABC1#not-a-legislator-tab" + ])("falls back to priorities for %s", asPath => { + mockRouter = { ...mockRouter, asPath } + renderTabs() + + expect(screen.getByRole("tab", { name: "Priorities" })).toHaveAttribute( + "aria-selected", + "true" + ) + }) + + it("adds the selected tab to the URL hash", () => { + renderTabs() + + fireEvent.click(screen.getByRole("tab", { name: "Campaign Finance" })) + + expect( + screen.getByRole("tab", { name: "Campaign Finance" }) + ).toHaveAttribute("aria-selected", "true") + expect(push).toHaveBeenCalledWith( + "/legislators/194/ABC1#finance", + undefined, + { shallow: true, scroll: false } + ) + }) + + it("updates the selected tab when browser history changes the hash", () => { + mockRouter = { + ...mockRouter, + asPath: "/legislators/194/ABC1#elections" + } + const { rerender } = renderTabs() + + expect(screen.getByRole("tab", { name: "Elections" })).toHaveAttribute( + "aria-selected", + "true" + ) + + mockRouter = { ...mockRouter, asPath: "/legislators/194/ABC1#bills" } + rerender( + + ) + + expect(screen.getByRole("tab", { name: "Bills" })).toHaveAttribute( + "aria-selected", + "true" + ) + }) +}) diff --git a/components/LegislatorProfile/LegislatorTabs.tsx b/components/LegislatorProfile/LegislatorTabs.tsx index f0d91c148..2d1c96f90 100644 --- a/components/LegislatorProfile/LegislatorTabs.tsx +++ b/components/LegislatorProfile/LegislatorTabs.tsx @@ -1,4 +1,6 @@ import { useTranslation } from "next-i18next" +import { useRouter } from "next/router" +import { useEffect, useState } from "react" import { TabPane } from "react-bootstrap" import TabContainer from "react-bootstrap/TabContainer" import styled from "styled-components" @@ -21,16 +23,24 @@ import { } from "components/EditProfilePage/StyledEditProfileComponents" import { MembersFinance } from "components/db/membersFinance" -const tabCategory = [ - "priorities", - "bills", - "elections", - "finance", - "district", - "testimony", - "votes" -] -type TabCategories = (typeof tabCategory)[number] +const tabCategories = { + priorities: "priorities", + bills: "bills", + elections: "elections", + finance: "finance", + district: "district", + testimony: "testimony", + votes: "votes" +} as const +type TabCategory = (typeof tabCategories)[keyof typeof tabCategories] + +const isTabCategory = (value?: string | null): value is TabCategory => + Object.values(tabCategories).some(category => category === value) + +const tabCategoryFromPath = (path: string): TabCategory => { + const hash = path.split("#", 2)[1] + return isTabCategory(hash) ? hash : tabCategories.priorities +} const TabNavLink = styled(Nav.Link).attrs(props => ({ className: `rounded-top m-0 p-0 ${props.className}` @@ -69,59 +79,79 @@ export function LegislatorTabs({ districtLoading, legislatorId, name, - tabCategory, finance }: { district?: District | undefined districtLoading?: boolean legislatorId: string name: string - tabCategory?: TabCategories finance?: MembersFinance }) { + const router = useRouter() const { t } = useTranslation("legislators") + const [activeTab, setActiveTab] = useState( + tabCategories.priorities + ) + + useEffect(() => { + if (router.isReady) setActiveTab(tabCategoryFromPath(router.asPath)) + }, [router.asPath, router.isReady]) + + const handleTabSelect = (nextTab: string | null) => { + if (!isTabCategory(nextTab)) return + + setActiveTab(nextTab) + + const [path, currentHash] = router.asPath.split("#", 2) + if (currentHash === nextTab) return + + void router.push(`${path}#${nextTab}`, undefined, { + shallow: true, + scroll: false + }) + } const tabs = [ { title: t("tabs.priorities"), - eventKey: "priorities", + eventKey: tabCategories.priorities, content: }, { title: t("tabs.bills"), - eventKey: "bills", + eventKey: tabCategories.bills, content: }, { title: t("tabs.elections"), - eventKey: "elections", + eventKey: tabCategories.elections, content: }, { title: t("tabs.finance"), - eventKey: "finance", + eventKey: tabCategories.finance, content: }, { title: t("tabs.district"), - eventKey: "district", + eventKey: tabCategories.district, content: }, { title: t("tabs.testimony"), - eventKey: "testimony", + eventKey: tabCategories.testimony, content: }, { title: t("tabs.votes"), - eventKey: "votes", + eventKey: tabCategories.votes, content: } ] return ( - + {tabs.map((t, i) => (