ownerInput.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import {Component, Fragment} from 'react';
  2. import TextareaAutosize from 'react-autosize-textarea';
  3. import styled from '@emotion/styled';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {Client} from 'sentry/api';
  6. import Button from 'sentry/components/button';
  7. import {t} from 'sentry/locale';
  8. import MemberListStore from 'sentry/stores/memberListStore';
  9. import ProjectsStore from 'sentry/stores/projectsStore';
  10. import {inputStyles} from 'sentry/styles/input';
  11. import {Organization, Project, Team} from 'sentry/types';
  12. import {defined} from 'sentry/utils';
  13. import RuleBuilder from './ruleBuilder';
  14. const defaultProps = {
  15. urls: [] as string[],
  16. paths: [] as string[],
  17. disabled: false,
  18. };
  19. type Props = {
  20. initialText: string;
  21. organization: Organization;
  22. project: Project;
  23. onSave?: (text: string | null) => void;
  24. } & typeof defaultProps;
  25. type State = {
  26. error: null | {
  27. raw: string[];
  28. };
  29. hasChanges: boolean;
  30. text: string | null;
  31. };
  32. class OwnerInput extends Component<Props, State> {
  33. static defaultProps = defaultProps;
  34. state: State = {
  35. hasChanges: false,
  36. text: null,
  37. error: null,
  38. };
  39. parseError(error: State['error']) {
  40. const text = error?.raw?.[0];
  41. if (!text) {
  42. return null;
  43. }
  44. if (text.startsWith('Invalid rule owners:')) {
  45. return <InvalidOwners>{text}</InvalidOwners>;
  46. }
  47. return (
  48. <SyntaxOverlay line={parseInt(text.match(/line (\d*),/)?.[1] ?? '', 10) - 1} />
  49. );
  50. }
  51. handleUpdateOwnership = () => {
  52. const {organization, project, onSave} = this.props;
  53. const {text} = this.state;
  54. this.setState({error: null});
  55. const api = new Client();
  56. const request = api.requestPromise(
  57. `/projects/${organization.slug}/${project.slug}/ownership/`,
  58. {
  59. method: 'PUT',
  60. data: {raw: text || ''},
  61. }
  62. );
  63. request
  64. .then(() => {
  65. addSuccessMessage(t('Updated issue ownership rules'));
  66. this.setState(
  67. {
  68. hasChanges: false,
  69. text,
  70. },
  71. () => onSave && onSave(text)
  72. );
  73. })
  74. .catch(error => {
  75. this.setState({error: error.responseJSON});
  76. if (error.status === 403) {
  77. addErrorMessage(
  78. t(
  79. "You don't have permission to modify issue ownership rules for this project"
  80. )
  81. );
  82. } else if (
  83. error.status === 400 &&
  84. error.responseJSON.raw &&
  85. error.responseJSON.raw[0].startsWith('Invalid rule owners:')
  86. ) {
  87. addErrorMessage(
  88. t('Unable to save issue ownership rule changes: ' + error.responseJSON.raw[0])
  89. );
  90. } else {
  91. addErrorMessage(t('Unable to save issue ownership rule changes'));
  92. }
  93. });
  94. return request;
  95. };
  96. mentionableUsers() {
  97. return MemberListStore.getAll().map(member => ({
  98. id: member.id,
  99. display: member.email,
  100. email: member.email,
  101. }));
  102. }
  103. mentionableTeams() {
  104. const {project} = this.props;
  105. const projectWithTeams = ProjectsStore.getBySlug(project.slug);
  106. if (!projectWithTeams) {
  107. return [];
  108. }
  109. return projectWithTeams.teams.map((team: Team) => ({
  110. id: team.id,
  111. display: `#${team.slug}`,
  112. email: team.id,
  113. }));
  114. }
  115. handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  116. this.setState({
  117. hasChanges: true,
  118. text: e.target.value,
  119. });
  120. };
  121. handleAddRule = (rule: string) => {
  122. const {initialText} = this.props;
  123. this.setState(
  124. ({text}) => ({
  125. text: (text || initialText) + '\n' + rule,
  126. }),
  127. this.handleUpdateOwnership
  128. );
  129. };
  130. render() {
  131. const {project, organization, disabled, urls, paths, initialText} = this.props;
  132. const {hasChanges, text, error} = this.state;
  133. return (
  134. <Fragment>
  135. <RuleBuilder
  136. urls={urls}
  137. paths={paths}
  138. organization={organization}
  139. project={project}
  140. onAddRule={this.handleAddRule.bind(this)}
  141. disabled={disabled}
  142. />
  143. <div
  144. style={{position: 'relative'}}
  145. onKeyDown={e => {
  146. if (e.metaKey && e.key === 'Enter') {
  147. this.handleUpdateOwnership();
  148. }
  149. }}
  150. >
  151. <StyledTextArea
  152. placeholder={
  153. '#example usage\n' +
  154. 'path:src/example/pipeline/* person@sentry.io #infra\n' +
  155. 'module:com.module.name.example #sdks\n' +
  156. 'url:http://example.com/settings/* #product\n' +
  157. 'tags.sku_class:enterprise #enterprise'
  158. }
  159. onChange={this.handleChange}
  160. disabled={disabled}
  161. value={defined(text) ? text : initialText}
  162. spellCheck="false"
  163. autoComplete="off"
  164. autoCorrect="off"
  165. autoCapitalize="off"
  166. />
  167. <ActionBar>
  168. <div>{this.parseError(error)}</div>
  169. <SaveButton>
  170. <Button
  171. size="sm"
  172. priority="primary"
  173. onClick={this.handleUpdateOwnership}
  174. disabled={disabled || !hasChanges}
  175. >
  176. {t('Save Changes')}
  177. </Button>
  178. </SaveButton>
  179. </ActionBar>
  180. </div>
  181. </Fragment>
  182. );
  183. }
  184. }
  185. const TEXTAREA_PADDING = 4;
  186. const TEXTAREA_LINE_HEIGHT = 24;
  187. const ActionBar = styled('div')`
  188. display: flex;
  189. align-items: center;
  190. justify-content: space-between;
  191. `;
  192. const SyntaxOverlay = styled('div')<{line: number}>`
  193. ${inputStyles};
  194. width: 100%;
  195. height: ${TEXTAREA_LINE_HEIGHT}px;
  196. background-color: red;
  197. opacity: 0.1;
  198. pointer-events: none;
  199. position: absolute;
  200. top: ${({line}) => TEXTAREA_PADDING + line * 24}px;
  201. `;
  202. const SaveButton = styled('div')`
  203. text-align: end;
  204. padding-top: 10px;
  205. `;
  206. const StyledTextArea = styled(TextareaAutosize)`
  207. ${p => inputStyles(p)};
  208. min-height: 140px;
  209. overflow: auto;
  210. outline: 0;
  211. width: 100%;
  212. resize: none;
  213. margin: 0;
  214. font-family: ${p => p.theme.text.familyMono};
  215. word-break: break-all;
  216. white-space: pre-wrap;
  217. padding-top: ${TEXTAREA_PADDING}px;
  218. line-height: ${TEXTAREA_LINE_HEIGHT}px;
  219. `;
  220. const InvalidOwners = styled('div')`
  221. color: ${p => p.theme.error};
  222. font-weight: bold;
  223. margin-top: 12px;
  224. `;
  225. export default OwnerInput;