瀏覽代碼

SONAR-11958 Enhance anchors in embedded documentation navigation

tags/8.9.0.43852
Wouter Admiraal 3 年之前
父節點
當前提交
356a35002c

+ 9
- 11
server/sonar-web/src/main/js/apps/documentation/components/App.tsx 查看文件

@@ -17,7 +17,6 @@
* 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 navigationTreeSonarCloud from 'Docs/../static/SonarCloudNavigationTree.json';
import * as navigationTreeSonarQube from 'Docs/../static/SonarQubeNavigationTree.json';
import { DocNavigationItem } from 'Docs/@types/types';
import * as React from 'react';
@@ -34,7 +33,6 @@ import NotFound from '../../../app/components/NotFound';
import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
import DocMarkdownBlock from '../../../components/docs/DocMarkdownBlock';
import { ParsedContent, separateFrontMatter } from '../../../helpers/markdown';
import { isSonarCloud } from '../../../helpers/system';
import { InstalledPlugin, PluginType } from '../../../types/plugins';
import { getUrlsList } from '../navTreeUtils';
import getPages from '../pages';
@@ -44,6 +42,7 @@ import Sidebar from './Sidebar';

interface Props {
params: { splat?: string };
location: { hash: string };
}

interface State {
@@ -68,9 +67,7 @@ export default class App extends React.PureComponent<Props, State> {

this.setState({ loading: true });

const tree = isSonarCloud()
? ((navigationTreeSonarCloud as any).default as DocNavigationItem[])
: ((navigationTreeSonarQube as any).default as DocNavigationItem[]);
const tree = (navigationTreeSonarQube as any).default as DocNavigationItem[];

this.getLanguagePluginsDocumentation(tree).then(
overrides => {
@@ -150,7 +147,10 @@ export default class App extends React.PureComponent<Props, State> {

render() {
const { loading, pages, tree } = this.state;
const { splat = '' } = this.props.params;
const {
params: { splat = '' },
location: { hash }
} = this.props;

if (loading) {
return (
@@ -161,10 +161,7 @@ export default class App extends React.PureComponent<Props, State> {
}

const page = pages.find(p => p.url === `/${splat}`);
const mainTitle = translate(
'documentation.page_title',
isSonarCloud() ? 'sonarcloud' : 'sonarqube'
);
const mainTitle = translate('documentation.page_title.sonarqube');
const isIndex = splat === 'index';

if (!page) {
@@ -184,7 +181,7 @@ export default class App extends React.PureComponent<Props, State> {
<Helmet
defer={false}
title={isIndex || !page.title ? mainTitle : `${page.title} | ${mainTitle}`}>
{!isSonarCloud() && <meta content="noindex nofollow" name="robots" />}
<meta content="noindex nofollow" name="robots" />
</Helmet>

<ScreenPositionHelper className="layout-page-side-outer">
@@ -220,6 +217,7 @@ export default class App extends React.PureComponent<Props, State> {
content={page.content}
stickyToc={true}
title={page.title}
scrollToHref={hash}
/>
</div>
</div>

+ 1
- 39
server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx 查看文件

@@ -22,17 +22,12 @@ import * as React from 'react';
import { addSideBarClass, removeSideBarClass } from 'sonar-ui-common/helpers/pages';
import { request } from 'sonar-ui-common/helpers/request';
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { isSonarCloud } from '../../../../helpers/system';
import { InstalledPlugin } from '../../../../types/plugins';
import getPages from '../../pages';
import App from '../App';

jest.mock('../../../../components/common/ScreenPositionHelper');

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

jest.mock('Docs/../static/SonarQubeNavigationTree.json', () => ({
default: [
{
@@ -56,28 +51,6 @@ jest.mock('Docs/../static/SonarQubeNavigationTree.json', () => ({
]
}));

jest.mock('Docs/../static/SonarCloudNavigationTree.json', () => ({
default: [
{
title: 'SonarCloud',
children: [
'/lorem/ipsum/',
{
title: 'Child category',
children: [
'/lorem/ipsum/dolor',
{
title: 'Grandchild category',
children: ['/lorem/ipsum/sit']
},
'/lorem/ipsum/amet'
]
}
]
}
]
}));

jest.mock('sonar-ui-common/helpers/pages', () => ({
addSideBarClass: jest.fn(),
removeSideBarClass: jest.fn()
@@ -136,13 +109,6 @@ it('should render correctly for SonarQube', async () => {
expect(removeSideBarClass).toBeCalled();
});

it('should render correctly for SonarCloud', async () => {
(isSonarCloud as jest.Mock).mockReturnValue(true);
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
});

it("should show a 404 if the page doesn't exist", async () => {
const wrapper = shallowRender({ params: { splat: 'unknown' } });
await waitAndUpdate(wrapper);
@@ -150,8 +116,6 @@ it("should show a 404 if the page doesn't exist", async () => {
});

it('should try to fetch language plugin documentation if documentationPath matches', async () => {
(isSonarCloud as jest.Mock).mockReturnValue(false);

const wrapper = shallowRender();
await waitAndUpdate(wrapper);

@@ -166,8 +130,6 @@ it('should try to fetch language plugin documentation if documentationPath match
});

it('should display the issue tracker url of the plugin if it exists', async () => {
(isSonarCloud as jest.Mock).mockReturnValue(false);

const wrapper = shallowRender({ params: { splat: 'analysis/languages/csharp/' } });
await waitAndUpdate(wrapper);

@@ -177,5 +139,5 @@ it('should display the issue tracker url of the plugin if it exists', async () =
});

function shallowRender(props: Partial<App['props']> = {}) {
return shallow(<App params={{ splat: 'lorem/ipsum' }} {...props} />);
return shallow(<App params={{ splat: 'lorem/ipsum' }} location={{ hash: '#foo' }} {...props} />);
}

+ 2
- 39
server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap 查看文件

@@ -1,43 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly for SonarCloud 1`] = `
<div
className="layout-page"
>
<Helmet
defer={false}
encodeSpecialCharacters={true}
title="Lorem | documentation.page_title.sonarcloud"
/>
<ScreenPositionHelper
className="layout-page-side-outer"
>
<Component />
</ScreenPositionHelper>
<div
className="layout-page-main"
>
<div
className="layout-page-main-inner"
>
<div
className="boxed-group"
>
<A11ySkipTarget
anchor="documentation_main"
/>
<DocMarkdownBlock
className="documentation-content cut-margins boxed-group-inner"
content="Lorem ipsum dolor sit amet fredum"
stickyToc={true}
title="Lorem"
/>
</div>
</div>
</div>
</div>
`;

exports[`should render correctly for SonarQube 1`] = `
<div
className="layout-page"
@@ -72,6 +34,7 @@ exports[`should render correctly for SonarQube 1`] = `
<DocMarkdownBlock
className="documentation-content cut-margins boxed-group-inner"
content="Lorem ipsum dolor sit amet fredum"
scrollToHref="#foo"
stickyToc={true}
title="Lorem"
/>
@@ -171,7 +134,7 @@ exports[`should show a 404 if the page doesn't exist 1`] = `
<Helmet
defer={true}
encodeSpecialCharacters={true}
title="documentation.page_title.sonarcloud"
title="documentation.page_title.sonarqube"
>
<meta
content="noindex nofollow"

+ 18
- 2
server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx 查看文件

@@ -38,19 +38,35 @@ interface Props {
className?: string;
content: string;
isTooltip?: boolean;
scrollToHref?: string;
stickyToc?: boolean;
title?: string;
}

const WAIT_TIMEOUT = 500;

export default class DocMarkdownBlock extends React.PureComponent<Props> {
node: HTMLElement | null = null;

handleAnchorClick = (href: string, event: React.MouseEvent<HTMLAnchorElement>) => {
componentDidMount() {
const { scrollToHref } = this.props;
if (scrollToHref) {
setTimeout(() => {
this.handleAnchorClick(scrollToHref);
}, WAIT_TIMEOUT);
}
}

handleAnchorClick = (href: string, event?: React.MouseEvent<HTMLAnchorElement>) => {
if (this.node) {
const element = this.node.querySelector(href);
if (element) {
event.preventDefault();
if (event) {
event.preventDefault();
}
scrollToElement(element, { bottomOffset: window.innerHeight - 80 });

// We cannot use React Router here, because we cannot simply replace a hash.
if (history.pushState) {
history.pushState(null, '', href);
}

+ 83
- 16
server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx 查看文件

@@ -19,6 +19,8 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { scrollToElement } from 'sonar-ui-common/helpers/scrolling';
import { mockEvent } from '../../../helpers/testMocks';
import DocMarkdownBlock from '../DocMarkdownBlock';

const CONTENT = `
@@ -48,37 +50,102 @@ jest.mock('rehype-raw', () => ({ default: jest.requireActual('rehype-raw') }));
jest.mock('rehype-react', () => ({ default: jest.requireActual('rehype-react') }));
jest.mock('rehype-slug', () => ({ default: jest.requireActual('rehype-slug') }));

jest.mock('../../../helpers/system', () => ({
getInstance: jest.fn(),
isSonarCloud: jest.fn()
jest.mock('sonar-ui-common/helpers/scrolling', () => ({
scrollToElement: jest.fn()
}));

it('should render simple markdown', () => {
expect(shallowRender({ content: 'this is *bold* text' })).toMatchSnapshot();
const WINDOW_HEIGHT = 800;
const originalWindowHeight = window.innerHeight;

const historyPushState = jest.fn();
const originalHistoryPushState = history.pushState;

beforeEach(jest.clearAllMocks);

beforeAll(() => {
Object.defineProperty(window, 'innerHeight', {
writable: true,
configurable: true,
value: WINDOW_HEIGHT
});
Object.defineProperty(history, 'pushState', {
writable: true,
configurable: true,
value: historyPushState
});
});

it('should use custom component for links', () => {
expect(
shallowRender({ content: 'some [link](/quality-profiles)' }).find('withChildProps')
).toMatchSnapshot();
afterAll(() => {
Object.defineProperty(window, 'innerHeight', {
writable: true,
configurable: true,
value: originalWindowHeight
});
Object.defineProperty(history, 'pushState', {
writable: true,
configurable: true,
value: originalHistoryPushState
});
});

it('should render with custom props for links', () => {
it('should render correctly', () => {
expect(shallowRender({ content: 'this is *bold* text' })).toMatchSnapshot('default');
expect(
shallowRender({ content: 'some [link](/quality-profiles)' }).find('withChildProps')
).toMatchSnapshot('custom component for links');
expect(
shallowRender({
childProps: { foo: 'bar' },
content: 'some [link](#quality-profiles)',
isTooltip: true
}).find('withChildProps')
).toMatchSnapshot();
).toMatchSnapshot('custom props for links');
expect(shallowRender({ content: CONTENT, stickyToc: true })).toMatchSnapshot('sticky TOC');
});

it('should correctly scroll to clicked headings', () => {
const element = {} as Element;
const querySelector: (selector: string) => Element | null = jest.fn((selector: string) =>
selector === '#id' ? element : null
);
const preventDefault = jest.fn();
const wrapper = shallowRender();
const instance = wrapper.instance();

// Node Ref isn't set yet.
instance.handleAnchorClick('#unknown', mockEvent());
expect(scrollToElement).not.toBeCalled();

// Set node Ref.
instance.node = { querySelector } as HTMLElement;

// Unknown element.
instance.handleAnchorClick('#unknown', mockEvent());
expect(scrollToElement).not.toBeCalled();

// Known element, should scroll.
instance.handleAnchorClick('#id', mockEvent({ preventDefault }));
expect(scrollToElement).toBeCalledWith(element, { bottomOffset: 720 });
expect(preventDefault).toBeCalled();
expect(historyPushState).toBeCalledWith(null, '', '#id');
});

it('should render a sticky TOC if available', () => {
const wrapper = shallowRender({ content: CONTENT, stickyToc: true });
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('DocToc').exists()).toBe(true);
it('should correctly scroll to a specific heading if passed as a prop', () => {
jest.useFakeTimers();

const element = {} as Element;
const querySelector: (_: string) => Element | null = jest.fn(() => element);
const wrapper = shallowRender({ scrollToHref: '#id' });
const instance = wrapper.instance();
instance.node = { querySelector } as HTMLElement;

expect(scrollToElement).not.toBeCalled();

jest.runAllTimers();

expect(scrollToElement).toBeCalledWith(element, { bottomOffset: 720 });
});

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

+ 43
- 43
server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap 查看文件

@@ -1,6 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render a sticky TOC if available 1`] = `
exports[`should render correctly: custom component for links 1`] = `
<withChildProps
href="/quality-profiles"
key="h-2"
>
link
</withChildProps>
`;

exports[`should render correctly: custom props for links 1`] = `
<withChildProps
href="#quality-profiles"
key="h-2"
>
link
</withChildProps>
`;

exports[`should render correctly: default 1`] = `
<div
className="markdown"
>
<div
className="markdown-content"
>
<div>
<p
key="h-1"
>
this is
<em
key="h-2"
>
bold
</em>
text
</p>
</div>
</div>
</div>
`;

exports[`should render correctly: sticky TOC 1`] = `
<div
className="markdown has-toc"
>
@@ -104,45 +146,3 @@ Risus placerat, efficitur enim ut, pellentesque sem. Mauris non lorem auctor, co
/>
</div>
`;

exports[`should render simple markdown 1`] = `
<div
className="markdown"
>
<div
className="markdown-content"
>
<div>
<p
key="h-1"
>
this is
<em
key="h-2"
>
bold
</em>
text
</p>
</div>
</div>
</div>
`;

exports[`should render with custom props for links 1`] = `
<withChildProps
href="#quality-profiles"
key="h-2"
>
link
</withChildProps>
`;

exports[`should use custom component for links 1`] = `
<withChildProps
href="/quality-profiles"
key="h-2"
>
link
</withChildProps>
`;

+ 0
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties 查看文件

@@ -3050,7 +3050,6 @@ api_documentation.search=Search by name...
#
#------------------------------------------------------------------------------
documentation.page=Documentation
documentation.page_title.sonarcloud=SonarCloud Docs
documentation.page_title.sonarqube=SonarQube Docs
documentation.on_this_page=On this page
documentation.skip_to_nav=Skip to documentation navigation

Loading…
取消
儲存