index.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import sortBy from 'lodash/sortBy';
  4. import Button from 'app/components/button';
  5. import DropdownAutoComplete from 'app/components/dropdownAutoComplete';
  6. import {IconAdd} from 'app/icons';
  7. import {t} from 'app/locale';
  8. import space from 'app/styles/space';
  9. import {Organization, Project} from 'app/types';
  10. import theme from 'app/utils/theme';
  11. import SelectorItem from './selectorItem';
  12. type DropdownAutoCompleteProps = React.ComponentProps<typeof DropdownAutoComplete>;
  13. type Props = {
  14. organization: Organization;
  15. /**
  16. * Used by multiProjectSelector
  17. */
  18. multiProjects: Array<Project>;
  19. nonMemberProjects: Array<Project>;
  20. /**
  21. * Use this if the component should be a controlled component
  22. */
  23. selectedProjects: Array<Project>;
  24. children: (
  25. args: Parameters<DropdownAutoCompleteProps['children']>[0] & {
  26. selectedProjects: Project[];
  27. }
  28. ) => React.ReactElement;
  29. /**
  30. * Allow selecting multiple projects
  31. */
  32. multi?: boolean;
  33. /**
  34. * Represents if a search is taking place
  35. */
  36. searching?: boolean;
  37. /**
  38. * Represents if the current project selector is paginated or fully loaded.
  39. * Currently only used to ensure that in an empty state the input is not
  40. * hidden. This is for the case in which a user searches for a project which
  41. * does not exist. If we hide the input due to no results, the user cannot
  42. * recover
  43. */
  44. paginated?: boolean;
  45. /**
  46. * Callback when a project is selected
  47. */
  48. onSelect: (project: Project) => void;
  49. /**
  50. * Callback when the input filter changes
  51. */
  52. onFilterChange?: () => void;
  53. /**
  54. * Callback when projects are selected via the multiple project selector
  55. * Calls back with (projects[], event)
  56. */
  57. onMultiSelect?: (projects: Array<Project>, event: React.MouseEvent) => void;
  58. } & Pick<
  59. DropdownAutoCompleteProps,
  60. 'menuFooter' | 'onScroll' | 'onClose' | 'rootClassName' | 'className'
  61. >;
  62. const ProjectSelector = ({
  63. children,
  64. organization,
  65. menuFooter,
  66. className,
  67. rootClassName,
  68. onClose,
  69. onFilterChange,
  70. onScroll,
  71. searching,
  72. paginated,
  73. multiProjects,
  74. onSelect,
  75. onMultiSelect,
  76. multi = false,
  77. selectedProjects = [],
  78. ...props
  79. }: Props) => {
  80. const getProjects = () => {
  81. const {nonMemberProjects = []} = props;
  82. return [
  83. sortBy(multiProjects, project => [
  84. !selectedProjects.find(selectedProject => selectedProject.slug === project.slug),
  85. !project.isBookmarked,
  86. project.slug,
  87. ]),
  88. sortBy(nonMemberProjects, project => [project.slug]),
  89. ];
  90. };
  91. const [projects, nonMemberProjects] = getProjects();
  92. const handleSelect = ({value: project}: {value: Project}) => {
  93. onSelect(project);
  94. };
  95. const handleMultiSelect = (project: Project, event: React.MouseEvent) => {
  96. if (!onMultiSelect) {
  97. // eslint-disable-next-line no-console
  98. console.error(
  99. 'ProjectSelector is a controlled component but `onMultiSelect` callback is not defined'
  100. );
  101. return;
  102. }
  103. const selectedProjectsMap = new Map(selectedProjects.map(p => [p.slug, p]));
  104. if (selectedProjectsMap.has(project.slug)) {
  105. // unselected a project
  106. selectedProjectsMap.delete(project.slug);
  107. onMultiSelect(Array.from(selectedProjectsMap.values()), event);
  108. return;
  109. }
  110. selectedProjectsMap.set(project.slug, project);
  111. onMultiSelect(Array.from(selectedProjectsMap.values()), event);
  112. };
  113. const getProjectItem = (project: Project) => ({
  114. value: project,
  115. searchKey: project.slug,
  116. label: ({inputValue}: {inputValue: typeof project.slug}) => (
  117. <SelectorItem
  118. project={project}
  119. organization={organization}
  120. multi={multi}
  121. inputValue={inputValue}
  122. isChecked={!!selectedProjects.find(({slug}) => slug === project.slug)}
  123. onMultiSelect={handleMultiSelect}
  124. />
  125. ),
  126. });
  127. const getItems = (hasProjects: boolean) => {
  128. if (!hasProjects) {
  129. return [];
  130. }
  131. return [
  132. {
  133. hideGroupLabel: true,
  134. items: projects.map(getProjectItem),
  135. },
  136. {
  137. hideGroupLabel: nonMemberProjects.length === 0,
  138. itemSize: 'small',
  139. id: 'no-membership-header', // needed for tests for non-virtualized lists
  140. label: <Label>{t("Projects I don't belong to")}</Label>,
  141. items: nonMemberProjects.map(getProjectItem),
  142. },
  143. ];
  144. };
  145. const hasProjects = !!projects?.length || !!nonMemberProjects?.length;
  146. const newProjectUrl = `/organizations/${organization.slug}/projects/new/`;
  147. const hasProjectWrite = organization.access.includes('project:write');
  148. return (
  149. <DropdownAutoComplete
  150. blendCorner={false}
  151. searchPlaceholder={t('Filter projects')}
  152. onSelect={handleSelect}
  153. onClose={onClose}
  154. onChange={onFilterChange}
  155. busyItemsStillVisible={searching}
  156. onScroll={onScroll}
  157. maxHeight={500}
  158. inputProps={{style: {padding: 8, paddingLeft: 10}}}
  159. rootClassName={rootClassName}
  160. className={className}
  161. emptyMessage={t('You have no projects')}
  162. noResultsMessage={t('No projects found')}
  163. virtualizedHeight={theme.headerSelectorRowHeight}
  164. virtualizedLabelHeight={theme.headerSelectorLabelHeight}
  165. emptyHidesInput={!paginated}
  166. inputActions={
  167. <AddButton
  168. disabled={!hasProjectWrite}
  169. to={newProjectUrl}
  170. size="xsmall"
  171. icon={<IconAdd size="xs" isCircled />}
  172. title={
  173. !hasProjectWrite ? t("You don't have permission to add a project") : undefined
  174. }
  175. >
  176. {t('Project')}
  177. </AddButton>
  178. }
  179. menuFooter={renderProps => {
  180. const renderedFooter =
  181. typeof menuFooter === 'function' ? menuFooter(renderProps) : menuFooter;
  182. const showCreateProjectButton = !hasProjects && hasProjectWrite;
  183. if (!renderedFooter && !showCreateProjectButton) {
  184. return null;
  185. }
  186. return (
  187. <React.Fragment>
  188. {showCreateProjectButton && (
  189. <CreateProjectButton priority="primary" size="small" to={newProjectUrl}>
  190. {t('Create project')}
  191. </CreateProjectButton>
  192. )}
  193. {renderedFooter}
  194. </React.Fragment>
  195. );
  196. }}
  197. items={getItems(hasProjects)}
  198. allowActorToggle
  199. closeOnSelect
  200. >
  201. {renderProps => children({...renderProps, selectedProjects})}
  202. </DropdownAutoComplete>
  203. );
  204. };
  205. export default ProjectSelector;
  206. const Label = styled('div')`
  207. font-size: ${p => p.theme.fontSizeSmall};
  208. color: ${p => p.theme.gray300};
  209. `;
  210. const AddButton = styled(Button)`
  211. display: block;
  212. margin: 0 ${space(1)};
  213. color: ${p => p.theme.gray300};
  214. :hover {
  215. color: ${p => p.theme.subText};
  216. }
  217. `;
  218. const CreateProjectButton = styled(Button)`
  219. display: block;
  220. text-align: center;
  221. margin: ${space(0.5)} 0;
  222. `;