* 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';
// 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),
}
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>
+ );
+}
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';
interface FormFields {
addTags?: Array<string>;
- assignee?: SingleValue<LabelValueSelectOption>;
+ assignee?: string;
comment?: string;
notifications?: boolean;
removeTags?: Array<string>;
return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE });
};
- handleAssigneeSelect = (assignee: SingleValue<LabelValueSelectOption>) => {
+ handleAssigneeSelect = (assignee: string) => {
this.setState({ assignee });
};
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 });
return Boolean(
(addTags && addTags.length > 0) ||
(removeTags && removeTags.length > 0) ||
- assignee ||
+ assignee !== undefined ||
severity ||
transition ||
type,
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 = (
</Highlight>
<RadioButtonGroup
+ ariaLabel="a"
id="bulk-change-transition"
options={transitions.map(({ transition, count }) => ({
label: translate('issue.transition', transition),
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 () => {
// 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();
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();
) {
return renderComponent(
<CurrentUserContextProvider currentUser={currentUser}>
- <AssigneeSelect inputId="id" issues={[]} onAssigneeSelect={jest.fn()} {...overrides} />
+ <AssigneeSelect
+ inputId="id"
+ issues={[]}
+ label=""
+ onAssigneeSelect={jest.fn()}
+ {...overrides}
+ />
</CurrentUserContextProvider>,
);
}