ownerInput.tsx 6.3 KB

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