]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-15366 Add Settings Search feature
authorJay <jeremy.davis@sonarsource.com>
Mon, 13 Sep 2021 13:55:09 +0000 (15:55 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 13 Sep 2021 20:03:33 +0000 (20:03 +0000)
19 files changed:
server/sonar-web/src/main/js/apps/settings/__tests__/utils-test.ts
server/sonar-web/src/main/js/apps/settings/components/DefinitionsList.tsx
server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx
server/sonar-web/src/main/js/apps/settings/components/SettingsSearch.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/SettingsSearchRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx
server/sonar-web/src/main/js/apps/settings/components/__tests__/PageHeader-test.tsx
server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearch-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsSearchRenderer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/PageHeader-test.tsx.snap
server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SettingsSearchRenderer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/SubCategoryDefinitionsList-test.tsx.snap
server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegration.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmIntegration-test.tsx
server/sonar-web/src/main/js/apps/settings/store/rootReducer.ts
server/sonar-web/src/main/js/apps/settings/styles.css
server/sonar-web/src/main/js/apps/settings/utils.ts
server/sonar-web/src/main/js/store/rootReducer.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index aa0c97c7898cf4b1ac65f2e60dc7590dc53093cd..38d70015b5567081b6e739949c7d5e0910efffb8 100644 (file)
  * 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);
+  });
+});
index 653cf225f8dab367190aa47ae9f443fcb42ea79f..8f810e009f019599d5517223a650f1f9addfc2e3 100644 (file)
@@ -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>
       ))}
index bca4bc823557ab7fe7cb99f6c59fa63ebe4ea170..75234993ac19e00d988c2df3c367f4ae7fe0d87d 100644 (file)
  * 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 (file)
index 0000000..951493b
--- /dev/null
@@ -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 (file)
index 0000000..c52febb
--- /dev/null
@@ -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>
+  );
+}
index 272c0e1d75e16ab41d63f102fa5c8d1b851e5c02..dfec70e0e8616bff61e0616676ae2597a775d82a 100644 (file)
@@ -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)}
index 5c328e880aee21a5ec5a9588f13583f72fcebc17..db0c76f1b571e546e802308df8a316fd4fb03c9d 100644 (file)
@@ -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 (file)
index 0000000..7ad7a84
--- /dev/null
@@ -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 (file)
index 0000000..9864135
--- /dev/null
@@ -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}
+    />
+  );
+}
index 93f8b63b54833ae30831560b14b56ed299d54663..682807a8995364cc5409f5fe1882c40835744f0f 100644 (file)
@@ -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 (file)
index 0000000..ba2d014
--- /dev/null
@@ -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>
+`;
index 3c0d1bd5eb8ffc49ddffeb4f0653929f1935071d..cc29119b1e9fda85c6fb8662da0186707c113cd2 100644 (file)
@@ -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 {
index d1570b2a4c42c2c516e9f7322811557ad0e4ebdf..043efedf2df56d6c7aa7387a859117a206f87fe6 100644 (file)
@@ -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 });
   };
 
index 9f34b7616236e4cfd5f44ed485f375ffa101b7eb..9937c7cbc158326c8f104e29753e6f1438596653 100644 (file)
@@ -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}
+    />
   );
 }
index 84731172dbb1ed9ef03f51076ae1c4433aad2bf9..0f8f236c8028d0022dd28d6a46d336f942d03008 100644 (file)
@@ -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);
 }
index 3574b2130ce47e6dbf004ac6215df68bcd5b7fde..1055538f26f4e473f1ef502eda07c0c6cba3d142 100644 (file)
   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;
+}
index fe956af3e50c75db74fc1ae6a37c57e03394a8fe..9fd4d106634707049cb8bc738ead34a655c87bbf 100644 (file)
  * 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: ''
+  }
+];
index 962a9d7edd9438fa3dbeb96bbc1c6ef5c92bb24c..5ec1467a7f07bf9794b0eee67686205668a4b19a 100644 (file)
@@ -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);
 }
index dd22228b22421bfc39d37e4cce510ca3e33aa690..4fa48c8c02d42406403f1fdd9d8b9adf8b5f0640 100644 (file)
@@ -1106,6 +1106,8 @@ settings.default.password=<password>
 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.