thresholdGroupRows.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import {Fragment, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import capitalize from 'lodash/capitalize';
  4. import moment from 'moment';
  5. import {APIRequestMethod} from 'sentry/api';
  6. import {Button} from 'sentry/components/button';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  9. import Input from 'sentry/components/input';
  10. import {IconAdd, IconClose, IconDelete, IconEdit} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import {Environment, Project} from 'sentry/types';
  14. import {getExactDuration, parseLargestSuffix} from 'sentry/utils/formatters';
  15. import useApi from 'sentry/utils/useApi';
  16. import {NEW_GROUP_PREFIX, NEW_THRESHOLD_PREFIX} from '../utils/constants';
  17. import {EditingThreshold, NewThresholdGroup, Threshold} from '../utils/types';
  18. type Props = {
  19. orgSlug: string;
  20. refetch: () => void;
  21. setError: (msg: string) => void;
  22. newGroup?: NewThresholdGroup;
  23. onFormClose?: (id: string) => void;
  24. thresholds?: Threshold[];
  25. };
  26. export function ThresholdGroupRows({
  27. thresholds = [],
  28. orgSlug,
  29. refetch,
  30. setError,
  31. newGroup,
  32. onFormClose,
  33. }: Props) {
  34. const [editingThresholds, setEditingThresholds] = useState<{
  35. [key: string]: EditingThreshold;
  36. }>(() => {
  37. const editingThreshold = {};
  38. if (newGroup) {
  39. const [windowValue, windowSuffix] = parseLargestSuffix(0);
  40. const newGroupEdit = {
  41. id: newGroup.id,
  42. project: newGroup.project,
  43. windowValue,
  44. windowSuffix,
  45. threshold_type: 'total_error_count',
  46. trigger_type: 'over',
  47. value: 0,
  48. hasError: false,
  49. };
  50. editingThreshold[newGroup.id] = newGroupEdit;
  51. }
  52. return editingThreshold;
  53. });
  54. const [newThresholdIterator, setNewThresholdIterator] = useState<number>(0); // used simply to initialize new threshold
  55. const api = useApi();
  56. const initialProject =
  57. (thresholds[0] && thresholds[0].project) || (newGroup && newGroup.project);
  58. const initialEnv: Environment = thresholds[0] && thresholds[0].environment;
  59. const initialWindow = thresholds[0] && thresholds[0].window_in_seconds;
  60. const thresholdsById: {[id: string]: Threshold} = useMemo(() => {
  61. const byId = {};
  62. thresholds.forEach(threshold => {
  63. byId[threshold.id] = threshold;
  64. });
  65. return byId;
  66. }, [thresholds]);
  67. const thresholdIdSet = useMemo(() => {
  68. return new Set([
  69. ...thresholds.map(threshold => threshold.id),
  70. ...Object.keys(editingThresholds),
  71. ]);
  72. }, [thresholds, editingThresholds]);
  73. const initializeNewThreshold = (
  74. project: Project | undefined,
  75. environment: string | undefined = undefined,
  76. defaultWindow: number = 0
  77. ) => {
  78. if (!project) {
  79. setError('No project provided');
  80. return;
  81. }
  82. const thresholdId = `${NEW_THRESHOLD_PREFIX}-${newThresholdIterator}`;
  83. const [windowValue, windowSuffix] = parseLargestSuffix(defaultWindow);
  84. const newThreshold: EditingThreshold = {
  85. id: thresholdId,
  86. project,
  87. environment,
  88. windowValue,
  89. windowSuffix,
  90. threshold_type: 'total_error_count',
  91. trigger_type: 'over',
  92. value: 0,
  93. hasError: false,
  94. };
  95. const updatedEditingThresholds = {...editingThresholds};
  96. updatedEditingThresholds[thresholdId] = newThreshold;
  97. setEditingThresholds(updatedEditingThresholds);
  98. setNewThresholdIterator(newThresholdIterator + 1);
  99. };
  100. const enableEditThreshold = thresholdId => {
  101. const updatedEditingThresholds = {...editingThresholds};
  102. const threshold = JSON.parse(JSON.stringify(thresholdsById[thresholdId]));
  103. const [windowValue, windowSuffix] = parseLargestSuffix(threshold.window_in_seconds);
  104. updatedEditingThresholds[thresholdId] = {
  105. ...threshold,
  106. environment: threshold.environment ? threshold.environment.name : '', // convert environment to string for editing
  107. windowValue,
  108. windowSuffix,
  109. hasError: false,
  110. };
  111. setEditingThresholds(updatedEditingThresholds);
  112. };
  113. const saveThreshold = (thresholdIds: string[]) => {
  114. thresholdIds.forEach(id => {
  115. const thresholdData = editingThresholds[id];
  116. const seconds = moment
  117. .duration(thresholdData.windowValue, thresholdData.windowSuffix)
  118. .as('seconds');
  119. if (!thresholdData.project) {
  120. setError('Project required');
  121. return;
  122. }
  123. const submitData = {
  124. ...thresholdData,
  125. window_in_seconds: seconds,
  126. };
  127. let path = `/projects/${orgSlug}/${thresholdData.project.slug}/release-thresholds/${id}/`;
  128. let method: APIRequestMethod = 'PUT';
  129. if (id.includes(NEW_THRESHOLD_PREFIX) || id.includes(NEW_GROUP_PREFIX)) {
  130. path = `/projects/${orgSlug}/${thresholdData.project.slug}/release-thresholds/`;
  131. method = 'POST';
  132. }
  133. const request = api.requestPromise(path, {
  134. method,
  135. data: submitData,
  136. });
  137. request
  138. .then(() => {
  139. refetch();
  140. closeEditForm(id);
  141. if (onFormClose) {
  142. onFormClose(id);
  143. }
  144. })
  145. .catch(_err => {
  146. setError('Issue saving threshold');
  147. setEditingThresholds(prevState => {
  148. const errorThreshold = {
  149. ...submitData,
  150. hasError: true,
  151. };
  152. const updatedEditingThresholds = {...prevState};
  153. updatedEditingThresholds[id] = errorThreshold;
  154. return updatedEditingThresholds;
  155. });
  156. });
  157. });
  158. };
  159. const deleteThreshold = thresholdId => {
  160. const updatedEditingThresholds = {...editingThresholds};
  161. const thresholdData = editingThresholds[thresholdId];
  162. const path = `/projects/${orgSlug}/${thresholdData.project.slug}/release-thresholds/${thresholdId}/`;
  163. const method = 'DELETE';
  164. if (!thresholdId.includes(NEW_THRESHOLD_PREFIX)) {
  165. const request = api.requestPromise(path, {
  166. method,
  167. });
  168. request
  169. .then(() => {
  170. refetch();
  171. })
  172. .catch(_err => {
  173. setError('Issue deleting threshold');
  174. const errorThreshold = {
  175. ...thresholdData,
  176. hasError: true,
  177. };
  178. updatedEditingThresholds[thresholdId] = errorThreshold as EditingThreshold;
  179. setEditingThresholds(updatedEditingThresholds);
  180. });
  181. }
  182. delete updatedEditingThresholds[thresholdId];
  183. setEditingThresholds(updatedEditingThresholds);
  184. };
  185. const closeEditForm = thresholdId => {
  186. const updatedEditingThresholds = {...editingThresholds};
  187. delete updatedEditingThresholds[thresholdId];
  188. setEditingThresholds(updatedEditingThresholds);
  189. if (onFormClose) {
  190. onFormClose(thresholdId);
  191. }
  192. };
  193. const editThresholdState = (thresholdId, key, value) => {
  194. if (editingThresholds[thresholdId]) {
  195. const updateEditing = JSON.parse(JSON.stringify(editingThresholds));
  196. updateEditing[thresholdId][key] = value;
  197. setEditingThresholds(updateEditing);
  198. }
  199. };
  200. return (
  201. <StyledThresholdGroup>
  202. {Array.from(thresholdIdSet).map((tId: string, idx: number) => {
  203. const threshold = editingThresholds[tId] || thresholdsById[tId];
  204. let environmentName;
  205. if (editingThresholds[tId]) {
  206. // editing environment type is String
  207. environmentName = editingThresholds[tId].environment;
  208. } else {
  209. // Threshold environment type is Environnment
  210. environmentName = thresholdsById[tId].environment
  211. ? thresholdsById[tId].environment.name
  212. : '';
  213. }
  214. return (
  215. <StyledRow
  216. key={threshold.id}
  217. lastRow={idx === thresholdIdSet.size - 1}
  218. hasError={threshold.hasError}
  219. >
  220. <FlexCenter style={{borderBottom: 0}}>
  221. {idx === 0 ? (
  222. <ProjectBadge
  223. project={threshold.project}
  224. avatarSize={16}
  225. hideOverflow
  226. disableLink
  227. />
  228. ) : (
  229. ''
  230. )}
  231. </FlexCenter>
  232. {/* TODO: IF this is a new threshold - allow environment select */}
  233. {newGroup ? (
  234. <CompactSelect
  235. style={{width: '100%'}}
  236. value={threshold.environment}
  237. onChange={selectedOption =>
  238. editThresholdState(threshold.id, 'environment', selectedOption.value)
  239. }
  240. options={newGroup.environments.map(env => ({
  241. value: env,
  242. textValue: env,
  243. label: env,
  244. }))}
  245. />
  246. ) : (
  247. <FlexCenter style={{borderBottom: 0}}>
  248. {idx === 0 ? environmentName || 'None' : ''}
  249. </FlexCenter>
  250. )}
  251. {/* FOLLOWING COLUMNS ARE EDITABLE */}
  252. {editingThresholds[threshold.id] ? (
  253. <Fragment>
  254. <FlexCenter>
  255. <Input
  256. style={{width: '50%'}}
  257. value={threshold.windowValue}
  258. type="number"
  259. min={0}
  260. onChange={e =>
  261. editThresholdState(threshold.id, 'windowValue', e.target.value)
  262. }
  263. />
  264. <CompactSelect
  265. style={{width: '50%'}}
  266. value={threshold.windowSuffix}
  267. onChange={selectedOption =>
  268. editThresholdState(
  269. threshold.id,
  270. 'windowSuffix',
  271. selectedOption.value
  272. )
  273. }
  274. options={[
  275. {
  276. value: 'seconds',
  277. textValue: 'seconds',
  278. label: 's',
  279. },
  280. {
  281. value: 'minutes',
  282. textValue: 'minutes',
  283. label: 'min',
  284. },
  285. {
  286. value: 'hours',
  287. textValue: 'hours',
  288. label: 'hrs',
  289. },
  290. {
  291. value: 'days',
  292. textValue: 'days',
  293. label: 'days',
  294. },
  295. ]}
  296. />
  297. </FlexCenter>
  298. <FlexCenter>
  299. <CompactSelect
  300. value={threshold.threshold_type}
  301. onChange={selectedOption =>
  302. editThresholdState(
  303. threshold.id,
  304. 'threshold_type',
  305. selectedOption.value
  306. )
  307. }
  308. options={[
  309. {
  310. value: 'total_error_count',
  311. textValue: 'Errors',
  312. label: 'Error Count',
  313. },
  314. ]}
  315. />
  316. {threshold.trigger_type === 'over' ? (
  317. <Button
  318. onClick={() =>
  319. editThresholdState(threshold.id, 'trigger_type', 'under')
  320. }
  321. >
  322. &gt;
  323. </Button>
  324. ) : (
  325. <Button
  326. onClick={() =>
  327. editThresholdState(threshold.id, 'trigger_type', 'over')
  328. }
  329. >
  330. &lt;
  331. </Button>
  332. )}
  333. <Input
  334. value={threshold.value}
  335. type="number"
  336. min={0}
  337. onChange={e =>
  338. editThresholdState(threshold.id, 'value', e.target.value)
  339. }
  340. />
  341. </FlexCenter>
  342. </Fragment>
  343. ) : (
  344. <Fragment>
  345. <FlexCenter>
  346. {getExactDuration(threshold.window_in_seconds || 0, false, 'seconds')}
  347. </FlexCenter>
  348. <FlexCenter>
  349. <div>
  350. {threshold.threshold_type
  351. .split('_')
  352. .map(word => capitalize(word))
  353. .join(' ')}
  354. </div>
  355. <div>&nbsp;{threshold.trigger_type === 'over' ? '>' : '<'}&nbsp;</div>
  356. <div>{threshold.value}</div>
  357. </FlexCenter>
  358. </Fragment>
  359. )}
  360. {/* END OF EDITABLE COLUMNS */}
  361. <ActionsColumn>
  362. {editingThresholds[threshold.id] ? (
  363. <Fragment>
  364. <Button size="xs" onClick={() => saveThreshold([threshold.id])}>
  365. Save
  366. </Button>
  367. {!(
  368. threshold.id.includes(NEW_THRESHOLD_PREFIX) ||
  369. threshold.id.includes(NEW_GROUP_PREFIX)
  370. ) && (
  371. <Button
  372. aria-label={t('Delete threshold')}
  373. borderless
  374. icon={<IconDelete color="danger" />}
  375. onClick={() => deleteThreshold(threshold.id)}
  376. size="xs"
  377. />
  378. )}
  379. <Button
  380. aria-label={t('Close')}
  381. borderless
  382. icon={<IconClose />}
  383. onClick={() => closeEditForm(threshold.id)}
  384. size="xs"
  385. />
  386. </Fragment>
  387. ) : (
  388. <Button
  389. aria-label={t('Edit threshold')}
  390. borderless
  391. icon={<IconEdit />}
  392. onClick={() => enableEditThreshold(threshold.id)}
  393. size="xs"
  394. />
  395. )}
  396. </ActionsColumn>
  397. </StyledRow>
  398. );
  399. })}
  400. {!newGroup && (
  401. <NewRowBtn
  402. aria-label={t('Add new row')}
  403. borderless
  404. icon={<IconAdd color="activeText" isCircled />}
  405. onClick={() =>
  406. initializeNewThreshold(
  407. initialProject,
  408. initialEnv && initialEnv.name,
  409. initialWindow
  410. )
  411. }
  412. size="xs"
  413. />
  414. )}
  415. </StyledThresholdGroup>
  416. );
  417. }
  418. const StyledThresholdGroup = styled('div')`
  419. display: contents;
  420. `;
  421. type StyledThresholdRowProps = {
  422. lastRow: boolean;
  423. hasError?: boolean;
  424. };
  425. const StyledRow = styled('div')<StyledThresholdRowProps>`
  426. display: contents;
  427. > * {
  428. padding: ${space(2)};
  429. border-bottom: ${p => (p.lastRow ? 0 : '1px solid ' + p.theme.border)};
  430. background-color: ${p =>
  431. p.hasError ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0)'};
  432. }
  433. `;
  434. const NewRowBtn = styled(Button)`
  435. display: flex;
  436. grid-column-start: 3;
  437. grid-column-end: -1;
  438. align-items: center;
  439. justify-content: center;
  440. background: ${p => p.theme.backgroundSecondary};
  441. border-radius: 0;
  442. `;
  443. const FlexCenter = styled('div')`
  444. display: flex;
  445. align-items: center;
  446. > * {
  447. margin: 0 ${space(1)};
  448. }
  449. `;
  450. const ActionsColumn = styled('div')`
  451. display: flex;
  452. align-items: center;
  453. justify-content: space-around;
  454. `;