]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11958 Enhance anchors in embedded documentation navigation
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Tue, 27 Apr 2021 14:45:51 +0000 (16:45 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 29 Apr 2021 20:03:27 +0000 (20:03 +0000)
server/sonar-web/src/main/js/apps/documentation/components/App.tsx
server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap
server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx
server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx
server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 1b9da762807f3828381b1e6a5f2e01369fe7a60e..a033af231a0d4946a5dc307f5b19aca3629b8d7c 100644 (file)
@@ -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<Props, State> {
 
     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<Props, State> {
 
   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<Props, State> {
     }
 
     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<Props, State> {
         <Helmet
           defer={false}
           title={isIndex || !page.title ? mainTitle : `${page.title} | ${mainTitle}`}>
-          {!isSonarCloud() && <meta content="noindex nofollow" name="robots" />}
+          <meta content="noindex nofollow" name="robots" />
         </Helmet>
 
         <ScreenPositionHelper className="layout-page-side-outer">
@@ -220,6 +217,7 @@ export default class App extends React.PureComponent<Props, State> {
                 content={page.content}
                 stickyToc={true}
                 title={page.title}
+                scrollToHref={hash}
               />
             </div>
           </div>
index 4f96566d9704f91b37910f603a39f722d50830b6..c754c4dde34bdf387b49455f474ae384b2e1db30 100644 (file)
@@ -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<App['props']> = {}) {
-  return shallow(<App params={{ splat: 'lorem/ipsum' }} {...props} />);
+  return shallow(<App params={{ splat: 'lorem/ipsum' }} location={{ hash: '#foo' }} {...props} />);
 }
index fc7aa1ecf74c84e76785ae40adcc71c015470082..dd6caa00c8b45cc4cbbbafdf3b05b41d863d396a 100644 (file)
@@ -1,43 +1,5 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render correctly for SonarCloud 1`] = `
-<div
-  className="layout-page"
->
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    title="Lorem | documentation.page_title.sonarcloud"
-  />
-  <ScreenPositionHelper
-    className="layout-page-side-outer"
-  >
-    <Component />
-  </ScreenPositionHelper>
-  <div
-    className="layout-page-main"
-  >
-    <div
-      className="layout-page-main-inner"
-    >
-      <div
-        className="boxed-group"
-      >
-        <A11ySkipTarget
-          anchor="documentation_main"
-        />
-        <DocMarkdownBlock
-          className="documentation-content cut-margins boxed-group-inner"
-          content="Lorem ipsum dolor sit amet fredum"
-          stickyToc={true}
-          title="Lorem"
-        />
-      </div>
-    </div>
-  </div>
-</div>
-`;
-
 exports[`should render correctly for SonarQube 1`] = `
 <div
   className="layout-page"
@@ -72,6 +34,7 @@ exports[`should render correctly for SonarQube 1`] = `
         <DocMarkdownBlock
           className="documentation-content cut-margins boxed-group-inner"
           content="Lorem ipsum dolor sit amet fredum"
+          scrollToHref="#foo"
           stickyToc={true}
           title="Lorem"
         />
@@ -171,7 +134,7 @@ exports[`should show a 404 if the page doesn't exist 1`] = `
   <Helmet
     defer={true}
     encodeSpecialCharacters={true}
-    title="documentation.page_title.sonarcloud"
+    title="documentation.page_title.sonarqube"
   >
     <meta
       content="noindex nofollow"
index f85d262ef41ea423d2be7bc8a5b061985ac6b56f..9a04f69953539a8d8514c381527c852f1f9b14db 100644 (file)
@@ -38,19 +38,35 @@ interface Props {
   className?: string;
   content: string;
   isTooltip?: boolean;
+  scrollToHref?: string;
   stickyToc?: boolean;
   title?: string;
 }
 
+const WAIT_TIMEOUT = 500;
+
 export default class DocMarkdownBlock extends React.PureComponent<Props> {
   node: HTMLElement | null = null;
 
-  handleAnchorClick = (href: string, event: React.MouseEvent<HTMLAnchorElement>) => {
+  componentDidMount() {
+    const { scrollToHref } = this.props;
+    if (scrollToHref) {
+      setTimeout(() => {
+        this.handleAnchorClick(scrollToHref);
+      }, WAIT_TIMEOUT);
+    }
+  }
+
+  handleAnchorClick = (href: string, event?: React.MouseEvent<HTMLAnchorElement>) => {
     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);
         }
index 3cdc7fdabe2f5d94ac97f8bca5509ed59541a422..86a4f1e9eb5ed4638493ca30abea68f452aaa1bb 100644 (file)
@@ -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<DocMarkdownBlock['props']> = {}) {
-  return shallow(<DocMarkdownBlock content="" {...props} />);
+  return shallow<DocMarkdownBlock>(<DocMarkdownBlock content="" {...props} />);
 }
index 6e98ebcc68b9933b4ad4dc8e892357d02611c152..e15b023ec8699387f800896f3a6621957499853c 100644 (file)
@@ -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`] = `
+<withChildProps
+  href="/quality-profiles"
+  key="h-2"
+>
+  link
+</withChildProps>
+`;
+
+exports[`should render correctly: custom props for links 1`] = `
+<withChildProps
+  href="#quality-profiles"
+  key="h-2"
+>
+  link
+</withChildProps>
+`;
+
+exports[`should render correctly: default 1`] = `
+<div
+  className="markdown"
+>
+  <div
+    className="markdown-content"
+  >
+    <div>
+      <p
+        key="h-1"
+      >
+        this is 
+        <em
+          key="h-2"
+        >
+          bold
+        </em>
+         text
+      </p>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: sticky TOC 1`] = `
 <div
   className="markdown has-toc"
 >
@@ -104,45 +146,3 @@ Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, co
   />
 </div>
 `;
-
-exports[`should render simple markdown 1`] = `
-<div
-  className="markdown"
->
-  <div
-    className="markdown-content"
-  >
-    <div>
-      <p
-        key="h-1"
-      >
-        this is 
-        <em
-          key="h-2"
-        >
-          bold
-        </em>
-         text
-      </p>
-    </div>
-  </div>
-</div>
-`;
-
-exports[`should render with custom props for links 1`] = `
-<withChildProps
-  href="#quality-profiles"
-  key="h-2"
->
-  link
-</withChildProps>
-`;
-
-exports[`should use custom component for links 1`] = `
-<withChildProps
-  href="/quality-profiles"
-  key="h-2"
->
-  link
-</withChildProps>
-`;
index c1135d61efc04e33c30fb067e6cc53034dc0c104..fd904c2248330b15db5df36636affae3f240b3a5 100644 (file)
@@ -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