ownerInput.tsx 7.5 KB

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