ownerInput.tsx 7.9 KB

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