From 356a35002cfce5ae1950966459ba4bd38b010693 Mon Sep 17 00:00:00 2001 From: Wouter Admiraal Date: Tue, 27 Apr 2021 16:45:51 +0200 Subject: [PATCH] SONAR-11958 Enhance anchors in embedded documentation navigation --- .../js/apps/documentation/components/App.tsx | 20 ++-- .../components/__tests__/App-test.tsx | 40 +------- .../__tests__/__snapshots__/App-test.tsx.snap | 41 +------- .../js/components/docs/DocMarkdownBlock.tsx | 20 +++- .../docs/__tests__/DocMarkdownBlock-test.tsx | 99 ++++++++++++++++--- .../DocMarkdownBlock-test.tsx.snap | 86 ++++++++-------- .../resources/org/sonar/l10n/core.properties | 1 - 7 files changed, 156 insertions(+), 151 deletions(-) diff --git a/server/sonar-web/src/main/js/apps/documentation/components/App.tsx b/server/sonar-web/src/main/js/apps/documentation/components/App.tsx index 1b9da762807..a033af231a0 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/documentation/components/App.tsx @@ -17,7 +17,6 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import * as navigationTreeSonarCloud from 'Docs/../static/SonarCloudNavigationTree.json'; import * as navigationTreeSonarQube from 'Docs/../static/SonarQubeNavigationTree.json'; import { DocNavigationItem } from 'Docs/@types/types'; import * as React from 'react'; @@ -34,7 +33,6 @@ import NotFound from '../../../app/components/NotFound'; import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; import DocMarkdownBlock from '../../../components/docs/DocMarkdownBlock'; import { ParsedContent, separateFrontMatter } from '../../../helpers/markdown'; -import { isSonarCloud } from '../../../helpers/system'; import { InstalledPlugin, PluginType } from '../../../types/plugins'; import { getUrlsList } from '../navTreeUtils'; import getPages from '../pages'; @@ -44,6 +42,7 @@ import Sidebar from './Sidebar'; interface Props { params: { splat?: string }; + location: { hash: string }; } interface State { @@ -68,9 +67,7 @@ export default class App extends React.PureComponent { this.setState({ loading: true }); - const tree = isSonarCloud() - ? ((navigationTreeSonarCloud as any).default as DocNavigationItem[]) - : ((navigationTreeSonarQube as any).default as DocNavigationItem[]); + const tree = (navigationTreeSonarQube as any).default as DocNavigationItem[]; this.getLanguagePluginsDocumentation(tree).then( overrides => { @@ -150,7 +147,10 @@ export default class App extends React.PureComponent { render() { const { loading, pages, tree } = this.state; - const { splat = '' } = this.props.params; + const { + params: { splat = '' }, + location: { hash } + } = this.props; if (loading) { return ( @@ -161,10 +161,7 @@ export default class App extends React.PureComponent { } const page = pages.find(p => p.url === `/${splat}`); - const mainTitle = translate( - 'documentation.page_title', - isSonarCloud() ? 'sonarcloud' : 'sonarqube' - ); + const mainTitle = translate('documentation.page_title.sonarqube'); const isIndex = splat === 'index'; if (!page) { @@ -184,7 +181,7 @@ export default class App extends React.PureComponent { - {!isSonarCloud() && } + @@ -220,6 +217,7 @@ export default class App extends React.PureComponent { content={page.content} stickyToc={true} title={page.title} + scrollToHref={hash} /> diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx index 4f96566d970..c754c4dde34 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx +++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx @@ -22,17 +22,12 @@ import * as React from 'react'; import { addSideBarClass, removeSideBarClass } from 'sonar-ui-common/helpers/pages'; import { request } from 'sonar-ui-common/helpers/request'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { isSonarCloud } from '../../../../helpers/system'; import { InstalledPlugin } from '../../../../types/plugins'; import getPages from '../../pages'; import App from '../App'; jest.mock('../../../../components/common/ScreenPositionHelper'); -jest.mock('../../../../helpers/system', () => ({ - isSonarCloud: jest.fn().mockReturnValue(false) -})); - jest.mock('Docs/../static/SonarQubeNavigationTree.json', () => ({ default: [ { @@ -56,28 +51,6 @@ jest.mock('Docs/../static/SonarQubeNavigationTree.json', () => ({ ] })); -jest.mock('Docs/../static/SonarCloudNavigationTree.json', () => ({ - default: [ - { - title: 'SonarCloud', - children: [ - '/lorem/ipsum/', - { - title: 'Child category', - children: [ - '/lorem/ipsum/dolor', - { - title: 'Grandchild category', - children: ['/lorem/ipsum/sit'] - }, - '/lorem/ipsum/amet' - ] - } - ] - } - ] -})); - jest.mock('sonar-ui-common/helpers/pages', () => ({ addSideBarClass: jest.fn(), removeSideBarClass: jest.fn() @@ -136,13 +109,6 @@ it('should render correctly for SonarQube', async () => { expect(removeSideBarClass).toBeCalled(); }); -it('should render correctly for SonarCloud', async () => { - (isSonarCloud as jest.Mock).mockReturnValue(true); - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); -}); - it("should show a 404 if the page doesn't exist", async () => { const wrapper = shallowRender({ params: { splat: 'unknown' } }); await waitAndUpdate(wrapper); @@ -150,8 +116,6 @@ it("should show a 404 if the page doesn't exist", async () => { }); it('should try to fetch language plugin documentation if documentationPath matches', async () => { - (isSonarCloud as jest.Mock).mockReturnValue(false); - const wrapper = shallowRender(); await waitAndUpdate(wrapper); @@ -166,8 +130,6 @@ it('should try to fetch language plugin documentation if documentationPath match }); it('should display the issue tracker url of the plugin if it exists', async () => { - (isSonarCloud as jest.Mock).mockReturnValue(false); - const wrapper = shallowRender({ params: { splat: 'analysis/languages/csharp/' } }); await waitAndUpdate(wrapper); @@ -177,5 +139,5 @@ it('should display the issue tracker url of the plugin if it exists', async () = }); function shallowRender(props: Partial = {}) { - return shallow(); + return shallow(); } diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap index fc7aa1ecf74..dd6caa00c8b 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap @@ -1,43 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render correctly for SonarCloud 1`] = ` -
- - - - -
-
-
- - -
-
-
-
-`; - exports[`should render correctly for SonarQube 1`] = `
@@ -171,7 +134,7 @@ exports[`should show a 404 if the page doesn't exist 1`] = ` { node: HTMLElement | null = null; - handleAnchorClick = (href: string, event: React.MouseEvent) => { + componentDidMount() { + const { scrollToHref } = this.props; + if (scrollToHref) { + setTimeout(() => { + this.handleAnchorClick(scrollToHref); + }, WAIT_TIMEOUT); + } + } + + handleAnchorClick = (href: string, event?: React.MouseEvent) => { if (this.node) { const element = this.node.querySelector(href); if (element) { - event.preventDefault(); + if (event) { + event.preventDefault(); + } scrollToElement(element, { bottomOffset: window.innerHeight - 80 }); + + // We cannot use React Router here, because we cannot simply replace a hash. if (history.pushState) { history.pushState(null, '', href); } diff --git a/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx b/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx index 3cdc7fdabe2..86a4f1e9eb5 100644 --- a/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx +++ b/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx @@ -19,6 +19,8 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; +import { scrollToElement } from 'sonar-ui-common/helpers/scrolling'; +import { mockEvent } from '../../../helpers/testMocks'; import DocMarkdownBlock from '../DocMarkdownBlock'; const CONTENT = ` @@ -48,37 +50,102 @@ jest.mock('rehype-raw', () => ({ default: jest.requireActual('rehype-raw') })); jest.mock('rehype-react', () => ({ default: jest.requireActual('rehype-react') })); jest.mock('rehype-slug', () => ({ default: jest.requireActual('rehype-slug') })); -jest.mock('../../../helpers/system', () => ({ - getInstance: jest.fn(), - isSonarCloud: jest.fn() +jest.mock('sonar-ui-common/helpers/scrolling', () => ({ + scrollToElement: jest.fn() })); -it('should render simple markdown', () => { - expect(shallowRender({ content: 'this is *bold* text' })).toMatchSnapshot(); +const WINDOW_HEIGHT = 800; +const originalWindowHeight = window.innerHeight; + +const historyPushState = jest.fn(); +const originalHistoryPushState = history.pushState; + +beforeEach(jest.clearAllMocks); + +beforeAll(() => { + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: WINDOW_HEIGHT + }); + Object.defineProperty(history, 'pushState', { + writable: true, + configurable: true, + value: historyPushState + }); }); -it('should use custom component for links', () => { - expect( - shallowRender({ content: 'some [link](/quality-profiles)' }).find('withChildProps') - ).toMatchSnapshot(); +afterAll(() => { + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: originalWindowHeight + }); + Object.defineProperty(history, 'pushState', { + writable: true, + configurable: true, + value: originalHistoryPushState + }); }); -it('should render with custom props for links', () => { +it('should render correctly', () => { + expect(shallowRender({ content: 'this is *bold* text' })).toMatchSnapshot('default'); + expect( + shallowRender({ content: 'some [link](/quality-profiles)' }).find('withChildProps') + ).toMatchSnapshot('custom component for links'); expect( shallowRender({ childProps: { foo: 'bar' }, content: 'some [link](#quality-profiles)', isTooltip: true }).find('withChildProps') - ).toMatchSnapshot(); + ).toMatchSnapshot('custom props for links'); + expect(shallowRender({ content: CONTENT, stickyToc: true })).toMatchSnapshot('sticky TOC'); +}); + +it('should correctly scroll to clicked headings', () => { + const element = {} as Element; + const querySelector: (selector: string) => Element | null = jest.fn((selector: string) => + selector === '#id' ? element : null + ); + const preventDefault = jest.fn(); + const wrapper = shallowRender(); + const instance = wrapper.instance(); + + // Node Ref isn't set yet. + instance.handleAnchorClick('#unknown', mockEvent()); + expect(scrollToElement).not.toBeCalled(); + + // Set node Ref. + instance.node = { querySelector } as HTMLElement; + + // Unknown element. + instance.handleAnchorClick('#unknown', mockEvent()); + expect(scrollToElement).not.toBeCalled(); + + // Known element, should scroll. + instance.handleAnchorClick('#id', mockEvent({ preventDefault })); + expect(scrollToElement).toBeCalledWith(element, { bottomOffset: 720 }); + expect(preventDefault).toBeCalled(); + expect(historyPushState).toBeCalledWith(null, '', '#id'); }); -it('should render a sticky TOC if available', () => { - const wrapper = shallowRender({ content: CONTENT, stickyToc: true }); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('DocToc').exists()).toBe(true); +it('should correctly scroll to a specific heading if passed as a prop', () => { + jest.useFakeTimers(); + + const element = {} as Element; + const querySelector: (_: string) => Element | null = jest.fn(() => element); + const wrapper = shallowRender({ scrollToHref: '#id' }); + const instance = wrapper.instance(); + instance.node = { querySelector } as HTMLElement; + + expect(scrollToElement).not.toBeCalled(); + + jest.runAllTimers(); + + expect(scrollToElement).toBeCalledWith(element, { bottomOffset: 720 }); }); function shallowRender(props: Partial = {}) { - return shallow(); + return shallow(); } diff --git a/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap b/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap index 6e98ebcc68b..e15b023ec86 100644 --- a/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap @@ -1,6 +1,48 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render a sticky TOC if available 1`] = ` +exports[`should render correctly: custom component for links 1`] = ` + + link + +`; + +exports[`should render correctly: custom props for links 1`] = ` + + link + +`; + +exports[`should render correctly: default 1`] = ` +
+
+
+

+ this is + + bold + + text +

+
+
+
+`; + +exports[`should render correctly: sticky TOC 1`] = `
@@ -104,45 +146,3 @@ Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, co />
`; - -exports[`should render simple markdown 1`] = ` -
-
-
-

- this is - - bold - - text -

-
-
-
-`; - -exports[`should render with custom props for links 1`] = ` - - link - -`; - -exports[`should use custom component for links 1`] = ` - - link - -`; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index c1135d61efc..fd904c22483 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3050,7 +3050,6 @@ api_documentation.search=Search by name... # #------------------------------------------------------------------------------ documentation.page=Documentation -documentation.page_title.sonarcloud=SonarCloud Docs documentation.page_title.sonarqube=SonarQube Docs documentation.on_this_page=On this page documentation.skip_to_nav=Skip to documentation navigation -- 2.39.5