@@ -29,7 +29,7 @@ export type DocNavigationItem = string | DocsNavigationBlock | DocsNavigationExt | |||
export interface DocsNavigationBlock { | |||
title: string; | |||
children: (DocNavigationItem | string)[]; | |||
children: DocNavigationItem[]; | |||
} | |||
export interface DocsNavigationExternalLink { |
@@ -67,6 +67,7 @@ export interface PluginPending extends Plugin { | |||
} | |||
export interface PluginInstalled extends PluginPending { | |||
documentationPath?: string; | |||
filename: string; | |||
hash: string; | |||
sonarLintSupported: boolean; |
@@ -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()); | |||
} |
@@ -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); | |||
} |
@@ -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> |
@@ -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} />); | |||
} |
@@ -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" |
@@ -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), |
@@ -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; |
@@ -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 { |