]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16240 Fix quality gate permission delegation search
authorPhilippe Perrin <philippe.perrin@sonarsource.com>
Mon, 11 Apr 2022 15:11:54 +0000 (17:11 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 12 Apr 2022 20:03:01 +0000 (20:03 +0000)
server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModal.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsAddModal-test.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsAddModalRenderer-test.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsAddModal-test.tsx.snap
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsAddModalRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx

index 2a2a7160b2c29dc68cd03c82c847c435e6fae846..741798c85a641b8dc9e1eef9f7581ddf6e4092dd 100644 (file)
@@ -25,6 +25,9 @@ import { QualityGate } from '../../../types/types';
 import { UserBase } from '../../../types/users';
 import QualityGatePermissionsAddModalRenderer from './QualityGatePermissionsAddModalRenderer';
 
+type Option = UserBase | Group;
+export type OptionWithValue = Option & { value: string };
+
 interface Props {
   onClose: () => void;
   onSubmit: (selection: UserBase | Group) => void;
@@ -33,9 +36,6 @@ interface Props {
 }
 
 interface State {
-  loading: boolean;
-  query: string;
-  searchResults: Array<UserBase | Group>;
   selection?: UserBase | Group;
 }
 
@@ -43,11 +43,7 @@ const DEBOUNCE_DELAY = 250;
 
 export default class QualityGatePermissionsAddModal extends React.Component<Props, State> {
   mounted = false;
-  state: State = {
-    loading: false,
-    query: '',
-    searchResults: []
-  };
+  state: State = {};
 
   constructor(props: Props) {
     super(props);
@@ -55,46 +51,26 @@ export default class QualityGatePermissionsAddModal extends React.Component<Prop
   }
 
   componentDidMount() {
-    const { query } = this.state;
     this.mounted = true;
-    this.handleSearch(query);
   }
 
   componentWillUnmount() {
     this.mounted = false;
   }
 
-  handleSearch = async (query: string) => {
+  handleSearch = (q: string, resolve: (options: OptionWithValue[]) => void) => {
     const { qualityGate } = this.props;
-    this.setState({ loading: true });
 
     const queryParams: SearchPermissionsParameters = {
       gateName: qualityGate.name,
-      q: query,
+      q,
       selected: 'deselected'
     };
 
-    try {
-      const [{ users }, { groups }] = await Promise.all([
-        searchUsers(queryParams),
-        searchGroups(queryParams)
-      ]);
-      if (this.mounted) {
-        this.setState({ loading: false, searchResults: [...users, ...groups] });
-      }
-    } catch {
-      if (this.mounted) {
-        this.setState({ loading: false });
-      }
-    }
-  };
-
-  handleInputChange = (newQuery: string) => {
-    const { query } = this.state;
-    if (query !== newQuery) {
-      this.setState({ query: newQuery });
-      this.handleSearch(newQuery);
-    }
+    Promise.all([searchUsers(queryParams), searchGroups(queryParams)])
+      .then(([usersResponse, groupsResponse]) => [...usersResponse.users, ...groupsResponse.groups])
+      .then(resolve)
+      .catch(() => resolve([]));
   };
 
   handleSelection = (selection: UserBase | Group) => {
@@ -111,16 +87,14 @@ export default class QualityGatePermissionsAddModal extends React.Component<Prop
 
   render() {
     const { submitting } = this.props;
-    const { loading, searchResults, selection } = this.state;
+    const { selection } = this.state;
 
     return (
       <QualityGatePermissionsAddModalRenderer
-        loading={loading}
         onClose={this.props.onClose}
-        onInputChange={this.handleInputChange}
         onSelection={this.handleSelection}
         onSubmit={this.handleSubmit}
-        searchResults={searchResults}
+        handleSearch={this.handleSearch}
         selection={selection}
         submitting={submitting}
       />
index f848fae60659cec98959daaecd9e84636cab7dbd..dcd3fb8bc291e8c2c8b1dc745ab0d6b2419fabc2 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 { identity, omit } from 'lodash';
+import { omit } from 'lodash';
 import * as React from 'react';
 import { components, ControlProps, OptionProps, SingleValueProps } from 'react-select';
 import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import Modal from '../../../components/controls/Modal';
-import Select from '../../../components/controls/Select';
+import { SearchSelect } from '../../../components/controls/Select';
 import GroupIcon from '../../../components/icons/GroupIcon';
 import Avatar from '../../../components/ui/Avatar';
 import { translate } from '../../../helpers/l10n';
 import { Group, isUser } from '../../../types/quality-gates';
 import { UserBase } from '../../../types/users';
+import { OptionWithValue } from './QualityGatePermissionsAddModal';
 
 export interface QualityGatePermissionsAddModalRendererProps {
   onClose: () => void;
-  onInputChange: (query: string) => void;
+  handleSearch: (q: string, resolve: (options: OptionWithValue[]) => void) => void;
+  onSelection: (selection: OptionWithValue) => void;
+  selection?: UserBase | Group;
   onSubmit: (event: React.SyntheticEvent<HTMLFormElement>) => void;
-  onSelection: (selection: Option) => void;
   submitting: boolean;
-  loading: boolean;
-  searchResults: Array<UserBase | Group>;
-  selection?: UserBase | Group;
 }
 
-export type Option = (UserBase | Group) & { value: string };
-
 export default function QualityGatePermissionsAddModalRenderer(
   props: QualityGatePermissionsAddModalRendererProps
 ) {
-  const { loading, searchResults, selection, submitting } = props;
+  const { selection, submitting } = props;
 
   const header = translate('quality_gates.permissions.grant');
 
   const noResultsText = translate('no_results');
 
-  const options = searchResults.map(r => ({ ...r, value: getValue(r) }));
-
   return (
     <Modal contentLabel={header} onRequestClose={props.onClose}>
       <header className="modal-head">
@@ -62,24 +57,21 @@ export default function QualityGatePermissionsAddModalRenderer(
         <div className="modal-body">
           <div className="modal-field">
             <label>{translate('quality_gates.permissions.search')}</label>
-            <Select
+            <SearchSelect
               className="Select-big"
               autoFocus={true}
               isClearable={false}
-              isSearchable={true}
               placeholder=""
-              isLoading={loading}
-              filterOptions={identity}
+              defaultOptions={true}
               noOptionsMessage={() => noResultsText}
               onChange={props.onSelection}
-              onInputChange={props.onInputChange}
+              loadOptions={props.handleSearch}
+              getOptionValue={opt => (isUser(opt) ? opt.login : opt.name)}
               components={{
                 Option: optionRenderer,
                 SingleValue: singleValueRenderer,
                 Control: controlRenderer
               }}
-              options={options}
-              value={options.find(o => o.value === (selection && getValue(selection)))}
             />
           </div>
         </div>
@@ -93,11 +85,7 @@ export default function QualityGatePermissionsAddModalRenderer(
   );
 }
 
-function getValue(option: UserBase | Group) {
-  return isUser(option) ? option.login : option.name;
-}
-
-export function customOptions(option: Option) {
+export function customOptions(option: OptionWithValue) {
   return (
     <>
       {isUser(option) ? (
@@ -111,7 +99,7 @@ export function customOptions(option: Option) {
   );
 }
 
-function optionRenderer(props: OptionProps<Option, false>) {
+function optionRenderer(props: OptionProps<OptionWithValue, false>) {
   return (
     <components.Option {...props} className="Select-option">
       {customOptions(props.data)}
@@ -119,7 +107,7 @@ function optionRenderer(props: OptionProps<Option, false>) {
   );
 }
 
-function singleValueRenderer(props: SingleValueProps<Option>) {
+function singleValueRenderer(props: SingleValueProps<OptionWithValue>) {
   return (
     <components.SingleValue {...props} className="Select-value-label">
       {customOptions(props.data)}
@@ -127,7 +115,7 @@ function singleValueRenderer(props: SingleValueProps<Option>) {
   );
 }
 
-function controlRenderer(props: ControlProps<Option, false>) {
+function controlRenderer(props: ControlProps<OptionWithValue, false>) {
   return (
     <components.Control {...omit(props, ['children'])} className="abs-height-100 Select-control">
       {props.children}
index 7ad0b1cecf64eeaa8485806a3aaf0ec732867a4e..e331472a1674de8f51d627e77f2c14033cabcd41 100644 (file)
@@ -22,8 +22,9 @@ import * as React from 'react';
 import { searchGroups, searchUsers } from '../../../../api/quality-gates';
 import { mockQualityGate } from '../../../../helpers/mocks/quality-gates';
 import { mockUserBase } from '../../../../helpers/mocks/users';
-import { mockEvent, waitAndUpdate } from '../../../../helpers/testUtils';
+import { mockEvent } from '../../../../helpers/testUtils';
 import QualityGatePermissionsAddModal from '../QualityGatePermissionsAddModal';
+import QualityGatePermissionsAddModalRenderer from '../QualityGatePermissionsAddModalRenderer';
 
 jest.mock('../../../../api/quality-gates', () => ({
   searchUsers: jest.fn().mockResolvedValue({ users: [] }),
@@ -44,71 +45,24 @@ it('should fetch users and groups on mount', async () => {
 
   const wrapper = shallowRender();
 
-  expect(wrapper.state().loading).toBe(true);
-
-  await waitAndUpdate(wrapper);
-
-  expect(wrapper.state().loading).toBe(false);
-  expect(searchUsers).toBeCalledWith({ gateName: 'qualitygate', q: '', selected: 'deselected' });
-  expect(searchGroups).toBeCalledWith({
-    gateName: 'qualitygate',
-    q: '',
-    selected: 'deselected'
+  const query = 'Waldo';
+  const results = await new Promise(resolve => {
+    wrapper.instance().handleSearch(query, resolve);
   });
-  expect(wrapper.state().searchResults).toHaveLength(2);
-});
-
-it('should fetch users and groups', async () => {
-  (searchUsers as jest.Mock).mockResolvedValueOnce({ users: [mockUserBase()] });
-  (searchGroups as jest.Mock).mockResolvedValueOnce({ groups: [{ name: 'group' }] });
-
-  const wrapper = shallowRender();
-  const query = 'query';
 
-  wrapper.instance().handleSearch(query);
+  expect(searchUsers).toBeCalledWith(expect.objectContaining({ q: query }));
+  expect(searchGroups).toBeCalledWith(expect.objectContaining({ q: query }));
 
-  expect(wrapper.state().loading).toBe(true);
-  expect(searchUsers).toBeCalledWith({ gateName: 'qualitygate', q: query, selected: 'deselected' });
-  expect(searchGroups).toBeCalledWith({
-    gateName: 'qualitygate',
-    q: query,
-    selected: 'deselected'
-  });
-
-  await waitAndUpdate(wrapper);
-
-  expect(wrapper.state().loading).toBe(false);
-  expect(wrapper.state().searchResults).toHaveLength(2);
-});
-
-it('should handle input change', () => {
-  const wrapper = shallowRender();
-
-  wrapper.instance().handleSearch = jest.fn();
-  const { handleSearch } = wrapper.instance();
-
-  wrapper.instance().handleInputChange('a');
-
-  expect(wrapper.state().query).toBe('a');
-  expect(handleSearch).toBeCalled();
-
-  const query = 'query';
-  wrapper.instance().handleInputChange(query);
-
-  expect(wrapper.state().query).toBe(query);
-  expect(handleSearch).toBeCalledWith(query);
-
-  jest.clearAllMocks();
-  wrapper.instance().handleInputChange(query); // input change with same parameter
-
-  expect(wrapper.state().query).toBe(query);
-  expect(handleSearch).not.toBeCalled();
+  expect(results).toHaveLength(2);
 });
 
 it('should handleSelection', () => {
   const wrapper = shallowRender();
-  const selection = mockUserBase();
-  wrapper.instance().handleSelection(selection);
+  const selection = { ...mockUserBase(), value: 'value' };
+  wrapper
+    .find(QualityGatePermissionsAddModalRenderer)
+    .props()
+    .onSelection(selection);
   expect(wrapper.state().selection).toBe(selection);
 });
 
index 4d2a5f0433631747328b42c21a8fee1c8103f5ed..5ddc17b7cef899aa768778c1e20c00c82b2b9f8b 100644 (file)
@@ -27,13 +27,9 @@ import QualityGatePermissionsAddModalRenderer, {
 
 it('should render correctly', () => {
   expect(shallowRender()).toMatchSnapshot('default');
-  expect(shallowRender({ selection: mockUserBase() })).toMatchSnapshot('selection');
   expect(shallowRender({ selection: mockUserBase(), submitting: true })).toMatchSnapshot(
-    'submitting'
+    'with selection and submitting'
   );
-  expect(
-    shallowRender({ searchResults: [mockUserBase(), { name: 'group name' }] })
-  ).toMatchSnapshot('query and results');
 });
 
 it('should render options correctly', () => {
@@ -46,12 +42,10 @@ it('should render options correctly', () => {
 function shallowRender(overrides: Partial<QualityGatePermissionsAddModalRendererProps> = {}) {
   return shallow(
     <QualityGatePermissionsAddModalRenderer
-      loading={false}
       onClose={jest.fn()}
-      onInputChange={jest.fn()}
       onSelection={jest.fn()}
       onSubmit={jest.fn()}
-      searchResults={[]}
+      handleSearch={jest.fn()}
       submitting={false}
       {...overrides}
     />
index 60b40cc6cca7306b5ba2e5c2ab5a6275a7847181..9535f6713e90c53dbde73e44c13d9a0a04da8606 100644 (file)
@@ -2,12 +2,10 @@
 
 exports[`should render correctly 1`] = `
 <QualityGatePermissionsAddModalRenderer
-  loading={true}
+  handleSearch={[Function]}
   onClose={[MockFunction]}
-  onInputChange={[Function]}
   onSelection={[Function]}
   onSubmit={[Function]}
-  searchResults={Array []}
   submitting={false}
 />
 `;
index 6feb60f1acc445990ef96345ecb8c81717306206..d1d1f01f70b0b01f0749844d206bfda076ca1c5f 100644 (file)
@@ -24,7 +24,7 @@ exports[`should render correctly: default 1`] = `
         <label>
           quality_gates.permissions.search
         </label>
-        <Select
+        <SearchSelect
           autoFocus={true}
           className="Select-big"
           components={
@@ -34,14 +34,12 @@ exports[`should render correctly: default 1`] = `
               "SingleValue": [Function],
             }
           }
-          filterOptions={[Function]}
+          defaultOptions={true}
+          getOptionValue={[Function]}
           isClearable={false}
-          isLoading={false}
-          isSearchable={true}
+          loadOptions={[MockFunction]}
           noOptionsMessage={[Function]}
           onChange={[MockFunction]}
-          onInputChange={[MockFunction]}
-          options={Array []}
           placeholder=""
         />
       </div>
@@ -64,7 +62,7 @@ exports[`should render correctly: default 1`] = `
 </Modal>
 `;
 
-exports[`should render correctly: query and results 1`] = `
+exports[`should render correctly: with selection and submitting 1`] = `
 <Modal
   contentLabel="quality_gates.permissions.grant"
   onRequestClose={[MockFunction]}
@@ -88,7 +86,7 @@ exports[`should render correctly: query and results 1`] = `
         <label>
           quality_gates.permissions.search
         </label>
-        <Select
+        <SearchSelect
           autoFocus={true}
           className="Select-big"
           components={
@@ -98,153 +96,12 @@ exports[`should render correctly: query and results 1`] = `
               "SingleValue": [Function],
             }
           }
-          filterOptions={[Function]}
+          defaultOptions={true}
+          getOptionValue={[Function]}
           isClearable={false}
-          isLoading={false}
-          isSearchable={true}
+          loadOptions={[MockFunction]}
           noOptionsMessage={[Function]}
           onChange={[MockFunction]}
-          onInputChange={[MockFunction]}
-          options={
-            Array [
-              Object {
-                "login": "userlogin",
-                "value": "userlogin",
-              },
-              Object {
-                "name": "group name",
-                "value": "group name",
-              },
-            ]
-          }
-          placeholder=""
-        />
-      </div>
-    </div>
-    <footer
-      className="modal-foot"
-    >
-      <SubmitButton
-        disabled={true}
-      >
-        add_verb
-      </SubmitButton>
-      <ResetButtonLink
-        onClick={[MockFunction]}
-      >
-        cancel
-      </ResetButtonLink>
-    </footer>
-  </form>
-</Modal>
-`;
-
-exports[`should render correctly: selection 1`] = `
-<Modal
-  contentLabel="quality_gates.permissions.grant"
-  onRequestClose={[MockFunction]}
->
-  <header
-    className="modal-head"
-  >
-    <h2>
-      quality_gates.permissions.grant
-    </h2>
-  </header>
-  <form
-    onSubmit={[MockFunction]}
-  >
-    <div
-      className="modal-body"
-    >
-      <div
-        className="modal-field"
-      >
-        <label>
-          quality_gates.permissions.search
-        </label>
-        <Select
-          autoFocus={true}
-          className="Select-big"
-          components={
-            Object {
-              "Control": [Function],
-              "Option": [Function],
-              "SingleValue": [Function],
-            }
-          }
-          filterOptions={[Function]}
-          isClearable={false}
-          isLoading={false}
-          isSearchable={true}
-          noOptionsMessage={[Function]}
-          onChange={[MockFunction]}
-          onInputChange={[MockFunction]}
-          options={Array []}
-          placeholder=""
-        />
-      </div>
-    </div>
-    <footer
-      className="modal-foot"
-    >
-      <SubmitButton
-        disabled={false}
-      >
-        add_verb
-      </SubmitButton>
-      <ResetButtonLink
-        onClick={[MockFunction]}
-      >
-        cancel
-      </ResetButtonLink>
-    </footer>
-  </form>
-</Modal>
-`;
-
-exports[`should render correctly: submitting 1`] = `
-<Modal
-  contentLabel="quality_gates.permissions.grant"
-  onRequestClose={[MockFunction]}
->
-  <header
-    className="modal-head"
-  >
-    <h2>
-      quality_gates.permissions.grant
-    </h2>
-  </header>
-  <form
-    onSubmit={[MockFunction]}
-  >
-    <div
-      className="modal-body"
-    >
-      <div
-        className="modal-field"
-      >
-        <label>
-          quality_gates.permissions.search
-        </label>
-        <Select
-          autoFocus={true}
-          className="Select-big"
-          components={
-            Object {
-              "Control": [Function],
-              "Option": [Function],
-              "SingleValue": [Function],
-            }
-          }
-          filterOptions={[Function]}
-          isClearable={false}
-          isLoading={false}
-          isSearchable={true}
-          noOptionsMessage={[Function]}
-          onChange={[MockFunction]}
-          onInputChange={[MockFunction]}
-          options={Array []}
           placeholder=""
         />
       </div>
index cb4d7abef7b390323f0a3bc560aafa25c5726ff7..5fb5f97cadc3a27867c2270a9b62f9398e2f02e3 100644 (file)
@@ -40,12 +40,12 @@ interface Props {
   profile: { language: string; name: string };
 }
 
-export default class ProfilePermissionsFormSelect extends React.PureComponent<Props> {
-  mounted = false;
+const DEBOUNCE_DELAY = 250;
 
+export default class ProfilePermissionsFormSelect extends React.PureComponent<Props> {
   constructor(props: Props) {
     super(props);
-    this.handleSearch = debounce(this.handleSearch, 250);
+    this.handleSearch = debounce(this.handleSearch, DEBOUNCE_DELAY);
   }
 
   optionRenderer(props: OptionProps<OptionWithValue, false>) {