From 908116a4c9dd62dbaf2c5137bc6a349531d5f4c1 Mon Sep 17 00:00:00 2001 From: Jeremy Davis Date: Tue, 25 Jun 2024 17:14:32 +0200 Subject: [PATCH] SONAR-22418 Migrate AssigneeSelect --- .../apps/issues/components/AssigneeSelect.tsx | 90 ++++++++++--------- .../issues/components/BulkChangeModal.tsx | 65 +++++++++----- .../__tests__/AssigneeSelect-test.tsx | 17 ++-- .../__tests__/BulkChangeModal-it.tsx | 4 +- 4 files changed, 102 insertions(+), 74 deletions(-) diff --git a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx index 2487062b626..664fb673a63 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx @@ -17,12 +17,12 @@ * 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; className?: string; inputId: string; issues: Issue[]; - onAssigneeSelect: (assignee: SingleValue) => 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(); - 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>) => 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 ( - - 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) { + const { label, Icon } = props; + + return ( +
+ {isDefined(Icon) && {Icon}} + {label} +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx index 439af636783..7ef0a24d307 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx @@ -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; - assignee?: SingleValue; + assignee?: string; comment?: string; notifications?: boolean; removeTags?: Array; @@ -126,7 +124,7 @@ export class BulkChangeModal extends React.PureComponent { return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE }); }; - handleAssigneeSelect = (assignee: SingleValue) => { + handleAssigneeSelect = (assignee: string) => { this.setState({ assignee }); }; @@ -160,21 +158,33 @@ export class BulkChangeModal extends React.PureComponent { handleSubmit = (event: React.FormEvent) => { 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 { 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 { return null; } - const input = ( - + return ( +
+ + {affected !== undefined && ( + + ({translateWithParameters('issue_bulk_change.x_issues', affected)}) + + )} +
); - - return this.renderField(field, 'issue.assign.formlink', affected, input); }; renderTagsField = ( @@ -298,6 +314,7 @@ export class BulkChangeModal extends React.PureComponent { ({ label: translate('issue.transition', transition), diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx index 5779552e809..fd139ca464b 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx @@ -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( - + , ); } diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx index 01ae1c6f3d9..09bc572597e 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx @@ -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')); -- 2.39.5