]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11282 Enhance embedded docs navigation sidebar
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Wed, 2 Jan 2019 08:10:08 +0000 (09:10 +0100)
committerSonarTech <sonartech@sonarsource.com>
Thu, 10 Jan 2019 19:21:02 +0000 (20:21 +0100)
15 files changed:
server/sonar-web/src/main/js/@types/remark-slug.d.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/styles/style.css
server/sonar-web/src/main/js/apps/documentation/components/App.tsx
server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResultEntry-test.tsx
server/sonar-web/src/main/js/apps/documentation/styles.css
server/sonar-web/src/main/js/components/controls/__tests__/ScreenPositionFixer-test.tsx
server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx
server/sonar-web/src/main/js/components/docs/DocToc.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx
server/sonar-web/src/main/js/components/docs/__tests__/DocToc-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap
server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocToc-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/docs/plugins/remark-only-toc.js [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/testUtils.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/src/main/js/@types/remark-slug.d.ts b/server/sonar-web/src/main/js/@types/remark-slug.d.ts
new file mode 100644 (file)
index 0000000..5965066
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+declare module 'remark-slug' {
+  export default function slug(): any;
+}
index f17b7ee3a9af0048c9c9ab573810137e71e1d64d..a41005d2dc3d7f222c7b2ae0114dcbfa1185c206 100644 (file)
   line-height: 1.5;
 }
 
