diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2019-01-02 09:10:08 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-01-10 20:21:02 +0100 |
commit | b7a61c44500dcfe64d82346514eb2313dfa312e8 (patch) | |
tree | d2cf6ec8c587f03fddbdce3a040017f454f7d281 | |
parent | c1bf65143fda35b0428b4995f6edaaab1b8b006c (diff) | |
download | sonarqube-b7a61c44500dcfe64d82346514eb2313dfa312e8.tar.gz sonarqube-b7a61c44500dcfe64d82346514eb2313dfa312e8.zip |
SONAR-11282 Enhance embedded docs navigation sidebar
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 #------------------------------------------------------------------------------ |