releaseSelector.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import {useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import type {SelectOption} from 'sentry/components/compactSelect';
  5. import {CompactSelect} from 'sentry/components/compactSelect';
  6. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  7. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  8. import {ReleasesSortOption} from 'sentry/constants/releases';
  9. import {IconReleases} from 'sentry/icons/iconReleases';
  10. import {t, tct, tn} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import {defined} from 'sentry/utils';
  13. import {getFormattedDate} from 'sentry/utils/dates';
  14. import {decodeScalar} from 'sentry/utils/queryString';
  15. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  16. import {useLocation} from 'sentry/utils/useLocation';
  17. import {useNavigate} from 'sentry/utils/useNavigate';
  18. import usePageFilters from 'sentry/utils/usePageFilters';
  19. import {
  20. ReleasesSort,
  21. type ReleasesSortByOption,
  22. SORT_BY_OPTIONS,
  23. } from 'sentry/views/insights/common/components/releasesSort';
  24. import {
  25. useReleases,
  26. useReleaseSelection,
  27. } from 'sentry/views/insights/common/queries/useReleases';
  28. import {formatVersionAndCenterTruncate} from 'sentry/views/insights/common/utils/centerTruncate';
  29. export const PRIMARY_RELEASE_ALIAS = 'R1';
  30. export const SECONDARY_RELEASE_ALIAS = 'R2';
  31. type Props = {
  32. selectorKey: string;
  33. sortBy: ReleasesSortByOption;
  34. selectorName?: string;
  35. selectorValue?: string;
  36. triggerLabelPrefix?: string;
  37. };
  38. export function ReleaseSelector({
  39. selectorKey,
  40. selectorValue,
  41. triggerLabelPrefix,
  42. sortBy,
  43. }: Props) {
  44. const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined);
  45. const {data, isLoading} = useReleases(searchTerm, sortBy);
  46. const {primaryRelease, secondaryRelease} = useReleaseSelection();
  47. const navigate = useNavigate();
  48. const location = useLocation();
  49. const options: Array<SelectOption<string> & {count?: number}> = [];
  50. if (defined(selectorValue)) {
  51. const index = data?.findIndex(({version}) => version === selectorValue);
  52. const selectedRelease = defined(index) ? data?.[index] : undefined;
  53. let selectedReleaseSessionCount: number | undefined = undefined;
  54. let selectedReleaseDateCreated: string | undefined = undefined;
  55. if (defined(selectedRelease)) {
  56. selectedReleaseSessionCount = selectedRelease.count;
  57. selectedReleaseDateCreated = selectedRelease.dateCreated;
  58. }
  59. options.push({
  60. value: selectorValue,
  61. count: selectedReleaseSessionCount,
  62. label: selectorValue,
  63. details: (
  64. <LabelDetails
  65. screenCount={selectedReleaseSessionCount}
  66. dateCreated={selectedReleaseDateCreated}
  67. />
  68. ),
  69. });
  70. }
  71. data
  72. ?.filter(({version}) => ![primaryRelease, secondaryRelease].includes(version))
  73. .forEach(release => {
  74. const option = {
  75. value: release.version,
  76. label: release.version,
  77. count: release.count,
  78. details: (
  79. <LabelDetails screenCount={release.count} dateCreated={release.dateCreated} />
  80. ),
  81. };
  82. options.push(option);
  83. });
  84. const triggerLabelContent = selectorValue
  85. ? formatVersionAndCenterTruncate(selectorValue, 16)
  86. : selectorValue;
  87. return (
  88. <StyledCompactSelect
  89. triggerProps={{
  90. icon: <IconReleases />,
  91. title: selectorValue,
  92. prefix: triggerLabelPrefix,
  93. }}
  94. triggerLabel={triggerLabelContent}
  95. menuTitle={t('Filter Release')}
  96. loading={isLoading}
  97. searchable
  98. value={selectorValue}
  99. options={[
  100. {
  101. value: '_selected_release',
  102. // We do this because the selected/default release might not be sorted,
  103. // but instead could have been added to the top of options list.
  104. options: options.slice(0, 1),
  105. },
  106. {
  107. value: '_releases',
  108. label: tct('Sorted by [sortBy]', {
  109. sortBy: SORT_BY_OPTIONS[sortBy].label,
  110. }),
  111. // Display other releases sorted by the selected option
  112. options: options.slice(1),
  113. },
  114. ]}
  115. onSearch={debounce(val => {
  116. setSearchTerm(val);
  117. }, DEFAULT_DEBOUNCE_DURATION)}
  118. onChange={newValue => {
  119. navigate({
  120. ...location,
  121. query: {
  122. ...location.query,
  123. [selectorKey]: newValue.value,
  124. },
  125. });
  126. }}
  127. onClose={() => {
  128. setSearchTerm(undefined);
  129. }}
  130. />
  131. );
  132. }
  133. type LabelDetailsProps = {
  134. dateCreated?: string;
  135. screenCount?: number;
  136. };
  137. function LabelDetails(props: LabelDetailsProps) {
  138. return (
  139. <DetailsContainer>
  140. <div>
  141. {defined(props.screenCount)
  142. ? tn('%s event', '%s events', props.screenCount)
  143. : t('No screens')}
  144. </div>
  145. <div>
  146. {defined(props.dateCreated)
  147. ? getFormattedDate(props.dateCreated, 'MMM D, YYYY')
  148. : null}
  149. </div>
  150. </DetailsContainer>
  151. );
  152. }
  153. function getReleasesSortBy(
  154. sort: ReleasesSortByOption,
  155. environments: string[]
  156. ): ReleasesSortByOption {
  157. // Require 1 environment for date adopted
  158. if (sort === ReleasesSortOption.ADOPTION && environments.length !== 1) {
  159. return ReleasesSortOption.DATE;
  160. }
  161. if (sort in SORT_BY_OPTIONS) {
  162. return sort;
  163. }
  164. // We could give a visual feedback to the user, saying that the sort by is invalid but
  165. // since this UI will be refactored, maybe we just don't do anything now.
  166. // This is the same fallback as the one used in static/app/views/insights/common/queries/useReleases.tsx.
  167. return ReleasesSortOption.DATE;
  168. }
  169. export function ReleaseComparisonSelector() {
  170. const {primaryRelease, secondaryRelease} = useReleaseSelection();
  171. const location = useLocation();
  172. const navigate = useNavigate();
  173. const {selection} = usePageFilters();
  174. const [localStoragedReleaseBy, setLocalStoragedReleaseBy] =
  175. useLocalStorageState<ReleasesSortByOption>(
  176. 'insightsReleasesSortBy',
  177. ReleasesSortOption.DATE
  178. );
  179. const urlStoragedReleaseBy = decodeScalar(
  180. location.query.sortReleasesBy
  181. ) as ReleasesSortByOption;
  182. useEffect(() => {
  183. if (urlStoragedReleaseBy === localStoragedReleaseBy) {
  184. return;
  185. }
  186. // this is useful in case the user shares the url with another user
  187. // and the user has a different sort by in their local storage
  188. if (!urlStoragedReleaseBy) {
  189. navigate(
  190. {
  191. ...location,
  192. query: {
  193. ...location.query,
  194. sortReleasesBy: localStoragedReleaseBy,
  195. },
  196. },
  197. {replace: true}
  198. );
  199. return;
  200. }
  201. setLocalStoragedReleaseBy(urlStoragedReleaseBy);
  202. }, [
  203. urlStoragedReleaseBy,
  204. localStoragedReleaseBy,
  205. setLocalStoragedReleaseBy,
  206. location,
  207. navigate,
  208. ]);
  209. const sortReleasesBy = getReleasesSortBy(
  210. localStoragedReleaseBy,
  211. selection.environments
  212. );
  213. return (
  214. <StyledPageSelector condensed>
  215. <ReleaseSelector
  216. selectorKey="primaryRelease"
  217. selectorValue={primaryRelease}
  218. selectorName={t('Release 1')}
  219. key="primaryRelease"
  220. triggerLabelPrefix={PRIMARY_RELEASE_ALIAS}
  221. sortBy={sortReleasesBy}
  222. />
  223. <ReleaseSelector
  224. selectorKey="secondaryRelease"
  225. selectorName={t('Release 2')}
  226. selectorValue={secondaryRelease}
  227. key="secondaryRelease"
  228. triggerLabelPrefix={SECONDARY_RELEASE_ALIAS}
  229. sortBy={sortReleasesBy}
  230. />
  231. <ReleasesSort
  232. sortBy={sortReleasesBy}
  233. environments={selection.environments}
  234. onChange={value =>
  235. navigate({
  236. ...location,
  237. query: {
  238. ...location.query,
  239. sortReleasesBy: value,
  240. },
  241. })
  242. }
  243. />
  244. </StyledPageSelector>
  245. );
  246. }
  247. const StyledCompactSelect = styled(CompactSelect)`
  248. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  249. max-width: 275px;
  250. }
  251. `;
  252. const StyledPageSelector = styled(PageFilterBar)`
  253. & > * {
  254. min-width: 135px;
  255. &:last-child {
  256. min-width: auto;
  257. > button[aria-haspopup] {
  258. padding-right: ${space(1.5)};
  259. }
  260. }
  261. }
  262. `;
  263. const DetailsContainer = styled('div')`
  264. display: flex;
  265. flex-direction: row;
  266. justify-content: space-between;
  267. gap: ${space(1)};
  268. min-width: 200px;
  269. `;