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) => (