From: Wouter Admiraal Date: Wed, 2 Jan 2019 08:10:08 +0000 (+0100) Subject: SONAR-11282 Enhance embedded docs navigation sidebar X-Git-Tag: 7.6~142 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=b7a61c44500dcfe64d82346514eb2313dfa312e8;p=sonarqube.git SONAR-11282 Enhance embedded docs navigation sidebar --- 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 { className="documentation-content cut-margins boxed-group-inner" content={page.content} displayH1={true} + stickyToc={true} /> 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 = {}) { 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(() =>
); mount({renderer}); - 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; - 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 { @@ -43,46 +46,62 @@ export default class DocMarkdownBlock extends React.PureComponent { handleAnchorClick = (href: string, event: React.MouseEvent) => { 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 ( -
(this.node = ref)}> - {displayH1 &&

{parsed.frontmatter.title}

} - { - 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 - } +
(this.node = ref)}> +
+ {displayH1 &&

{parsed.frontmatter.title}

} + {md.processSync(filteredContent).contents} +
+ {stickyToc && }
); } 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) => void; +} + +interface State { + anchors: AnchorObject[]; + highlightAnchor?: string; +} + +interface AnchorObject { + href: string; + title: string; +} + +export default class DocToc extends React.PureComponent { + 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 = 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 ( +
+
+

{translate('documentation.on_this_page')}

+ {anchors.map(anchor => { + return ( + { + this.props.onAnchorClick(anchor.href, event); + }}> + {anchor.title} + + ); + })} +
+
+ ); + } +} 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()).toMatchSnapshot(); + expect(shallowRender({ content: 'this is *bold* text' })).toMatchSnapshot(); }); it('should use custom component for links', () => { expect( - shallow().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()).toMatchSnapshot(); + expect(shallowRender({ content })).toMatchSnapshot(); (isSonarCloud as jest.Mock).mockImplementation(() => true); - expect(shallow()).toMatchSnapshot(); + expect(shallowRender({ content })).toMatchSnapshot(); }); it('should render with custom props for links', () => { expect( - shallow( - - ).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 = {}) { + return shallow(); +} 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 = {}) { + return mount(); +} + +function mockDomEnv() { + const findDOMNode = require('react-dom').findDOMNode as jest.Mock; + const parent = document.createElement('div'); + const element = document.createElement('div'); + parent.appendChild(element); + + let offset = OFFSET; + (CONTENT_WITH_TOC.match(/^## .+$/gm) as Array).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`] = `
- -

- some -

- +

+ some +

+ -

- sonarqube -

- +

+ sonarqube +

+ -

- long -

- +

+ long +

+ -

- multiline -

- +

+ multiline +

+ -

- text -

-
+

+ text +

+ +
`; @@ -48,29 +52,311 @@ exports[`should cut sonarqube/sonarcloud/static content 2`] = `
- -

- some -

- +

+ some +

+ -

+ sonarcloud +

+ + +

+ text +

+
+
+
+`; + +exports[`should render a TOC if available 1`] = ` +
+
+ - sonarcloud -

- +

+ Table of Contents +

+ + +
    + + +
  • + + Lorem ipsum + +
  • + + +
  • + + +

    + + Sit amet + +

    + + +
      + + +
    • + + Maecenas diam + +
    • + + +
    • + + Integer + +
    • + + +
    + + +
  • + + +
  • + + Nam blandit + +
  • + + +
+ + +

+ 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. +

+
+
+
+`; + +exports[`should render a sticky TOC if available 1`] = ` +
+
+ - text -

-
+

+ 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. +

+ +
+
`; @@ -78,21 +364,25 @@ exports[`should render simple markdown 1`] = `
- -

- this is - - bold - - text -

-
+ this is + + bold + + text +

+ +
`; 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`] = ` + +
+
+

+ documentation.on_this_page +

+ + Lorem ipsum + + + Sit amet + + + Nam blandit + +
+
+
+`; + +exports[`should render correctly if no TOC is available 1`] = ` + +`; 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 { 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 #------------------------------------------------------------------------------