From 59d6aa9036d2529f0f6e25d35fc6f91f7ca6a64a Mon Sep 17 00:00:00 2001 From: Nikhil Kumar Rajak Date: Sun, 22 Mar 2026 19:01:34 +0000 Subject: [PATCH 1/3] refactor: modernize SidebarItem to functional component with Tailwind CSS - Convert SidebarItem from class component to functional component with hooks - Replace deprecated UNSAFE_componentWillReceiveProps with useEffect - Migrate SidebarItem.scss to Tailwind CSS utilities (addresses #8047) - Update Print component to use Tailwind instead of removed BEM classes - Remove orphaned sidebar-item selectors from dark.scss - Add comprehensive unit tests for SidebarItem (7 test cases) - Support both light and dark mode with proper color tokens --- src/components/SidebarItem/SidebarItem.jsx | 237 +++++++++--------- src/components/SidebarItem/SidebarItem.scss | 119 --------- .../SidebarItem/SidebarItem.test.jsx | 103 ++++++++ .../__snapshots__/SidebarItem.test.jsx.snap | 43 ++++ src/styles/dark.scss | 12 - 5 files changed, 261 insertions(+), 253 deletions(-) delete mode 100644 src/components/SidebarItem/SidebarItem.scss create mode 100644 src/components/SidebarItem/SidebarItem.test.jsx create mode 100644 src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap diff --git a/src/components/SidebarItem/SidebarItem.jsx b/src/components/SidebarItem/SidebarItem.jsx index 12b950111fba..3a3f05442d57 100644 --- a/src/components/SidebarItem/SidebarItem.jsx +++ b/src/components/SidebarItem/SidebarItem.jsx @@ -1,143 +1,136 @@ import PropTypes from "prop-types"; -import { Component } from "react"; -import "./SidebarItem.scss"; +import { useCallback, useEffect, useState } from "react"; import { NavLink } from "react-router-dom"; import ChevronRightIcon from "../../styles/icons/chevron-right.svg"; import BarIcon from "../../styles/icons/vertical-bar.svg"; import list2Tree from "../../utilities/list2Tree/index.js"; -const block = "sidebar-item"; - -export default class SidebarItem extends Component { - static propTypes = { - title: PropTypes.string, - anchors: PropTypes.array, - url: PropTypes.string, - currentPage: PropTypes.string, - }; +/** + * Checks whether the sidebar item should be expanded + * based on whether the current page URL matches this item's URL. + * + * @param {string} currentPage - The current page pathname + * @param {string} url - The sidebar item URL + * @returns {boolean} + */ +function isOpen(currentPage, url) { + return new RegExp(`${currentPage}/?$`).test(url); +} - state = { - open: this._isOpen(this.props), - }; +/** + * Generate the url for the given anchor depending on the current page + * + * @param {string} url - The base URL + * @param {object} anchor - The anchor object containing its id + * @returns {string} + */ +function generateAnchorURL(url, anchor) { + return anchor.id ? `${url}#${anchor.id}` : url; +} - scrollTop(event) { - // there're two cases - // 1. location.pathname or location.hash changes which will be handled by useEffect in Page.jsx - // 2. location.pathname and location.hash doesn't change at all - if (window.location.hash !== "") { - // case 1 - return; - } - if (!event.metaKey && !event.ctrlKey) { - // case 2 - window.scrollTo(0, 0); - } +function scrollTop(event) { + // there're two cases + // 1. location.pathname or location.hash changes which will be handled by useEffect in Page.jsx + // 2. location.pathname and location.hash doesn't change at all + if (window.location.hash !== "") { + // case 1 + return; } + if (!event.metaKey && !event.ctrlKey) { + // case 2 + window.scrollTo(0, 0); + } +} - renderAnchors(anchors) { - return ( - - ); - } + {anchor.title2} + + {anchor.children && } + + ))} + + ); +} - render() { - const { title, anchors = [] } = this.props; - const openMod = this.state.open ? `${block}--open` : ""; - const disabledMod = anchors.length === 0 ? `${block}--disabled` : ""; +Anchors.propTypes = { + anchors: PropTypes.array.isRequired, + url: PropTypes.string.isRequired, +}; - const filteredAnchors = anchors.filter((anchor) => anchor.level > 1); - const tree = list2Tree(title, filteredAnchors); +export default function SidebarItem({ title, anchors = [], url, currentPage }) { + const [open, setOpen] = useState(() => isOpen(currentPage, url)); - return ( -
- {anchors.length > 0 ? ( - - ) : ( - { + setOpen(isOpen(currentPage, url)); + }, [currentPage, url]); + + const toggle = useCallback(() => { + setOpen((prev) => !prev); + }, []); + + const filteredAnchors = anchors.filter((anchor) => anchor.level > 1); + const tree = list2Tree(title, filteredAnchors); + + return ( +
+ {anchors.length > 0 ? ( +
- ); - } - - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.currentPage !== this.props.currentPage) { - this.setState({ - open: this._isOpen(nextProps), - }); - } - } - - /** - * Checks whether the item should be expanded - * - * @param {object} props - The current props - */ - _isOpen(props) { - return new RegExp(`${props.currentPage}/?$`).test(props.url); - } + + ) : ( + + )} - /** - * Toggles the open state (expanded/collapsed) - * - * @param {object} e - Click event - */ - _toggle() { - this.setState({ - open: !this.state.open, - }); - } + + `flex-1 max-w-[85%] overflow-hidden whitespace-nowrap text-ellipsis ${isActive ? "font-semibold text-[#333] dark:text-white" : "text-[#2b3a42] dark:text-[#b8b8b8]"}` + } + to={url} + onClick={scrollTop} + > + {title} + - /** - * Generate the url for the given [anchor] depending on the current page - * - * @param {object} anchor - The anchor object containing its id - * @returns {string} - */ - _generateAnchorURL(anchor) { - const { url } = this.props; - return anchor.id ? `${url}#${anchor.id}` : url; - } + {anchors.length > 0 ? : null} +
+ ); } + +SidebarItem.propTypes = { + title: PropTypes.string, + anchors: PropTypes.array, + url: PropTypes.string, + currentPage: PropTypes.string, +}; diff --git a/src/components/SidebarItem/SidebarItem.scss b/src/components/SidebarItem/SidebarItem.scss deleted file mode 100644 index ec34b1420423..000000000000 --- a/src/components/SidebarItem/SidebarItem.scss +++ /dev/null @@ -1,119 +0,0 @@ -@use "../../styles/partials/mixins" as *; -@use "../../styles/partials/functions" as *; - -.sidebar-item { - position: relative; - display: flex; - flex-wrap: wrap; - font-size: 15px; - margin: 0.6em 0; - align-items: center; - min-height: 40px; - - &__toggle { - flex: 0 0 auto; - margin-top: 0.125em; - margin-right: 0.5em; - cursor: pointer; - color: getColor(denim); - transition: all 250ms; - - &:hover { - color: getColor(mine-shaft); - } - } - - &__toggle-button { - background: none; - border: none; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 40px; - min-height: 40px; - } - - &__title { - flex: 1 1 auto; - color: getColor(elephant); - max-width: 85%; - min-height: 40px; - display: inline-flex; - align-items: center; - @include control-overflow; - &.active { - font-weight: 600; - color: getColor(mine-shaft); - } - } - - &__anchors { - position: relative; - display: none; - flex: 0 0 100%; - flex-wrap: wrap; - margin: 0.35em 0; - padding-left: 1.5em; - overflow: hidden; - list-style: none; - line-height: 19px; - - &:before { - content: ""; - position: absolute; - height: calc(100% - 0.6em); - top: 0; - left: 1.5em; - border-left: 1px dashed getColor(dusty-grey); - } - } - - &__anchor { - position: relative; - flex: 0 0 100%; - margin: 0.25em 0; - padding-left: 1em; - @include control-overflow; - - &:first-child { - margin-top: 0; - } - &:last-child { - margin-bottom: 0; - } - &:before { - content: ""; - position: absolute; - width: 0.5em; - left: 0; - top: 10px; - border-bottom: 1px dashed getColor(dusty-grey); - } - - a { - color: getColor(elephant); - - &:hover { - color: getColor(denim); - } - } - } - - &--open { - .sidebar-item__anchors { - display: flex; - } - - .sidebar-item__toggle { - transform-origin: center center; - transform: rotate(90deg); - } - } - - &--disabled { - .sidebar-item__toggle { - color: #aaa; - } - } -} diff --git a/src/components/SidebarItem/SidebarItem.test.jsx b/src/components/SidebarItem/SidebarItem.test.jsx new file mode 100644 index 000000000000..c1eb4a46bcfb --- /dev/null +++ b/src/components/SidebarItem/SidebarItem.test.jsx @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import SidebarItem from "./SidebarItem.jsx"; + +function renderWithRouter(ui, { route = "/" } = {}) { + return render({ui}); +} + +describe("SidebarItem", () => { + const defaultProps = { + title: "Getting Started", + url: "/guides/getting-started/", + currentPage: "/guides/", + anchors: [], + }; + + it("renders the title", () => { + renderWithRouter(); + expect(screen.getByText("Getting Started")).toBeTruthy(); + }); + + it("renders collapsed by default when not matching currentPage", () => { + const { container } = renderWithRouter(); + const wrapper = container.firstChild; + expect(wrapper.getAttribute("data-open")).toBeNull(); + }); + + it("renders expanded when url matches currentPage", () => { + const { container } = renderWithRouter( + , + ); + const wrapper = container.firstChild; + expect(wrapper.getAttribute("data-open")).toBe("true"); + }); + + it("toggles open state when chevron button is clicked", () => { + const anchors = [ + { + id: "introduction", + title: "Introduction", + title2: "Introduction", + level: 2, + }, + { id: "setup", title: "Setup", title2: "Setup", level: 2 }, + ]; + const { container } = renderWithRouter( + , + ); + + const wrapper = container.firstChild; + expect(wrapper.getAttribute("data-open")).toBeNull(); + + const toggleButton = screen.getByRole("button", { + name: /toggle getting started section/i, + }); + fireEvent.click(toggleButton); + + expect(wrapper.getAttribute("data-open")).toBe("true"); + + fireEvent.click(toggleButton); + expect(wrapper.getAttribute("data-open")).toBeNull(); + }); + + it("renders anchor links when anchors are provided", () => { + const anchors = [ + { id: "intro", title: "Introduction", title2: "Introduction", level: 2 }, + { + id: "basic-setup", + title: "Basic Setup", + title2: "Basic Setup", + level: 2, + }, + ]; + renderWithRouter(); + expect(screen.getByText("Introduction")).toBeTruthy(); + expect(screen.getByText("Basic Setup")).toBeTruthy(); + }); + + it("renders a bar icon when no anchors are provided", () => { + const { container } = renderWithRouter( + , + ); + // No toggle button should exist when there are no anchors + expect(screen.queryByRole("button")).toBeNull(); + // The wrapper should still render + expect(container.firstChild).toBeTruthy(); + }); + + it("matches snapshot", () => { + const anchors = [ + { id: "step-1", title: "Step 1", title2: "Step 1", level: 2 }, + ]; + const { container } = renderWithRouter( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap b/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap new file mode 100644 index 000000000000..e8b50f72e58e --- /dev/null +++ b/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`SidebarItem matches snapshot 1`] = ` + +`; diff --git a/src/styles/dark.scss b/src/styles/dark.scss index 749990c63857..63ec251e2a54 100644 --- a/src/styles/dark.scss +++ b/src/styles/dark.scss @@ -98,13 +98,6 @@ } } } - .sidebar-item__title, - .sidebar-item__anchor a { - color: #b8b8b8; - } - .sidebar-item__title.active { - color: #fff; - } .gitter__button { background: #1c3b39; } @@ -115,10 +108,6 @@ .page-links__gap { color: #999; } - .sidebar-item__toggle, - .sidebar-item--disabled .sidebar-item__toggle { - color: #69a8ee; - } .site { background: #121212 !important; } @@ -175,7 +164,6 @@ } @layer base { - [data-theme="dark"] .sidebar-item__anchor a:hover, [data-theme="dark"] a:hover { color: #82b7f6; } From faa317872f2903e1fd1097c5484b968307551092 Mon Sep 17 00:00:00 2001 From: Nikhil Kumar Rajak Date: Mon, 23 Mar 2026 04:44:26 +0000 Subject: [PATCH 2/3] fix(cypress): update pr_4435 test selector after BEM to Tailwind migration The sidebar refactor removed the BEM class. Add to the NavLink in SidebarItem.jsx and update the Cypress test to use the new attribute selector. --- cypress/e2e/pr_4435.cy.js | 7 +++++-- src/components/SidebarItem/SidebarItem.jsx | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/pr_4435.cy.js b/cypress/e2e/pr_4435.cy.js index 70cc6e6931af..e2e1007e9ffa 100644 --- a/cypress/e2e/pr_4435.cy.js +++ b/cypress/e2e/pr_4435.cy.js @@ -10,13 +10,16 @@ describe("Open page in new tab", { scrollBehavior: false }, () => { }, }); // wait for page content to load before asserting scroll - cy.get('.sidebar-item__title[href="/concepts/plugins/"]').should("exist"); + cy.get( + '[data-testid="sidebar-item-title"][href="/concepts/plugins/"]', + ).should("exist"); // there's one call in Page.jsx when componentDidMount cy.window().should((win) => { expect(win.scrollTo).to.be.calledOnce; }); - const selector = '.sidebar-item__title[href="/concepts/plugins/"]'; + const selector = + '[data-testid="sidebar-item-title"][href="/concepts/plugins/"]'; // we click the menu cy.get(selector).click(); diff --git a/src/components/SidebarItem/SidebarItem.jsx b/src/components/SidebarItem/SidebarItem.jsx index 3a3f05442d57..328c3dc5359c 100644 --- a/src/components/SidebarItem/SidebarItem.jsx +++ b/src/components/SidebarItem/SidebarItem.jsx @@ -114,6 +114,7 @@ export default function SidebarItem({ title, anchors = [], url, currentPage }) { `flex-1 max-w-[85%] overflow-hidden whitespace-nowrap text-ellipsis ${isActive ? "font-semibold text-[#333] dark:text-white" : "text-[#2b3a42] dark:text-[#b8b8b8]"}` } From 6dd30130260919290235196e6d976c0e2bab751c Mon Sep 17 00:00:00 2001 From: Nikhil Kumar Rajak Date: Mon, 23 Mar 2026 04:53:03 +0000 Subject: [PATCH 3/3] fix(cypress): update click-menu-scroll-top selector and refresh snapshot - Replace removed .sidebar-item__title BEM class in click-menu-scroll-top.cy.js with data-testid attribute selector (same fix as pr_4435.cy.js) - Update SidebarItem snapshot to reflect new data-testid attribute on NavLink --- cypress/e2e/click-menu-scroll-top.cy.js | 3 ++- .../SidebarItem/__snapshots__/SidebarItem.test.jsx.snap | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/click-menu-scroll-top.cy.js b/cypress/e2e/click-menu-scroll-top.cy.js index 712780441286..08de6118caf1 100644 --- a/cypress/e2e/click-menu-scroll-top.cy.js +++ b/cypress/e2e/click-menu-scroll-top.cy.js @@ -7,7 +7,8 @@ describe("Click menu", () => { // note that there's no hash in url cy.get('[data-testid="contributors"]').scrollIntoView(); - const selector = '.sidebar-item__title[href="/concepts/modules/"]'; + const selector = + '[data-testid="sidebar-item-title"][href="/concepts/modules/"]'; cy.get(selector).click(); cy.window().then((win) => { diff --git a/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap b/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap index e8b50f72e58e..edfbd4f8f5fe 100644 --- a/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap +++ b/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap @@ -19,6 +19,7 @@ exports[`SidebarItem matches snapshot 1`] = ` Getting Started