Browse Source

SONAR-11282 Enhance embedded docs navigation sidebar

tags/7.6
Wouter Admiraal 5 years ago
parent
commit
b7a61c4450

+ 22
- 0
server/sonar-web/src/main/js/@types/remark-slug.d.ts View File

@@ -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;
}

+ 4
- 2
server/sonar-web/src/main/js/app/styles/style.css View File

@@ -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;
}


+ 1
- 0
server/sonar-web/src/main/js/apps/documentation/components/App.tsx View File

@@ -100,6 +100,7 @@ export default class App extends React.PureComponent<Props> {
className="documentation-content cut-margins boxed-group-inner"
content={page.content}
displayH1={true}
stickyToc={true}
/>
</div>
</div>

+ 2
- 1
server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResultEntry-test.tsx View File

@@ -20,6 +20,7 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import SearchResultEntry, {
SearchResult,
SearchResultText,
SearchResultTitle,
SearchResultTokens
@@ -86,7 +87,7 @@ describe('SearchResultTokens', () => {
});
});

function mockSearchResult(overrides = {}) {
function mockSearchResult(overrides: Partial<SearchResult> = {}) {
return {
page: {
content: '',

+ 46
- 1
server/sonar-web/src/main/js/apps/documentation/styles.css View File

@@ -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;
}

+ 5
- 12
server/sonar-web/src/main/js/components/controls/__tests__/ScreenPositionFixer-test.tsx View File

@@ -20,7 +20,7 @@
import * as React from 'react';
import { mount } from 'enzyme';
import ScreenPositionFixer from '../ScreenPositionFixer';
import { resizeWindowTo } from '../../../helpers/testUtils';
import { resizeWindowTo, setNodeRect } from '../../../helpers/testUtils';

jest.mock('lodash', () => {
const lodash = require.requireActual('lodash');
@@ -33,7 +33,7 @@ jest.mock('react-dom', () => ({
}));

beforeEach(() => {
setNodeRect({ width: 50, height: 50, left: 50, top: 50 });
setNodeRect({ left: 50, top: 50 });
resizeWindowTo(1000, 1000);
});

@@ -41,18 +41,18 @@ it('should fix position', () => {
const renderer = jest.fn(() => <div />);
mount(<ScreenPositionFixer>{renderer}</ScreenPositionFixer>);

setNodeRect({ width: 50, height: 50, left: 50, top: 50 });
setNodeRect({ left: 50, top: 50 });
resizeWindowTo(75, 1000);
expect(renderer).toHaveBeenLastCalledWith({ leftFix: -29, topFix: 0 });

resizeWindowTo(1000, 75);
expect(renderer).toHaveBeenLastCalledWith({ leftFix: 0, topFix: -29 });

setNodeRect({ width: 50, height: 50, left: -10, top: 50 });
setNodeRect({ left: -10, top: 50 });
resizeWindowTo(1000, 1000);
expect(renderer).toHaveBeenLastCalledWith({ leftFix: 14, topFix: 0 });

setNodeRect({ width: 50, height: 50, left: 50, top: -10 });
setNodeRect({ left: 50, top: -10 });
resizeWindowTo();
expect(renderer).toHaveBeenLastCalledWith({ leftFix: 0, topFix: 14 });
});
@@ -87,10 +87,3 @@ it('should re-position when window is resized', () => {
resizeWindowTo();
expect(renderer).toHaveBeenCalledTimes(3);
});

function setNodeRect(rect: { width: number; height: number; left: number; top: number }) {
const findDOMNode = require('react-dom').findDOMNode as jest.Mock<any>;
const element = document.createElement('div');
Object.defineProperty(element, 'getBoundingClientRect', { value: () => rect });
findDOMNode.mockReturnValue(element);
}

+ 49
- 30
server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx View File

@@ -21,9 +21,11 @@ import * as React from 'react';
import * as classNames from 'classnames';
import remark from 'remark';
import reactRenderer from 'remark-react';
import slug from 'remark-slug';
import remarkCustomBlocks from 'remark-custom-blocks';
import DocLink from './DocLink';
import DocImg from './DocImg';
import DocToc from './DocToc';
import DocTooltipLink from './DocTooltipLink';
import remarkToc from './plugins/remark-toc';
import DocCollapsibleBlock from './DocCollapsibleBlock';
@@ -36,6 +38,7 @@ interface Props {
content: string | undefined;
displayH1?: boolean;
isTooltip?: boolean;
stickyToc?: boolean;
}

export default class DocMarkdownBlock extends React.PureComponent<Props> {
@@ -43,46 +46,62 @@ export default class DocMarkdownBlock extends React.PureComponent<Props> {

handleAnchorClick = (href: string, event: React.MouseEvent<HTMLAnchorElement>) => {
if (this.node) {
const element = this.node.querySelector(`#${href.substr(1)}`);
const element = this.node.querySelector(href);
if (element) {
event.preventDefault();
scrollToElement(element, { bottomOffset: window.innerHeight - 80 });
if (history.pushState) {
history.pushState(null, '', href);
}
}
}
};

render() {
const { childProps, content, className, displayH1, isTooltip } = this.props;
const { childProps, content, className, displayH1, stickyToc, isTooltip } = this.props;
const parsed = separateFrontMatter(content || '');
let filteredContent = filterContent(parsed.content);
const tocContent = filteredContent;
const md = remark();

// TODO find a way to replace these custom blocks with real Alert components
md.use(remarkCustomBlocks, {
danger: { classes: 'alert alert-danger' },
warning: { classes: 'alert alert-warning' },
info: { classes: 'alert alert-info' },
success: { classes: 'alert alert-success' },
collapse: { classes: 'collapse' }
})
.use(reactRenderer, {
remarkReactComponents: {
div: Block,
// use custom link to render documentation anchors
a: isTooltip
? withChildProps(DocTooltipLink, childProps)
: withChildProps(DocLink, { onAnchorClick: this.handleAnchorClick }),
// use custom img tag to render documentation images
img: DocImg
},
toHast: {},
sanitize: false
})
.use(slug);

if (stickyToc) {
filteredContent = filteredContent.replace(/#*\s*(toc|table[ -]of[ -]contents?).*/i, '');
} else {
md.use(remarkToc, { maxDepth: 3 });
}

return (
<div className={classNames('markdown', className)} ref={ref => (this.node = ref)}>
{displayH1 && <h1>{parsed.frontmatter.title}</h1>}
{
remark()
.use(remarkToc, { maxDepth: 3 })
// TODO find a way to replace these custom blocks with real Alert components
.use(remarkCustomBlocks, {
danger: { classes: 'alert alert-danger' },
warning: { classes: 'alert alert-warning' },
info: { classes: 'alert alert-info' },
success: { classes: 'alert alert-success' },
collapse: { classes: 'collapse' }
})
.use(reactRenderer, {
remarkReactComponents: {
div: Block,
// use custom link to render documentation anchors
a: isTooltip
? withChildProps(DocTooltipLink, childProps)
: withChildProps(DocLink, { onAnchorClick: this.handleAnchorClick }),
// use custom img tag to render documentation images
img: DocImg
},
toHast: {},
sanitize: false
})
.processSync(filterContent(parsed.content)).contents
}
<div
className={classNames('markdown', className, { 'has-toc': stickyToc })}
ref={ref => (this.node = ref)}>
<div className="markdown-content">
{displayH1 && <h1 className="documentation-title">{parsed.frontmatter.title}</h1>}
{md.processSync(filteredContent).contents}
</div>
{stickyToc && <DocToc content={tocContent} onAnchorClick={this.handleAnchorClick} />}
</div>
);
}

+ 155
- 0
server/sonar-web/src/main/js/components/docs/DocToc.tsx View File

@@ -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>
);
}
}

+ 53
- 12
server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx View File

@@ -22,7 +22,29 @@ import { shallow } from 'enzyme';
import DocMarkdownBlock from '../DocMarkdownBlock';
import { isSonarCloud } from '../../../helpers/system';

// mock `remark` and `remark-react` to work around the issue with cjs imports
const CONTENT_WITH_TOC = `
## Table of Contents

## Lorem ipsum

Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.

## Sit amet

### Maecenas diam

Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.

### Integer

At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.

## Nam blandit

Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
`;

// mock `remark` & co to work around the issue with cjs imports
jest.mock('remark', () => {
const remark = require.requireActual('remark');
return { default: remark };
@@ -33,18 +55,23 @@ jest.mock('remark-react', () => {
return { default: remarkReact };
});

jest.mock('remark-slug', () => {
const remarkSlug = require.requireActual('remark-slug');
return { default: remarkSlug };
});

jest.mock('../../../helpers/system', () => ({
getInstance: jest.fn(),
isSonarCloud: jest.fn()
}));

it('should render simple markdown', () => {
expect(shallow(<DocMarkdownBlock content="this is *bold* text" />)).toMatchSnapshot();
expect(shallowRender({ content: 'this is *bold* text' })).toMatchSnapshot();
});

it('should use custom component for links', () => {
expect(
shallow(<DocMarkdownBlock content="some [link](/quality-profiles)" />).find('withChildProps')
shallowRender({ content: 'some [link](/quality-profiles)' }).find('withChildProps')
).toMatchSnapshot();
});

@@ -73,20 +100,34 @@ static
text`;

(isSonarCloud as jest.Mock).mockImplementation(() => false);
expect(shallow(<DocMarkdownBlock content={content} />)).toMatchSnapshot();
expect(shallowRender({ content })).toMatchSnapshot();

(isSonarCloud as jest.Mock).mockImplementation(() => true);
expect(shallow(<DocMarkdownBlock content={content} />)).toMatchSnapshot();
expect(shallowRender({ content })).toMatchSnapshot();
});

it('should render with custom props for links', () => {
expect(
shallow(
<DocMarkdownBlock
childProps={{ foo: 'bar' }}
content="some [link](#quality-profiles)"
isTooltip={true}
/>
).find('withChildProps')
shallowRender({
childProps: { foo: 'bar' },
content: 'some [link](#quality-profiles)',
isTooltip: true
}).find('withChildProps')
).toMatchSnapshot();
});

it('should render a TOC if available', () => {
const wrapper = shallowRender({ content: CONTENT_WITH_TOC });
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('ul').exists()).toBe(true);
});

it('should render a sticky TOC if available', () => {
const wrapper = shallowRender({ content: CONTENT_WITH_TOC, stickyToc: true });
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('DocToc').exists()).toBe(true);
});

function shallowRender(props: Partial<DocMarkdownBlock['props']> = {}) {
return shallow(<DocMarkdownBlock content="" {...props} />);
}

+ 131
- 0
server/sonar-web/src/main/js/components/docs/__tests__/DocToc-test.tsx View File

@@ -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);
}

+ 350
- 60
server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap View File

@@ -4,43 +4,47 @@ exports[`should cut sonarqube/sonarcloud/static content 1`] = `
<div
className="markdown"
>
<Block
key="h-1"
<div
className="markdown-content"
>
<p
key="h-2"
<Block
key="h-1"
>
some
</p>
<p
key="h-2"
>
some
</p>

<p
key="h-3"
>
sonarqube
</p>
<p
key="h-3"
>
sonarqube
</p>

<p
key="h-4"
>
long
</p>
<p
key="h-4"
>
long
</p>

<p
key="h-5"
>
multiline
</p>
<p
key="h-5"
>
multiline
</p>

<p
key="h-6"
>
text
</p>
</Block>
<p
key="h-6"
>
text
</p>
</Block>
</div>
</div>
`;

@@ -48,29 +52,311 @@ exports[`should cut sonarqube/sonarcloud/static content 2`] = `
<div
className="markdown"
>
<Block
key="h-1"
<div
className="markdown-content"
>
<p
key="h-2"
<Block
key="h-1"
>
some
</p>
<p
key="h-2"
>
some
</p>

<p
key="h-3"
<p
key="h-3"
>
sonarcloud
</p>

<p
key="h-4"
>
text
</p>
</Block>
</div>
</div>
`;

exports[`should render a TOC if available 1`] = `
<div
className="markdown"
>
<div
className="markdown-content"
>
<Block
key="h-1"
>
sonarcloud
</p>
<h2
id="table-of-contents"
key="h-2"
>
Table of Contents
</h2>

<ul
key="h-3"
>

<li
key="h-4"
>
<withChildProps
href="#lorem-ipsum"
key="h-5"
>
Lorem ipsum
</withChildProps>
</li>

<li
key="h-6"
>

<p
key="h-7"
>
<withChildProps
href="#sit-amet"
key="h-8"
>
Sit amet
</withChildProps>
</p>

<ul
key="h-9"
>

<li
key="h-10"
>
<withChildProps
href="#maecenas-diam"
key="h-11"
>
Maecenas diam
</withChildProps>
</li>

<li
key="h-12"
>
<withChildProps
href="#integer"
key="h-13"
>
Integer
</withChildProps>
</li>

</ul>

</li>

<li
key="h-14"
>
<withChildProps
href="#nam-blandit"
key="h-15"
>
Nam blandit
</withChildProps>
</li>

</ul>

<h2
id="lorem-ipsum"
key="h-16"
>
Lorem ipsum
</h2>

<p
key="h-17"
>
Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
</p>

<h2
id="sit-amet"
key="h-18"
>
Sit amet
</h2>

<h3
id="maecenas-diam"
key="h-19"
>
Maecenas diam
</h3>

<p
key="h-4"
<p
key="h-20"
>
Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
</p>

<h3
id="integer"
key="h-21"
>
Integer
</h3>

<p
key="h-22"
>
At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
</p>

<h2
id="nam-blandit"
key="h-23"
>
Nam blandit
</h2>

<p
key="h-24"
>
Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
</p>
</Block>
</div>
</div>
`;

exports[`should render a sticky TOC if available 1`] = `
<div
className="markdown has-toc"
>
<div
className="markdown-content"
>
<Block
key="h-1"
>
text
</p>
</Block>
<h2
id="lorem-ipsum"
key="h-2"
>
Lorem ipsum
</h2>

<p
key="h-3"
>
Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
</p>

<h2
id="sit-amet"
key="h-4"
>
Sit amet
</h2>

<h3
id="maecenas-diam"
key="h-5"
>
Maecenas diam
</h3>

<p
key="h-6"
>
Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.
</p>

<h3
id="integer"
key="h-7"
>
Integer
</h3>

<p
key="h-8"
>
At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.
</p>

<h2
id="nam-blandit"
key="h-9"
>
Nam blandit
</h2>

<p
key="h-10"
>
Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
</p>
</Block>
</div>
<DocToc
content="
## Table of Contents

## Lorem ipsum

Quisque vitae tincidunt felis. Nam blandit risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.

## Sit amet

### Maecenas diam

Velit, vestibulum nec ultrices id, mollis eget arcu. Sed dapibus, sapien ut auctor consectetur, mi tortor vestibulum ante, eget dapibus lacus risus.

### Integer

At cursus turpis. Aenean at elit fringilla, porttitor mi eget, dapibus nisi. Donec quis congue odio.

## Nam blandit

Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, consequat neque eget, dignissim augue.
"
onAnchorClick={[Function]}
/>
</div>
`;

@@ -78,21 +364,25 @@ exports[`should render simple markdown 1`] = `
<div
className="markdown"
>
<Block
key="h-1"
<div
className="markdown-content"
>
<p
key="h-2"
<Block
key="h-1"
>
this is
<em
key="h-3"
<p
key="h-2"
>
bold
</em>
text
</p>
</Block>
this is
<em
key="h-3"
>
bold
</em>
text
</p>
</Block>
</div>
</div>
`;


+ 91
- 0
server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocToc-test.tsx.snap View File

@@ -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]}
/>
`;

+ 41
- 0
server/sonar-web/src/main/js/components/docs/plugins/remark-only-toc.js View File

@@ -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];
}
}
}

+ 16
- 0
server/sonar-web/src/main/js/helpers/testUtils.ts View File

@@ -101,6 +101,22 @@ export function resizeWindowTo(width?: number, height?: number) {
window.dispatchEvent(resizeEvent);
}

export function scrollTo({ left = 0, top = 0 }) {
Object.defineProperty(window, 'pageYOffset', { value: top });
Object.defineProperty(window, 'pageXOffset', { value: left });
const resizeEvent = new Event('scroll');
window.dispatchEvent(resizeEvent);
}

export function setNodeRect({ width = 50, height = 50, left = 0, top = 0 }) {
const { findDOMNode } = require('react-dom');
const element = document.createElement('div');
Object.defineProperty(element, 'getBoundingClientRect', {
value: () => ({ width, height, left, top })
});
findDOMNode.mockReturnValue(element);
}

export function doAsync(fn?: Function): Promise<void> {
return new Promise(resolve => {
setImmediate(() => {

+ 2
- 2
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -2553,6 +2553,7 @@ api_documentation.search=Search by name...
#------------------------------------------------------------------------------
documentation.page=Documentation
documentation.page_title=SonarCloud Docs
documentation.on_this_page=On this page


#------------------------------------------------------------------------------
@@ -2687,8 +2688,7 @@ organization.members.add_to_members=Add to members
organization.paid_plan.badge=Paid plan
organization.default_visibility_of_new_projects=Default visibility of new projects:
organization.change_visibility_form.header=Set Default Visibility of New Projects
organization.change_visibility_form.warning=This will not change the visibility of already existing projects.
organization.change_visibility_form.submit=Change Default Visibility
organization.change_visibility_form.warning=This will not change the visibility of already existing projects.organization.change_visibility_form.submit=Change Default Visibility


#------------------------------------------------------------------------------

Loading…
Cancel
Save