thresholdGroupRows.tsx 15 KB

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