From 995807ad4d6473f819764bb55b59c641292596cf Mon Sep 17 00:00:00 2001 From: philippe-perrin-sonarsource Date: Mon, 29 Jul 2019 15:17:35 +0200 Subject: [PATCH] SONAR-12325 Include documentation from plugins --- server/sonar-docs/src/@types/types.d.ts | 2 +- server/sonar-web/src/main/js/api/plugins.ts | 1 + server/sonar-web/src/main/js/api/static.ts | 27 +++++ .../documentation/__tests__/pages-test.ts | 106 ++++++++++++++++++ .../js/apps/documentation/components/App.tsx | 91 +++++++++++++-- .../components/__tests__/App-test.tsx | 69 ++++++++---- .../__tests__/__snapshots__/App-test.tsx.snap | 32 ++++++ .../src/main/js/apps/documentation/pages.ts | 44 +++++++- .../src/main/js/helpers/markdown.d.ts | 7 +- .../src/main/js/helpers/testMocks.ts | 22 ++++ 10 files changed, 366 insertions(+), 35 deletions(-) create mode 100644 server/sonar-web/src/main/js/api/static.ts create mode 100644 server/sonar-web/src/main/js/apps/documentation/__tests__/pages-test.ts diff --git a/server/sonar-docs/src/@types/types.d.ts b/server/sonar-docs/src/@types/types.d.ts index 289ad620c2a..4c8fffde463 100644 --- a/server/sonar-docs/src/@types/types.d.ts +++ b/server/sonar-docs/src/@types/types.d.ts @@ -29,7 +29,7 @@ export type DocNavigationItem = string | DocsNavigationBlock | DocsNavigationExt export interface DocsNavigationBlock { title: string; - children: (DocNavigationItem | string)[]; + children: DocNavigationItem[]; } export interface DocsNavigationExternalLink { diff --git a/server/sonar-web/src/main/js/api/plugins.ts b/server/sonar-web/src/main/js/api/plugins.ts index 04300ff8bae..97e5b22bbc0 100644 --- a/server/sonar-web/src/main/js/api/plugins.ts +++ b/server/sonar-web/src/main/js/api/plugins.ts @@ -67,6 +67,7 @@ export interface PluginPending extends Plugin { } export interface PluginInstalled extends PluginPending { + documentationPath?: string; filename: string; hash: string; sonarLintSupported: boolean; diff --git a/server/sonar-web/src/main/js/api/static.ts b/server/sonar-web/src/main/js/api/static.ts new file mode 100644 index 00000000000..c47046b5373 --- /dev/null +++ b/server/sonar-web/src/main/js/api/static.ts @@ -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()); +} diff --git a/server/sonar-web/src/main/js/apps/documentation/__tests__/pages-test.ts b/server/sonar-web/src/main/js/apps/documentation/__tests__/pages-test.ts new file mode 100644 index 00000000000..4bcd5fae2ab --- /dev/null +++ b/server/sonar-web/src/main/js/apps/documentation/__tests__/pages-test.ts @@ -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); +} 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 80e081dcb55..1c4a430daf7 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 @@ -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 { +interface State { + loading: boolean; + pages: DocumentationEntry[]; + tree: DocNavigationItem[]; +} + +export default class App extends React.PureComponent { 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 ( +
+ +
+ ); + } + + 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 { ); } - const isIndex = splat === 'index'; - return (
@@ -97,7 +170,7 @@ export default class App extends React.PureComponent {

{translate('documentation.page')}

- + diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx index aca3d13d404..df65c2620ab 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx +++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx @@ -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 = {}) { return shallow(); } diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap index 3fe5325afa7..d5701016b02 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap @@ -115,6 +115,30 @@ exports[`should render correctly for SonarQube 2`] = ` ).map(file => { - const parsed = separateFrontMatter(file.content); +const LANGUAGES_BASE_URL = 'analysis/languages'; + +export default function getPages(overrides: string[] = []): DocumentationEntry[] { + const parsedOverrides: T.Dict = {}; + 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), diff --git a/server/sonar-web/src/main/js/helpers/markdown.d.ts b/server/sonar-web/src/main/js/helpers/markdown.d.ts index 92b8c67b609..bebbbe8f393 100644 --- a/server/sonar-web/src/main/js/helpers/markdown.d.ts +++ b/server/sonar-web/src/main/js/helpers/markdown.d.ts @@ -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; diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index fcf39a2124d..852dbadf38e 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -746,6 +746,28 @@ export function mockUser(overrides: Partial = {}): 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 { -- 2.39.5