index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import {Fragment, useMemo, useRef, useState} from 'react';
  2. // eslint-disable-next-line no-restricted-imports
  3. import {withRouter, WithRouterProps} from 'react-router';
  4. import {ClassNames} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import sortBy from 'lodash/sortBy';
  7. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  8. import {MenuActions} from 'sentry/components/deprecatedDropdownMenu';
  9. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  10. import PageFilterPinButton from 'sentry/components/organizations/pageFilters/pageFilterPinButton';
  11. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  12. import {t} from 'sentry/locale';
  13. import space from 'sentry/styles/space';
  14. import {Organization, Project} from 'sentry/types';
  15. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  16. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  17. import theme from 'sentry/utils/theme';
  18. import ProjectSelectorFooter from './footer';
  19. import SelectorItem from './selectorItem';
  20. type Props = WithRouterProps & {
  21. /**
  22. * Used to render a custom dropdown button for the DropdownAutoComplete
  23. */
  24. customDropdownButton: (config: {
  25. actions: MenuActions;
  26. isOpen: boolean;
  27. selectedProjects: Project[];
  28. }) => React.ReactElement;
  29. /**
  30. * The loading indicator to render when global selection is not yet ready.
  31. */
  32. customLoadingIndicator: React.ReactNode;
  33. /**
  34. * Projects the member is a part of
  35. */
  36. memberProjects: Project[];
  37. /**
  38. * Projects the member is _not_ part of
  39. */
  40. nonMemberProjects: Project[];
  41. /**
  42. * Triggered when the selection changes are applied
  43. */
  44. onApplyChange: (newProjects: number[]) => void;
  45. /**
  46. * Triggers any time a selection is changed, but the menu has not yet been closed or "applied"
  47. */
  48. onChange: (selected: number[]) => void;
  49. organization: Organization;
  50. /**
  51. * The selected projects
  52. */
  53. value: number[];
  54. /**
  55. * Only allow a single project to be selected at once
  56. */
  57. disableMultipleProjectSelection?: boolean;
  58. /**
  59. * Disable the dropdown
  60. */
  61. disabled?: boolean;
  62. /**
  63. * Message to show in the footer
  64. */
  65. footerMessage?: React.ReactNode;
  66. isGlobalSelectionReady?: boolean;
  67. };
  68. function ProjectSelector({
  69. customDropdownButton,
  70. customLoadingIndicator,
  71. disableMultipleProjectSelection,
  72. footerMessage,
  73. isGlobalSelectionReady,
  74. memberProjects,
  75. nonMemberProjects = [],
  76. onApplyChange,
  77. onChange,
  78. organization,
  79. router,
  80. value,
  81. disabled,
  82. }: Props) {
  83. // Used to determine if we should show the 'apply' changes button
  84. const [hasChanges, setHasChanges] = useState(false);
  85. // Used to keep selected projects sorted in the same order when opening /
  86. // closing the project selector
  87. const lastSelected = useRef(value);
  88. const isMulti =
  89. !disableMultipleProjectSelection && organization.features.includes('global-views');
  90. /**
  91. * Reset "hasChanges" state and call `onApplyChange` callback
  92. *
  93. * @param value optional parameter that will be passed to onApplyChange callback
  94. */
  95. const doApplyChange = (newValue: number[]) => {
  96. setHasChanges(false);
  97. onApplyChange(newValue);
  98. };
  99. /**
  100. * Handler for when an explicit update call should be made.
  101. * e.g. an "Update" button
  102. *
  103. * Should perform an "update" callback
  104. */
  105. const handleUpdate = (actions: {close: () => void}) => {
  106. actions.close();
  107. doApplyChange(value);
  108. };
  109. /**
  110. * Handler for when a dropdown item was selected directly (and not via multi select)
  111. *
  112. * Should perform an "update" callback
  113. */
  114. const handleQuickSelect = (selected: Pick<Project, 'id'>) => {
  115. trackAdvancedAnalyticsEvent('projectselector.direct_selection', {
  116. path: getRouteStringFromRoutes(router.routes),
  117. organization,
  118. });
  119. const newValue = selected.id === null ? [] : [parseInt(selected.id, 10)];
  120. onChange(newValue);
  121. doApplyChange(newValue);
  122. };
  123. /**
  124. * Handler for when dropdown menu closes
  125. *
  126. * Should perform an "update" callback
  127. */
  128. const handleClose = () => {
  129. // Only update if there are changes
  130. if (!hasChanges) {
  131. return;
  132. }
  133. trackAdvancedAnalyticsEvent('projectselector.update', {
  134. count: value.length,
  135. path: getRouteStringFromRoutes(router.routes),
  136. organization,
  137. multi: isMulti,
  138. });
  139. doApplyChange(value);
  140. lastSelected.current = value;
  141. };
  142. /**
  143. * Handler for clearing the current value
  144. *
  145. * Should perform an "update" callback
  146. */
  147. const handleClear = () => {
  148. trackAdvancedAnalyticsEvent('projectselector.clear', {
  149. path: getRouteStringFromRoutes(router.routes),
  150. organization,
  151. });
  152. onChange([]);
  153. doApplyChange([]);
  154. };
  155. const allProjects = [...memberProjects, ...nonMemberProjects];
  156. const selectedProjectIds = useMemo(() => new Set(value), [value]);
  157. const selected = allProjects.filter(project =>
  158. selectedProjectIds.has(parseInt(project.id, 10))
  159. );
  160. if (!isGlobalSelectionReady) {
  161. return <Fragment>{customLoadingIndicator}</Fragment>;
  162. }
  163. const listSort = (project: Project) => [
  164. !lastSelected.current.includes(parseInt(project.id, 10)),
  165. !project.isBookmarked,
  166. project.slug,
  167. ];
  168. const projects = sortBy(memberProjects, listSort);
  169. const otherProjects = sortBy(nonMemberProjects, listSort);
  170. const handleMultiSelect = (project: Project) => {
  171. const selectedProjectsMap = new Map(selected.map(p => [p.slug, p]));
  172. if (selectedProjectsMap.has(project.slug)) {
  173. // unselected a project
  174. selectedProjectsMap.delete(project.slug);
  175. } else {
  176. selectedProjectsMap.set(project.slug, project);
  177. }
  178. trackAdvancedAnalyticsEvent('projectselector.toggle', {
  179. action: selected.length > value.length ? 'added' : 'removed',
  180. path: getRouteStringFromRoutes(router.routes),
  181. organization,
  182. });
  183. const selectedList = [...selectedProjectsMap.values()]
  184. .map(({id}) => parseInt(id, 10))
  185. .filter(i => i);
  186. onChange(selectedList);
  187. setHasChanges(true);
  188. };
  189. const getProjectItem = (project: Project) => ({
  190. item: project,
  191. searchKey: project.slug,
  192. label: ({inputValue}: {inputValue: typeof project.slug}) => (
  193. <SelectorItem
  194. key={project.slug}
  195. project={project}
  196. organization={organization}
  197. multi={isMulti}
  198. inputValue={inputValue}
  199. isChecked={!!selected.find(({slug}) => slug === project.slug)}
  200. onMultiSelect={handleMultiSelect}
  201. />
  202. ),
  203. });
  204. const hasProjects = !!projects?.length || !!otherProjects?.length;
  205. const items = !hasProjects
  206. ? []
  207. : [
  208. {
  209. hideGroupLabel: true,
  210. items: projects.map(getProjectItem),
  211. },
  212. {
  213. hideGroupLabel: otherProjects.length === 0,
  214. itemSize: 'small',
  215. id: 'no-membership-header', // needed for tests for non-virtualized lists
  216. label: <Label>{t("Projects I don't belong to")}</Label>,
  217. items: otherProjects.map(getProjectItem),
  218. },
  219. ];
  220. return (
  221. <ClassNames>
  222. {({css}) => (
  223. <StyledDropdownAutocomplete
  224. detached
  225. blendCorner={false}
  226. disabled={disabled}
  227. searchPlaceholder={t('Filter projects')}
  228. onSelect={i => handleQuickSelect(i.item)}
  229. onClose={handleClose}
  230. maxHeight={500}
  231. minWidth={350}
  232. inputProps={{style: {padding: 8, paddingLeft: 10}}}
  233. rootClassName={css`
  234. display: flex;
  235. `}
  236. emptyMessage={t('You have no projects')}
  237. noResultsMessage={t('No projects found')}
  238. virtualizedHeight={theme.headerSelectorRowHeight}
  239. virtualizedLabelHeight={theme.headerSelectorLabelHeight}
  240. inputActions={
  241. <InputActions>
  242. <GuideAnchor target="new_page_filter_pin" position="bottom">
  243. <PageFilterPinButton
  244. organization={organization}
  245. filter="projects"
  246. size="xs"
  247. />
  248. </GuideAnchor>
  249. </InputActions>
  250. }
  251. menuFooter={({actions}) => (
  252. <ProjectSelectorFooter
  253. selected={selectedProjectIds}
  254. disableMultipleProjectSelection={disableMultipleProjectSelection}
  255. organization={organization}
  256. hasChanges={hasChanges}
  257. onApply={() => handleUpdate(actions)}
  258. onShowAllProjects={() => {
  259. handleQuickSelect({id: ALL_ACCESS_PROJECTS.toString()});
  260. trackAdvancedAnalyticsEvent('projectselector.multi_button_clicked', {
  261. button_type: 'all',
  262. path: getRouteStringFromRoutes(router.routes),
  263. organization,
  264. });
  265. // The close action here triggers the onClose() handler which we
  266. // use to apply the current selection. We need that to happen on the
  267. // next render so that the state will reflect All Projects instead of
  268. // the outdated selection that exists when this callback is triggered.
  269. setTimeout(actions.close);
  270. }}
  271. onShowMyProjects={() => {
  272. handleClear();
  273. trackAdvancedAnalyticsEvent('projectselector.multi_button_clicked', {
  274. button_type: 'my',
  275. path: getRouteStringFromRoutes(router.routes),
  276. organization,
  277. });
  278. // The close action here triggers the onClose() handler which we
  279. // use to apply the current selection. We need that to happen on the
  280. // next render so that the state will reflect My Projects instead of
  281. // the outdated selection that exists when this callback is triggered.
  282. setTimeout(actions.close);
  283. }}
  284. message={footerMessage}
  285. />
  286. )}
  287. items={items}
  288. allowActorToggle
  289. closeOnSelect
  290. >
  291. {({actions, isOpen}) =>
  292. customDropdownButton({actions, selectedProjects: selected, isOpen})
  293. }
  294. </StyledDropdownAutocomplete>
  295. )}
  296. </ClassNames>
  297. );
  298. }
  299. export default withRouter(ProjectSelector);
  300. const StyledDropdownAutocomplete = styled(DropdownAutoComplete)`
  301. background-color: ${p => p.theme.background};
  302. color: ${p => p.theme.textColor};
  303. width: 100%;
  304. `;
  305. const Label = styled('div')`
  306. font-size: ${p => p.theme.fontSizeSmall};
  307. color: ${p => p.theme.gray300};
  308. `;
  309. const InputActions = styled('div')`
  310. display: grid;
  311. margin: 0 ${space(1)};
  312. gap: ${space(1)};
  313. grid-auto-flow: column;
  314. grid-auto-columns: auto;
  315. `;