-.cut-margins > *:first-child {
+.cut-margins > *:first-child,
+.cut-margins > .markdown-content > *:first-child {
   margin-top: 0 !important;
 }
 
-.cut-margins > *:last-child {
+.cut-margins > *:last-child,
+.cut-margins > .markdown-content > *:last-child {
   margin-bottom: 0 !important;
 }
 
index 923be34ed2ecbfc6ba58742f09d08344b1168ff7..790e04b8ee632874c81f86f77d4b43c9061b0ac5 100644 (file)
@@ -100,6 +100,7 @@ export default class App extends React.PureComponent<Props> {
                 className="documentation-content cut-margins boxed-group-inner"
                 content={page.content}
                 displayH1={true}
+                stickyToc={true}
               />
             </div>
           </div>
index 49ada2d46a1e042125e44867ee0a6e27580105c8..a2a805fde199da5f28aea728feb87ceb586eebef 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import SearchResultEntry, {
+  SearchResult,
   SearchResultText,
   SearchResultTitle,
   SearchResultTokens
@@ -86,7 +87,7 @@ describe('SearchResultTokens', () => {
   });
 });
 
-function mockSearchResult(overrides = {}) {
+function mockSearchResult(overrides: Partial<SearchResult> = {}) {
   return {
     page: {
       content: '',
index 39fd9f7cbe8f54b571eecb02fdf72451964cd1c5..9d443ce281e8f258c01138d6f29f73fcf82952b5 100644 (file)
 }
 
 .documentation-content.markdown {
+  position: relative;
   font-size: 16px;
   line-height: 1.7;
 }
 
-.documentation-content.markdown > h1 {
+.documentation-content.markdown .documentation-title {
   font-size: 24px;
   padding-top: var(--gridSize);
   margin-bottom: 2em;
 .documentation-content.markdown .collapse-container *:last-child {
   margin-bottom: 0;
 }
+
+.markdown.has-toc {
+  display: flex;
+  padding-right: var(--gridSize);
+}
+
+.markdown.has-toc .markdown-content {
+  flex-shrink: 1;
+}
+
+.markdown-toc {
+  flex: 0 0 240px;
+}
+
+.markdown-toc-content {
+  margin-left: calc(4 * var(--gridSize));
+  padding: 0 var(--gridSize);
+  font-size: var(--baseFontSize);
+  background: white;
+  position: sticky;
+  top: calc(20px + var(--globalNavHeight));
+}
+
+.markdown-toc-content h4 {
+  margin: 0 var(--gridSize) var(--gridSize) var(--gridSize);
+  font-size: var(--mediumFontSize);
+}
+
+.markdown-toc-content a {
+  display: block;
+  color: var(--sonarcloudBlack900);
+  padding: calc(var(--gridSize) / 2) var(--gridSize);
+  border: 1px solid white;
+  line-height: 1.2;
+  transition: none;
+}
+
+.markdown-toc a:hover {
+  border-color: var(--blue);
+}
+
+.markdown-toc a.active {
+  font-weight: bold;
+}
index 5033e87a6ca02b0fe6bba226e39aff864f7a10b7..9c97be579211a6851ef9af127389d7bca793be81 100644 (file)
@@ -20,7 +20,7 @@
 import * as React from 'react';
 import { mount } from 'enzyme';
 import ScreenPositionFixer from '../ScreenPositionFixer';
-import { resizeWindowTo } from '../../../helpers/testUtils';
+import { resizeWindowTo, setNodeRect } from '../../../helpers/testUtils';
 
 jest.mock('lodash', () => {
   const lodash = require.requireActual('lodash');
@@ -33,7 +33,7 @@ jest.mock('react-dom', () => ({
 }));
 
 beforeEach(() => {
-  setNodeRect({ width: 50, height: 50, left: 50, top: 50 });
+  setNodeRect({ left: 50, top: 50 });
   resizeWindowTo(1000, 1000);
 });
 
@@ -41,18 +41,18 @@ it('should fix position', () => {
   const renderer = jest.fn(() => <div />);
   mount(<ScreenPositionFixer>{renderer}</ScreenPositionFixer>);
 
-  setNodeRect({ width: 50, height: 50, left: 50, top: 50 });
+  setNodeRect({ left: 50, top: 50 });
   resizeWindowTo(75, 1000);
   expect(renderer).toHaveBeenLastCalledWith({ leftFix: -29, topFix: 0 });
 
   resizeWindowTo(1000, 75);
   expect(renderer).toHaveBeenLastCalledWith({ leftFix: 0, topFix: -29 });
 
-  setNodeRect({ width: 50, height: 50, left: -10, top: 50 });
+  setNodeRect({ left: -10, top: 50 });
   resizeWindowTo(1000, 1000);
   expect(renderer).toHaveBeenLastCalledWith({ leftFix: 14, topFix: 0 });
 
-  setNodeRect({ width: 50, height: 50, left: 50, top: -10 });
+  setNodeRect({ left: 50, top: -10 });
   resizeWindowTo();
   expect(renderer).toHaveBeenLastCalledWith({ leftFix: 0, topFix: 14 });
 });
@@ -87,10 +87,3 @@ it('should re-position when window is resized', () => {
   resizeWindowTo();
   expect(renderer).toHaveBeenCalledTimes(3);
 });
-
-function setNodeRect(rect: { width: number; height: number; left: number; top: number }) {
-  const findDOMNode = require('react-dom').findDOMNode as jest.Mock<any>;
-  const element = document.createElement('div');
-  Object.defineProperty(element, 'getBoundingClientRect', { value: () => rect });
-  findDOMNode.mockReturnValue(element);
-}
index 1ba88fbd14a71de86f0e7f4476f4b3c8454fb8d9..389b799d863e383d4ffb420c243f9450445318c6 100644 (file)
@@ -21,9 +21,11 @@ import * as React from 'react';
 import * as classNames from 'classnames';
 import remark from 'remark';
 import reactRenderer from 'remark-react';
+import slug from 'remark-slug';
 import remarkCustomBlocks from 'remark-custom-blocks';
 import DocLink from './DocLink';
 import DocImg from './DocImg';
+import DocToc from './DocToc';
 import DocTooltipLink from './DocTooltipLink';
 import remarkToc from './plugins/remark-toc';
 import DocCollapsibleBlock from './DocCollapsibleBlock';
@@ -36,6 +38,7 @@ interface Props {
   content: string | undefined;
   displayH1?: boolean;
   isTooltip?: boolean;
+  stickyToc?: boolean;
 }
 
 export default class DocMarkdownBlock extends React.PureComponent<Props> {
@@ -43,46 +46,62 @@ export default class DocMarkdownBlock extends React.PureComponent<Props> {
 
   handleAnchorClick = (href: string, event: React.MouseEvent<HTMLAnchorElement>) => {
     if (this.node) {
-      const element = this.node.querySelector(`#${href.substr(1)}`);
+      const element = this.node.querySelector(href);
       if (element) {
         event.preventDefault();
         scrollToElement(element, { bottomOffset: window.innerHeight - 80 });
+        if (history.pushState) {
+          history.pushState(null, '', href);
+        }
       }
     }
   };
 
   render() {
-    const { childProps, content, className, displayH1, isTooltip } = this.props;
+    const { childProps, content, className, displayH1, stickyToc, isTooltip } = this.props;
     const parsed = separateFrontMatter(content || '');
+    let filteredContent = filterContent(parsed.content);
+    const tocContent = filteredContent;
+    const md = remark();
+
+    // TODO find a way to replace these custom blocks with real Alert components
+    md.use(remarkCustomBlocks, {
+      danger: { classes: 'alert alert-danger' },
+      warning: { classes: 'alert alert-warning' },
+      info: { classes: 'alert alert-info' },
+      success: { classes: 'alert alert-success' },
+      collapse: { classes: 'collapse' }
+    })
+      .use(reactRenderer, {
+        remarkReactComponents: {
+          div: Block,
+          // use custom link to render documentation anchors
+          a: isTooltip
+            ? withChildProps(DocTooltipLink, childProps)
+            : withChildProps(DocLink, { onAnchorClick: this.handleAnchorClick }),
+          // use custom img tag to render documentation images
+          img: DocImg
+        },
+        toHast: {},
+        sanitize: false
+      })
+      .use(slug);
+
+    if (stickyToc) {
+      filteredContent = filteredContent.replace(/#*\s*(toc|table[ -]of[ -]contents?).*/i, '');
+    } else {
+      md.use(remarkToc, { maxDepth: 3 });
+    }
+
     return (
-      <div className={classNames('markdown', className)} ref={ref => (this.node = ref)}>
-        {displayH1 && <h1>{parsed.frontmatter.title}</h1>}
-        {
-          remark()
-            .use(remarkToc, { maxDepth: 3 })
-            // TODO find a way to replace these custom blocks with real Alert components
-            .use(remarkCustomBlocks, {
-              danger: { classes: 'alert alert-danger' },
-              warning: { classes: 'alert alert-warning' },
-              info: { classes: 'alert alert-info' },
-              success: { classes: 'alert alert-success' },
-              collapse: { classes: 'collapse' }
-            })
-            .use(reactRenderer, {
-              remarkReactComponents: {
-                div: Block,
-                // use custom link to render documentation anchors
-                a: isTooltip
-                  ? withChildProps(DocTooltipLink, childProps)
-                  : withChildProps(DocLink, { onAnchorClick: this.handleAnchorClick }),
-                // use custom img tag to render documentation images
-                img: DocImg
-              },
-              toHast: {},
-              sanitize: false
-            })
-            .processSync(filterContent(parsed.content)).contents
-        }
+      <div
+        className={classNames('markdown', className, { 'has-toc': stickyToc })}
+        ref={ref => (this.node = ref)}>
+        <div className="markdown-content">
+          {displayH1 && <h1 className="documentation-title">{parsed.frontmatter.title}</h1>}
+          {md.processSync(filteredContent).contents}
+        </div>
+        {stickyToc && <DocToc content={tocContent} onAnchorClick={this.handleAnchorClick} />}
       </div>
     );
   }
diff --git a/server/sonar-web/src/main/js/components/docs/DocToc.tsx b/server/sonar-web/src/main/js/components/docs/DocToc.tsx
new file mode 100644 (file)
index 0000000..6d1aed8
--- /dev/null
@@ -0,0 +1,155 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * 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 React from 'react';
+import remark from 'remark';
+import reactRenderer from 'remark-react';
+import { findDOMNode } from 'react-dom';
+import * as classNames from 'classnames';
+import { debounce, memoize } from 'lodash';
+import onlyToc from './plugins/remark-only-toc';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+  content: string;
+  onAnchorClick: (href: string, event: React.MouseEvent<HTMLAnchorElement>) => void;
+}
+
+interface State {
+  anchors: AnchorObject[];
+  highlightAnchor?: string;
+}
+
+interface AnchorObject {
+  href: string;
+  title: string;
+}
+
+export default class DocToc extends React.PureComponent<Props, State> {
+  debouncedScrollHandler: () => void;
+
+  node: HTMLDivElement | null = null;
+
+  state: State = { anchors: [] };
+
+  static getAnchors = memoize(content => {
+    const file: { contents: JSX.Element } = remark()
+      .use(reactRenderer)
+      .use(onlyToc)
+      .processSync(content);
+
+    if (file && file.contents.props.children) {
+      let list = file.contents;
+      let limit = 10;
+      while (limit && list.props.children.length && list.type !== 'ul') {
+        list = list.props.children[0];
+        limit--;
+      }
+
+      if (list.type === 'ul' && list.props.children.length) {
+        return list.props.children
+          .map((li: JSX.Element | string) => {
+            if (typeof li === 'string') {
+              return null;
+            }
+
+            const anchor = li.props.children[0];
+            return {
+              href: anchor.props.href,
+              title: anchor.props.children[0]
+            } as AnchorObject;
+          })
+          .filter((item: AnchorObject | null) => item);
+      }
+    }
+    return [];
+  });
+
+  static getDerivedStateFromProps(props: Props) {
+    const { content } = props;
+    return { anchors: DocToc.getAnchors(content) };
+  }
+
+  constructor(props: Props) {
+    super(props);
+    this.debouncedScrollHandler = debounce(this.scrollHandler);
+  }
+
+  componentDidMount() {
+    window.addEventListener('scroll', this.debouncedScrollHandler, true);
+    this.scrollHandler();
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('scroll', this.debouncedScrollHandler, true);
+  }
+
+  scrollHandler = () => {
+    // eslint-disable-next-line react/no-find-dom-node
+    const node = findDOMNode(this) as HTMLElement;
+
+    if (!node || !node.parentNode) {
+      return;
+    }
+
+    const headings: NodeListOf<HTMLHeadingElement> = node.parentNode.querySelectorAll('h2[id]');
+    const scrollTop = window.pageYOffset || document.body.scrollTop;
+    let highlightAnchor;
+
+    for (let i = 0, len = headings.length; i < len; i++) {
+      if (headings.item(i).offsetTop > scrollTop + 120) {
+        break;
+      }
+      highlightAnchor = `#${headings.item(i).id}`;
+    }
+
+    this.setState({
+      highlightAnchor
+    });
+  };
+
+  render() {
+    const { anchors, highlightAnchor } = this.state;
+
+    if (anchors.length === 0) {
+      return null;
+    }
+
+    return (
+      <div className="markdown-toc">
+        <div className="markdown-toc-content">
+          <h4>{translate('documentation.on_this_page')}</h4>
+          {anchors.map(anchor => {
+            return (
+              <a
+                className={classNames({ active: highlightAnchor === anchor.href })}
+                href={anchor.href}
+                key={anchor.title}
+                onClick={event => {
+                  this.props.onAnchorClick(anchor.href, event);
+                }}>
+                {anchor.title}
+              </a>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+}
index 26b0adea790d711cfabfc84b76411b09e381f3e5..b7574befa1c5611db121b28ac16d7a36e150cb68 100644 (file)
@@ -22,7 +22,29 @@ import { shallow } from 'enzyme';
 import DocMarkdownBlock from '../DocMarkdownBlock';
 import { isSonarCloud } from '../../../helpers/system';
 
-// mock `remark` and `remark-react` to work around the issue with cjs imports
+const CONTENT_WITH_TOC = `
+## Table of Contents
+
+## Lorem ipsum
+
+Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+
+## Sit amet
+
+### Maecenas diam
+
+Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
+
+### Integer
+
+At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
+
+## Nam blandit 
+
+Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+`;
+
+// mock `remark` & co to work around the issue with cjs imports
 jest.mock('remark', () => {
   const remark = require.requireActual('remark');
   return { default: remark };
@@ -33,18 +55,23 @@ jest.mock('remark-react', () => {
   return { default: remarkReact };
 });
 
+jest.mock('remark-slug', () => {
+  const remarkSlug = require.requireActual('remark-slug');
+  return { default: remarkSlug };
+});
+
 jest.mock('../../../helpers/system', () => ({
   getInstance: jest.fn(),
   isSonarCloud: jest.fn()
 }));
 
 it('should render simple markdown', () => {
-  expect(shallow(<DocMarkdownBlock content="this is *bold* text" />)).toMatchSnapshot();
+  expect(shallowRender({ content: 'this is *bold* text' })).toMatchSnapshot();
 });
 
 it('should use custom component for links', () => {
   expect(
-    shallow(<DocMarkdownBlock content="some [link](/quality-profiles)" />).find('withChildProps')
+    shallowRender({ content: 'some [link](/quality-profiles)' }).find('withChildProps')
   ).toMatchSnapshot();
 });
 
@@ -73,20 +100,34 @@ static
 text`;
 
   (isSonarCloud as jest.Mock).mockImplementation(() => false);
-  expect(shallow(<DocMarkdownBlock content={content} />)).toMatchSnapshot();
+  expect(shallowRender({ content })).toMatchSnapshot();
 
   (isSonarCloud as jest.Mock).mockImplementation(() => true);
-  expect(shallow(<DocMarkdownBlock content={content} />)).toMatchSnapshot();
+  expect(shallowRender({ content })).toMatchSnapshot();
 });
 
 it('should render with custom props for links', () => {
   expect(
-    shallow(
-      <DocMarkdownBlock
-        childProps={{ foo: 'bar' }}
-        content="some [link](#quality-profiles)"
-        isTooltip={true}
-      />
-    ).find('withChildProps')
+    shallowRender({
+      childProps: { foo: 'bar' },
+      content: 'some [link](#quality-profiles)',
+      isTooltip: true
+    }).find('withChildProps')
   ).toMatchSnapshot();
 });
+
+it('should render a TOC if available', () => {
+  const wrapper = shallowRender({ content: CONTENT_WITH_TOC });
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('ul').exists()).toBe(true);
+});
+
+it('should render a sticky TOC if available', () => {
+  const wrapper = shallowRender({ content: CONTENT_WITH_TOC, stickyToc: true });
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('DocToc').exists()).toBe(true);
+});
+
+function shallowRender(props: Partial<DocMarkdownBlock['props']> = {}) {
+  return shallow(<DocMarkdownBlock content="" {...props} />);
+}
diff --git a/server/sonar-web/src/main/js/components/docs/__tests__/DocToc-test.tsx b/server/sonar-web/src/main/js/components/docs/__tests__/DocToc-test.tsx
new file mode 100644 (file)
index 0000000..ce8f393
--- /dev/null
@@ -0,0 +1,131 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * 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 React from 'react';
+import { mount } from 'enzyme';
+import DocToc from '../DocToc';
+import { click, scrollTo } from '../../../helpers/testUtils';
+
+const OFFSET = 300;
+
+const CONTENT_NO_TOC = `
+## Lorem ipsum
+
+Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+
+## Sit amet
+
+### Maecenas diam
+
+Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
+
+### Integer
+
+At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
+
+## Nam blandit 
+
+Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+`;
+
+const CONTENT_WITH_TOC = `
+## toc
+
+${CONTENT_NO_TOC}
+`;
+
+jest.mock('remark', () => {
+  const remark = require.requireActual('remark');
+  return { default: remark };
+});
+
+jest.mock('remark-react', () => {
+  const remarkReact = require.requireActual('remark-react');
+  return { default: remarkReact };
+});
+
+jest.mock('lodash', () => {
+  const lodash = require.requireActual('lodash');
+  lodash.debounce = (fn: any) => fn;
+  return lodash;
+});
+
+jest.mock('react-dom', () => ({
+  findDOMNode: jest.fn()
+}));
+
+it('should render correctly', () => {
+  const wrapper = renderComponent();
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should render correctly if no TOC is available', () => {
+  const wrapper = renderComponent({ content: CONTENT_NO_TOC });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should trigger the handler when an anchor is clicked', () => {
+  const onAnchorClick = jest.fn();
+  const wrapper = renderComponent({ onAnchorClick });
+  click(wrapper.find('a[href="#sit-amet"]'));
+  expect(onAnchorClick).toBeCalled();
+});
+
+it('should highlight anchors when scrolling', () => {
+  mockDomEnv();
+  const wrapper = renderComponent();
+
+  scrollTo({ top: OFFSET });
+  expect(wrapper.state('highlightAnchor')).toEqual('#lorem-ipsum');
+
+  scrollTo({ top: OFFSET * 3 });
+  expect(wrapper.state('highlightAnchor')).toEqual('#nam-blandit');
+});
+
+function renderComponent(props: Partial<DocToc['props']> = {}) {
+  return mount(<DocToc content={CONTENT_WITH_TOC} onAnchorClick={jest.fn()} {...props} />);
+}
+
+function mockDomEnv() {
+  const findDOMNode = require('react-dom').findDOMNode as jest.Mock<any>;
+  const parent = document.createElement('div');
+  const element = document.createElement('div');
+  parent.appendChild(element);
+
+  let offset = OFFSET;
+  (CONTENT_WITH_TOC.match(/^## .+$/gm) as Array<string>).forEach(match => {
+    if (/toc/.test(match)) {
+      return;
+    }
+
+    const slug = match
+      .replace(/^#+ */, '')
+      .replace(' ', '-')
+      .toLowerCase()
+      .trim();
+    const heading = document.createElement('h2');
+    heading.id = slug;
+    Object.defineProperty(heading, 'offsetTop', { value: offset });
+    offset += OFFSET;
+
+    parent.appendChild(heading);
+  });
+
+  findDOMNode.mockReturnValue(element);
+}
index 2245cd2300a15c055f19fdc3fe2ee6812396611d..90d7b35cab40c29d5bb8bb070643d1cecfac9e48 100644 (file)
@@ -4,43 +4,47 @@ exports[`should cut sonarqube/sonarcloud/static content 1`] = `
 <div
   className="markdown"
 >
-  <Block
-    key="h-1"
+  <div
+    className="markdown-content"
   >
-    <p
-      key="h-2"
+    <Block
+      key="h-1"
     >
-      some
-    </p>
-    
+      <p
+        key="h-2"
+      >
+        some
+      </p>
+      
 
-    <p
-      key="h-3"
-    >
-      sonarqube
-    </p>
-    
+      <p
+        key="h-3"
+      >
+        sonarqube
+      </p>
+      
 
-    <p
-      key="h-4"
-    >
-        long
-    </p>
-    
+      <p
+        key="h-4"
+      >
+          long
+      </p>
+      
 
-    <p
-      key="h-5"
-    >
-        multiline
-    </p>
-    
+      <p
+        key="h-5"
+      >
+          multiline
+      </p>
+      
 
-    <p
-      key="h-6"
-    >
-      text
-    </p>
-  </Block>
+      <p
+        key="h-6"
+      >
+        text
+      </p>
+    </Block>
+  </div>
 </div>
 `;
 
@@ -48,29 +52,311 @@ exports[`should cut sonarqube/sonarcloud/static content 2`] = `
 <div
   className="markdown"
 >
-  <Block
-    key="h-1"
+  <div
+    className="markdown-content"
   >
-    <p
-      key="h-2"
+    <Block
+      key="h-1"
     >
-      some
-    </p>
-    
+      <p
+        key="h-2"
+      >
+        some
+      </p>
+      
 
-    <p
-      key="h-3"
+      <p
+        key="h-3"
+      >
+        sonarcloud
+      </p>
+      
+
+      <p
+        key="h-4"
+      >
+        text
+      </p>
+    </Block>
+  </div>
+</div>
+`;
+
+exports[`should render a TOC if available 1`] = `
+<div
+  className="markdown"
+>
+  <div
+    className="markdown-content"
+  >
+    <Block
+      key="h-1"
     >
-      sonarcloud
-    </p>
-    
+      <h2
+        id="table-of-contents"
+        key="h-2"
+      >
+        Table of Contents
+      </h2>
+      
+
+      <ul
+        key="h-3"
+      >
+        
+
+        <li
+          key="h-4"
+        >
+          <withChildProps
+            href="#lorem-ipsum"
+            key="h-5"
+          >
+            Lorem ipsum
+          </withChildProps>
+        </li>
+        
+
+        <li
+          key="h-6"
+        >
+          
+
+          <p
+            key="h-7"
+          >
+            <withChildProps
+              href="#sit-amet"
+              key="h-8"
+            >
+              Sit amet
+            </withChildProps>
+          </p>
+          
+
+          <ul
+            key="h-9"
+          >
+            
+
+            <li
+              key="h-10"
+            >
+              <withChildProps
+                href="#maecenas-diam"
+                key="h-11"
+              >
+                Maecenas diam
+              </withChildProps>
+            </li>
+            
+
+            <li
+              key="h-12"
+            >
+              <withChildProps
+                href="#integer"
+                key="h-13"
+              >
+                Integer
+              </withChildProps>
+            </li>
+            
+
+          </ul>
+          
+
+        </li>
+        
+
+        <li
+          key="h-14"
+        >
+          <withChildProps
+            href="#nam-blandit"
+            key="h-15"
+          >
+            Nam blandit
+          </withChildProps>
+        </li>
+        
+
+      </ul>
+      
+
+      <h2
+        id="lorem-ipsum"
+        key="h-16"
+      >
+        Lorem ipsum
+      </h2>
+      
+
+      <p
+        key="h-17"
+      >
+        Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+      </p>
+      
+
+      <h2
+        id="sit-amet"
+        key="h-18"
+      >
+        Sit amet
+      </h2>
+      
+
+      <h3
+        id="maecenas-diam"
+        key="h-19"
+      >
+        Maecenas diam
+      </h3>
+      
 
-    <p
-      key="h-4"
+      <p
+        key="h-20"
+      >
+        Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
+      </p>
+      
+
+      <h3
+        id="integer"
+        key="h-21"
+      >
+        Integer
+      </h3>
+      
+
+      <p
+        key="h-22"
+      >
+        At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
+      </p>
+      
+
+      <h2
+        id="nam-blandit"
+        key="h-23"
+      >
+        Nam blandit
+      </h2>
+      
+
+      <p
+        key="h-24"
+      >
+        Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+      </p>
+    </Block>
+  </div>
+</div>
+`;
+
+exports[`should render a sticky TOC if available 1`] = `
+<div
+  className="markdown has-toc"
+>
+  <div
+    className="markdown-content"
+  >
+    <Block
+      key="h-1"
     >
-      text
-    </p>
-  </Block>
+      <h2
+        id="lorem-ipsum"
+        key="h-2"
+      >
+        Lorem ipsum
+      </h2>
+      
+
+      <p
+        key="h-3"
+      >
+        Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+      </p>
+      
+
+      <h2
+        id="sit-amet"
+        key="h-4"
+      >
+        Sit amet
+      </h2>
+      
+
+      <h3
+        id="maecenas-diam"
+        key="h-5"
+      >
+        Maecenas diam
+      </h3>
+      
+
+      <p
+        key="h-6"
+      >
+        Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
+      </p>
+      
+
+      <h3
+        id="integer"
+        key="h-7"
+      >
+        Integer
+      </h3>
+      
+
+      <p
+        key="h-8"
+      >
+        At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
+      </p>
+      
+
+      <h2
+        id="nam-blandit"
+        key="h-9"
+      >
+        Nam blandit
+      </h2>
+      
+
+      <p
+        key="h-10"
+      >
+        Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+      </p>
+    </Block>
+  </div>
+  <DocToc
+    content="
+## Table of Contents
+
+## Lorem ipsum
+
+Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+
+## Sit amet
+
+### Maecenas diam
+
+Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
+
+### Integer
+
+At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
+
+## Nam blandit 
+
+Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+"
+    onAnchorClick={[Function]}
+  />
 </div>
 `;
 
@@ -78,21 +364,25 @@ exports[`should render simple markdown 1`] = `
 <div
   className="markdown"
 >
-  <Block
-    key="h-1"
+  <div
+    className="markdown-content"
   >
-    <p
-      key="h-2"
+    <Block
+      key="h-1"
     >
-      this is 
-      <em
-        key="h-3"
+      <p
+        key="h-2"
       >
-        bold
-      </em>
-       text
-    </p>
-  </Block>
+        this is 
+        <em
+          key="h-3"
+        >
+          bold
+        </em>
+         text
+      </p>
+    </Block>
+  </div>
 </div>
 `;
 
diff --git a/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocToc-test.tsx.snap b/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocToc-test.tsx.snap
new file mode 100644 (file)
index 0000000..c89f2c0
--- /dev/null
@@ -0,0 +1,91 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<DocToc
+  content="
+## toc
+
+
+## Lorem ipsum
+
+Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+
+## Sit amet
+
+### Maecenas diam
+
+Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
+
+### Integer
+
+At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
+
+## Nam blandit 
+
+Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+
+"
+  onAnchorClick={[MockFunction]}
+>
+  <div
+    className="markdown-toc"
+  >
+    <div
+      className="markdown-toc-content"
+    >
+      <h4>
+        documentation.on_this_page
+      </h4>
+      <a
+        className=""
+        href="#lorem-ipsum"
+        key="Lorem ipsum"
+        onClick={[Function]}
+      >
+        Lorem ipsum
+      </a>
+      <a
+        className=""
+        href="#sit-amet"
+        key="Sit amet"
+        onClick={[Function]}
+      >
+        Sit amet
+      </a>
+      <a
+        className=""
+        href="#nam-blandit"
+        key="Nam blandit"
+        onClick={[Function]}
+      >
+        Nam blandit
+      </a>
+    </div>
+  </div>
+</DocToc>
+`;
+
+exports[`should render correctly if no TOC is available 1`] = `
+<DocToc
+  content="
+## Lorem ipsum
+
+Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+
+## Sit amet
+
+### Maecenas diam
+
+Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
+
+### Integer
+
+At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
+
+## Nam blandit 
+
+Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
+"
+  onAnchorClick={[MockFunction]}
+/>
+`;
diff --git a/server/sonar-web/src/main/js/components/docs/plugins/remark-only-toc.js b/server/sonar-web/src/main/js/components/docs/plugins/remark-only-toc.js
new file mode 100644 (file)
index 0000000..7a63b30
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import slug from 'remark-slug';
+import util from 'mdast-util-toc';
+
+/**
+ * This is a simplified version of the remark-toc plugin: https://github.com/remarkjs/remark-toc
+ * It *only* renders the TOC, and leaves all the rest out.
+ */
+export default function onlyToc() {
+  this.use(slug);
+
+  return transformer;
+
+  function transformer(node) {
+    const result = util(node, { heading: 'toc|table[ -]of[ -]contents?', maxDepth: 2 });
+
+    if (result.index === null || result.index === -1 || !result.map) {
+      node.children = [];
+    } else {
+      node.children = [result.map];
+    }
+  }
+}
index c2cac596d28c3dc5a534fbaecd49e92d72fc91ba..fdf8bf0728ba54fe0d84201645695079367fb344 100644 (file)
@@ -101,6 +101,22 @@ export function resizeWindowTo(width?: number, height?: number) {
   window.dispatchEvent(resizeEvent);
 }
 
+export function scrollTo({ left = 0, top = 0 }) {
+  Object.defineProperty(window, 'pageYOffset', { value: top });
+  Object.defineProperty(window, 'pageXOffset', { value: left });
+  const resizeEvent = new Event('scroll');
+  window.dispatchEvent(resizeEvent);
+}
+
+export function setNodeRect({ width = 50, height = 50, left = 0, top = 0 }) {
+  const { findDOMNode } = require('react-dom');
+  const element = document.createElement('div');
+  Object.defineProperty(element, 'getBoundingClientRect', {
+    value: () => ({ width, height, left, top })
+  });
+  findDOMNode.mockReturnValue(element);
+}
+
 export function doAsync(fn?: Function): Promise<void> {
   return new Promise(resolve => {
     setImmediate(() => {
index b07d58a4d21ee5e615b4bc22d146b3e777c0125b..c33b6890392408f9e5657b42e69097fe92e9347b 100644 (file)
@@ -2553,6 +2553,7 @@ api_documentation.search=Search by name...
 #------------------------------------------------------------------------------
 documentation.page=Documentation
 documentation.page_title=SonarCloud Docs
+documentation.on_this_page=On this page
 
 
 #------------------------------------------------------------------------------
@@ -2687,8 +2688,7 @@ organization.members.add_to_members=Add to members
 organization.paid_plan.badge=Paid plan
 organization.default_visibility_of_new_projects=Default visibility of new projects:
 organization.change_visibility_form.header=Set Default Visibility of New Projects
-organization.change_visibility_form.warning=This will not change the visibility of already existing projects.
-organization.change_visibility_form.submit=Change Default Visibility
+organization.change_visibility_form.warning=This will not change the visibility of already existing projects.organization.change_visibility_form.submit=Change Default Visibility
 
 
 #------------------------------------------------------------------------------