ownerInput.tsx 7.6 KB

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