thresholdGroupRows.tsx 15 KB

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