index.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import {useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  4. import * as Layout from 'sentry/components/layouts/thirds';
  5. import LoadingError from 'sentry/components/loadingError';
  6. import LoadingIndicator from 'sentry/components/loadingIndicator';
  7. import NoProjectMessage from 'sentry/components/noProjectMessage';
  8. import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
  9. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  10. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  11. import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
  12. import PanelTable from 'sentry/components/panels/panelTable';
  13. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  14. import {t} from 'sentry/locale';
  15. import ConfigStore from 'sentry/stores/configStore';
  16. import {space} from 'sentry/styles/space';
  17. import {Project} from 'sentry/types';
  18. import useOrganization from 'sentry/utils/useOrganization';
  19. import usePageFilters from 'sentry/utils/usePageFilters';
  20. import useProjects from 'sentry/utils/useProjects';
  21. import useRouter from 'sentry/utils/useRouter';
  22. import Header from '../components/header';
  23. import {NEW_GROUP_PREFIX} from '../utils/constants';
  24. import {NewThresholdGroup, Threshold} from '../utils/types';
  25. import useFetchThresholdsListData from '../utils/useFetchThresholdsListData';
  26. import {ThresholdGroupRows} from './thresholdGroupRows';
  27. type Props = {};
  28. function ReleaseThresholdList({}: Props) {
  29. const [listError, setListError] = useState<string>('');
  30. const router = useRouter();
  31. const organization = useOrganization();
  32. const [newThresholdGroup, setNewThresholdGroup] = useState<NewThresholdGroup[]>([]);
  33. const [newGroupIterator, setNewGroupIterator] = useState<number>(0);
  34. useEffect(() => {
  35. const hasV2ReleaseUIEnabled = organization.features.includes('release-ui-v2');
  36. if (!hasV2ReleaseUIEnabled) {
  37. router.replace('/releases/');
  38. }
  39. }, [router, organization]);
  40. const {projects} = useProjects();
  41. const {selection} = usePageFilters();
  42. const {
  43. data: thresholds = [],
  44. error: requestError,
  45. isLoading,
  46. isError,
  47. refetch,
  48. } = useFetchThresholdsListData({
  49. selectedProjectIds: selection.projects,
  50. selectedEnvs: selection.environments,
  51. });
  52. const selectedProjects: Project[] = useMemo(() => {
  53. return projects.filter(project =>
  54. selection.projects.some(id => String(id) === project.id || id === -1)
  55. );
  56. }, [projects, selection.projects]);
  57. const getAllEnvironments = (): string[] => {
  58. const selectedProjectIds = selection.projects.map(id => String(id));
  59. const {user} = ConfigStore.getState();
  60. const allEnvSet = new Set(projects.flatMap(project => project.environments));
  61. // NOTE: mostly taken from environmentSelector.tsx
  62. const unSortedEnvs = new Set(
  63. projects.flatMap(project => {
  64. /**
  65. * Include environments from:
  66. * all projects if the user is a superuser
  67. * the requested projects
  68. * all member projects if 'my projects' (empty list) is selected.
  69. * all projects if -1 is the only selected project.
  70. */
  71. if (
  72. (selectedProjectIds.length === 1 &&
  73. selectedProjectIds[0] === String(ALL_ACCESS_PROJECTS) &&
  74. project.hasAccess) ||
  75. (selectedProjectIds.length === 0 && (project.isMember || user.isSuperuser)) ||
  76. selectedProjectIds.includes(project.id)
  77. ) {
  78. return project.environments;
  79. }
  80. return [];
  81. })
  82. );
  83. const envDiff = new Set([...allEnvSet].filter(x => !unSortedEnvs.has(x)));
  84. // bubble the selected projects envs first, then concat the rest of the envs
  85. return Array.from(unSortedEnvs)
  86. .sort()
  87. .concat([...envDiff].sort());
  88. };
  89. /**
  90. * Thresholds filtered by environment selection
  91. * NOTE: currently no way to filter for 'None' environments
  92. */
  93. const filteredThresholds = selection.environments.length
  94. ? thresholds.filter(threshold => {
  95. return threshold.environment?.name
  96. ? selection.environments.indexOf(threshold.environment.name) > -1
  97. : !selection.environments.length;
  98. })
  99. : thresholds;
  100. const thresholdGroups: {[key: string]: {[key: string]: Threshold[]}} = useMemo(() => {
  101. const byProj = {};
  102. filteredThresholds.forEach(threshold => {
  103. const projId = threshold.project.id;
  104. if (!byProj[projId]) {
  105. byProj[projId] = {};
  106. }
  107. const env = threshold.environment ? threshold.environment.name : '';
  108. if (!byProj[projId][env]) {
  109. byProj[projId][env] = [];
  110. }
  111. byProj[projId][env].push(threshold);
  112. });
  113. return byProj;
  114. }, [filteredThresholds]);
  115. const tempError = msg => {
  116. setListError(msg);
  117. setTimeout(() => setListError(''), 5000);
  118. };
  119. if (isError) {
  120. return <LoadingError onRetry={refetch} message={requestError.message} />;
  121. }
  122. if (isLoading) {
  123. return <LoadingIndicator />;
  124. }
  125. const createNewThresholdGroup = () => {
  126. if (selectedProjects.length === 1) {
  127. setNewThresholdGroup([
  128. ...newThresholdGroup,
  129. {
  130. id: `${NEW_GROUP_PREFIX}-${newGroupIterator}`,
  131. project: selectedProjects[0],
  132. environments: getAllEnvironments(),
  133. },
  134. ]);
  135. setNewGroupIterator(newGroupIterator + 1);
  136. } else {
  137. tempError('Must select a single project in order to create a new threshold');
  138. }
  139. };
  140. const onNewGroupFinished = id => {
  141. const newGroups = newThresholdGroup.filter(group => group.id !== id);
  142. setNewThresholdGroup(newGroups);
  143. };
  144. return (
  145. <PageFiltersContainer>
  146. <NoProjectMessage organization={organization}>
  147. <Header
  148. router={router}
  149. hasV2ReleaseUIEnabled
  150. newThresholdAction={createNewThresholdGroup}
  151. newThresholdDisabled={
  152. selection.projects.length !== 1 ||
  153. selection.projects.includes(ALL_ACCESS_PROJECTS)
  154. }
  155. />
  156. <Layout.Body>
  157. <Layout.Main fullWidth>
  158. <FilterRow>
  159. <ReleaseThresholdsPageFilterBar condensed>
  160. <GuideAnchor target="release_projects">
  161. <ProjectPageFilter />
  162. </GuideAnchor>
  163. <EnvironmentPageFilter />
  164. </ReleaseThresholdsPageFilterBar>
  165. <ListError>{listError}</ListError>
  166. </FilterRow>
  167. <StyledPanelTable
  168. isLoading={isLoading}
  169. isEmpty={
  170. filteredThresholds.length === 0 &&
  171. newThresholdGroup.length === 0 &&
  172. !isError
  173. }
  174. emptyMessage={t('No thresholds found.')}
  175. headers={[
  176. t('Project Name'),
  177. t('Environment'),
  178. t('Window'),
  179. t('Condition'),
  180. t(' '),
  181. ]}
  182. >
  183. {thresholdGroups &&
  184. Object.entries(thresholdGroups).map(([projId, byEnv]) => {
  185. return Object.entries(byEnv).map(([envName, thresholdGroup]) => (
  186. <ThresholdGroupRows
  187. key={`${projId}-${envName}`}
  188. thresholds={thresholdGroup}
  189. refetch={refetch}
  190. orgSlug={organization.slug}
  191. setError={tempError}
  192. />
  193. ));
  194. })}
  195. {selectedProjects.length === 1 &&
  196. newThresholdGroup[0] &&
  197. newThresholdGroup
  198. .filter(group => group.project === selectedProjects[0])
  199. .map(group => (
  200. <ThresholdGroupRows
  201. key={group.id}
  202. newGroup={group}
  203. refetch={refetch}
  204. orgSlug={organization.slug}
  205. setError={tempError}
  206. onFormClose={onNewGroupFinished}
  207. />
  208. ))}
  209. </StyledPanelTable>
  210. </Layout.Main>
  211. </Layout.Body>
  212. </NoProjectMessage>
  213. </PageFiltersContainer>
  214. );
  215. }
  216. export default ReleaseThresholdList;
  217. const FilterRow = styled('div')`
  218. display: flex;
  219. align-items: center;
  220. `;
  221. const ListError = styled('div')`
  222. color: red;
  223. margin: 0 ${space(2)};
  224. width: 100%;
  225. display: flex;
  226. justify-content: center;
  227. `;
  228. const StyledPanelTable = styled(PanelTable)`
  229. @media (min-width: ${p => p.theme.breakpoints.small}) {
  230. overflow: initial;
  231. }
  232. grid-template-columns:
  233. minmax(100px, 1fr) minmax(100px, 1fr) minmax(250px, 1fr) minmax(200px, 4fr)
  234. minmax(150px, auto);
  235. white-space: nowrap;
  236. font-size: ${p => p.theme.fontSizeMedium};
  237. > * {
  238. border-bottom: inherit;
  239. }
  240. > *:last-child {
  241. > *:last-child {
  242. border-radius: 0 0 ${p => p.theme.borderRadius} 0;
  243. border-bottom: 0;
  244. }
  245. }
  246. `;
  247. const ReleaseThresholdsPageFilterBar = styled(PageFilterBar)`
  248. margin-bottom: ${space(2)};
  249. `;