ruleBuilder.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import Button from 'sentry/components/button';
  5. import SelectField from 'sentry/components/deprecatedforms/selectField';
  6. import Input from 'sentry/components/input';
  7. import Tag from 'sentry/components/tag';
  8. import TextOverflow from 'sentry/components/textOverflow';
  9. import {IconAdd, IconChevron} from 'sentry/icons';
  10. import {t} from 'sentry/locale';
  11. import MemberListStore from 'sentry/stores/memberListStore';
  12. import space from 'sentry/styles/space';
  13. import {Organization, Project} from 'sentry/types';
  14. import SelectOwners, {
  15. Owner,
  16. } from 'sentry/views/settings/project/projectOwnership/selectOwners';
  17. const initialState = {
  18. text: '',
  19. tagName: '',
  20. type: 'path',
  21. owners: [],
  22. isValid: false,
  23. };
  24. function getMatchPlaceholder(type: string): string {
  25. switch (type) {
  26. case 'path':
  27. return 'src/example/*';
  28. case 'module':
  29. return 'com.module.name.example';
  30. case 'url':
  31. return 'https://example.com/settings/*';
  32. case 'tag':
  33. return 'tag-value';
  34. default:
  35. return '';
  36. }
  37. }
  38. type Props = {
  39. disabled: boolean;
  40. onAddRule: (rule: string) => void;
  41. organization: Organization;
  42. paths: string[];
  43. project: Project;
  44. urls: string[];
  45. };
  46. type State = {
  47. isValid: boolean;
  48. owners: Owner[];
  49. tagName: string;
  50. text: string;
  51. type: string;
  52. };
  53. class RuleBuilder extends Component<Props, State> {
  54. state: State = initialState;
  55. checkIsValid = () => {
  56. this.setState(state => ({
  57. isValid: !!state.text && state.owners && !!state.owners.length,
  58. }));
  59. };
  60. handleTypeChange = (val: string | number | boolean) => {
  61. this.setState({type: val as string}); // TODO(ts): Add select value type as generic to select controls
  62. this.checkIsValid();
  63. };
  64. handleTagNameChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
  65. this.setState({tagName: e.target.value}, this.checkIsValid);
  66. };
  67. handleChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
  68. this.setState({text: e.target.value});
  69. this.checkIsValid();
  70. };
  71. handleChangeOwners = (owners: Owner[]) => {
  72. this.setState({owners});
  73. this.checkIsValid();
  74. };
  75. handleAddRule = () => {
  76. const {type, text, tagName, owners, isValid} = this.state;
  77. if (!isValid) {
  78. addErrorMessage('A rule needs a type, a value, and one or more issue owners.');
  79. return;
  80. }
  81. const ownerText = owners
  82. .map(owner =>
  83. owner.actor.type === 'team'
  84. ? `#${owner.actor.name}`
  85. : MemberListStore.getById(owner.actor.id)?.email
  86. )
  87. .join(' ');
  88. const quotedText = text.match(/\s/) ? `"${text}"` : text;
  89. const rule = `${
  90. type === 'tag' ? `tags.${tagName}` : type
  91. }:${quotedText} ${ownerText}`;
  92. this.props.onAddRule(rule);
  93. this.setState(initialState);
  94. };
  95. handleSelectCandidate = (text: string, type: string) => {
  96. this.setState({text, type});
  97. this.checkIsValid();
  98. };
  99. render() {
  100. const {urls, paths, disabled, project, organization} = this.props;
  101. const {type, text, tagName, owners, isValid} = this.state;
  102. return (
  103. <Fragment>
  104. {(paths || urls) && (
  105. <Candidates>
  106. {paths &&
  107. paths.map(v => (
  108. <RuleCandidate
  109. key={v}
  110. onClick={() => this.handleSelectCandidate(v, 'path')}
  111. >
  112. <StyledIconAdd isCircled />
  113. <StyledTextOverflow>{v}</StyledTextOverflow>
  114. <Tag>{t('Path')}</Tag>
  115. </RuleCandidate>
  116. ))}
  117. {urls &&
  118. urls.map(v => (
  119. <RuleCandidate
  120. key={v}
  121. onClick={() => this.handleSelectCandidate(v, 'url')}
  122. >
  123. <StyledIconAdd isCircled />
  124. <StyledTextOverflow>{v}</StyledTextOverflow>
  125. <Tag>{t('URL')}</Tag>
  126. </RuleCandidate>
  127. ))}
  128. </Candidates>
  129. )}
  130. <BuilderBar>
  131. <BuilderSelect
  132. name="select-type"
  133. value={type}
  134. onChange={this.handleTypeChange}
  135. options={[
  136. {value: 'path', label: t('Path')},
  137. {value: 'module', label: t('Module')},
  138. {value: 'tag', label: t('Tag')},
  139. {value: 'url', label: t('URL')},
  140. ]}
  141. style={{width: 140}}
  142. clearable={false}
  143. disabled={disabled}
  144. />
  145. {type === 'tag' && (
  146. <BuilderTagNameInput
  147. value={tagName}
  148. onChange={this.handleTagNameChangeValue}
  149. disabled={disabled}
  150. placeholder="tag-name"
  151. />
  152. )}
  153. <BuilderInput
  154. value={text}
  155. onChange={this.handleChangeValue}
  156. disabled={disabled}
  157. placeholder={getMatchPlaceholder(type)}
  158. />
  159. <Divider direction="right" />
  160. <SelectOwnersWrapper>
  161. <SelectOwners
  162. organization={organization}
  163. project={project}
  164. value={owners}
  165. onChange={this.handleChangeOwners}
  166. disabled={disabled}
  167. />
  168. </SelectOwnersWrapper>
  169. <AddButton
  170. priority="primary"
  171. disabled={!isValid}
  172. onClick={this.handleAddRule}
  173. icon={<IconAdd isCircled />}
  174. size="sm"
  175. aria-label={t('Add rule')}
  176. />
  177. </BuilderBar>
  178. </Fragment>
  179. );
  180. }
  181. }
  182. const Candidates = styled('div')`
  183. margin-bottom: 10px;
  184. `;
  185. const StyledTextOverflow = styled(TextOverflow)`
  186. flex: 1;
  187. `;
  188. const RuleCandidate = styled('div')`
  189. font-family: ${p => p.theme.text.familyMono};
  190. border: 1px solid ${p => p.theme.border};
  191. border-radius: ${p => p.theme.borderRadius};
  192. background-color: ${p => p.theme.background};
  193. padding: ${space(0.25)} ${space(0.5)};
  194. margin-bottom: ${space(0.5)};
  195. cursor: pointer;
  196. overflow: hidden;
  197. display: flex;
  198. align-items: center;
  199. `;
  200. const StyledIconAdd = styled(IconAdd)`
  201. color: ${p => p.theme.border};
  202. margin-right: 5px;
  203. flex-shrink: 0;
  204. `;
  205. const BuilderBar = styled('div')`
  206. display: flex;
  207. height: 40px;
  208. align-items: center;
  209. margin-bottom: ${space(2)};
  210. `;
  211. const BuilderSelect = styled(SelectField)`
  212. margin-right: ${space(1.5)};
  213. width: 50px;
  214. flex-shrink: 0;
  215. `;
  216. const BuilderInput = styled(Input)`
  217. padding: ${space(1)};
  218. line-height: 19px;
  219. margin-right: ${space(0.5)};
  220. `;
  221. const BuilderTagNameInput = styled(Input)`
  222. padding: ${space(1)};
  223. line-height: 19px;
  224. margin-right: ${space(0.5)};
  225. width: 200px;
  226. `;
  227. const Divider = styled(IconChevron)`
  228. color: ${p => p.theme.border};
  229. flex-shrink: 0;
  230. margin-right: 5px;
  231. `;
  232. const SelectOwnersWrapper = styled('div')`
  233. display: flex;
  234. align-items: center;
  235. margin-right: ${space(1)};
  236. `;
  237. const AddButton = styled(Button)`
  238. padding: ${space(0.5)}; /* this sizes the button up to align with the inputs */
  239. `;
  240. export default RuleBuilder;