multipleProjectSelector.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import {ClassNames} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import Feature from 'sentry/components/acl/feature';
  6. import Button from 'sentry/components/button';
  7. import {MenuActions} from 'sentry/components/dropdownMenu';
  8. import Link from 'sentry/components/links/link';
  9. import HeaderItem from 'sentry/components/organizations/headerItem';
  10. import PlatformList from 'sentry/components/platformList';
  11. import Tooltip from 'sentry/components/tooltip';
  12. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  13. import {IconProject} from 'sentry/icons';
  14. import {t, tct} from 'sentry/locale';
  15. import {growIn} from 'sentry/styles/animations';
  16. import space from 'sentry/styles/space';
  17. import {MinimalProject, Organization, Project} from 'sentry/types';
  18. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  19. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  20. import ProjectSelector from './projectSelector';
  21. type Props = WithRouterProps & {
  22. memberProjects: Project[];
  23. nonMemberProjects: Project[];
  24. onChange: (selected: number[]) => void;
  25. onUpdate: (newProjects?: number[]) => void;
  26. organization: Organization;
  27. value: number[];
  28. customDropdownButton?: (config: {
  29. actions: MenuActions;
  30. isOpen: boolean;
  31. selectedProjects: Project[];
  32. }) => React.ReactElement;
  33. customLoadingIndicator?: React.ReactNode;
  34. detached?: boolean;
  35. disableMultipleProjectSelection?: boolean;
  36. footerMessage?: React.ReactNode;
  37. forceProject?: MinimalProject | null;
  38. isGlobalSelectionReady?: boolean;
  39. lockedMessageSubject?: React.ReactNode;
  40. shouldForceProject?: boolean;
  41. showIssueStreamLink?: boolean;
  42. showPin?: boolean;
  43. showProjectSettingsLink?: boolean;
  44. };
  45. type State = {
  46. hasChanges: boolean;
  47. };
  48. class MultipleProjectSelector extends React.PureComponent<Props, State> {
  49. static defaultProps = {
  50. lockedMessageSubject: t('page'),
  51. };
  52. state: State = {
  53. hasChanges: false,
  54. };
  55. get multi() {
  56. const {organization, disableMultipleProjectSelection} = this.props;
  57. return (
  58. !disableMultipleProjectSelection && organization.features.includes('global-views')
  59. );
  60. }
  61. /**
  62. * Reset "hasChanges" state and call `onUpdate` callback
  63. * @param value optional parameter that will be passed to onUpdate callback
  64. */
  65. doUpdate = (value?: number[]) => {
  66. this.setState({hasChanges: false}, () => this.props.onUpdate(value));
  67. };
  68. /**
  69. * Handler for when an explicit update call should be made.
  70. * e.g. an "Update" button
  71. *
  72. * Should perform an "update" callback
  73. */
  74. handleUpdate = (actions: {close: () => void}) => {
  75. actions.close();
  76. this.doUpdate();
  77. };
  78. /**
  79. * Handler for when a dropdown item was selected directly (and not via multi select)
  80. *
  81. * Should perform an "update" callback
  82. */
  83. handleQuickSelect = (selected: Pick<Project, 'id'>) => {
  84. trackAdvancedAnalyticsEvent('projectselector.direct_selection', {
  85. path: getRouteStringFromRoutes(this.props.router.routes),
  86. organization: this.props.organization,
  87. });
  88. const value = selected.id === null ? [] : [parseInt(selected.id, 10)];
  89. this.props.onChange(value);
  90. this.doUpdate(value);
  91. };
  92. /**
  93. * Handler for when dropdown menu closes
  94. *
  95. * Should perform an "update" callback
  96. */
  97. handleClose = () => {
  98. // Only update if there are changes
  99. if (!this.state.hasChanges) {
  100. return;
  101. }
  102. const {value} = this.props;
  103. trackAdvancedAnalyticsEvent('projectselector.update', {
  104. count: value.length,
  105. path: getRouteStringFromRoutes(this.props.router.routes),
  106. organization: this.props.organization,
  107. multi: this.multi,
  108. });
  109. this.doUpdate();
  110. };
  111. /**
  112. * Handler for clearing the current value
  113. *
  114. * Should perform an "update" callback
  115. */
  116. handleClear = () => {
  117. trackAdvancedAnalyticsEvent('projectselector.clear', {
  118. path: getRouteStringFromRoutes(this.props.router.routes),
  119. organization: this.props.organization,
  120. });
  121. this.props.onChange([]);
  122. // Update on clear
  123. this.doUpdate();
  124. };
  125. /**
  126. * Handler for selecting multiple items, should NOT call update
  127. */
  128. handleMultiSelect = (selected: Project[]) => {
  129. const {onChange, value} = this.props;
  130. trackAdvancedAnalyticsEvent('projectselector.toggle', {
  131. action: selected.length > value.length ? 'added' : 'removed',
  132. path: getRouteStringFromRoutes(this.props.router.routes),
  133. organization: this.props.organization,
  134. });
  135. const selectedList = selected.map(({id}) => parseInt(id, 10)).filter(i => i);
  136. onChange(selectedList);
  137. this.setState({hasChanges: true});
  138. };
  139. renderProjectName() {
  140. const {forceProject, location, organization, showIssueStreamLink} = this.props;
  141. if (showIssueStreamLink && forceProject && this.multi) {
  142. return (
  143. <Tooltip title={t('Issues Stream')} position="bottom">
  144. <StyledLink
  145. to={{
  146. pathname: `/organizations/${organization.slug}/issues/`,
  147. query: {...location.query, project: forceProject.id},
  148. }}
  149. >
  150. {forceProject.slug}
  151. </StyledLink>
  152. </Tooltip>
  153. );
  154. }
  155. if (forceProject) {
  156. return forceProject.slug;
  157. }
  158. return '';
  159. }
  160. getLockedMessage() {
  161. const {forceProject, lockedMessageSubject} = this.props;
  162. if (forceProject) {
  163. return tct('This [subject] is unique to the [projectSlug] project', {
  164. subject: lockedMessageSubject,
  165. projectSlug: forceProject.slug,
  166. });
  167. }
  168. return tct('This [subject] is unique to a project', {subject: lockedMessageSubject});
  169. }
  170. render() {
  171. const {
  172. value,
  173. memberProjects,
  174. isGlobalSelectionReady,
  175. disableMultipleProjectSelection,
  176. nonMemberProjects,
  177. organization,
  178. shouldForceProject,
  179. forceProject,
  180. showProjectSettingsLink,
  181. footerMessage,
  182. customDropdownButton,
  183. customLoadingIndicator,
  184. } = this.props;
  185. const selectedProjectIds = new Set(value);
  186. const multi = this.multi;
  187. const allProjects = [...memberProjects, ...nonMemberProjects];
  188. const selected = allProjects.filter(project =>
  189. selectedProjectIds.has(parseInt(project.id, 10))
  190. );
  191. // `forceProject` can be undefined if it is loading the project
  192. // We are intentionally using an empty string as its "loading" state
  193. return shouldForceProject ? (
  194. <StyledHeaderItem
  195. data-test-id="global-header-project-selector"
  196. icon={
  197. forceProject && (
  198. <PlatformList
  199. platforms={forceProject.platform ? [forceProject.platform] : []}
  200. max={1}
  201. />
  202. )
  203. }
  204. locked
  205. lockedMessage={this.getLockedMessage()}
  206. settingsLink={
  207. (forceProject &&
  208. showProjectSettingsLink &&
  209. `/settings/${organization.slug}/projects/${forceProject.slug}/`) ||
  210. undefined
  211. }
  212. >
  213. {this.renderProjectName()}
  214. </StyledHeaderItem>
  215. ) : !isGlobalSelectionReady ? (
  216. customLoadingIndicator ?? (
  217. <StyledHeaderItem
  218. data-test-id="global-header-project-selector-loading"
  219. icon={<IconProject />}
  220. loading
  221. >
  222. {t('Loading\u2026')}
  223. </StyledHeaderItem>
  224. )
  225. ) : (
  226. <ClassNames>
  227. {({css}) => (
  228. <StyledProjectSelector
  229. {...this.props}
  230. multi={!!multi}
  231. selectedProjects={selected}
  232. multiProjects={memberProjects}
  233. onSelect={this.handleQuickSelect}
  234. onClose={this.handleClose}
  235. onMultiSelect={this.handleMultiSelect}
  236. rootClassName={css`
  237. display: flex;
  238. `}
  239. menuFooter={({actions}) => (
  240. <SelectorFooterControls
  241. selected={selectedProjectIds}
  242. disableMultipleProjectSelection={disableMultipleProjectSelection}
  243. organization={organization}
  244. hasChanges={this.state.hasChanges}
  245. onApply={() => this.handleUpdate(actions)}
  246. onShowAllProjects={() => {
  247. this.handleQuickSelect({id: ALL_ACCESS_PROJECTS.toString()});
  248. actions.close();
  249. trackAdvancedAnalyticsEvent('projectselector.multi_button_clicked', {
  250. button_type: 'all',
  251. path: getRouteStringFromRoutes(this.props.router.routes),
  252. organization,
  253. });
  254. }}
  255. onShowMyProjects={() => {
  256. this.handleClear();
  257. actions.close();
  258. trackAdvancedAnalyticsEvent('projectselector.multi_button_clicked', {
  259. button_type: 'my',
  260. path: getRouteStringFromRoutes(this.props.router.routes),
  261. organization,
  262. });
  263. }}
  264. message={footerMessage}
  265. />
  266. )}
  267. >
  268. {({actions, selectedProjects, isOpen}) => {
  269. if (customDropdownButton) {
  270. return customDropdownButton({actions, selectedProjects, isOpen});
  271. }
  272. const hasSelected = !!selectedProjects.length;
  273. const title = hasSelected
  274. ? selectedProjects.map(({slug}) => slug).join(', ')
  275. : selectedProjectIds.has(ALL_ACCESS_PROJECTS)
  276. ? t('All Projects')
  277. : t('My Projects');
  278. const icon = hasSelected ? (
  279. <PlatformList
  280. platforms={selectedProjects.map(p => p.platform ?? 'other').reverse()}
  281. max={5}
  282. />
  283. ) : (
  284. <IconProject />
  285. );
  286. return (
  287. <StyledHeaderItem
  288. data-test-id="global-header-project-selector"
  289. icon={icon}
  290. hasSelected={hasSelected}
  291. hasChanges={this.state.hasChanges}
  292. isOpen={isOpen}
  293. onClear={this.handleClear}
  294. allowClear={multi}
  295. settingsLink={
  296. selectedProjects.length === 1
  297. ? `/settings/${organization.slug}/projects/${selected[0]?.slug}/`
  298. : ''
  299. }
  300. >
  301. {title}
  302. </StyledHeaderItem>
  303. );
  304. }}
  305. </StyledProjectSelector>
  306. )}
  307. </ClassNames>
  308. );
  309. }
  310. }
  311. type FeatureRenderProps = {
  312. hasFeature: boolean;
  313. renderShowAllButton?: (p: {
  314. canShowAllProjects: boolean;
  315. onButtonClick: () => void;
  316. }) => React.ReactNode;
  317. };
  318. type ControlProps = {
  319. onApply: () => void;
  320. onShowAllProjects: () => void;
  321. onShowMyProjects: () => void;
  322. organization: Organization;
  323. disableMultipleProjectSelection?: boolean;
  324. hasChanges?: boolean;
  325. message?: React.ReactNode;
  326. selected?: Set<number>;
  327. };
  328. const SelectorFooterControls = ({
  329. selected,
  330. disableMultipleProjectSelection,
  331. hasChanges,
  332. onApply,
  333. onShowAllProjects,
  334. onShowMyProjects,
  335. organization,
  336. message,
  337. }: ControlProps) => {
  338. // Nothing to show.
  339. if (disableMultipleProjectSelection && !hasChanges && !message) {
  340. return null;
  341. }
  342. // see if we should show "All Projects" or "My Projects" if disableMultipleProjectSelection isn't true
  343. const hasGlobalRole = organization.role === 'owner' || organization.role === 'manager';
  344. const hasOpenMembership = organization.features.includes('open-membership');
  345. const allSelected = selected && selected.has(ALL_ACCESS_PROJECTS);
  346. const canShowAllProjects = (hasGlobalRole || hasOpenMembership) && !allSelected;
  347. const onProjectClick = canShowAllProjects ? onShowAllProjects : onShowMyProjects;
  348. const buttonText = canShowAllProjects
  349. ? t('Select All Projects')
  350. : t('Select My Projects');
  351. return (
  352. <FooterContainer hasMessage={!!message}>
  353. {message && <FooterMessage>{message}</FooterMessage>}
  354. <FooterActions>
  355. {!disableMultipleProjectSelection && (
  356. <Feature
  357. features={['organizations:global-views']}
  358. organization={organization}
  359. hookName="feature-disabled:project-selector-all-projects"
  360. renderDisabled={false}
  361. >
  362. {({renderShowAllButton, hasFeature}: FeatureRenderProps) => {
  363. // if our hook is adding renderShowAllButton, render that
  364. if (renderShowAllButton) {
  365. return renderShowAllButton({
  366. onButtonClick: onProjectClick,
  367. canShowAllProjects,
  368. });
  369. }
  370. // if no hook, render null if feature is disabled
  371. if (!hasFeature) {
  372. return null;
  373. }
  374. // otherwise render the buton
  375. return (
  376. <Button priority="default" size="xsmall" onClick={onProjectClick}>
  377. {buttonText}
  378. </Button>
  379. );
  380. }}
  381. </Feature>
  382. )}
  383. {hasChanges && (
  384. <SubmitButton onClick={onApply} size="xsmall" priority="primary">
  385. {t('Apply Filter')}
  386. </SubmitButton>
  387. )}
  388. </FooterActions>
  389. </FooterContainer>
  390. );
  391. };
  392. export default withRouter(MultipleProjectSelector);
  393. const FooterContainer = styled('div')<{hasMessage: boolean}>`
  394. display: flex;
  395. justify-content: ${p => (p.hasMessage ? 'space-between' : 'flex-end')};
  396. `;
  397. const FooterActions = styled('div')`
  398. padding: ${space(1)} 0;
  399. display: flex;
  400. justify-content: flex-end;
  401. & > * {
  402. margin-left: ${space(0.5)};
  403. }
  404. &:empty {
  405. display: none;
  406. }
  407. `;
  408. const SubmitButton = styled(Button)`
  409. animation: 0.1s ${growIn} ease-in;
  410. `;
  411. const FooterMessage = styled('div')`
  412. font-size: ${p => p.theme.fontSizeSmall};
  413. padding: ${space(1)} ${space(0.5)};
  414. `;
  415. const StyledProjectSelector = styled(ProjectSelector)`
  416. background-color: ${p => p.theme.background};
  417. color: ${p => p.theme.textColor};
  418. ${p =>
  419. !p.detached &&
  420. `
  421. width: 100%;
  422. margin: 1px 0 0 -1px;
  423. border-radius: ${p.theme.borderRadiusBottom};
  424. `}
  425. `;
  426. const StyledHeaderItem = styled(HeaderItem)`
  427. height: 100%;
  428. width: 100%;
  429. ${p => p.locked && 'cursor: default'};
  430. `;
  431. const StyledLink = styled(Link)`
  432. color: ${p => p.theme.subText};
  433. &:hover {
  434. color: ${p => p.theme.subText};
  435. }
  436. `;