]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12325 Include documentation from plugins
authorphilippe-perrin-sonarsource <philippe.perrin@sonarsource.com>
Mon, 29 Jul 2019 13:17:35 +0000 (15:17 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 7 Aug 2019 18:21:22 +0000 (20:21 +0200)
server/sonar-docs/src/@types/types.d.ts
server/sonar-web/src/main/js/api/plugins.ts
server/sonar-web/src/main/js/api/static.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/documentation/__tests__/pages-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/documentation/components/App.tsx
server/sonar-web/src/main/js/apps/documentation/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/App-test.tsx.snap
server/sonar-web/src/main/js/apps/documentation/pages.ts
server/sonar-web/src/main/js/helpers/markdown.d.ts
server/sonar-web/src/main/js/helpers/testMocks.ts

index 289ad620c2af383bc83cba5c779d99f15ad7a060..4c8fffde4631e25546114f980a7d0e09d6643a0a 100644 (file)
@@ -29,7 +29,7 @@ export type DocNavigationItem = string | DocsNavigationBlock | DocsNavigationExt
 
 export interface DocsNavigationBlock {
   title: string;
-  children: (DocNavigationItem | string)[];
+  children: DocNavigationItem[];
 }
 
 export interface DocsNavigationExternalLink {
index 04300ff8bae6a5921e2c292ebb335e68e2d6ef63..97e5b22bbc0c32e9e60c7b9acfa9f2fc3496eb36 100644 (file)
@@ -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 (file)
index 0000000..c47046b
--- /dev/null
@@ -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 (file)
index 0000000..4bcd5fa
--- /dev/null
@@ -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);
+}
index 80e081dcb553d36dfdfd7d5a1a982fe6156003d1..1c4a430daf7fc1704a2db054583667a0e61a3441 100644 (file)
@@ -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>
index aca3d13d4043dfcf991ba48124e1188ef0cf0aff..df65c2620ab7f340f99e73519bc5111ae2ac89a0 100644 (file)
@@ -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} />);
 }
index 3fe5325afa76712391f13bd888e0429ef2d961d8..d5701016b02c1c7fc40dc2ce87ab17609f0f0ead 100644 (file)
@@ -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"
index 585228f6c06981a2aa18316526de63221db70608..15ded856918aca3a24e446e65cca013558dff349 100644 (file)
  */
 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),
index 92b8c67b609b0a384112b92e41f4897893f94b3b..bebbbe8f393b4c07f4590eda7ef80f20d2edd2c1 100644 (file)
@@ -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;
index fcf39a2124dd1878cfd35cb911ec7c4fbd33e15d..852dbadf38e6a9b90ff417b1ea9f78c12886272e 100644 (file)
@@ -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 {