environmentSelector.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import {Fragment, useEffect, 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 isEqual from 'lodash/isEqual';
  7. import sortBy from 'lodash/sortBy';
  8. import {MenuActions} from 'sentry/components/deprecatedDropdownMenu';
  9. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  10. import {MenuFooterChildProps} from 'sentry/components/dropdownAutoComplete/menu';
  11. import {Item} from 'sentry/components/dropdownAutoComplete/types';
  12. import Highlight from 'sentry/components/highlight';
  13. import MultipleSelectorSubmitRow from 'sentry/components/organizations/multipleSelectorSubmitRow';
  14. import PageFilterRow from 'sentry/components/organizations/pageFilterRow';
  15. import PageFilterPinButton from 'sentry/components/organizations/pageFilters/pageFilterPinButton';
  16. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  17. import {t} from 'sentry/locale';
  18. import ConfigStore from 'sentry/stores/configStore';
  19. import space from 'sentry/styles/space';
  20. import {Organization, Project} from 'sentry/types';
  21. import {analytics} from 'sentry/utils/analytics';
  22. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  23. import theme from 'sentry/utils/theme';
  24. type Props = WithRouterProps & {
  25. customDropdownButton: (config: {
  26. actions: MenuActions;
  27. isOpen: boolean;
  28. value: string[];
  29. }) => React.ReactElement;
  30. customLoadingIndicator: React.ReactNode;
  31. loadingProjects: boolean;
  32. /**
  33. * When menu is closed
  34. */
  35. onUpdate: (environments: string[]) => void;
  36. organization: Organization;
  37. projects: Project[];
  38. selectedProjects: number[];
  39. /**
  40. * This component must be controlled using a value array
  41. */
  42. value: string[];
  43. /**
  44. * Aligns dropdown menu to left or right of button
  45. */
  46. alignDropdown?: 'left' | 'right';
  47. disabled?: boolean;
  48. };
  49. /**
  50. * Environment Selector
  51. *
  52. * Note we only fetch environments when this component is mounted
  53. */
  54. function EnvironmentSelector({
  55. loadingProjects,
  56. onUpdate,
  57. organization,
  58. projects,
  59. selectedProjects,
  60. value,
  61. alignDropdown,
  62. customDropdownButton,
  63. customLoadingIndicator,
  64. disabled,
  65. router,
  66. }: Props) {
  67. const [selectedEnvs, setSelectedEnvs] = useState(value);
  68. const hasChanges = !isEqual(selectedEnvs, value);
  69. // Update selected envs value on change
  70. useEffect(() => {
  71. setSelectedEnvs(previousSelectedEnvs => {
  72. lastSelectedEnvs.current = previousSelectedEnvs;
  73. return value;
  74. });
  75. }, [value]);
  76. // We keep a separate list of selected environments to use for sorting. This
  77. // allows us to only update it after the list is closed, to avoid the list
  78. // jumping around while selecting projects.
  79. const lastSelectedEnvs = useRef(value);
  80. // Ref to help avoid updating stale selected values
  81. const didQuickSelect = useRef(false);
  82. /**
  83. * Toggle selected state of an environment
  84. */
  85. const toggleCheckbox = (environment: string) => {
  86. const willRemove = selectedEnvs.includes(environment);
  87. const updatedSelectedEnvs = willRemove
  88. ? selectedEnvs.filter(env => env !== environment)
  89. : [...selectedEnvs, environment];
  90. analytics('environmentselector.toggle', {
  91. action: willRemove ? 'removed' : 'added',
  92. path: getRouteStringFromRoutes(router.routes),
  93. org_id: parseInt(organization.id, 10),
  94. });
  95. setSelectedEnvs(updatedSelectedEnvs);
  96. };
  97. const handleSave = (actions: MenuFooterChildProps['actions']) => {
  98. actions.close();
  99. onUpdate(selectedEnvs);
  100. };
  101. const handleMenuClose = () => {
  102. // Only update if there are changes
  103. if (!hasChanges || didQuickSelect.current) {
  104. didQuickSelect.current = false;
  105. return;
  106. }
  107. analytics('environmentselector.update', {
  108. count: selectedEnvs.length,
  109. path: getRouteStringFromRoutes(router.routes),
  110. org_id: parseInt(organization.id, 10),
  111. });
  112. onUpdate(selectedEnvs);
  113. };
  114. const handleQuickSelect = (item: Item) => {
  115. analytics('environmentselector.direct_selection', {
  116. path: getRouteStringFromRoutes(router.routes),
  117. org_id: parseInt(organization.id, 10),
  118. });
  119. const selectedEnvironments = [item.value];
  120. setSelectedEnvs(selectedEnvironments);
  121. onUpdate(selectedEnvironments);
  122. // Track that we just did a click select so we don't trigger an update in
  123. // the close handler.
  124. didQuickSelect.current = true;
  125. };
  126. const {user} = ConfigStore.getState();
  127. const unsortedEnvironments = projects.flatMap(project => {
  128. const projectId = parseInt(project.id, 10);
  129. // Include environments from:
  130. // - all projects if the user is a superuser
  131. // - the requested projects
  132. // - all member projects if 'my projects' (empty list) is selected.
  133. // - all projects if -1 is the only selected project.
  134. if (
  135. (selectedProjects.length === 1 &&
  136. selectedProjects[0] === ALL_ACCESS_PROJECTS &&
  137. project.hasAccess) ||
  138. (selectedProjects.length === 0 && (project.isMember || user.isSuperuser)) ||
  139. selectedProjects.includes(projectId)
  140. ) {
  141. return project.environments;
  142. }
  143. return [];
  144. });
  145. const uniqueEnvironments = Array.from(new Set(unsortedEnvironments));
  146. // Sort with the last selected environments at the top
  147. const environments = sortBy(uniqueEnvironments, env => [
  148. !lastSelectedEnvs.current.find(e => e === env),
  149. env,
  150. ]);
  151. const validatedValue = value.filter(env => environments.includes(env));
  152. if (loadingProjects) {
  153. return <Fragment>{customLoadingIndicator}</Fragment>;
  154. }
  155. return (
  156. <ClassNames>
  157. {({css}) => (
  158. <StyledDropdownAutoComplete
  159. alignMenu={alignDropdown}
  160. allowActorToggle
  161. closeOnSelect
  162. blendCorner={false}
  163. detached
  164. disabled={disabled}
  165. searchPlaceholder={t('Filter environments')}
  166. onSelect={handleQuickSelect}
  167. onClose={handleMenuClose}
  168. maxHeight={500}
  169. rootClassName={css`
  170. position: relative;
  171. display: flex;
  172. `}
  173. inputProps={{style: {padding: 8, paddingLeft: 14}}}
  174. emptyMessage={t('You have no environments')}
  175. noResultsMessage={t('No environments found')}
  176. virtualizedHeight={theme.headerSelectorRowHeight}
  177. emptyHidesInput
  178. inputActions={
  179. <StyledPinButton
  180. organization={organization}
  181. filter="environments"
  182. size="xs"
  183. />
  184. }
  185. menuFooter={({actions}) =>
  186. hasChanges ? (
  187. <MultipleSelectorSubmitRow onSubmit={() => handleSave(actions)} />
  188. ) : null
  189. }
  190. items={environments.map(env => ({
  191. value: env,
  192. searchKey: env,
  193. label: ({inputValue}) => (
  194. <PageFilterRow
  195. data-test-id={`environment-${env}`}
  196. checked={selectedEnvs.includes(env)}
  197. onCheckClick={e => {
  198. e.stopPropagation();
  199. toggleCheckbox(env);
  200. }}
  201. >
  202. <Highlight text={inputValue}>{env}</Highlight>
  203. </PageFilterRow>
  204. ),
  205. }))}
  206. >
  207. {({isOpen, actions}) =>
  208. customDropdownButton({isOpen, actions, value: validatedValue})
  209. }
  210. </StyledDropdownAutoComplete>
  211. )}
  212. </ClassNames>
  213. );
  214. }
  215. export default withRouter(EnvironmentSelector);
  216. const StyledDropdownAutoComplete = styled(DropdownAutoComplete)`
  217. background: ${p => p.theme.background};
  218. border: 1px solid ${p => p.theme.border};
  219. position: absolute;
  220. top: 100%;
  221. ${p =>
  222. !p.detached &&
  223. `
  224. margin-top: 0;
  225. border-radius: ${p.theme.borderRadiusBottom};
  226. `};
  227. `;
  228. const StyledPinButton = styled(PageFilterPinButton)`
  229. margin: 0 ${space(1)};
  230. `;