123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- import * as React from 'react';
- import {withRouter, WithRouterProps} from 'react-router';
- import {ClassNames} from '@emotion/react';
- import styled from '@emotion/styled';
- import Feature from 'sentry/components/acl/feature';
- import Button from 'sentry/components/button';
- import {MenuActions} from 'sentry/components/dropdownMenu';
- import Link from 'sentry/components/links/link';
- import HeaderItem from 'sentry/components/organizations/headerItem';
- import PlatformList from 'sentry/components/platformList';
- import Tooltip from 'sentry/components/tooltip';
- import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
- import {IconProject} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import {growIn} from 'sentry/styles/animations';
- import space from 'sentry/styles/space';
- import {MinimalProject, Organization, Project} from 'sentry/types';
- import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
- import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
- import ProjectSelector from './projectSelector';
- type Props = WithRouterProps & {
- memberProjects: Project[];
- nonMemberProjects: Project[];
- onChange: (selected: number[]) => void;
- onUpdate: (newProjects?: number[]) => void;
- organization: Organization;
- value: number[];
- customDropdownButton?: (config: {
- actions: MenuActions;
- isOpen: boolean;
- selectedProjects: Project[];
- }) => React.ReactElement;
- customLoadingIndicator?: React.ReactNode;
- detached?: boolean;
- disableMultipleProjectSelection?: boolean;
- footerMessage?: React.ReactNode;
- forceProject?: MinimalProject | null;
- isGlobalSelectionReady?: boolean;
- lockedMessageSubject?: React.ReactNode;
- shouldForceProject?: boolean;
- showIssueStreamLink?: boolean;
- showPin?: boolean;
- showProjectSettingsLink?: boolean;
- };
- type State = {
- hasChanges: boolean;
- };
- class MultipleProjectSelector extends React.PureComponent<Props, State> {
- static defaultProps = {
- lockedMessageSubject: t('page'),
- };
- state: State = {
- hasChanges: false,
- };
- get multi() {
- const {organization, disableMultipleProjectSelection} = this.props;
- return (
- !disableMultipleProjectSelection && organization.features.includes('global-views')
- );
- }
- /**
- * Reset "hasChanges" state and call `onUpdate` callback
- * @param value optional parameter that will be passed to onUpdate callback
- */
- doUpdate = (value?: number[]) => {
- this.setState({hasChanges: false}, () => this.props.onUpdate(value));
- };
- /**
- * Handler for when an explicit update call should be made.
- * e.g. an "Update" button
- *
- * Should perform an "update" callback
- */
- handleUpdate = (actions: {close: () => void}) => {
- actions.close();
- this.doUpdate();
- };
- /**
- * Handler for when a dropdown item was selected directly (and not via multi select)
- *
- * Should perform an "update" callback
- */
- handleQuickSelect = (selected: Pick<Project, 'id'>) => {
- trackAdvancedAnalyticsEvent('projectselector.direct_selection', {
- path: getRouteStringFromRoutes(this.props.router.routes),
- organization: this.props.organization,
- });
- const value = selected.id === null ? [] : [parseInt(selected.id, 10)];
- this.props.onChange(value);
- this.doUpdate(value);
- };
- /**
- * Handler for when dropdown menu closes
- *
- * Should perform an "update" callback
- */
- handleClose = () => {
- // Only update if there are changes
- if (!this.state.hasChanges) {
- return;
- }
- const {value} = this.props;
- trackAdvancedAnalyticsEvent('projectselector.update', {
- count: value.length,
- path: getRouteStringFromRoutes(this.props.router.routes),
- organization: this.props.organization,
- multi: this.multi,
- });
- this.doUpdate();
- };
- /**
- * Handler for clearing the current value
- *
- * Should perform an "update" callback
- */
- handleClear = () => {
- trackAdvancedAnalyticsEvent('projectselector.clear', {
- path: getRouteStringFromRoutes(this.props.router.routes),
- organization: this.props.organization,
- });
- this.props.onChange([]);
- // Update on clear
- this.doUpdate();
- };
- /**
- * Handler for selecting multiple items, should NOT call update
- */
- handleMultiSelect = (selected: Project[]) => {
- const {onChange, value} = this.props;
- trackAdvancedAnalyticsEvent('projectselector.toggle', {
- action: selected.length > value.length ? 'added' : 'removed',
- path: getRouteStringFromRoutes(this.props.router.routes),
- organization: this.props.organization,
- });
- const selectedList = selected.map(({id}) => parseInt(id, 10)).filter(i => i);
- onChange(selectedList);
- this.setState({hasChanges: true});
- };
- renderProjectName() {
- const {forceProject, location, organization, showIssueStreamLink} = this.props;
- if (showIssueStreamLink && forceProject && this.multi) {
- return (
- <Tooltip title={t('Issues Stream')} position="bottom">
- <StyledLink
- to={{
- pathname: `/organizations/${organization.slug}/issues/`,
- query: {...location.query, project: forceProject.id},
- }}
- >
- {forceProject.slug}
- </StyledLink>
- </Tooltip>
- );
- }
- if (forceProject) {
- return forceProject.slug;
- }
- return '';
- }
- getLockedMessage() {
- const {forceProject, lockedMessageSubject} = this.props;
- if (forceProject) {
- return tct('This [subject] is unique to the [projectSlug] project', {
- subject: lockedMessageSubject,
- projectSlug: forceProject.slug,
- });
- }
- return tct('This [subject] is unique to a project', {subject: lockedMessageSubject});
- }
- render() {
- const {
- value,
- memberProjects,
- isGlobalSelectionReady,
- disableMultipleProjectSelection,
- nonMemberProjects,
- organization,
- shouldForceProject,
- forceProject,
- showProjectSettingsLink,
- footerMessage,
- customDropdownButton,
- customLoadingIndicator,
- } = this.props;
- const selectedProjectIds = new Set(value);
- const multi = this.multi;
- const allProjects = [...memberProjects, ...nonMemberProjects];
- const selected = allProjects.filter(project =>
- selectedProjectIds.has(parseInt(project.id, 10))
- );
- // `forceProject` can be undefined if it is loading the project
- // We are intentionally using an empty string as its "loading" state
- return shouldForceProject ? (
- <StyledHeaderItem
- data-test-id="global-header-project-selector"
- icon={
- forceProject && (
- <PlatformList
- platforms={forceProject.platform ? [forceProject.platform] : []}
- max={1}
- />
- )
- }
- locked
- lockedMessage={this.getLockedMessage()}
- settingsLink={
- (forceProject &&
- showProjectSettingsLink &&
- `/settings/${organization.slug}/projects/${forceProject.slug}/`) ||
- undefined
- }
- >
- {this.renderProjectName()}
- </StyledHeaderItem>
- ) : !isGlobalSelectionReady ? (
- customLoadingIndicator ?? (
- <StyledHeaderItem
- data-test-id="global-header-project-selector-loading"
- icon={<IconProject />}
- loading
- >
- {t('Loading\u2026')}
- </StyledHeaderItem>
- )
- ) : (
- <ClassNames>
- {({css}) => (
- <StyledProjectSelector
- {...this.props}
- multi={!!multi}
- selectedProjects={selected}
- multiProjects={memberProjects}
- onSelect={this.handleQuickSelect}
- onClose={this.handleClose}
- onMultiSelect={this.handleMultiSelect}
- rootClassName={css`
- display: flex;
- `}
- menuFooter={({actions}) => (
- <SelectorFooterControls
- selected={selectedProjectIds}
- disableMultipleProjectSelection={disableMultipleProjectSelection}
- organization={organization}
- hasChanges={this.state.hasChanges}
- onApply={() => this.handleUpdate(actions)}
- onShowAllProjects={() => {
- this.handleQuickSelect({id: ALL_ACCESS_PROJECTS.toString()});
- actions.close();
- trackAdvancedAnalyticsEvent('projectselector.multi_button_clicked', {
- button_type: 'all',
- path: getRouteStringFromRoutes(this.props.router.routes),
- organization,
- });
- }}
- onShowMyProjects={() => {
- this.handleClear();
- actions.close();
- trackAdvancedAnalyticsEvent('projectselector.multi_button_clicked', {
- button_type: 'my',
- path: getRouteStringFromRoutes(this.props.router.routes),
- organization,
- });
- }}
- message={footerMessage}
- />
- )}
- >
- {({actions, selectedProjects, isOpen}) => {
- if (customDropdownButton) {
- return customDropdownButton({actions, selectedProjects, isOpen});
- }
- const hasSelected = !!selectedProjects.length;
- const title = hasSelected
- ? selectedProjects.map(({slug}) => slug).join(', ')
- : selectedProjectIds.has(ALL_ACCESS_PROJECTS)
- ? t('All Projects')
- : t('My Projects');
- const icon = hasSelected ? (
- <PlatformList
- platforms={selectedProjects.map(p => p.platform ?? 'other').reverse()}
- max={5}
- />
- ) : (
- <IconProject />
- );
- return (
- <StyledHeaderItem
- data-test-id="global-header-project-selector"
- icon={icon}
- hasSelected={hasSelected}
- hasChanges={this.state.hasChanges}
- isOpen={isOpen}
- onClear={this.handleClear}
- allowClear={multi}
- settingsLink={
- selectedProjects.length === 1
- ? `/settings/${organization.slug}/projects/${selected[0]?.slug}/`
- : ''
- }
- >
- {title}
- </StyledHeaderItem>
- );
- }}
- </StyledProjectSelector>
- )}
- </ClassNames>
- );
- }
- }
- type FeatureRenderProps = {
- hasFeature: boolean;
- renderShowAllButton?: (p: {
- canShowAllProjects: boolean;
- onButtonClick: () => void;
- }) => React.ReactNode;
- };
- type ControlProps = {
- onApply: () => void;
- onShowAllProjects: () => void;
- onShowMyProjects: () => void;
- organization: Organization;
- disableMultipleProjectSelection?: boolean;
- hasChanges?: boolean;
- message?: React.ReactNode;
- selected?: Set<number>;
- };
- const SelectorFooterControls = ({
- selected,
- disableMultipleProjectSelection,
- hasChanges,
- onApply,
- onShowAllProjects,
- onShowMyProjects,
- organization,
- message,
- }: ControlProps) => {
- // Nothing to show.
- if (disableMultipleProjectSelection && !hasChanges && !message) {
- return null;
- }
- // see if we should show "All Projects" or "My Projects" if disableMultipleProjectSelection isn't true
- const hasGlobalRole = organization.role === 'owner' || organization.role === 'manager';
- const hasOpenMembership = organization.features.includes('open-membership');
- const allSelected = selected && selected.has(ALL_ACCESS_PROJECTS);
- const canShowAllProjects = (hasGlobalRole || hasOpenMembership) && !allSelected;
- const onProjectClick = canShowAllProjects ? onShowAllProjects : onShowMyProjects;
- const buttonText = canShowAllProjects
- ? t('Select All Projects')
- : t('Select My Projects');
- return (
- <FooterContainer hasMessage={!!message}>
- {message && <FooterMessage>{message}</FooterMessage>}
- <FooterActions>
- {!disableMultipleProjectSelection && (
- <Feature
- features={['organizations:global-views']}
- organization={organization}
- hookName="feature-disabled:project-selector-all-projects"
- renderDisabled={false}
- >
- {({renderShowAllButton, hasFeature}: FeatureRenderProps) => {
- // if our hook is adding renderShowAllButton, render that
- if (renderShowAllButton) {
- return renderShowAllButton({
- onButtonClick: onProjectClick,
- canShowAllProjects,
- });
- }
- // if no hook, render null if feature is disabled
- if (!hasFeature) {
- return null;
- }
- // otherwise render the buton
- return (
- <Button priority="default" size="xsmall" onClick={onProjectClick}>
- {buttonText}
- </Button>
- );
- }}
- </Feature>
- )}
- {hasChanges && (
- <SubmitButton onClick={onApply} size="xsmall" priority="primary">
- {t('Apply Filter')}
- </SubmitButton>
- )}
- </FooterActions>
- </FooterContainer>
- );
- };
- export default withRouter(MultipleProjectSelector);
- const FooterContainer = styled('div')<{hasMessage: boolean}>`
- display: flex;
- justify-content: ${p => (p.hasMessage ? 'space-between' : 'flex-end')};
- `;
- const FooterActions = styled('div')`
- padding: ${space(1)} 0;
- display: flex;
- justify-content: flex-end;
- & > * {
- margin-left: ${space(0.5)};
- }
- &:empty {
- display: none;
- }
- `;
- const SubmitButton = styled(Button)`
- animation: 0.1s ${growIn} ease-in;
- `;
- const FooterMessage = styled('div')`
- font-size: ${p => p.theme.fontSizeSmall};
- padding: ${space(1)} ${space(0.5)};
- `;
- const StyledProjectSelector = styled(ProjectSelector)`
- background-color: ${p => p.theme.background};
- color: ${p => p.theme.textColor};
- ${p =>
- !p.detached &&
- `
- width: 100%;
- margin: 1px 0 0 -1px;
- border-radius: ${p.theme.borderRadiusBottom};
- `}
- `;
- const StyledHeaderItem = styled(HeaderItem)`
- height: 100%;
- width: 100%;
- ${p => p.locked && 'cursor: default'};
- `;
- const StyledLink = styled(Link)`
- color: ${p => p.theme.subText};
- &:hover {
- color: ${p => p.theme.subText};
- }
- `;
|