import {Fragment, useEffect, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {withRouter, WithRouterProps} from 'react-router'; import {ClassNames} from '@emotion/react'; import styled from '@emotion/styled'; import isEqual from 'lodash/isEqual'; import sortBy from 'lodash/sortBy'; import {MenuActions} from 'sentry/components/deprecatedDropdownMenu'; import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; import {MenuFooterChildProps} from 'sentry/components/dropdownAutoComplete/menu'; import {Item} from 'sentry/components/dropdownAutoComplete/types'; import Highlight from 'sentry/components/highlight'; import HeaderItem from 'sentry/components/organizations/headerItem'; import MultipleSelectorSubmitRow from 'sentry/components/organizations/multipleSelectorSubmitRow'; import PageFilterRow from 'sentry/components/organizations/pageFilterRow'; import PageFilterPinButton from 'sentry/components/organizations/pageFilters/pageFilterPinButton'; import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters'; import {IconWindow} from 'sentry/icons'; import {t} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import space from 'sentry/styles/space'; import {Organization, Project} from 'sentry/types'; import {analytics} from 'sentry/utils/analytics'; import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes'; import theme from 'sentry/utils/theme'; type Props = WithRouterProps & { loadingProjects: boolean; /** * When menu is closed */ onUpdate: (environments: string[]) => void; organization: Organization; projects: Project[]; selectedProjects: number[]; /** * This component must be controlled using a value array */ value: string[]; /** * Aligns dropdown menu to left or right of button */ alignDropdown?: 'left' | 'right'; customDropdownButton?: (config: { actions: MenuActions; isOpen: boolean; value: string[]; }) => React.ReactElement; customLoadingIndicator?: React.ReactNode; detached?: boolean; disabled?: boolean; forceEnvironment?: string; /** * Show the pin button in the dropdown's header actions */ showPin?: boolean; }; /** * Environment Selector * * Note we only fetch environments when this component is mounted */ function EnvironmentSelector({ loadingProjects, onUpdate, organization, projects, selectedProjects, value, alignDropdown, customDropdownButton, customLoadingIndicator, detached, disabled, forceEnvironment, router, showPin, }: Props) { const [selectedEnvs, setSelectedEnvs] = useState(value); const hasChanges = !isEqual(selectedEnvs, value); // Update selected envs value on change useEffect(() => { setSelectedEnvs(previousSelectedEnvs => { lastSelectedEnvs.current = previousSelectedEnvs; return value; }); }, [value]); // We keep a separate list of selected environments to use for sorting. This // allows us to only update it after the list is closed, to avoid the list // jumping around while selecting projects. const lastSelectedEnvs = useRef(value); // Ref to help avoid updating stale selected values const didQuickSelect = useRef(false); /** * Toggle selected state of an environment */ const toggleCheckbox = (environment: string) => { const willRemove = selectedEnvs.includes(environment); const updatedSelectedEnvs = willRemove ? selectedEnvs.filter(env => env !== environment) : [...selectedEnvs, environment]; analytics('environmentselector.toggle', { action: willRemove ? 'removed' : 'added', path: getRouteStringFromRoutes(router.routes), org_id: parseInt(organization.id, 10), }); setSelectedEnvs(updatedSelectedEnvs); }; const handleSave = (actions: MenuFooterChildProps['actions']) => { actions.close(); onUpdate(selectedEnvs); }; const handleMenuClose = () => { // Only update if there are changes if (!hasChanges || didQuickSelect.current) { didQuickSelect.current = false; return; } analytics('environmentselector.update', { count: selectedEnvs.length, path: getRouteStringFromRoutes(router.routes), org_id: parseInt(organization.id, 10), }); onUpdate(selectedEnvs); }; /** * Clears all selected environments and updates */ const handleClear = () => { analytics('environmentselector.clear', { path: getRouteStringFromRoutes(router.routes), org_id: parseInt(organization.id, 10), }); setSelectedEnvs([]); onUpdate([]); }; const handleQuickSelect = (item: Item) => { analytics('environmentselector.direct_selection', { path: getRouteStringFromRoutes(router.routes), org_id: parseInt(organization.id, 10), }); const selectedEnvironments = [item.value]; setSelectedEnvs(selectedEnvironments); onUpdate(selectedEnvironments); // Track that we just did a click select so we don't trigger an update in // the close handler. didQuickSelect.current = true; }; const config = ConfigStore.getConfig(); const unsortedEnvironments = projects.flatMap(project => { const projectId = parseInt(project.id, 10); // Include environments from: // - all projects if the user is a superuser // - the requested projects // - all member projects if 'my projects' (empty list) is selected. // - all projects if -1 is the only selected project. if ( (selectedProjects.length === 1 && selectedProjects[0] === ALL_ACCESS_PROJECTS && project.hasAccess) || (selectedProjects.length === 0 && (project.isMember || config.user.isSuperuser)) || selectedProjects.includes(projectId) ) { return project.environments; } return []; }); const uniqueEnvironments = Array.from(new Set(unsortedEnvironments)); // Sort with the last selected environments at the top const environments = sortBy(uniqueEnvironments, env => [ !lastSelectedEnvs.current.find(e => e === env), env, ]); const validatedValue = value.filter(env => environments.includes(env)); const summary = validatedValue.length ? `${validatedValue.join(', ')}` : t('All Environments'); if (forceEnvironment !== undefined) { return ( } isOpen={false} locked > {forceEnvironment ? forceEnvironment : t('All Environments')} ); } if (loadingProjects && customLoadingIndicator) { return {customLoadingIndicator}; } if (loadingProjects) { return ( } loading={loadingProjects} hasChanges={false} hasSelected={false} isOpen={false} locked={false} > {t('Loading\u2026')} ); } return ( {({css}) => ( : undefined } menuFooter={({actions}) => hasChanges ? ( handleSave(actions)} /> ) : null } items={environments.map(env => ({ value: env, searchKey: env, label: ({inputValue}) => ( { e.stopPropagation(); toggleCheckbox(env); }} > {env} ), }))} > {({isOpen, actions}) => customDropdownButton ? ( customDropdownButton({isOpen, actions, value: validatedValue}) ) : ( } isOpen={isOpen} hasSelected={value && !!value.length} onClear={handleClear} hasChanges={false} locked={false} loading={false} > {summary} ) } )} ); } export default withRouter(EnvironmentSelector); const StyledHeaderItem = styled(HeaderItem)` height: 100%; width: 100%; `; const StyledDropdownAutoComplete = styled(DropdownAutoComplete)` background: ${p => p.theme.background}; border: 1px solid ${p => p.theme.border}; position: absolute; top: 100%; ${p => !p.detached && ` margin-top: 0; border-radius: ${p.theme.borderRadiusBottom}; `}; `; const StyledPinButton = styled(PageFilterPinButton)` margin: 0 ${space(1)}; `;