thresholdGroupRows.tsx 15 KB

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