Selaa lähdekoodia

SONAR-12325 Include documentation from plugins

tags/8.0
philippe-perrin-sonarsource 4 vuotta sitten
vanhempi
commit
995807ad4d

+ 1
- 1
server/sonar-docs/src/@types/types.d.ts Näytä tiedosto

@@ -29,7 +29,7 @@ export type DocNavigationItem = string | DocsNavigationBlock | DocsNavigationExt

export interface DocsNavigationBlock {
title: string;
children: (DocNavigationItem | string)[];
children: DocNavigationItem[];
}

export interface DocsNavigationExternalLink {

+ 1
- 0
server/sonar-web/src/main/js/api/plugins.ts Näytä tiedosto

@@ -67,6 +67,7 @@ export interface PluginPending extends Plugin {
}

export interface PluginInstalled extends PluginPending {
documentationPath?: string;
filename: string;
hash: string;
sonarLintSupported: boolean;

+ 27
- 0
server/sonar-web/src/main/js/api/static.ts Näytä tiedosto

@@ -0,0 +1,27 @@
/*
* 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 { checkStatus, request } from 'sonar-ui-common/helpers/request';

export function getPluginStaticFileContent(pluginKey: string, staticFilePath: string) {
return request(`/static/${pluginKey}/${staticFilePath}`)
.submit()
.then(checkStatus)
.then(response => response.text());
}

+ 106
- 0
server/sonar-web/src/main/js/apps/documentation/__tests__/pages-test.ts Näytä tiedosto

@@ -0,0 +1,106 @@
/*
* 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 { mockDocumentationMarkdown } from '../../../helpers/testMocks';

jest.mock('remark', () => ({
default: () => ({
parse: jest.fn().mockReturnValue({})
})
}));

jest.mock('unist-util-visit', () => ({
default: (_: any, cb: (node: { type: string; value: string }) => void) => {
cb({ type: 'text', value: 'Text content' });
}
}));

const lorem = {
url: 'analysis/languages/lorem',
title: 'toto doc',
key: 'toto',
content: 'TOTO CONTENT'
};
const foo = {
url: `analysis/languages/foo`,
title: 'foo doc',
key: 'foo'
};
const loremDoc = mockDocumentationMarkdown(lorem);
const fooDoc = mockDocumentationMarkdown(foo);

jest.mock('../documentation.directory-loader', () => [
{ content: loremDoc, path: lorem.url },
{ content: fooDoc, path: foo.url }
]);

it('should correctly parse files', () => {
const pages = getPages();
expect(pages.length).toBe(2);

expect(pages[0]).toMatchObject({
relativeName: lorem.url,
url: `/${lorem.url}/`,
title: lorem.title,
navTitle: undefined,
order: -1,
scope: undefined,
content: lorem.content
});

expect(pages[1]).toMatchObject({
relativeName: foo.url,
url: `/${foo.url}/`,
title: foo.title,
navTitle: undefined,
order: -1,
scope: undefined
});
});

it('should correctly handle overrides (replace & add)', () => {
const overrideFooDoc = {
content: 'OVERRIDE_FOO',
title: 'OVERRIDE_TITLE_FOO',
key: foo.key
};
const newDoc = {
content: 'TATA',
title: 'TATA_TITLE',
key: 'tata'
};

const overrides: string[] = [
mockDocumentationMarkdown(overrideFooDoc),
mockDocumentationMarkdown(newDoc)
];
const pages = getPages(overrides);

expect(pages.length).toBe(3);
expect(pages[1].content).toBe(overrideFooDoc.content);
expect(pages[1].title).toBe(overrideFooDoc.title);
expect(pages[2].content).toBe(newDoc.content);
expect(pages[2].title).toBe(newDoc.title);
});

function getPages(overrides: string[] = []) {
// This allows the use of out-of-scope data inside jest.mock
// Usually, it is impossible as jest.mock'ed module is hoisted on the top of the file
return require.requireActual('../pages').default(overrides);
}

+ 82
- 9
server/sonar-web/src/main/js/apps/documentation/components/App.tsx Näytä tiedosto

@@ -23,8 +23,12 @@ import { DocNavigationItem } from 'Docs/@types/types';
import * as React from 'react';
import Helmet from 'react-helmet';
import { Link } from 'react-router';
import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
import { translate } from 'sonar-ui-common/helpers/l10n';
import { addSideBarClass, removeSideBarClass } from 'sonar-ui-common/helpers/pages';
import { isDefined } from 'sonar-ui-common/helpers/types';
import { getInstalledPlugins } from '../../../api/plugins';
import { getPluginStaticFileContent } from '../../../api/static';
import A11ySkipTarget from '../../../app/components/a11y/A11ySkipTarget';
import NotFound from '../../../app/components/NotFound';
import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
@@ -32,34 +36,105 @@ import DocMarkdownBlock from '../../../components/docs/DocMarkdownBlock';
import { isSonarCloud } from '../../../helpers/system';
import getPages from '../pages';
import '../styles.css';
import { DocumentationEntry } from '../utils';
import Sidebar from './Sidebar';

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

export default class App extends React.PureComponent<Props> {
interface State {
loading: boolean;
pages: DocumentationEntry[];
tree: DocNavigationItem[];
}

export default class App extends React.PureComponent<Props, State> {
mounted = false;
pages = getPages();
state: State = {
loading: false,
pages: [],
tree: []
};

componentDidMount() {
this.mounted = true;
addSideBarClass();

this.setState({ loading: true });

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

this.getLanguagesOverrides().then(
overrides => {
if (this.mounted) {
this.setState({
loading: false,
pages: getPages(overrides),
tree
});
}
},
() => {
if (this.mounted) {
this.setState({
loading: false
});
}
}
);
}

componentWillUnmount() {
this.mounted = false;
removeSideBarClass();
}

getLanguagesOverrides = () => {
const pluginStaticFileNameRegEx = new RegExp(`^static/(.*)`);

return getInstalledPlugins()
.then(plugins =>
Promise.all(
plugins.map(plugin => {
if (plugin.documentationPath) {
const matchArray = pluginStaticFileNameRegEx.exec(plugin.documentationPath);

if (matchArray && matchArray.length > 1) {
// eslint-disable-next-line promise/no-nesting
return getPluginStaticFileContent(plugin.key, matchArray[1]).then(
content => content,
() => undefined
);
}
}
return undefined;
})
)
)
.then(contents => contents.filter(isDefined));
};

render() {
const tree = isSonarCloud()
? ((navigationTreeSonarCloud as any).default as DocNavigationItem[])
: ((navigationTreeSonarQube as any).default as DocNavigationItem[]);
const { loading, pages, tree } = this.state;
const { splat = '' } = this.props.params;
const page = this.pages.find(p => p.url === '/' + splat);

if (loading) {
return (
<div className="page page-limited">
<DeferredSpinner />
</div>
);
}

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

if (!page) {
return (
@@ -73,8 +148,6 @@ export default class App extends React.PureComponent<Props> {
);
}

const isIndex = splat === 'index';

return (
<div className="layout-page">
<Helmet title={isIndex || !page.title ? mainTitle : `${page.title} | ${mainTitle}`}>
@@ -97,7 +170,7 @@ export default class App extends React.PureComponent<Props> {
<h1>{translate('documentation.page')}</h1>
</Link>
</div>
<Sidebar navigation={tree} pages={this.pages} splat={splat} />
<Sidebar navigation={tree} pages={pages} splat={splat} />
</div>
</div>
</div>

+ 50
- 19
server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx Näytä tiedosto

@@ -20,6 +20,8 @@
import { shallow } from 'enzyme';
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 App from '../App';

@@ -38,13 +40,13 @@ jest.mock('../../../../helpers/system', () => ({
isSonarCloud: jest.fn().mockReturnValue(false)
}));

jest.mock(
'Docs/../static/SonarQubeNavigationTree.json',
() => [
jest.mock('Docs/../static/SonarQubeNavigationTree.json', () => ({
default: [
{
title: 'SonarQube',
children: [
'/lorem/ipsum/',
'/analysis/languages/csharp/',
{
title: 'Child category',
children: [
@@ -58,13 +60,11 @@ jest.mock(
}
]
}
],
{ virtual: true }
);
]
}));

jest.mock(
'Docs/../static/SonarCloudNavigationTree.json',
() => [
jest.mock('Docs/../static/SonarCloudNavigationTree.json', () => ({
default: [
{
title: 'SonarCloud',
children: [
@@ -82,44 +82,75 @@ jest.mock(
}
]
}
],
{ virtual: true }
);
]
}));

jest.mock('sonar-ui-common/helpers/pages', () => ({
addSideBarClass: jest.fn(),
removeSideBarClass: jest.fn()
}));

jest.mock('sonar-ui-common/helpers/request', () => ({
request: jest.fn(() => ({
submit: jest.fn().mockResolvedValue({ status: 200, text: jest.fn().mockReturnValue('TEST') })
}))
}));

jest.mock('../../pages', () => {
const { mockDocumentationEntry } = require.requireActual('../../../../helpers/testMocks');
return {
default: () => [mockDocumentationEntry()]
default: () => [
mockDocumentationEntry(),
mockDocumentationEntry({ url: '/analysis/languages/csharp/' })
]
};
});

it('should render correctly for SonarQube', () => {
const wrapper = shallowRender();
jest.mock('../../../../api/plugins', () => ({
getInstalledPlugins: jest
.fn()
.mockResolvedValue([
{ key: 'csharp', documentationPath: 'static/documentation.md' },
{ key: 'vbnet', documentationPath: 'Sstatic/documentation.md' },
{ key: 'vbnett', documentationPath: undefined }
])
}));

expect(wrapper).toMatchSnapshot();
it('should render correctly for SonarQube', async () => {
const wrapper = shallowRender();
expect(wrapper.find('DeferredSpinner').exists()).toBe(true);
expect(addSideBarClass).toBeCalled();

await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('ScreenPositionHelper').dive()).toMatchSnapshot();

wrapper.unmount();
expect(removeSideBarClass).toBeCalled();
});

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

it("should show a 404 if the page doesn't exist", () => {
it("should show a 404 if the page doesn't exist", async () => {
const wrapper = shallowRender({ params: { splat: 'unknown' } });
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
});

it('should try to fetch language plugin documentation if documentationPath matches', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);

expect(request).toHaveBeenCalledWith('/static/csharp/documentation.md');
expect(request).not.toHaveBeenCalledWith('/static/vbnet/documentation.md');
expect(request).not.toHaveBeenCalledWith('/static/vbnett/documentation.md');
});

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

+ 32
- 0
server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap Näytä tiedosto

@@ -115,6 +115,30 @@ exports[`should render correctly for SonarQube 2`] = `
</Link>
</div>
<Sidebar
navigation={
Array [
Object {
"children": Array [
"/lorem/ipsum/",
"/analysis/languages/csharp/",
Object {
"children": Array [
"/lorem/ipsum/dolor",
Object {
"children": Array [
"/lorem/ipsum/sit",
],
"title": "Grandchild category",
},
"/lorem/ipsum/amet",
],
"title": "Child category",
},
],
"title": "SonarQube",
},
]
}
pages={
Array [
Object {
@@ -125,6 +149,14 @@ exports[`should render correctly for SonarQube 2`] = `
"title": "Lorem",
"url": "/lorem/ipsum",
},
Object {
"content": "Lorem ipsum dolor sit amet fredum",
"navTitle": undefined,
"relativeName": "Lorem",
"text": "Lorem ipsum dolor sit amet fredum",
"title": "Lorem",
"url": "/analysis/languages/csharp/",
},
]
}
splat="lorem/ipsum"

+ 39
- 5
server/sonar-web/src/main/js/apps/documentation/pages.ts Näytä tiedosto

@@ -19,19 +19,53 @@
*/
import remark from 'remark';
import visit from 'unist-util-visit';
import { filterContent, separateFrontMatter } from '../../helpers/markdown';
import { filterContent, ParsedContent, separateFrontMatter } from '../../helpers/markdown';
import * as Docs from './documentation.directory-loader';
import { DocumentationEntry, DocumentationEntryScope } from './utils';

export default function getPages(): DocumentationEntry[] {
return ((Docs as unknown) as Array<{ content: string; path: string }>).map(file => {
const parsed = separateFrontMatter(file.content);
const LANGUAGES_BASE_URL = 'analysis/languages';

export default function getPages(overrides: string[] = []): DocumentationEntry[] {
const parsedOverrides: T.Dict<ParsedContent> = {};
overrides.forEach(override => {
const parsedOverride = separateFrontMatter(override);
if (parsedOverride && parsedOverride.frontmatter && parsedOverride.frontmatter.key) {
parsedOverrides[`${LANGUAGES_BASE_URL}/${parsedOverride.frontmatter.key}`] = parsedOverride;
}
});

// Merge with existing entries.
const pages = ((Docs as unknown) as Array<{ content: string; path: string }>).map(file => {
let parsed = separateFrontMatter(file.content);

if (parsedOverrides[file.path]) {
const parsedOverride = parsedOverrides[file.path];
parsed = {
content: parsedOverride.content,
frontmatter: { ...parsed.frontmatter, ...parsedOverride.frontmatter }
};
delete parsedOverrides[file.path];
}

return { parsed, file };
});

// Add new entries.
Object.keys(parsedOverrides).forEach(path => {
const parsed = parsedOverrides[path];
pages.push({
parsed,
file: { content: parsed.content, path }
});
});

return pages.map(({ parsed, file }) => {
const content = filterContent(parsed.content);
const text = getText(content);

return {
relativeName: file.path,
url: parsed.frontmatter.url || `/${file.path}`,
url: parsed.frontmatter.url || `/${file.path}/`,
title: parsed.frontmatter.title,
navTitle: parsed.frontmatter.nav || undefined,
order: Number(parsed.frontmatter.order || -1),

+ 6
- 1
server/sonar-web/src/main/js/helpers/markdown.d.ts Näytä tiedosto

@@ -21,9 +21,14 @@ interface FrontMatter {
[x: string]: string;
}

interface ParsedContent {
content: string;
frontmatter: FrontMatter;
}

export function getFrontMatter(content: string): FrontMatter;

export function separateFrontMatter(content: string): { content: string; frontmatter: FrontMatter };
export function separateFrontMatter(content: string): ParsedContent;

/** Removes SonarQube/SonarCloud only content */
export function filterContent(content: string): string;

+ 22
- 0
server/sonar-web/src/main/js/helpers/testMocks.ts Näytä tiedosto

@@ -746,6 +746,28 @@ export function mockUser(overrides: Partial<T.User> = {}): T.User {
};
}

export function mockDocumentationMarkdown(
overrides: Partial<{ content: string; title: string; key: string }> = {}
): string {
const content =
overrides.content ||
`
## Lorem Ipsum

Donec at est elit. In finibus justo ut augue rhoncus, vitae consequat mauris mattis.
Nunc ante est, volutpat ac volutpat ac, pharetra in libero.
`;

const frontMatter = `
---
${overrides.title ? 'title: ' + overrides.title : ''}
${overrides.key ? 'key: ' + overrides.key : ''}
---`;

return `${frontMatter}
${content}`;
}

export function mockDocumentationEntry(
overrides: Partial<DocumentationEntry> = {}
): DocumentationEntry {

Loading…
Peruuta
Tallenna