ruleBuilder.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  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 SelectControl from 'sentry/components/forms/controls/selectControl';
  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 type {Organization, Project} from 'sentry/types';
  14. import type {Owner} from 'sentry/views/settings/project/projectOwnership/selectOwners';
  15. import SelectOwners from 'sentry/views/settings/project/projectOwnership/selectOwners';
  16. const initialState = {
  17. text: '',
  18. tagName: '',
  19. type: 'path',
  20. owners: [],
  21. isValid: false,
  22. };
  23. function getMatchPlaceholder(type: string): string {
  24. switch (type) {
  25. case 'path':
  26. return 'src/example/*';
  27. case 'module':
  28. return 'com.module.name.example';
  29. case 'url':
  30. return 'https://example.com/settings/*';
  31. case 'tag':
  32. return 'tag-value';
  33. default:
  34. return '';
  35. }
  36. }
  37. type Props = {
  38. disabled: boolean;
  39. onAddRule: (rule: string) => void;
  40. organization: Organization;
  41. paths: string[];
  42. project: Project;
  43. urls: string[];
  44. };
  45. type State = {
  46. isValid: boolean;
  47. owners: Owner[];
  48. tagName: string;
  49. text: string;
  50. type: string;
  51. };
  52. class RuleBuilder extends Component<Props, State> {
  53. state: State = initialState;
  54. checkIsValid = () => {
  55. this.setState(state => ({
  56. isValid: !!state.text && state.owners && !!state.owners.length,
  57. }));
  58. };
  59. handleTypeChange = (option: {label: string; value: string}) => {
  60. this.setState({type: option.value});
  61. this.checkIsValid();
  62. };
  63. handleTagNameChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
  64. this.setState({tagName: e.target.value}, this.checkIsValid);
  65. };
  66. handleChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
  67. this.setState({text: e.target.value});
  68. this.checkIsValid();
  69. };
  70. handleChangeOwners = (owners: Owner[]) => {
  71. this.setState({owners});
  72. this.checkIsValid();
  73. };
  74. handleAddRule = () => {
  75. const {type, text, tagName, owners, isValid} = this.state;
  76. if (!isValid) {
  77. addErrorMessage('A rule needs a type, a value, and one or more issue owners.');
  78. return;
  79. }
  80. const ownerText = owners
  81. .map(owner =>
  82. owner.actor.type === 'team'
  83. ? `#${owner.actor.name}`
  84. : MemberListStore.getById(owner.actor.id)?.email
  85. )
  86. .join(' ');
  87. const quotedText = text.match(/\s/) ? `"${text}"` : text;
  88. const rule = `${
  89. type === 'tag' ? `tags.${tagName}` : type
  90. }:${quotedText} ${ownerText}`;
  91. this.props.onAddRule(rule);
  92. this.setState(initialState);
  93. };
  94. handleSelectCandidate = (text: string, type: string) => {
  95. this.setState({text, type});
  96. this.checkIsValid();
  97. };
  98. render() {
  99. const {urls, paths, disabled, project, organization} = this.props;
  100. const {type, text, tagName, owners, isValid} = this.state;
  101. const hasCandidates = paths || urls;
  102. return (
  103. <Fragment>
  104. {hasCandidates && (
  105. <Candidates>
  106. {paths.map(v => (
  107. <RuleCandidate
  108. key={v}
  109. role="button"
  110. aria-label={t('Path rule candidate')}
  111. onClick={() => this.handleSelectCandidate(v, 'path')}
  112. >
  113. <IconAdd color="border" isCircled />
  114. <TextOverflow>{v}</TextOverflow>
  115. <Tag>{t('Path')}</Tag>
  116. </RuleCandidate>
  117. ))}
  118. {urls.map(v => (
  119. <RuleCandidate
  120. key={v}
  121. role="button"
  122. aria-label={t('URL rule candidate')}
  123. onClick={() => this.handleSelectCandidate(v, 'url')}
  124. >
  125. <IconAdd color="border" isCircled />
  126. <TextOverflow>{v}</TextOverflow>
  127. <Tag>{t('URL')}</Tag>
  128. </RuleCandidate>
  129. ))}
  130. </Candidates>
  131. )}
  132. <BuilderBar>
  133. <BuilderSelect
  134. aria-label={t('Rule type')}
  135. name="select-type"
  136. value={type}
  137. onChange={this.handleTypeChange}
  138. options={[
  139. {value: 'path', label: t('Path')},
  140. {value: 'module', label: t('Module')},
  141. {value: 'tag', label: t('Tag')},
  142. {value: 'url', label: t('URL')},
  143. ]}
  144. clearable={false}
  145. disabled={disabled}
  146. />
  147. {type === 'tag' && (
  148. <BuilderTagNameInput
  149. value={tagName}
  150. onChange={this.handleTagNameChangeValue}
  151. disabled={disabled}
  152. placeholder="tag-name"
  153. />
  154. )}
  155. <Input
  156. value={text}
  157. onChange={this.handleChangeValue}
  158. disabled={disabled}
  159. placeholder={getMatchPlaceholder(type)}
  160. aria-label={t('Rule pattern')}
  161. />
  162. <IconChevron color="border" direction="right" />
  163. <SelectOwners
  164. organization={organization}
  165. project={project}
  166. value={owners}
  167. onChange={this.handleChangeOwners}
  168. disabled={disabled}
  169. />
  170. <Button
  171. priority="primary"
  172. disabled={!isValid}
  173. onClick={this.handleAddRule}
  174. icon={<IconAdd isCircled />}
  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 RuleCandidate = styled('div')`
  186. font-family: ${p => p.theme.text.familyMono};
  187. border: 1px solid ${p => p.theme.border};
  188. border-radius: ${p => p.theme.borderRadius};
  189. background-color: ${p => p.theme.background};
  190. padding: ${space(0.25)} ${space(0.5)};
  191. margin-bottom: ${space(0.5)};
  192. cursor: pointer;
  193. overflow: hidden;
  194. display: flex;
  195. gap: ${space(0.5)};
  196. align-items: center;
  197. `;
  198. const BuilderBar = styled('div')`
  199. display: flex;
  200. gap: ${space(1)};
  201. align-items: center;
  202. margin-bottom: ${space(2)};
  203. `;
  204. const BuilderSelect = styled(SelectControl)`
  205. width: 140px;
  206. flex-shrink: 0;
  207. `;
  208. const BuilderTagNameInput = styled(Input)`
  209. width: 200px;
  210. `;
  211. export default RuleBuilder;