ownerInput.tsx 7.4 KB

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