]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22418 Migrate AssigneeSelect
authorJeremy Davis <jeremy.davis@sonarsource.com>
Tue, 25 Jun 2024 15:14:32 +0000 (17:14 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 10 Jul 2024 20:02:38 +0000 (20:02 +0000)
server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx

index 2487062b626fe4521f3ea6c54a1657e245af3e80..664fb673a636ab6dd28b587c3d8cca437adcb796 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 { LabelValueSelectOption, SearchSelectDropdown } from 'design-system';
+import { SelectAsync } from '@sonarsource/echoes-react';
 import * as React from 'react';
-import { Options, SingleValue } from 'react-select';
 import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
 import Avatar from '../../../components/ui/Avatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { isDefined } from '../../../helpers/types';
 import { Issue } from '../../../types/types';
 import { RestUser, UserActive, isLoggedIn, isUserActive } from '../../../types/users';
 import { searchAssignees } from '../utils';
@@ -30,18 +30,25 @@ import { searchAssignees } from '../utils';
 // exported for test
 export const MIN_QUERY_LENGTH = 2;
 
-const UNASSIGNED = { value: '', label: translate('unassigned') };
+const UNASSIGNED: Option = { value: '', label: translate('unassigned') };
+
+interface Option {
+  Icon?: React.JSX.Element;
+  label: string;
+  value: string;
+}
 
 export interface AssigneeSelectProps {
-  assignee?: SingleValue<LabelValueSelectOption>;
   className?: string;
   inputId: string;
   issues: Issue[];
-  onAssigneeSelect: (assignee: SingleValue<LabelValueSelectOption>) => void;
+  label: string;
+  onAssigneeSelect: (assigneeKey: string) => void;
+  selectedAssigneeKey?: string;
 }
 
 function userToOption(user: RestUser | UserActive) {
-  const userInfo = user.name || user.login;
+  const userInfo = user.name ?? user.login;
   return {
     value: user.login,
     label: isUserActive(user) ? userInfo : translateWithParameters('user.x_deleted', userInfo),
@@ -50,58 +57,59 @@ function userToOption(user: RestUser | UserActive) {
 }
 
 export default function AssigneeSelect(props: AssigneeSelectProps) {
-  const { assignee, className, issues, inputId } = props;
+  const { className, issues, inputId, label, selectedAssigneeKey } = props;
 
   const { currentUser } = React.useContext(CurrentUserContext);
 
-  const allowCurrentUserSelection =
-    isLoggedIn(currentUser) && issues.some((issue) => currentUser.login !== issue.assignee);
+  const [options, setOptions] = React.useState<Option[]>();
 
-  const defaultOptions = allowCurrentUserSelection
-    ? [UNASSIGNED, userToOption(currentUser)]
-    : [UNASSIGNED];
+  const defaultOptions = React.useMemo((): Option[] => {
+    const allowCurrentUserSelection =
+      isLoggedIn(currentUser) && issues.some((issue) => currentUser.login !== issue.assignee);
 
-  const controlLabel = assignee ? (
-    <>
-      {assignee.Icon} {assignee.label}
-    </>
-  ) : (
-    translate('select_verb')
-  );
+    return allowCurrentUserSelection ? [UNASSIGNED, userToOption(currentUser)] : [UNASSIGNED];
+  }, [currentUser, issues]);
 
   const handleAssigneeSearch = React.useCallback(
-    (query: string, resolve: (options: Options<LabelValueSelectOption<string>>) => void) => {
+    async (query: string) => {
       if (query.length < MIN_QUERY_LENGTH) {
-        resolve([]);
+        setOptions(defaultOptions);
         return;
       }
 
-      searchAssignees(query)
-        .then(({ results }) => results.map(userToOption))
-        .then(resolve)
-        .catch(() => resolve([]));
+      const assignees = await searchAssignees(query).then(({ results }) =>
+        results.map(userToOption),
+      );
+
+      setOptions(assignees);
     },
-    [],
+    [defaultOptions],
   );
 
   return (
-    <SearchSelectDropdown
-      aria-label={translate('search.search_for_users')}
+    <SelectAsync
+      ariaLabel={translate('issue_bulk_change.assignee.change')}
       className={className}
-      size="full"
-      controlSize="full"
-      inputId={inputId}
-      defaultOptions={defaultOptions}
-      loadOptions={handleAssigneeSearch}
+      id={inputId}
+      data={options ?? defaultOptions}
+      helpText={translateWithParameters('select.search.tooShort', MIN_QUERY_LENGTH)}
+      label={label}
+      labelNotFound={translate('select.search.noMatches')}
       onChange={props.onAssigneeSelect}
-      noOptionsMessage={({ inputValue }) =>
-        inputValue.length < MIN_QUERY_LENGTH
-          ? translateWithParameters('select2.tooShort', MIN_QUERY_LENGTH)
-          : translate('select2.noMatches')
-      }
-      placeholder={translate('search.search_for_users')}
-      controlLabel={controlLabel}
-      controlAriaLabel={translate('issue_bulk_change.assignee.change')}
+      onSearch={handleAssigneeSearch}
+      optionComponent={AssigneeOption}
+      value={selectedAssigneeKey}
     />
   );
 }
+
+function AssigneeOption(props: Omit<Option, 'value'>) {
+  const { label, Icon } = props;
+
+  return (
+    <div className="sw-flex sw-flex-nowrap sw-items-center sw-overflow-hidden">
+      {isDefined(Icon) && <span className="sw-mr-2">{Icon}</span>}
+      <span className="sw-whitespace-nowrap sw-text-ellipsis">{label}</span>
+    </div>
+  );
+}
index 439af6367832ee2c7488d3122be286bf951ad4de..7ef0a24d307142a708f79cf1d8a2bffdf9d6539e 100644 (file)
@@ -25,14 +25,12 @@ import {
   FormField,
   Highlight,
   InputTextArea,
-  LabelValueSelectOption,
   LightLabel,
   Modal,
 } from 'design-system';
 import { countBy, flattenDeep, pickBy, sortBy } from 'lodash';
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
-import { SingleValue } from 'react-select';
 import { throwGlobalError } from '~sonar-aligned/helpers/error';
 import { bulkChangeIssues, searchIssueTags } from '../../../api/issues';
 import FormattingTips from '../../../components/common/FormattingTips';
@@ -54,7 +52,7 @@ interface Props {
 
 interface FormFields {
   addTags?: Array<string>;
-  assignee?: SingleValue<LabelValueSelectOption>;
+  assignee?: string;
   comment?: string;
   notifications?: boolean;
   removeTags?: Array<string>;
@@ -126,7 +124,7 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
     return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE });
   };
 
-  handleAssigneeSelect = (assignee: SingleValue<LabelValueSelectOption>) => {
+  handleAssigneeSelect = (assignee: string) => {
     this.setState({ assignee });
   };
 
@@ -160,21 +158,33 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
   handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
 
+    const {
+      addTags,
+      assignee,
+      comment,
+      issues,
+      notifications,
+      removeTags,
+      severity,
+      transition,
+      type,
+    } = this.state;
+
     const query = pickBy(
       {
-        add_tags: this.state.addTags?.join(),
-        assign: this.state.assignee ? this.state.assignee.value : null,
-        comment: this.state.comment,
-        do_transition: this.state.transition,
-        remove_tags: this.state.removeTags?.join(),
-        sendNotifications: this.state.notifications,
-        set_severity: this.state.severity,
-        set_type: this.state.type,
+        add_tags: addTags?.join(),
+        assign: assignee,
+        comment,
+        do_transition: transition,
+        remove_tags: removeTags?.join(),
+        sendNotifications: notifications,
+        set_severity: severity,
+        set_type: type,
       },
       (x) => x !== undefined,
     );
 
-    const issueKeys = this.state.issues.map((issue) => issue.key);
+    const issueKeys = issues.map((issue) => issue.key);
 
     this.setState({ submitting: true });
 
@@ -207,7 +217,7 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
     return Boolean(
       (addTags && addTags.length > 0) ||
         (removeTags && removeTags.length > 0) ||
-        assignee ||
+        assignee !== undefined ||
         severity ||
         transition ||
         type,
@@ -242,17 +252,23 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
       return null;
     }
 
-    const input = (
-      <AssigneeSelect
-        assignee={assignee}
-        className="sw-max-w-abs-300"
-        inputId={`issues-bulk-change-${field}`}
-        issues={issues}
-        onAssigneeSelect={this.handleAssigneeSelect}
-      />
+    return (
+      <div className="sw-flex sw-items-center sw-justify-between sw-mb-6">
+        <AssigneeSelect
+          className="sw-max-w-abs-300"
+          inputId={`issues-bulk-change-${field}`}
+          issues={issues}
+          label={translate('issue.assign.formlink')}
+          onAssigneeSelect={this.handleAssigneeSelect}
+          selectedAssigneeKey={assignee}
+        />
+        {affected !== undefined && (
+          <LightLabel>
+            ({translateWithParameters('issue_bulk_change.x_issues', affected)})
+          </LightLabel>
+        )}
+      </div>
     );
-
-    return this.renderField(field, 'issue.assign.formlink', affected, input);
   };
 
   renderTagsField = (
@@ -298,6 +314,7 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
           </Highlight>
 
           <RadioButtonGroup
+            ariaLabel="a"
             id="bulk-change-transition"
             options={transitions.map(({ transition, count }) => ({
               label: translate('issue.transition', transition),
index 5779552e809d1e71bedeb21af5736b5e9c473e9f..fd139ca464b6acce079cbca225daad106e64dc5c 100644 (file)
@@ -54,7 +54,6 @@ jest.mock('../../utils', () => ({
 
 const ui = {
   combobox: byLabelText('issue_bulk_change.assignee.change'),
-  searchbox: byLabelText('search.search_for_users'),
 };
 
 it('should show correct suggestions when there is assignable issue for the current user', async () => {
@@ -97,13 +96,13 @@ it('should handle assignee search correctly', async () => {
 
   // Minimum MIN_QUERY_LENGTH charachters to trigger search
   await user.click(ui.combobox.get());
-  await user.type(ui.searchbox.get(), 'a');
+  await user.type(ui.combobox.get(), 'a');
 
-  expect(await screen.findByText(`select2.tooShort.${MIN_QUERY_LENGTH}`)).toBeInTheDocument();
+  expect(await screen.findByText(`select.search.tooShort.${MIN_QUERY_LENGTH}`)).toBeInTheDocument();
 
   // Trigger search
   await user.click(ui.combobox.get());
-  await user.type(ui.searchbox.get(), 'someone');
+  await user.type(ui.combobox.get(), 'someone');
 
   expect(await screen.findByText('toto')).toBeInTheDocument();
   expect(await screen.findByText('user.x_deleted.tata')).toBeInTheDocument();
@@ -116,7 +115,7 @@ it('should handle assignee selection', async () => {
   renderAssigneeSelect({ onAssigneeSelect });
 
   await user.click(ui.combobox.get());
-  await user.type(ui.searchbox.get(), 'tot');
+  await user.type(ui.combobox.get(), 'tot');
 
   // Do not select assignee until suggestion is selected
   expect(onAssigneeSelect).not.toHaveBeenCalled();
@@ -132,7 +131,13 @@ function renderAssigneeSelect(
 ) {
   return renderComponent(
     <CurrentUserContextProvider currentUser={currentUser}>
-      <AssigneeSelect inputId="id" issues={[]} onAssigneeSelect={jest.fn()} {...overrides} />
+      <AssigneeSelect
+        inputId="id"
+        issues={[]}
+        label=""
+        onAssigneeSelect={jest.fn()}
+        {...overrides}
+      />
     </CurrentUserContextProvider>,
   );
 }
index 01ae1c6f3d9cabced13d450a0073886db0d2ac09..09bc572597ef3f6392a1c56938d1d89362c48764 100644 (file)
@@ -151,9 +151,7 @@ it('should properly submit', async () => {
   expect(onDone).toHaveBeenCalledTimes(0);
 
   // Assign
-  await user.click(
-    await screen.findByRole('combobox', { name: 'issue_bulk_change.assignee.change' }),
-  );
+  await user.click(await screen.findByLabelText('issue_bulk_change.assignee.change'));
 
   await user.click(await screen.findByText('Toto'));