You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

BulkChangeModal.tsx 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2019 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import * as React from 'react';
  21. import { FormattedMessage } from 'react-intl';
  22. import { pickBy, sortBy } from 'lodash';
  23. import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
  24. import { SubmitButton, ResetButtonLink } from 'sonar-ui-common/components/controls/buttons';
  25. import Modal from 'sonar-ui-common/components/controls/Modal';
  26. import IssueTypeIcon from 'sonar-ui-common/components/icons/IssueTypeIcon';
  27. import { Alert } from 'sonar-ui-common/components/ui/Alert';
  28. import Checkbox from 'sonar-ui-common/components/controls/Checkbox';
  29. import HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip';
  30. import Radio from 'sonar-ui-common/components/controls/Radio';
  31. import Select from 'sonar-ui-common/components/controls/Select';
  32. import SearchSelect from 'sonar-ui-common/components/controls/SearchSelect';
  33. import { searchAssignees } from '../utils';
  34. import Avatar from '../../../components/ui/Avatar';
  35. import MarkdownTips from '../../../components/common/MarkdownTips';
  36. import SeverityHelper from '../../../components/shared/SeverityHelper';
  37. import throwGlobalError from '../../../app/utils/throwGlobalError';
  38. import { searchIssueTags, bulkChangeIssues } from '../../../api/issues';
  39. import { isLoggedIn, isUserActive } from '../../../helpers/users';
  40. interface AssigneeOption {
  41. avatar?: string;
  42. email?: string;
  43. label: string;
  44. value: string;
  45. }
  46. interface TagOption {
  47. label: string;
  48. value: string;
  49. }
  50. interface Props {
  51. component: T.Component | undefined;
  52. currentUser: T.CurrentUser;
  53. fetchIssues: (x: {}) => Promise<{ issues: T.Issue[]; paging: T.Paging }>;
  54. onClose: () => void;
  55. onDone: () => void;
  56. organization: { key: string } | undefined;
  57. }
  58. interface FormFields {
  59. addTags?: Array<{ label: string; value: string }>;
  60. assignee?: AssigneeOption;
  61. comment?: string;
  62. notifications?: boolean;
  63. organization?: string;
  64. removeTags?: Array<{ label: string; value: string }>;
  65. severity?: string;
  66. transition?: string;
  67. type?: string;
  68. }
  69. interface State extends FormFields {
  70. initialTags: Array<{ label: string; value: string }>;
  71. issues: T.Issue[];
  72. // used for initial loading of issues
  73. loading: boolean;
  74. paging?: T.Paging;
  75. // used when submitting a form
  76. submitting: boolean;
  77. }
  78. type AssigneeSelectType = new () => SearchSelect<AssigneeOption>;
  79. const AssigneeSelect = SearchSelect as AssigneeSelectType;
  80. type TagSelectType = new () => SearchSelect<TagOption>;
  81. const TagSelect = SearchSelect as TagSelectType;
  82. export const MAX_PAGE_SIZE = 500;
  83. export default class BulkChangeModal extends React.PureComponent<Props, State> {
  84. mounted = false;
  85. constructor(props: Props) {
  86. super(props);
  87. let organization = props.component && props.component.organization;
  88. if (props.organization && !organization) {
  89. organization = props.organization.key;
  90. }
  91. this.state = { initialTags: [], issues: [], loading: true, submitting: false, organization };
  92. }
  93. componentDidMount() {
  94. this.mounted = true;
  95. Promise.all([
  96. this.loadIssues(),
  97. searchIssueTags({ organization: this.state.organization })
  98. ]).then(
  99. ([{ issues, paging }, tags]) => {
  100. if (this.mounted) {
  101. if (issues.length > MAX_PAGE_SIZE) {
  102. issues = issues.slice(0, MAX_PAGE_SIZE);
  103. }
  104. this.setState({
  105. initialTags: tags.map(tag => ({ label: tag, value: tag })),
  106. issues,
  107. loading: false,
  108. paging
  109. });
  110. }
  111. },
  112. () => {}
  113. );
  114. }
  115. componentWillUnmount() {
  116. this.mounted = false;
  117. }
  118. loadIssues = () => {
  119. return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE });
  120. };
  121. getDefaultAssignee = () => {
  122. const { currentUser } = this.props;
  123. const { issues } = this.state;
  124. const options = [];
  125. if (isLoggedIn(currentUser)) {
  126. const canBeAssignedToMe =
  127. issues.filter(issue => issue.assignee !== currentUser.login).length > 0;
  128. if (canBeAssignedToMe) {
  129. options.push({
  130. avatar: currentUser.avatar,
  131. label: currentUser.name,
  132. value: currentUser.login
  133. });
  134. }
  135. }
  136. const canBeUnassigned = issues.filter(issue => issue.assignee).length > 0;
  137. if (canBeUnassigned) {
  138. options.push({ label: translate('unassigned'), value: '' });
  139. }
  140. return options;
  141. };
  142. handleAssigneeSearch = (query: string) => {
  143. return searchAssignees(query, this.state.organization).then(({ results }) =>
  144. results.map(r => ({
  145. avatar: r.avatar,
  146. label: isUserActive(r) ? r.name : translateWithParameters('user.x_deleted', r.login),
  147. value: r.login
  148. }))
  149. );
  150. };
  151. handleAssigneeSelect = (assignee: AssigneeOption) => {
  152. this.setState({ assignee });
  153. };
  154. handleTagsSearch = (query: string) => {
  155. return searchIssueTags({ organization: this.state.organization, q: query }).then(tags =>
  156. tags.map(tag => ({ label: tag, value: tag }))
  157. );
  158. };
  159. handleTagsSelect = (field: 'addTags' | 'removeTags') => (
  160. options: Array<{ label: string; value: string }>
  161. ) => {
  162. this.setState<keyof FormFields>({ [field]: options });
  163. };
  164. handleFieldCheck = (field: keyof FormFields) => (checked: boolean) => {
  165. if (!checked) {
  166. this.setState<keyof FormFields>({ [field]: undefined });
  167. } else if (field === 'notifications') {
  168. this.setState<keyof FormFields>({ [field]: true });
  169. }
  170. };
  171. handleRadioTransitionChange = (transition: string) => {
  172. this.setState({ transition });
  173. };
  174. handleCommentChange = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
  175. this.setState({ comment: event.currentTarget.value });
  176. };
  177. handleSelectFieldChange = (field: 'severity' | 'type') => (data: { value: string } | null) => {
  178. if (data) {
  179. this.setState<keyof FormFields>({ [field]: data.value });
  180. } else {
  181. this.setState<keyof FormFields>({ [field]: undefined });
  182. }
  183. };
  184. handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  185. event.preventDefault();
  186. const query = pickBy(
  187. {
  188. add_tags: this.state.addTags && this.state.addTags.map(t => t.value).join(),
  189. assign: this.state.assignee ? this.state.assignee.value : null,
  190. comment: this.state.comment,
  191. do_transition: this.state.transition,
  192. remove_tags: this.state.removeTags && this.state.removeTags.map(t => t.value).join(),
  193. sendNotifications: this.state.notifications,
  194. set_severity: this.state.severity,
  195. set_type: this.state.type
  196. },
  197. x => x !== undefined
  198. );
  199. const issueKeys = this.state.issues.map(issue => issue.key);
  200. this.setState({ submitting: true });
  201. bulkChangeIssues(issueKeys, query).then(
  202. () => {
  203. this.setState({ submitting: false });
  204. this.props.onDone();
  205. },
  206. error => {
  207. this.setState({ submitting: false });
  208. throwGlobalError(error);
  209. }
  210. );
  211. };
  212. getAvailableTransitions(issues: T.Issue[]) {
  213. const transitions: T.Dict<number> = {};
  214. issues.forEach(issue => {
  215. if (issue.transitions) {
  216. issue.transitions.forEach(t => {
  217. if (transitions[t] !== undefined) {
  218. transitions[t]++;
  219. } else {
  220. transitions[t] = 1;
  221. }
  222. });
  223. }
  224. });
  225. return sortBy(Object.keys(transitions)).map(transition => ({
  226. transition,
  227. count: transitions[transition]
  228. }));
  229. }
  230. renderLoading = () => (
  231. <div>
  232. <div className="modal-head">
  233. <h2>{translate('bulk_change')}</h2>
  234. </div>
  235. <div className="modal-body">
  236. <div className="text-center">
  237. <i className="spinner spacer" />
  238. </div>
  239. </div>
  240. <div className="modal-foot">
  241. <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink>
  242. </div>
  243. </div>
  244. );
  245. renderAffected = (affected: number) => (
  246. <div className="pull-right note">
  247. ({translateWithParameters('issue_bulk_change.x_issues', affected)})
  248. </div>
  249. );
  250. renderField = (
  251. field: 'addTags' | 'assignee' | 'removeTags' | 'severity' | 'type',
  252. label: string,
  253. affected: number | undefined,
  254. input: React.ReactNode
  255. ) => (
  256. <div className="modal-field" id={`issues-bulk-change-${field}`}>
  257. <label htmlFor={field}>{translate(label)}</label>
  258. {input}
  259. {affected !== undefined && this.renderAffected(affected)}
  260. </div>
  261. );
  262. renderAssigneeOption = (option: AssigneeOption) => {
  263. return (
  264. <span>
  265. {option.avatar !== undefined && (
  266. <Avatar className="spacer-right" hash={option.avatar} name={option.label} size={16} />
  267. )}
  268. {option.label}
  269. </span>
  270. );
  271. };
  272. renderAssigneeField = () => {
  273. const affected = this.state.issues.filter(hasAction('assign')).length;
  274. if (affected === 0) {
  275. return null;
  276. }
  277. const input = (
  278. <AssigneeSelect
  279. className="input-super-large"
  280. clearable={true}
  281. defaultOptions={this.getDefaultAssignee()}
  282. onSearch={this.handleAssigneeSearch}
  283. onSelect={this.handleAssigneeSelect}
  284. renderOption={this.renderAssigneeOption}
  285. resetOnBlur={false}
  286. value={this.state.assignee}
  287. />
  288. );
  289. return this.renderField('assignee', 'issue.assign.formlink', affected, input);
  290. };
  291. renderTypeField = () => {
  292. const affected = this.state.issues.filter(hasAction('set_type')).length;
  293. if (affected === 0) {
  294. return null;
  295. }
  296. const types: T.IssueType[] = ['BUG', 'VULNERABILITY', 'CODE_SMELL'];
  297. const options = types.map(type => ({ label: translate('issue.type', type), value: type }));
  298. const optionRenderer = (option: { label: string; value: string }) => (
  299. <span>
  300. <IssueTypeIcon className="little-spacer-right" query={option.value} />
  301. {option.label}
  302. </span>
  303. );
  304. const input = (
  305. <Select
  306. className="input-super-large"
  307. clearable={true}
  308. onChange={this.handleSelectFieldChange('type')}
  309. optionRenderer={optionRenderer}
  310. options={options}
  311. searchable={false}
  312. value={this.state.type}
  313. valueRenderer={optionRenderer}
  314. />
  315. );
  316. return this.renderField('type', 'issue.set_type', affected, input);
  317. };
  318. renderSeverityField = () => {
  319. const affected = this.state.issues.filter(hasAction('set_severity')).length;
  320. if (affected === 0) {
  321. return null;
  322. }
  323. const severities = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO'];
  324. const options = severities.map(severity => ({
  325. label: translate('severity', severity),
  326. value: severity
  327. }));
  328. const input = (
  329. <Select
  330. className="input-super-large"
  331. clearable={true}
  332. onChange={this.handleSelectFieldChange('severity')}
  333. optionRenderer={(option: { value: string }) => <SeverityHelper severity={option.value} />}
  334. options={options}
  335. searchable={false}
  336. value={this.state.severity}
  337. valueRenderer={(option: { value: string }) => <SeverityHelper severity={option.value} />}
  338. />
  339. );
  340. return this.renderField('severity', 'issue.set_severity', affected, input);
  341. };
  342. renderTagOption = (option: TagOption) => {
  343. return <span>{option.label}</span>;
  344. };
  345. renderTagsField = (field: 'addTags' | 'removeTags', label: string, allowCreate: boolean) => {
  346. const { initialTags } = this.state;
  347. const affected = this.state.issues.filter(hasAction('set_tags')).length;
  348. if (initialTags === undefined || affected === 0) {
  349. return null;
  350. }
  351. const input = (
  352. <TagSelect
  353. canCreate={allowCreate}
  354. className="input-super-large"
  355. clearable={true}
  356. defaultOptions={this.state.initialTags}
  357. minimumQueryLength={0}
  358. multi={true}
  359. onMultiSelect={this.handleTagsSelect(field)}
  360. onSearch={this.handleTagsSearch}
  361. promptTextCreator={promptCreateTag}
  362. renderOption={this.renderTagOption}
  363. resetOnBlur={false}
  364. value={this.state[field]}
  365. />
  366. );
  367. return this.renderField(field, label, affected, input);
  368. };
  369. renderTransitionsField = () => {
  370. const transitions = this.getAvailableTransitions(this.state.issues);
  371. if (transitions.length === 0) {
  372. return null;
  373. }
  374. return (
  375. <div className="modal-field">
  376. <label>{translate('issue.transition')}</label>
  377. {transitions.map(transition => (
  378. <span
  379. className="bulk-change-radio-button display-flex-center display-flex-space-between"
  380. key={transition.transition}>
  381. <Radio
  382. checked={this.state.transition === transition.transition}
  383. onCheck={this.handleRadioTransitionChange}
  384. value={transition.transition}>
  385. {translate('issue.transition', transition.transition)}
  386. </Radio>
  387. {this.renderAffected(transition.count)}
  388. </span>
  389. ))}
  390. </div>
  391. );
  392. };
  393. renderCommentField = () => {
  394. const affected = this.state.issues.filter(hasAction('comment')).length;
  395. if (affected === 0) {
  396. return null;
  397. }
  398. return (
  399. <div className="modal-field">
  400. <label htmlFor="comment">
  401. <span className="text-middle">{translate('issue.comment.formlink')}</span>
  402. <HelpTooltip
  403. className="spacer-left"
  404. overlay={translate('issue_bulk_change.comment.help')}
  405. />
  406. </label>
  407. <textarea
  408. id="comment"
  409. onChange={this.handleCommentChange}
  410. rows={4}
  411. value={this.state.comment || ''}
  412. />
  413. <MarkdownTips className="modal-field-descriptor text-right" />
  414. </div>
  415. );
  416. };
  417. renderNotificationsField = () => (
  418. <Checkbox
  419. checked={this.state.notifications !== undefined}
  420. className="display-inline-block spacer-top"
  421. id="send-notifications"
  422. onCheck={this.handleFieldCheck('notifications')}
  423. right={true}>
  424. <strong className="little-spacer-right">{translate('issue.send_notifications')}</strong>
  425. </Checkbox>
  426. );
  427. renderForm = () => {
  428. const { issues, paging, submitting } = this.state;
  429. const limitReached = paging && paging.total > MAX_PAGE_SIZE;
  430. return (
  431. <form id="bulk-change-form" onSubmit={this.handleSubmit}>
  432. <div className="modal-head">
  433. <h2>{translateWithParameters('issue_bulk_change.form.title', issues.length)}</h2>
  434. </div>
  435. <div className="modal-body modal-container">
  436. {limitReached && (
  437. <Alert variant="warning">
  438. <FormattedMessage
  439. defaultMessage={translate('issue_bulk_change.max_issues_reached')}
  440. id="issue_bulk_change.max_issues_reached"
  441. values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
  442. />
  443. </Alert>
  444. )}
  445. {this.renderAssigneeField()}
  446. {this.renderTypeField()}
  447. {this.renderSeverityField()}
  448. {this.renderTagsField('addTags', 'issue.add_tags', true)}
  449. {this.renderTagsField('removeTags', 'issue.remove_tags', false)}
  450. {this.renderTransitionsField()}
  451. {this.renderCommentField()}
  452. {issues.length > 0 && this.renderNotificationsField()}
  453. {issues.length === 0 && (
  454. <Alert variant="warning">{translate('issue_bulk_change.no_match')}</Alert>
  455. )}
  456. </div>
  457. <div className="modal-foot">
  458. {submitting && <i className="spinner spacer-right" />}
  459. <SubmitButton disabled={submitting || issues.length === 0} id="bulk-change-submit">
  460. {translate('apply')}
  461. </SubmitButton>
  462. <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink>
  463. </div>
  464. </form>
  465. );
  466. };
  467. render() {
  468. return (
  469. <Modal contentLabel="modal" onRequestClose={this.props.onClose} size="small">
  470. {this.state.loading ? this.renderLoading() : this.renderForm()}
  471. </Modal>
  472. );
  473. }
  474. }
  475. function hasAction(action: string) {
  476. return (issue: T.Issue) => issue.actions && issue.actions.includes(action);
  477. }
  478. function promptCreateTag(label: string) {
  479. return `+ ${label}`;
  480. }