aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2019-01-02 09:10:08 +0100
committerSonarTech <sonartech@sonarsource.com>2019-01-10 20:21:02 +0100
commitb7a61c44500dcfe64d82346514eb2313dfa312e8 (patch)
treed2cf6ec8c587f03fddbdce3a040017f454f7d281
parentc1bf65143fda35b0428b4995f6edaaab1b8b006c (diff)
downloadsonarqube-b7a61c44500dcfe64d82346514eb2313dfa312e8.tar.gz
sonarqube-b7a61c44500dcfe64d82346514eb2313dfa312e8.zip
SONAR-11282 Enhance embedded docs navigation sidebar
-rw-r--r--server/sonar-web/src/main/js/@types/remark-slug.d.ts22
-rw-r--r--server/sonar-web/src/main/js/app/styles/style.css6
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/App.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResultEntry-test.tsx3
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/styles.css47
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/ScreenPositionFixer-test.tsx17
-rw-r--r--server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx79
-rw-r--r--server/sonar-web/src/main/js/components/docs/DocToc.tsx155
-rw-r--r--server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx65
-rw-r--r--server/sonar-web/src/main/js/components/docs/__tests__/DocToc-test.tsx131
-rw-r--r--server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap410
-rw-r--r--server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocToc-test.tsx.snap91
-rw-r--r--server/sonar-web/src/main/js/components/docs/plugins/remark-only-toc.js41
-rw-r--r--server/sonar-web/src/main/js/helpers/testUtils.ts16
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties4
15 files changed, 968 insertions, 120 deletions
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
index 00000000000..59650669f87
--- /dev/null
+++ b/server/sonar-web/src/main/js/@types/remark-slug.d.ts
@@ -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;
+}
diff --git a/server/sonar-web/src/main/js/app/styles/style.css b/server/sonar-web/src/main/js/app/styles/style.css
index f17b7ee3a9a..a41005d2dc3 100644
--- a/server/sonar-web/src/main/js/app/styles/style.css
+++ b/server/sonar-web/src/main/js/app/styles/style.css
@@ -65,11 +65,13 @@
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;
}
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 923be34ed2e..790e04b8ee6 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
@@ -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>
diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResultEntry-test.tsx b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResultEntry-test.tsx
index 49ada2d46a1..a2a805fde19 100644
--- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResultEntry-test.tsx
+++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResultEntry-test.tsx
@@ -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: '',
diff --git a/server/sonar-web/src/main/js/apps/documentation/styles.css b/server/sonar-web/src/main/js/apps/documentation/styles.css
index 39fd9f7cbe8..9d443ce281e 100644
--- a/server/sonar-web/src/main/js/apps/documentation/styles.css
+++ b/server/sonar-web/src/main/js/apps/documentation/styles.css
@@ -31,11 +31,12 @@
}
.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;
@@ -116,3 +117,47 @@
.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;
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ScreenPositionFixer-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ScreenPositionFixer-test.tsx
index 5033e87a6ca..9c97be57921 100644
--- a/server/sonar-web/src/main/js/components/controls/__tests__/ScreenPositionFixer-test.tsx
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/ScreenPositionFixer-test.tsx
@@ -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);
-}
diff --git a/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx b/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx
index 1ba88fbd14a..389b799d863 100644
--- a/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx
+++ b/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx
@@ -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
index 00000000000..6d1aed8b1f3
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/docs/DocToc.tsx
@@ -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>
+ );
+ }
+}
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 26b0adea790..b7574befa1c 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
@@ -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
index 00000000000..ce8f39319f2
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/docs/__tests__/DocToc-test.tsx
@@ -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);
+}
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 2245cd2300a..90d7b35cab4 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
@@ -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
index 00000000000..c89f2c0a0d3
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocToc-test.tsx.snap
@@ -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
index 00000000000..7a63b30bebf
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/docs/plugins/remark-only-toc.js
@@ -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];
+ }
+ }
+}
diff --git a/server/sonar-web/src/main/js/helpers/testUtils.ts b/server/sonar-web/src/main/js/helpers/testUtils.ts
index c2cac596d28..fdf8bf0728b 100644
--- a/server/sonar-web/src/main/js/helpers/testUtils.ts
+++ b/server/sonar-web/src/main/js/helpers/testUtils.ts
@@ -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(() => {
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 b07d58a4d21..c33b6890392 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -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
#------------------------------------------------------------------------------