ownerInput.tsx 7.8 KB

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