diff options
author | Jay <jeremy.davis@sonarsource.com> | 2021-09-13 15:55:09 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-09-13 20:03:33 +0000 |
commit | 80e5b85977523f14c1bb14ea90cb0cc73f4aa59d (patch) | |
tree | 5dd8e82ee6656145efbd8c73e40e4726b4f1cb95 /server/sonar-web/src/main | |
parent | 31013914e9b61d4d40ec71cbaaf7f852465a6aec (diff) | |
download | sonarqube-80e5b85977523f14c1bb14ea90cb0cc73f4aa59d.tar.gz sonarqube-80e5b85977523f14c1bb14ea90cb0cc73f4aa59d.zip |
SONAR-15366 Add Settings Search feature
Diffstat (limited to 'server/sonar-web/src/main')
18 files changed, 932 insertions, 24 deletions
diff --git a/server/sonar-web/src/main/js/apps/settings/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/settings/__tests__/utils-test.ts index aa0c97c7898..38d70015b55 100644 --- a/server/sonar-web/src/main/js/apps/settings/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/settings/__tests__/utils-test.ts @@ -17,12 +17,13 @@ * 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, @@ -82,3 +83,38 @@ describe('#getDefaultValue()', () => { } ); }); + +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); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/settings/components/DefinitionsList.tsx b/server/sonar-web/src/main/js/apps/settings/components/DefinitionsList.tsx index 653cf225f8d..8f810e009f0 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/DefinitionsList.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/DefinitionsList.tsx @@ -23,14 +23,19 @@ import Definition from './Definition'; 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> ))} diff --git a/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx index bca4bc82355..75234993ac1 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx @@ -17,9 +17,11 @@ * 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; @@ -35,11 +37,15 @@ export default function PageHeader({ component }: PageHeaderProps) { ); 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> diff --git a/server/sonar-web/src/main/js/apps/settings/components/SettingsSearch.tsx b/server/sonar-web/src/main/js/apps/settings/components/SettingsSearch.tsx new file mode 100644 index 00000000000..951493b3b06 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/SettingsSearch.tsx @@ -0,0 +1,194 @@ +/* + * 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)); diff --git a/server/sonar-web/src/main/js/apps/settings/components/SettingsSearchRenderer.tsx b/server/sonar-web/src/main/js/apps/settings/components/SettingsSearchRenderer.tsx new file mode 100644 index 00000000000..c52febbcb5c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/SettingsSearchRenderer.tsx @@ -0,0 +1,103 @@ +/* + * 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> + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx b/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx index 272c0e1d75e..dfec70e0e86 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx @@ -55,12 +55,13 @@ export class SubCategoryDefinitionsList extends React.PureComponent< 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')) { @@ -106,7 +107,7 @@ export class SubCategoryDefinitionsList extends React.PureComponent< <h2 className="settings-sub-category-name" data-key={subCategory.key} - ref={this.scrollToSubCategory}> + ref={this.scrollToSubCategoryOrDefinition}> {subCategory.name} </h2> {subCategory.description != null && ( @@ -120,6 +121,7 @@ export class SubCategoryDefinitionsList extends React.PureComponent< )} <DefinitionsList component={this.props.component} + scrollToDefinition={this.scrollToSubCategoryOrDefinition} settings={bySubCategory[subCategory.key]} /> {this.renderEmailForm(subCategory.key)} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/PageHeader-test.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/PageHeader-test.tsx index 5c328e880ae..db0c76f1b57 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/PageHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/PageHeader-test.tsx @@ -23,7 +23,7 @@ import { mockComponent } from '../../../../helpers/mocks/component'; import PageHeader, { PageHeaderProps } from '../PageHeader'; it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender()).toMatchSnapshot('global'); expect(shallowRender({ component: mockComponent() })).toMatchSnapshot('for project'); }); diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearch-test.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearch-test.tsx new file mode 100644 index 00000000000..7ad7a845d6f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearch-test.tsx @@ -0,0 +1,143 @@ +/* + * 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} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearchRenderer-test.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearchRenderer-test.tsx new file mode 100644 index 00000000000..9864135ab3f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearchRenderer-test.tsx @@ -0,0 +1,79 @@ +/* + * 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} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/PageHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/PageHeader-test.tsx.snap index 93f8b63b548..682807a8995 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/PageHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/PageHeader-test.tsx.snap @@ -1,6 +1,6 @@ // 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" > @@ -13,40 +13,43 @@ exports[`should render correctly: default 1`] = ` <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> diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SettingsSearchRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SettingsSearchRenderer-test.tsx.snap new file mode 100644 index 00000000000..ba2d014d06b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SettingsSearchRenderer-test.tsx.snap @@ -0,0 +1,142 @@ +// 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> +`; diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SubCategoryDefinitionsList-test.tsx.snap b/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SubCategoryDefinitionsList-test.tsx.snap index 3c0d1bd5eb8..cc29119b1e9 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SubCategoryDefinitionsList-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SubCategoryDefinitionsList-test.tsx.snap @@ -14,6 +14,7 @@ exports[`should render correctly 1`] = ` email </h2> <DefinitionsList + scrollToDefinition={[Function]} settings={ Array [ Object { @@ -46,6 +47,7 @@ exports[`should render correctly 1`] = ` qg </h2> <DefinitionsList + scrollToDefinition={[Function]} settings={ Array [ Object { @@ -82,6 +84,7 @@ exports[`should render correctly: subcategory 1`] = ` qg </h2> <DefinitionsList + scrollToDefinition={[Function]} settings={ Array [ Object { diff --git a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegration.tsx b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegration.tsx index d1570b2a4c4..043efedf2df 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegration.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegration.tsx @@ -36,7 +36,7 @@ import { } 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'>; } @@ -99,6 +99,13 @@ export class AlmIntegration extends React.PureComponent<Props, State> { }); } + componentDidUpdate() { + const { location } = this.props; + if (location.query.alm && this.mounted) { + this.setState({ currentAlmTab: location.query.alm }); + } + } + componentWillUnmount() { this.mounted = false; } @@ -133,6 +140,10 @@ export class AlmIntegration extends React.PureComponent<Props, State> { }; handleSelectAlm = (currentAlmTab: AlmTabs) => { + const { location, router } = this.props; + location.query.alm = currentAlmTab; + location.hash = ''; + router.push(location); this.setState({ currentAlmTab }); }; diff --git a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmIntegration-test.tsx b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmIntegration-test.tsx index 9f34b761623..9937c7cbc15 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmIntegration-test.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmIntegration-test.tsx @@ -25,7 +25,7 @@ import { 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'; @@ -71,7 +71,8 @@ it('should validate existing configurations', async () => { }); it('should handle alm selection', async () => { - const wrapper = shallowRender(); + const router = mockRouter(); + const wrapper = shallowRender({ router }); wrapper.setState({ currentAlmTab: AlmKeys.Azure }); @@ -80,6 +81,7 @@ it('should handle alm selection', async () => { await waitAndUpdate(wrapper); expect(wrapper.state().currentAlmTab).toBe(AlmKeys.GitHub); + expect(router.push).toBeCalled(); }); it('should handle delete', async () => { @@ -179,10 +181,18 @@ it('should detect the current ALM from the query', () => { 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} + /> ); } diff --git a/server/sonar-web/src/main/js/apps/settings/store/rootReducer.ts b/server/sonar-web/src/main/js/apps/settings/store/rootReducer.ts index 84731172dbb..0f8f236c802 100644 --- a/server/sonar-web/src/main/js/apps/settings/store/rootReducer.ts +++ b/server/sonar-web/src/main/js/apps/settings/store/rootReducer.ts @@ -36,6 +36,10 @@ export function getDefinition(state: State, key: string) { 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); } diff --git a/server/sonar-web/src/main/js/apps/settings/styles.css b/server/sonar-web/src/main/js/apps/settings/styles.css index 3574b2130ce..1055538f26f 100644 --- a/server/sonar-web/src/main/js/apps/settings/styles.css +++ b/server/sonar-web/src/main/js/apps/settings/styles.css @@ -55,6 +55,11 @@ 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; @@ -191,3 +196,10 @@ margin: 0 20px; } } + +.settings-search-results { + max-height: 50vh; + width: 500px; + overflow-y: auto; + overflow-x: hidden; +} diff --git a/server/sonar-web/src/main/js/apps/settings/utils.ts b/server/sonar-web/src/main/js/apps/settings/utils.ts index fe956af3e50..9fd4d106634 100644 --- a/server/sonar-web/src/main/js/apps/settings/utils.ts +++ b/server/sonar-web/src/main/js/apps/settings/utils.ts @@ -17,7 +17,10 @@ * 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'; @@ -146,3 +149,151 @@ export function getDefaultValue(setting: Setting) { 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: '' + } +]; diff --git a/server/sonar-web/src/main/js/store/rootReducer.ts b/server/sonar-web/src/main/js/store/rootReducer.ts index 962a9d7edd9..5ec1467a7f0 100644 --- a/server/sonar-web/src/main/js/store/rootReducer.ts +++ b/server/sonar-web/src/main/js/store/rootReducer.ts @@ -87,6 +87,10 @@ export function getGlobalSettingValue(state: Store, key: string) { 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); } |