* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { mockDefinition } from '../../../helpers/mocks/settings';
import {
Setting,
SettingCategoryDefinition,
SettingFieldDefinition
} from '../../../types/settings';
-import { getDefaultValue, getEmptyValue } from '../utils';
+import { buildSettingLink, getDefaultValue, getEmptyValue } from '../utils';
const fields = [
{ key: 'foo', type: 'STRING' } as SettingFieldDefinition,
}
);
});
+
+describe('buildSettingLink', () => {
+ it.each([
+ [
+ mockDefinition({ key: 'anykey' }),
+ { hash: '#anykey', pathname: '/admin/settings', query: { category: 'foo category' } }
+ ],
+ [
+ mockDefinition({ key: 'sonar.auth.gitlab.name' }),
+ {
+ hash: '#sonar.auth.gitlab.name',
+ pathname: '/admin/settings',
+ query: { alm: 'gitlab', category: 'foo category' }
+ }
+ ],
+ [
+ mockDefinition({ key: 'sonar.auth.github.token' }),
+ {
+ hash: '#sonar.auth.github.token',
+ pathname: '/admin/settings',
+ query: { alm: 'github', category: 'foo category' }
+ }
+ ],
+ [
+ mockDefinition({ key: 'sonar.almintegration.azure' }),
+ {
+ hash: '#sonar.almintegration.azure',
+ pathname: '/admin/settings',
+ query: { alm: 'azure', category: 'foo category' }
+ }
+ ]
+ ])('should work as expected', (definition, expectedUrl) => {
+ expect(buildSettingLink(definition)).toEqual(expectedUrl);
+ });
+});
interface Props {
component?: T.Component;
+ scrollToDefinition: (element: HTMLLIElement) => void;
settings: Setting[];
}
-export default function DefinitionsList({ component, settings }: Props) {
+export default function DefinitionsList(props: Props) {
+ const { component, settings } = props;
return (
<ul className="settings-definitions-list">
{settings.map(setting => (
- <li key={setting.definition.key}>
+ <li
+ key={setting.definition.key}
+ data-key={setting.definition.key}
+ ref={props.scrollToDefinition}>
<Definition component={component} setting={setting} />
</li>
))}
* 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 classNames from 'classnames';
import * as React from 'react';
import InstanceMessage from '../../../components/common/InstanceMessage';
import { translate } from '../../../helpers/l10n';
+import SettingsSearch from './SettingsSearch';
export interface PageHeaderProps {
component?: T.Component;
);
return (
- <header className="top-bar-outer">
+ <header className={classNames('top-bar-outer', { 'with-search': component === undefined })}>
<div className="top-bar">
- <div className="top-bar-inner bordered-bottom big-padded-top padded-bottom">
+ <div
+ className={classNames('top-bar-inner bordered-bottom big-padded-top padded-bottom', {
+ 'with-search': component === undefined
+ })}>
<h1 className="page-title">{title}</h1>
<div className="page-description spacer-top">{description}</div>
+ {!component && <SettingsSearch className="big-spacer-top" />}
</div>
</div>
</header>
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 { debounce, keyBy } from 'lodash';
+import lunr, { LunrIndex } from 'lunr';
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { InjectedRouter } from 'react-router';
+import { withRouter } from '../../../components/hoc/withRouter';
+import { KeyCodes } from '../../../helpers/keycodes';
+import { getSettingsAppAllDefinitions, Store } from '../../../store/rootReducer';
+import { SettingCategoryDefinition } from '../../../types/settings';
+import { ADDITIONAL_SETTING_DEFINITIONS, buildSettingLink } from '../utils';
+import SettingsSearchRenderer from './SettingsSearchRenderer';
+
+interface Props {
+ className?: string;
+ definitions: SettingCategoryDefinition[];
+ router: InjectedRouter;
+}
+
+interface State {
+ results?: SettingCategoryDefinition[];
+ searchQuery: string;
+ selectedResult?: string;
+ showResults: boolean;
+}
+
+const DEBOUNCE_DELAY = 250;
+
+export class SettingsSearch extends React.Component<Props, State> {
+ definitionsByKey: T.Dict<SettingCategoryDefinition>;
+ index: LunrIndex;
+ state: State = {
+ searchQuery: '',
+ showResults: false
+ };
+
+ constructor(props: Props) {
+ super(props);
+
+ this.doSearch = debounce(this.doSearch, DEBOUNCE_DELAY);
+ this.handleFocus = debounce(this.handleFocus, DEBOUNCE_DELAY);
+
+ const definitions = props.definitions.concat(ADDITIONAL_SETTING_DEFINITIONS);
+ this.index = this.buildSearchIndex(definitions);
+ this.definitionsByKey = keyBy(definitions, 'key');
+ }
+
+ buildSearchIndex(definitions: SettingCategoryDefinition[]) {
+ return lunr(function() {
+ this.ref('key');
+ this.field('key');
+ this.field('name');
+ this.field('description');
+ this.field('splitkey');
+
+ definitions.forEach(definition => {
+ this.add({ ...definition, splitkey: definition.key.replace('.', ' ') });
+ });
+ });
+ }
+
+ doSearch = (query: string) => {
+ const cleanQuery = query.replace(/[\^\-+:~*]/g, '');
+
+ if (!cleanQuery) {
+ this.setState({ showResults: false });
+ return;
+ }
+
+ const results = this.index
+ .search(
+ cleanQuery
+ .split(/\s+/)
+ .map(s => `${s}~1 *${s}*`)
+ .join(' ')
+ )
+ .map(match => this.definitionsByKey[match.ref]);
+
+ this.setState({ showResults: true, results, selectedResult: results[0]?.key });
+ };
+
+ hideResults = () => {
+ this.setState({ showResults: false });
+ };
+
+ handleFocus = () => {
+ const { searchQuery, showResults } = this.state;
+ if (searchQuery && !showResults) {
+ this.setState({ showResults: true });
+ }
+ };
+
+ handleSearchChange = (searchQuery: string) => {
+ this.setState({ searchQuery });
+ this.doSearch(searchQuery);
+ };
+
+ handleMouseOverResult = (key: string) => {
+ this.setState({ selectedResult: key });
+ };
+
+ handleKeyDown = (event: React.KeyboardEvent) => {
+ switch (event.keyCode) {
+ case KeyCodes.Enter:
+ event.preventDefault();
+ this.openSelected();
+ return;
+ case KeyCodes.UpArrow:
+ event.preventDefault();
+ this.selectPrevious();
+ return;
+ case KeyCodes.DownArrow:
+ event.preventDefault();
+ this.selectNext();
+ // keep this return to prevent fall-through in case more cases will be adder later
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ };
+
+ selectPrevious = () => {
+ const { results, selectedResult } = this.state;
+
+ if (results && selectedResult) {
+ const index = results.findIndex(r => r.key === selectedResult);
+
+ if (index > 0) {
+ this.setState({ selectedResult: results[index - 1].key });
+ }
+ }
+ };
+
+ selectNext = () => {
+ const { results, selectedResult } = this.state;
+
+ if (results && selectedResult) {
+ const index = results.findIndex(r => r.key === selectedResult);
+
+ if (index < results.length - 1) {
+ this.setState({ selectedResult: results[index + 1].key });
+ }
+ }
+ };
+
+ openSelected = () => {
+ const { router } = this.props;
+ const { selectedResult } = this.state;
+ if (selectedResult) {
+ const definition = this.definitionsByKey[selectedResult];
+ router.push(buildSettingLink(definition));
+ this.setState({ showResults: false });
+ }
+ };
+
+ render() {
+ const { className } = this.props;
+
+ return (
+ <SettingsSearchRenderer
+ className={className}
+ onClickOutside={this.hideResults}
+ onMouseOverResult={this.handleMouseOverResult}
+ onSearchInputChange={this.handleSearchChange}
+ onSearchInputFocus={this.handleFocus}
+ onSearchInputKeyDown={this.handleKeyDown}
+ {...this.state}
+ />
+ );
+ }
+}
+
+const mapStateToProps = (state: Store) => ({
+ definitions: getSettingsAppAllDefinitions(state)
+});
+
+export default withRouter(connect(mapStateToProps)(SettingsSearch));
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 * as classNames from 'classnames';
+import * as React from 'react';
+import { Link } from 'react-router';
+import { DropdownOverlay } from '../../../components/controls/Dropdown';
+import OutsideClickHandler from '../../../components/controls/OutsideClickHandler';
+import SearchBox from '../../../components/controls/SearchBox';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { scrollToElement } from '../../../helpers/scrolling';
+import { SettingCategoryDefinition } from '../../../types/settings';
+import { buildSettingLink, isRealSettingKey } from '../utils';
+
+export interface SettingsSearchRendererProps {
+ className?: string;
+ results?: SettingCategoryDefinition[];
+ searchQuery: string;
+ selectedResult?: string;
+ showResults: boolean;
+ onClickOutside: () => void;
+ onMouseOverResult: (key: string) => void;
+ onSearchInputChange: (query: string) => void;
+ onSearchInputFocus: () => void;
+ onSearchInputKeyDown: (event: React.KeyboardEvent) => void;
+}
+
+export default function SettingsSearchRenderer(props: SettingsSearchRendererProps) {
+ const { className, results, searchQuery, selectedResult, showResults } = props;
+
+ const scrollableNodeRef = React.useRef(null);
+ const selectedNodeRef = React.useRef<HTMLLIElement>(null);
+
+ React.useEffect(() => {
+ const parent = scrollableNodeRef.current;
+ const selectedNode = selectedNodeRef.current;
+ if (selectedNode && parent) {
+ scrollToElement(selectedNode, { topOffset: 30, bottomOffset: 30, parent });
+ }
+ });
+
+ return (
+ <OutsideClickHandler onClickOutside={props.onClickOutside}>
+ <div className={classNames('dropdown', className)}>
+ <SearchBox
+ onChange={props.onSearchInputChange}
+ onFocus={props.onSearchInputFocus}
+ onKeyDown={props.onSearchInputKeyDown}
+ placeholder={translate('settings.search.placeholder')}
+ value={searchQuery}
+ />
+ {showResults && (
+ <DropdownOverlay noPadding={true}>
+ <ul className="settings-search-results menu" ref={scrollableNodeRef}>
+ {results && results.length > 0 ? (
+ results.map(r => (
+ <li
+ key={r.key}
+ className={classNames('spacer-bottom spacer-top', {
+ active: selectedResult === r.key
+ })}
+ ref={selectedResult === r.key ? selectedNodeRef : undefined}>
+ <Link
+ onClick={props.onClickOutside}
+ onMouseEnter={() => props.onMouseOverResult(r.key)}
+ to={buildSettingLink(r)}>
+ <div className="settings-search-result-title display-flex-space-between">
+ <h3>{r.name || r.subCategory}</h3>
+ </div>
+ {isRealSettingKey(r.key) && (
+ <div className="note spacer-top">
+ {translateWithParameters('settings.key_x', r.key)}
+ </div>
+ )}
+ </Link>
+ </li>
+ ))
+ ) : (
+ <div className="big-padded">{translate('no_results')}</div>
+ )}
+ </ul>
+ </DropdownOverlay>
+ )}
+ </div>
+ </OutsideClickHandler>
+ );
+}
const { hash } = this.props.location;
if (hash && prevProps.location.hash !== hash) {
- const element = document.querySelector<HTMLHeadingElement>(`h2[data-key=${hash.substr(1)}]`);
- this.scrollToSubCategory(element);
+ const query = `[data-key=${hash.substr(1).replace(/[.#/]/g, '\\$&')}]`;
+ const element = document.querySelector<HTMLHeadingElement | HTMLLIElement>(query);
+ this.scrollToSubCategoryOrDefinition(element);
}
}
- scrollToSubCategory = (element: HTMLHeadingElement | null) => {
+ scrollToSubCategoryOrDefinition = (element: HTMLHeadingElement | HTMLLIElement | null) => {
if (element) {
const { hash } = this.props.location;
if (hash && hash.substr(1) === element.getAttribute('data-key')) {
<h2
className="settings-sub-category-name"
data-key={subCategory.key}
- ref={this.scrollToSubCategory}>
+ ref={this.scrollToSubCategoryOrDefinition}>
{subCategory.name}
</h2>
{subCategory.description != null && (
)}
<DefinitionsList
component={this.props.component}
+ scrollToDefinition={this.scrollToSubCategoryOrDefinition}
settings={bySubCategory[subCategory.key]}
/>
{this.renderEmailForm(subCategory.key)}
import PageHeader, { PageHeaderProps } from '../PageHeader';
it('should render correctly', () => {
- expect(shallowRender()).toMatchSnapshot('default');
+ expect(shallowRender()).toMatchSnapshot('global');
expect(shallowRender({ component: mockComponent() })).toMatchSnapshot('for project');
});
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockDefinition } from '../../../../helpers/mocks/settings';
+import { mockRouter } from '../../../../helpers/testMocks';
+import { mockEvent, waitAndUpdate } from '../../../../helpers/testUtils';
+import { SettingsSearch } from '../SettingsSearch';
+
+jest.mock('lunr', () => ({
+ default: jest.fn(() => ({
+ search: jest.fn(() => [
+ {
+ ref: 'foo'
+ },
+ {
+ ref: 'sonar.new_code_period'
+ }
+ ])
+ }))
+}));
+
+describe('instance', () => {
+ const router = mockRouter();
+ const wrapper = shallowRender({ router });
+
+ it('should build the index', () => {
+ expect(wrapper.instance().index).not.toBeNull();
+
+ const def = mockDefinition();
+ expect(wrapper.instance().definitionsByKey).toEqual(
+ expect.objectContaining({ [def.key]: def })
+ );
+ });
+
+ it('should handle search', async () => {
+ wrapper.instance().handleSearchChange('query');
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper.state().searchQuery).toBe('query');
+
+ expect(wrapper.instance().index.search).toBeCalled();
+ expect(wrapper.state().showResults).toBe(true);
+ expect(wrapper.state().results).toHaveLength(2);
+ });
+
+ it('should handle empty search', async () => {
+ wrapper.instance().handleSearchChange('');
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper.state().searchQuery).toBe('');
+
+ expect(wrapper.instance().index.search).toBeCalled();
+ expect(wrapper.state().showResults).toBe(false);
+ });
+
+ it('should hide results', () => {
+ wrapper.setState({ showResults: true });
+ wrapper.instance().hideResults();
+ expect(wrapper.state().showResults).toBe(false);
+ wrapper.instance().hideResults();
+ });
+
+ it('should handle focus', () => {
+ wrapper.setState({ searchQuery: 'hi', showResults: false });
+ wrapper.instance().handleFocus();
+ expect(wrapper.state().showResults).toBe(true);
+
+ wrapper.setState({ searchQuery: '', showResults: false });
+ wrapper.instance().handleFocus();
+ expect(wrapper.state().showResults).toBe(false);
+ });
+
+ it('should handle mouseover', () => {
+ wrapper.setState({ selectedResult: undefined });
+ wrapper.instance().handleMouseOverResult('selection');
+ expect(wrapper.state().selectedResult).toBe('selection');
+ });
+
+ it('should handle "enter" keyboard event', () => {
+ wrapper.setState({ selectedResult: undefined });
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 13 }));
+ expect(router.push).not.toBeCalled();
+
+ wrapper.setState({ selectedResult: 'foo' });
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 13 }));
+
+ expect(router.push).toBeCalledWith({
+ hash: '#foo',
+ pathname: '/admin/settings',
+ query: { category: 'foo category' }
+ });
+ });
+
+ it('should handle "down" keyboard event', () => {
+ wrapper.setState({ selectedResult: undefined });
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 40 }));
+ expect(wrapper.state().selectedResult).toBeUndefined();
+
+ wrapper.setState({ selectedResult: 'foo' });
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 40 }));
+ expect(wrapper.state().selectedResult).toBe('sonar.new_code_period');
+
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 40 }));
+ expect(wrapper.state().selectedResult).toBe('sonar.new_code_period');
+ });
+
+ it('should handle "up" keyboard event', () => {
+ wrapper.setState({ selectedResult: undefined });
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 38 }));
+ expect(wrapper.state().selectedResult).toBeUndefined();
+
+ wrapper.setState({ selectedResult: 'sonar.new_code_period' });
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 38 }));
+ expect(wrapper.state().selectedResult).toBe('foo');
+
+ wrapper.instance().handleKeyDown(mockEvent({ keyCode: 38 }));
+ expect(wrapper.state().selectedResult).toBe('foo');
+ });
+});
+
+function shallowRender(overrides: Partial<SettingsSearch['props']> = {}) {
+ return shallow<SettingsSearch>(
+ <SettingsSearch definitions={[mockDefinition()]} router={mockRouter()} {...overrides} />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockDefinition } from '../../../../helpers/mocks/settings';
+import { scrollToElement } from '../../../../helpers/scrolling';
+import SettingsSearchRenderer, { SettingsSearchRendererProps } from '../SettingsSearchRenderer';
+
+jest.mock('../../../../helpers/scrolling', () => ({
+ scrollToElement: jest.fn()
+}));
+
+afterAll(() => {
+ jest.clearAllMocks();
+});
+
+it('should render correctly when closed', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should render correctly when open', () => {
+ expect(shallowRender({ showResults: true })).toMatchSnapshot('no results');
+ expect(
+ shallowRender({
+ results: [mockDefinition({ name: 'Foo!' }), mockDefinition({ key: 'bar' })],
+ selectedResult: 'bar',
+ showResults: true
+ })
+ ).toMatchSnapshot('results');
+});
+
+it('should scroll to selected element', () => {
+ const scrollable = {};
+ const scrollableRef = { current: scrollable };
+ const selected = {};
+ const selectedRef = { current: selected };
+
+ jest
+ .spyOn(React, 'useRef')
+ .mockImplementationOnce(() => scrollableRef)
+ .mockImplementationOnce(() => selectedRef);
+ jest.spyOn(React, 'useEffect').mockImplementationOnce(f => f());
+
+ shallowRender();
+
+ expect(scrollToElement).toBeCalled();
+});
+
+function shallowRender(overrides: Partial<SettingsSearchRendererProps> = {}) {
+ return shallow<SettingsSearchRendererProps>(
+ <SettingsSearchRenderer
+ searchQuery=""
+ showResults={false}
+ onClickOutside={jest.fn()}
+ onMouseOverResult={jest.fn()}
+ onSearchInputChange={jest.fn()}
+ onSearchInputFocus={jest.fn()}
+ onSearchInputKeyDown={jest.fn()}
+ {...overrides}
+ />
+ );
+}
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`should render correctly: default 1`] = `
+exports[`should render correctly: for project 1`] = `
<header
className="top-bar-outer"
>
<h1
className="page-title"
>
- settings.page
+ project_settings.page
</h1>
<div
className="page-description spacer-top"
>
- <InstanceMessage
- message="settings.page.description"
- />
+ project_settings.page.description
</div>
</div>
</div>
</header>
`;
-exports[`should render correctly: for project 1`] = `
+exports[`should render correctly: global 1`] = `
<header
- className="top-bar-outer"
+ className="top-bar-outer with-search"
>
<div
className="top-bar"
>
<div
- className="top-bar-inner bordered-bottom big-padded-top padded-bottom"
+ className="top-bar-inner bordered-bottom big-padded-top padded-bottom with-search"
>
<h1
className="page-title"
>
- project_settings.page
+ settings.page
</h1>
<div
className="page-description spacer-top"
>
- project_settings.page.description
+ <InstanceMessage
+ message="settings.page.description"
+ />
</div>
+ <withRouter(Connect(SettingsSearch))
+ className="big-spacer-top"
+ />
</div>
</div>
</header>
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly when closed 1`] = `
+<OutsideClickHandler
+ onClickOutside={[MockFunction]}
+>
+ <div
+ className="dropdown"
+ >
+ <SearchBox
+ onChange={[MockFunction]}
+ onFocus={[MockFunction]}
+ onKeyDown={[MockFunction]}
+ placeholder="settings.search.placeholder"
+ value=""
+ />
+ </div>
+</OutsideClickHandler>
+`;
+
+exports[`should render correctly when open: no results 1`] = `
+<OutsideClickHandler
+ onClickOutside={[MockFunction]}
+>
+ <div
+ className="dropdown"
+ >
+ <SearchBox
+ onChange={[MockFunction]}
+ onFocus={[MockFunction]}
+ onKeyDown={[MockFunction]}
+ placeholder="settings.search.placeholder"
+ value=""
+ />
+ <DropdownOverlay
+ noPadding={true}
+ >
+ <ul
+ className="settings-search-results menu"
+ >
+ <div
+ className="big-padded"
+ >
+ no_results
+ </div>
+ </ul>
+ </DropdownOverlay>
+ </div>
+</OutsideClickHandler>
+`;
+
+exports[`should render correctly when open: results 1`] = `
+<OutsideClickHandler
+ onClickOutside={[MockFunction]}
+>
+ <div
+ className="dropdown"
+ >
+ <SearchBox
+ onChange={[MockFunction]}
+ onFocus={[MockFunction]}
+ onKeyDown={[MockFunction]}
+ placeholder="settings.search.placeholder"
+ value=""
+ />
+ <DropdownOverlay
+ noPadding={true}
+ >
+ <ul
+ className="settings-search-results menu"
+ >
+ <li
+ className="spacer-bottom spacer-top"
+ key="foo"
+ >
+ <Link
+ onClick={[MockFunction]}
+ onMouseEnter={[Function]}
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "hash": "#foo",
+ "pathname": "/admin/settings",
+ "query": Object {
+ "category": "foo category",
+ },
+ }
+ }
+ >
+ <div
+ className="settings-search-result-title display-flex-space-between"
+ >
+ <h3>
+ Foo!
+ </h3>
+ </div>
+ <div
+ className="note spacer-top"
+ >
+ settings.key_x.foo
+ </div>
+ </Link>
+ </li>
+ <li
+ className="spacer-bottom spacer-top active"
+ key="bar"
+ >
+ <Link
+ onClick={[MockFunction]}
+ onMouseEnter={[Function]}
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "hash": "#bar",
+ "pathname": "/admin/settings",
+ "query": Object {
+ "category": "foo category",
+ },
+ }
+ }
+ >
+ <div
+ className="settings-search-result-title display-flex-space-between"
+ >
+ <h3>
+ foo subCat
+ </h3>
+ </div>
+ <div
+ className="note spacer-top"
+ >
+ settings.key_x.bar
+ </div>
+ </Link>
+ </li>
+ </ul>
+ </DropdownOverlay>
+ </div>
+</OutsideClickHandler>
+`;
email
</h2>
<DefinitionsList
+ scrollToDefinition={[Function]}
settings={
Array [
Object {
qg
</h2>
<DefinitionsList
+ scrollToDefinition={[Function]}
settings={
Array [
Object {
qg
</h2>
<DefinitionsList
+ scrollToDefinition={[Function]}
settings={
Array [
Object {
} from '../../../../types/alm-settings';
import AlmIntegrationRenderer from './AlmIntegrationRenderer';
-interface Props extends Pick<WithRouterProps, 'location'> {
+interface Props extends Pick<WithRouterProps, 'location' | 'router'> {
appState: Pick<T.AppState, 'branchesEnabled' | 'multipleAlmEnabled'>;
}
});
}
+ componentDidUpdate() {
+ const { location } = this.props;
+ if (location.query.alm && this.mounted) {
+ this.setState({ currentAlmTab: location.query.alm });
+ }
+ }
+
componentWillUnmount() {
this.mounted = false;
}
};
handleSelectAlm = (currentAlmTab: AlmTabs) => {
+ const { location, router } = this.props;
+ location.query.alm = currentAlmTab;
+ location.hash = '';
+ router.push(location);
this.setState({ currentAlmTab });
};
getAlmDefinitions,
validateAlmSettings
} from '../../../../../api/alm-settings';
-import { mockLocation } from '../../../../../helpers/testMocks';
+import { mockLocation, mockRouter } from '../../../../../helpers/testMocks';
import { waitAndUpdate } from '../../../../../helpers/testUtils';
import { AlmKeys, AlmSettingsBindingStatusType } from '../../../../../types/alm-settings';
import { AlmIntegration } from '../AlmIntegration';
});
it('should handle alm selection', async () => {
- const wrapper = shallowRender();
+ const router = mockRouter();
+ const wrapper = shallowRender({ router });
wrapper.setState({ currentAlmTab: AlmKeys.Azure });
await waitAndUpdate(wrapper);
expect(wrapper.state().currentAlmTab).toBe(AlmKeys.GitHub);
+ expect(router.push).toBeCalled();
});
it('should handle delete', async () => {
wrapper = shallowRender({ location: mockLocation({ query: { alm: AlmKeys.BitbucketCloud } }) });
expect(wrapper.state().currentAlmTab).toBe(AlmKeys.BitbucketServer);
+
+ wrapper.setProps({ location: mockLocation({ query: { alm: AlmKeys.GitLab } }) });
+ expect(wrapper.state().currentAlmTab).toBe(AlmKeys.GitLab);
});
function shallowRender(props: Partial<AlmIntegration['props']> = {}) {
return shallow<AlmIntegration>(
- <AlmIntegration appState={{ branchesEnabled: true }} location={mockLocation()} {...props} />
+ <AlmIntegration
+ appState={{ branchesEnabled: true }}
+ location={mockLocation()}
+ router={mockRouter()}
+ {...props}
+ />
);
}
return fromDefinitions.getDefinition(state.definitions, key);
}
+export function getAllDefinitions(state: State) {
+ return fromDefinitions.getAllDefinitions(state.definitions);
+}
+
export function getAllCategories(state: State) {
return fromDefinitions.getAllCategories(state.definitions);
}
box-sizing: border-box;
}
+#settings-page .top-bar-outer.with-search,
+#settings-page .top-bar-inner.with-search {
+ height: 120px;
+}
+
#settings-page .page-title,
#settings-page .page-description {
float: none;
margin: 0 20px;
}
}
+
+.settings-search-results {
+ max-height: 50vh;
+ width: 500px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { LocationDescriptor } from 'history';
import { hasMessage, translate } from '../../helpers/l10n';
+import { getGlobalSettingsUrl } from '../../helpers/urls';
+import { AlmKeys } from '../../types/alm-settings';
import { Setting, SettingCategoryDefinition, SettingDefinition } from '../../types/settings';
export const DEFAULT_CATEGORY = 'general';
return parentValue;
}
+
+export function isRealSettingKey(key: string) {
+ return ![
+ 'sonar.new_code_period',
+ `sonar.almintegration.${AlmKeys.Azure}`,
+ `sonar.almintegration.${AlmKeys.BitbucketServer}`,
+ `sonar.almintegration.${AlmKeys.GitHub}`,
+ `sonar.almintegration.${AlmKeys.GitLab}`
+ ].includes(key);
+}
+
+export function buildSettingLink(definition: SettingCategoryDefinition): LocationDescriptor {
+ const { category, key } = definition;
+
+ const query: T.Dict<string> = {};
+
+ if (key.startsWith('sonar.auth.gitlab')) {
+ query.alm = 'gitlab';
+ } else if (key.startsWith('sonar.auth.github')) {
+ query.alm = 'github';
+ } else if (key.startsWith('sonar.almintegration')) {
+ query.alm = key.split('.').pop() || '';
+ }
+
+ return {
+ ...getGlobalSettingsUrl(category, query),
+ hash: `#${escape(key)}`
+ };
+}
+
+export const ADDITIONAL_SETTING_DEFINITIONS: SettingCategoryDefinition[] = [
+ {
+ name: 'Default New Code behavior',
+ description: `
+ The New Code definition is used to compare measures and track new issues.
+ This setting is the default for all projects. A specific New Code definition can be configured at project level.
+ `,
+ category: 'new_code_period',
+ key: `sonar.new_code_period`,
+ fields: [],
+ options: [],
+ subCategory: ''
+ },
+ {
+ name: 'Azure DevOps integration',
+ description: `azure devops integration configuration
+ Configuration name
+ Give your configuration a clear and succinct name.
+ This name will be used at project level to identify the correct configured Azure instance for a project.
+ Azure DevOps URL
+ For Azure DevOps Server, provide the full collection URL:
+ https://ado.your-company.com/your_collection
+
+ For Azure DevOps Services, provide the full organization URL:
+ https://dev.azure.com/your_organization
+ Personal Access Token
+ SonarQube needs a Personal Access Token to report the Quality Gate status on Pull Requests in Azure DevOps.
+ To create this token, we recommend using a dedicated Azure DevOps account with administration permissions.
+ The token itself needs Code > Read & Write permission.
+ `,
+ category: 'almintegration',
+ key: `sonar.almintegration.${AlmKeys.Azure}`,
+ fields: [],
+ options: [],
+ subCategory: ''
+ },
+ {
+ name: 'Bitbucket integration',
+ description: `bitbucket server cloud integration configuration
+ Configuration name
+ Give your configuration a clear and succinct name.
+ This name will be used at project level to identify the correct configured Bitbucket instance for a project.
+ Bitbucket Server URL
+ Example: https://bitbucket-server.your-company.com
+ Personal Access Token
+ SonarQube needs a Personal Access Token to report the Quality Gate status on Pull Requests in Bitbucket Server.
+ To create this token, we recommend using a dedicated Bitbucket Server account with administration permissions.
+ The token itself needs Read permission.
+ Workspace ID
+ The workspace ID is part of your bitbucket cloud URL https://bitbucket.org/{workspace}/{repository}
+ SonarQube needs you to create an OAuth consumer in your Bitbucket Cloud workspace settings
+ to report the Quality Gate status on Pull Requests.
+ It needs to be a private consumer with Pull Requests: Read permission.
+ An OAuth callback URL is required by Bitbucket Cloud but not used by SonarQube so any URL works.
+ OAuth Key
+ Bitbucket automatically creates an OAuth key when you create your OAuth consumer.
+ You can find it in your Bitbucket Cloud workspace settings under OAuth consumers.
+ OAuth Secret
+ Bitbucket automatically creates an OAuth secret when you create your OAuth consumer.
+ You can find it in your Bitbucket Cloud workspace settings under OAuth consumers.
+ `,
+ category: 'almintegration',
+ key: `sonar.almintegration.${AlmKeys.BitbucketServer}`,
+ fields: [],
+ options: [],
+ subCategory: ''
+ },
+ {
+ name: 'GitHub integration',
+ description: `github integration configuration
+ Configuration name
+ Give your configuration a clear and succinct name.
+ This name will be used at project level to identify the correct configured GitHub App for a project.
+ GitHub API URL
+ Example for Github Enterprise:
+ https://github.company.com/api/v3
+ If using GitHub.com:
+ https://api.github.com/
+ You need to install a GitHub App with specific settings and permissions to enable
+ Pull Request Decoration on your Organization or Repository.
+ GitHub App ID
+ The App ID is found on your GitHub App's page on GitHub at Settings > Developer Settings > GitHub Apps
+ Client ID
+ The Client ID is found on your GitHub App's page.
+ Client Secret
+ The Client secret is found on your GitHub App's page.
+ Private Key
+ Your GitHub App's private key. You can generate a .pem file from your GitHub App's page under Private keys.
+ Copy and paste the whole contents of the file here.
+ `,
+ category: 'almintegration',
+ key: `sonar.almintegration.${AlmKeys.GitHub}`,
+ fields: [],
+ options: [],
+ subCategory: ''
+ },
+ {
+ name: 'Gitlab integration',
+ description: `gitlab integration configuration
+ Configuration name
+ Give your configuration a clear and succinct name.
+ This name will be used at project level to identify the correct configured GitLab instance for a project.
+ GitLab API URL
+ Provide the GitLab API URL. For example:
+ https://gitlab.com/api/v4
+ Personal Access Token
+ SonarQube needs a Personal Access Token to report the Quality Gate status on Merge Requests in GitLab.
+ To create this token,
+ we recommend using a dedicated GitLab account with Reporter permission to all target projects.
+ The token itself needs the api scope.
+ `,
+ category: 'almintegration',
+ key: `sonar.almintegration.${AlmKeys.GitLab}`,
+ fields: [],
+ options: [],
+ subCategory: ''
+ }
+];
return fromSettingsApp.getValue(state.settingsApp, key);
}
+export function getSettingsAppAllDefinitions(state: Store) {
+ return fromSettingsApp.getAllDefinitions(state.settingsApp);
+}
+
export function getSettingsAppDefinition(state: Store, key: string) {
return fromSettingsApp.getDefinition(state.settingsApp, key);
}
settings.reset_confirm.title=Reset Setting
settings.reset_confirm.description=Are you sure that you want to reset this setting?
+settings.search.placeholder=Find in Settings
+
settings.json.format=Format JSON
settings.json.format_error=Formatting requires valid JSON. Please fix it and retry.