thresholdGroupRows.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import {Fragment, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment';
  4. import type {APIRequestMethod} from 'sentry/api';
  5. import {Button} from 'sentry/components/button';
  6. import {CompactSelect} from 'sentry/components/compactSelect';
  7. import Input from 'sentry/components/input';
  8. import {IconAdd, IconClose, IconDelete, IconEdit} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import type {Project} from 'sentry/types';
  12. import {getExactDuration, parseLargestSuffix} from 'sentry/utils/formatters';
  13. import {capitalize} from 'sentry/utils/string/capitalize';
  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,
  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 type {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. value: NEW_ISSUE_COUNT_STR,
  96. textValue: 'New Issue Count',
  97. label: 'New Issue Count',
  98. }
  99. );
  100. }
  101. return list;
  102. }, [organization]);
  103. const windowOptions = thresholdType => {
  104. let options = [
  105. {
  106. value: 'hours',
  107. textValue: 'hours',
  108. label: 'hrs',
  109. },
  110. {
  111. value: 'days',
  112. textValue: 'days',
  113. label: 'days',
  114. },
  115. ];
  116. if (thresholdType !== CRASH_FREE_SESSION_RATE_STR) {
  117. options = [
  118. {
  119. value: 'seconds',
  120. textValue: 'seconds',
  121. label: 's',
  122. },
  123. {
  124. value: 'minutes',
  125. textValue: 'minutes',
  126. label: 'min',
  127. },
  128. ...options,
  129. ];
  130. }
  131. return options;
  132. };
  133. const initializeNewThreshold = (
  134. environmentName: string | undefined = undefined,
  135. defaultWindow: number = 0
  136. ) => {
  137. if (!project) {
  138. setTempError('No project provided');
  139. return;
  140. }
  141. const thresholdId = `${NEW_THRESHOLD_PREFIX}-${newThresholdIterator}`;
  142. const [windowValue, windowSuffix] = parseLargestSuffix(defaultWindow);
  143. const newThreshold: EditingThreshold = {
  144. id: thresholdId,
  145. project,
  146. environmentName,
  147. windowValue,
  148. windowSuffix,
  149. threshold_type: 'total_error_count',
  150. trigger_type: 'over',
  151. value: 0,
  152. hasError: false,
  153. };
  154. const updatedEditingThresholds = {...editingThresholds};
  155. updatedEditingThresholds[thresholdId] = newThreshold;
  156. setEditingThresholds(updatedEditingThresholds);
  157. setNewThresholdIterator(newThresholdIterator + 1);
  158. };
  159. const enableEditThreshold = (threshold: Threshold) => {
  160. const updatedEditingThresholds = {...editingThresholds};
  161. const [windowValue, windowSuffix] = parseLargestSuffix(threshold.window_in_seconds);
  162. updatedEditingThresholds[threshold.id] = {
  163. ...JSON.parse(JSON.stringify(threshold)), // Deep copy the original threshold object
  164. environmentName: threshold.environment ? threshold.environment.name : '', // convert environment to string for editing
  165. windowValue,
  166. windowSuffix,
  167. hasError: false,
  168. };
  169. setEditingThresholds(updatedEditingThresholds);
  170. };
  171. const saveThreshold = (saveIds: string[]) => {
  172. saveIds.forEach(id => {
  173. const thresholdData = editingThresholds[id];
  174. const seconds = moment
  175. .duration(thresholdData.windowValue, thresholdData.windowSuffix)
  176. .as('seconds');
  177. if (!thresholdData.project) {
  178. setTempError('Project required');
  179. return;
  180. }
  181. const submitData = {
  182. ...thresholdData,
  183. environment: thresholdData.environmentName,
  184. window_in_seconds: seconds,
  185. };
  186. let path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/${id}/`;
  187. let method: APIRequestMethod = 'PUT';
  188. if (id.includes(NEW_THRESHOLD_PREFIX)) {
  189. path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/`;
  190. method = 'POST';
  191. }
  192. const request = api.requestPromise(path, {
  193. method,
  194. data: submitData,
  195. });
  196. request
  197. .then(() => {
  198. refetch();
  199. closeEditForm(id);
  200. if (onFormClose) {
  201. onFormClose(id);
  202. }
  203. })
  204. .catch(_err => {
  205. setTempError('Issue saving threshold');
  206. setEditingThresholds(prevState => {
  207. const errorThreshold = {
  208. ...submitData,
  209. hasError: true,
  210. };
  211. const updatedEditingThresholds = {...prevState};
  212. updatedEditingThresholds[id] = errorThreshold;
  213. return updatedEditingThresholds;
  214. });
  215. });
  216. });
  217. };
  218. const deleteThreshold = thresholdId => {
  219. const updatedEditingThresholds = {...editingThresholds};
  220. const thresholdData = editingThresholds[thresholdId];
  221. const path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/${thresholdId}/`;
  222. const method = 'DELETE';
  223. if (!thresholdId.includes(NEW_THRESHOLD_PREFIX)) {
  224. const request = api.requestPromise(path, {
  225. method,
  226. });
  227. request
  228. .then(() => {
  229. refetch();
  230. })
  231. .catch(_err => {
  232. setTempError('Issue deleting threshold');
  233. const errorThreshold = {
  234. ...thresholdData,
  235. hasError: true,
  236. };
  237. updatedEditingThresholds[thresholdId] = errorThreshold as EditingThreshold;
  238. setEditingThresholds(updatedEditingThresholds);
  239. });
  240. }
  241. delete updatedEditingThresholds[thresholdId];
  242. setEditingThresholds(updatedEditingThresholds);
  243. };
  244. const closeEditForm = thresholdId => {
  245. const updatedEditingThresholds = {...editingThresholds};
  246. delete updatedEditingThresholds[thresholdId];
  247. setEditingThresholds(updatedEditingThresholds);
  248. if (onFormClose) {
  249. onFormClose(thresholdId);
  250. }
  251. };
  252. const editThresholdState = (thresholdId, key, value) => {
  253. if (editingThresholds[thresholdId]) {
  254. const updateEditing = JSON.parse(JSON.stringify(editingThresholds));
  255. const currentThresholdValues = updateEditing[thresholdId];
  256. updateEditing[thresholdId][key] = value;
  257. if (key === 'threshold_type' && value === CRASH_FREE_SESSION_RATE_STR) {
  258. if (['seconds', 'minutes'].indexOf(currentThresholdValues.windowSuffix) > -1) {
  259. updateEditing[thresholdId].windowSuffix = 'hours';
  260. }
  261. }
  262. setEditingThresholds(updateEditing);
  263. }
  264. };
  265. return (
  266. <StyledThresholdGroup>
  267. {Array.from(thresholdIdSet).map((tId: string, idx: number) => {
  268. const isEditing = tId in editingThresholds;
  269. // NOTE: we're casting the threshold type because we can't dynamically derive type below
  270. const threshold = isEditing
  271. ? (editingThresholds[tId] as EditingThreshold)
  272. : (initialThreshold as Threshold);
  273. return (
  274. <StyledRow
  275. key={threshold.id}
  276. lastRow={isLastRow && idx === thresholdIdSet.size - 1}
  277. hasError={isEditing && (threshold as EditingThreshold).hasError}
  278. >
  279. {/* ENV ONLY EDITABLE IF NEW */}
  280. {!initialThreshold || threshold.id !== initialThreshold.id ? (
  281. <CompactSelect
  282. style={{width: '100%'}}
  283. value={(threshold as EditingThreshold).environmentName || ''}
  284. onChange={selectedOption =>
  285. editThresholdState(
  286. threshold.id,
  287. 'environmentName',
  288. selectedOption.value
  289. )
  290. }
  291. options={[
  292. {
  293. value: '',
  294. textValue: '',
  295. label: '',
  296. },
  297. ...allEnvironmentNames.map(env => ({
  298. value: env,
  299. textValue: env,
  300. label: env,
  301. })),
  302. ]}
  303. />
  304. ) : (
  305. <FlexCenter>
  306. {/* '' means it _has_ an environment, but the env has no name */}
  307. {(threshold as Threshold).environment
  308. ? (threshold as Threshold).environment.name || ''
  309. : '{No environment}'}
  310. </FlexCenter>
  311. )}
  312. {/* FOLLOWING COLUMNS ARE EDITABLE */}
  313. {isEditing ? (
  314. <Fragment>
  315. <FlexCenter>
  316. <Input
  317. style={{width: '50%'}}
  318. value={(threshold as EditingThreshold).windowValue}
  319. type="number"
  320. min={0}
  321. onChange={e =>
  322. editThresholdState(threshold.id, 'windowValue', e.target.value)
  323. }
  324. />
  325. <CompactSelect
  326. style={{width: '50%'}}
  327. value={(threshold as EditingThreshold).windowSuffix}
  328. onChange={selectedOption =>
  329. editThresholdState(
  330. threshold.id,
  331. 'windowSuffix',
  332. selectedOption.value
  333. )
  334. }
  335. options={windowOptions(threshold.threshold_type)}
  336. />
  337. </FlexCenter>
  338. <FlexCenter>
  339. <CompactSelect
  340. value={threshold.threshold_type}
  341. onChange={selectedOption =>
  342. editThresholdState(
  343. threshold.id,
  344. 'threshold_type',
  345. selectedOption.value
  346. )
  347. }
  348. options={thresholdTypeList}
  349. />
  350. {threshold.trigger_type === 'over' ? (
  351. <Button
  352. onClick={() =>
  353. editThresholdState(threshold.id, 'trigger_type', 'under')
  354. }
  355. >
  356. &gt;
  357. </Button>
  358. ) : (
  359. <Button
  360. onClick={() =>
  361. editThresholdState(threshold.id, 'trigger_type', 'over')
  362. }
  363. >
  364. &lt;
  365. </Button>
  366. )}
  367. <Input
  368. value={threshold.value}
  369. type="number"
  370. min={0}
  371. onChange={e =>
  372. editThresholdState(threshold.id, 'value', e.target.value)
  373. }
  374. />
  375. </FlexCenter>
  376. </Fragment>
  377. ) : (
  378. <Fragment>
  379. <FlexCenter>
  380. {getExactDuration(
  381. (threshold as Threshold).window_in_seconds || 0,
  382. false,
  383. 'seconds'
  384. )}
  385. </FlexCenter>
  386. <FlexCenter>
  387. <div>
  388. {threshold.threshold_type
  389. .split('_')
  390. .map(word => capitalize(word))
  391. .join(' ')}
  392. </div>
  393. <div>&nbsp;{threshold.trigger_type === 'over' ? '>' : '<'}&nbsp;</div>
  394. <div>{threshold.value}</div>
  395. </FlexCenter>
  396. </Fragment>
  397. )}
  398. {/* END OF EDITABLE COLUMNS */}
  399. <ActionsColumn>
  400. {isEditing ? (
  401. <Fragment>
  402. <Button size="xs" onClick={() => saveThreshold([threshold.id])}>
  403. Save
  404. </Button>
  405. {!threshold.id.includes(NEW_THRESHOLD_PREFIX) && (
  406. <Button
  407. aria-label={t('Delete threshold')}
  408. borderless
  409. icon={<IconDelete color="danger" />}
  410. onClick={() => deleteThreshold(threshold.id)}
  411. size="xs"
  412. />
  413. )}
  414. <Button
  415. aria-label={t('Close')}
  416. borderless
  417. icon={<IconClose />}
  418. onClick={() => closeEditForm(threshold.id)}
  419. size="xs"
  420. />
  421. </Fragment>
  422. ) : (
  423. <Fragment>
  424. <Button
  425. aria-label={t('Edit threshold')}
  426. icon={<IconEdit />}
  427. onClick={() => enableEditThreshold(threshold as Threshold)}
  428. size="xs"
  429. />
  430. <Button
  431. aria-label={t('New Threshold')}
  432. icon={<IconAdd color="activeText" isCircled />}
  433. onClick={() =>
  434. initializeNewThreshold(
  435. initialThreshold && initialThreshold.environment
  436. ? initialThreshold.environment.name
  437. : undefined,
  438. initialThreshold ? initialThreshold.window_in_seconds : 0
  439. )
  440. }
  441. size="xs"
  442. />
  443. </Fragment>
  444. )}
  445. </ActionsColumn>
  446. </StyledRow>
  447. );
  448. })}
  449. </StyledThresholdGroup>
  450. );
  451. }
  452. const StyledThresholdGroup = styled('div')`
  453. display: contents;
  454. `;
  455. type StyledThresholdRowProps = {
  456. lastRow: boolean;
  457. hasError?: boolean;
  458. };
  459. const StyledRow = styled('div')<StyledThresholdRowProps>`
  460. display: contents;
  461. > * {
  462. padding: ${space(2)};
  463. background-color: ${p =>
  464. p.hasError ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0)'};
  465. border-bottom: ${p => (p.lastRow ? 0 : '1px solid ' + p.theme.border)};
  466. }
  467. `;
  468. const FlexCenter = styled('div')`
  469. display: flex;
  470. align-items: center;
  471. > * {
  472. margin: 0 ${space(1)};
  473. }
  474. `;
  475. const ActionsColumn = styled('div')`
  476. display: flex;
  477. align-items: center;
  478. justify-content: space-around;
  479. `;