editAccessSelector.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import isEqual from 'lodash/isEqual';
  5. import sortBy from 'lodash/sortBy';
  6. import {hasEveryAccess} from 'sentry/components/acl/access';
  7. import Avatar from 'sentry/components/avatar';
  8. import AvatarList, {CollapsedAvatars} from 'sentry/components/avatar/avatarList';
  9. import TeamAvatar from 'sentry/components/avatar/teamAvatar';
  10. import Badge from 'sentry/components/badge/badge';
  11. import FeatureBadge from 'sentry/components/badge/featureBadge';
  12. import {Button} from 'sentry/components/button';
  13. import ButtonBar from 'sentry/components/buttonBar';
  14. import {CompactSelect} from 'sentry/components/compactSelect';
  15. import {CheckWrap} from 'sentry/components/compactSelect/styles';
  16. import UserBadge from 'sentry/components/idBadge/userBadge';
  17. import {InnerWrap, LeadingItems} from 'sentry/components/menuListItem';
  18. import {Tooltip} from 'sentry/components/tooltip';
  19. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  20. import {t, tct} from 'sentry/locale';
  21. import {space} from 'sentry/styles/space';
  22. import type {Team} from 'sentry/types/organization';
  23. import type {User} from 'sentry/types/user';
  24. import {defined} from 'sentry/utils';
  25. import {trackAnalytics} from 'sentry/utils/analytics';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import {useTeams} from 'sentry/utils/useTeams';
  28. import {useTeamsById} from 'sentry/utils/useTeamsById';
  29. import {useUser} from 'sentry/utils/useUser';
  30. import type {
  31. DashboardDetails,
  32. DashboardListItem,
  33. DashboardPermissions,
  34. } from 'sentry/views/dashboards/types';
  35. interface EditAccessSelectorProps {
  36. dashboard: DashboardDetails | DashboardListItem;
  37. listOnly?: boolean;
  38. onChangeEditAccess?: (newDashboardPermissions: DashboardPermissions) => void;
  39. }
  40. /**
  41. * Dropdown multiselect button to enable selective Dashboard editing access to
  42. * specific users and teams
  43. */
  44. function EditAccessSelector({
  45. dashboard,
  46. onChangeEditAccess,
  47. listOnly = false,
  48. }: EditAccessSelectorProps) {
  49. const currentUser: User = useUser();
  50. const dashboardCreator: User | undefined = dashboard.createdBy;
  51. const organization = useOrganization();
  52. const userCanEditDashboardPermissions =
  53. dashboardCreator?.id === currentUser.id ||
  54. hasEveryAccess(['org:write'], {organization});
  55. // Retrieves teams from the team store, which may contain only a subset of all teams
  56. const {teams: teamsToRender} = useTeamsById();
  57. const {onSearch} = useTeams();
  58. const teamIds: string[] = Object.values(teamsToRender).map(team => team.id);
  59. const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
  60. const [stagedOptions, setStagedOptions] = useState<string[]>([]);
  61. const [isMenuOpen, setMenuOpen] = useState<boolean>(false);
  62. const [isCollapsedAvatarTooltipOpen, setIsCollapsedAvatarTooltipOpen] =
  63. useState<boolean>(false);
  64. const {teams: selectedTeam} = useTeamsById({
  65. ids:
  66. selectedOptions[1] && selectedOptions[1] !== '_allUsers'
  67. ? [selectedOptions[1]]
  68. : [],
  69. });
  70. const {teams: allSelectedTeams} = useTeamsById({
  71. ids: selectedOptions.filter(option => option !== '_allUsers'),
  72. });
  73. // Gets selected options for the dropdown from dashboard object
  74. useEffect(() => {
  75. const teamIdsList: string[] = Object.values(teamsToRender).map(team => team.id);
  76. const selectedOptionsFromDashboard =
  77. !defined(dashboard.permissions) || dashboard.permissions.isEditableByEveryone
  78. ? ['_creator', '_allUsers', ...teamIdsList]
  79. : [
  80. '_creator',
  81. ...(dashboard.permissions.teamsWithEditAccess?.map(teamId =>
  82. String(teamId)
  83. ) ?? []),
  84. ];
  85. setSelectedOptions(selectedOptionsFromDashboard);
  86. }, [dashboard, teamsToRender, isMenuOpen]); // isMenuOpen dependency ensures perms are 'refreshed'
  87. // Handles state change when dropdown options are selected
  88. const onSelectOptions = (newSelectedOptions: any) => {
  89. let newSelectedValues = newSelectedOptions.map(
  90. (option: {value: string}) => option.value
  91. );
  92. const areAllTeamsSelected = teamIds.every(teamId =>
  93. newSelectedValues.includes(teamId)
  94. );
  95. if (
  96. !selectedOptions.includes('_allUsers') &&
  97. newSelectedValues.includes('_allUsers')
  98. ) {
  99. newSelectedValues = ['_creator', '_allUsers', ...teamIds];
  100. } else if (
  101. selectedOptions.includes('_allUsers') &&
  102. !newSelectedValues.includes('_allUsers')
  103. ) {
  104. newSelectedValues = ['_creator'];
  105. } else {
  106. if (areAllTeamsSelected) {
  107. // selecting all teams deselects 'all users'
  108. newSelectedValues = ['_creator', '_allUsers', ...teamIds];
  109. } else {
  110. // deselecting any team deselects 'all users'
  111. newSelectedValues = newSelectedValues.filter(
  112. (value: any) => value !== '_allUsers'
  113. );
  114. }
  115. }
  116. setSelectedOptions(newSelectedValues);
  117. };
  118. // Creates a permissions object based on the options selected
  119. function getDashboardPermissions() {
  120. return {
  121. isEditableByEveryone: selectedOptions.includes('_allUsers'),
  122. teamsWithEditAccess: selectedOptions.includes('_allUsers')
  123. ? []
  124. : selectedOptions
  125. .filter(option => option !== '_creator')
  126. .map(teamId => parseInt(teamId, 10))
  127. .sort((a, b) => a - b),
  128. };
  129. }
  130. // Creates tooltip for the + bubble in avatar list
  131. const renderCollapsedAvatarTooltip = () => {
  132. const permissions = getDashboardPermissions();
  133. if (permissions.teamsWithEditAccess.length > 1) {
  134. return (
  135. <CollapsedAvatarTooltip>
  136. {allSelectedTeams.map((team, index) => (
  137. <CollapsedAvatarTooltipListItem
  138. key={team.id}
  139. style={{
  140. marginBottom: index === allSelectedTeams.length - 1 ? 0 : space(1),
  141. }}
  142. >
  143. <Avatar team={team} size={18} />
  144. <div>#{team.name}</div>
  145. </CollapsedAvatarTooltipListItem>
  146. ))}
  147. </CollapsedAvatarTooltip>
  148. );
  149. }
  150. return null;
  151. };
  152. const renderCollapsedAvatars = (avatarSize: number, numCollapsedAvatars: number) => {
  153. return (
  154. <Tooltip
  155. title={renderCollapsedAvatarTooltip()}
  156. isHoverable
  157. overlayStyle={{
  158. pointerEvents: 'auto',
  159. zIndex: 1000,
  160. }}
  161. >
  162. <div
  163. onMouseEnter={() => setIsCollapsedAvatarTooltipOpen(true)}
  164. onMouseLeave={() => setIsCollapsedAvatarTooltipOpen(false)}
  165. >
  166. <CollapsedAvatars size={avatarSize}>
  167. {numCollapsedAvatars < 99 && <Plus>+</Plus>}
  168. {numCollapsedAvatars}
  169. </CollapsedAvatars>
  170. </div>
  171. </Tooltip>
  172. );
  173. };
  174. const makeCreatorOption = useCallback(
  175. () => ({
  176. value: '_creator',
  177. label: (
  178. <UserBadge
  179. avatarSize={18}
  180. user={dashboardCreator}
  181. displayName={
  182. <StyledDisplayName>
  183. {dashboardCreator?.id === currentUser.id
  184. ? tct('You ([email])', {email: currentUser.email})
  185. : dashboardCreator?.email ||
  186. tct('You ([email])', {email: currentUser.email})}
  187. </StyledDisplayName>
  188. }
  189. displayEmail={t('Creator')}
  190. />
  191. ),
  192. textValue: `creator_${currentUser.email}`,
  193. disabled: true,
  194. }),
  195. [dashboardCreator, currentUser]
  196. );
  197. // Single team option in the dropdown
  198. const makeTeamOption = (team: Team) => ({
  199. value: team.id,
  200. label: `#${team.slug}`,
  201. leadingItems: <TeamAvatar team={team} size={18} />,
  202. });
  203. // Avatars/Badges in the Edit Access Selector Button
  204. const triggerAvatars =
  205. selectedOptions.includes('_allUsers') || !dashboardCreator ? (
  206. <StyledBadge key="_all" text={'All'} size={listOnly ? 26 : 20} />
  207. ) : selectedOptions.length === 2 ? (
  208. // Case where we display 1 Creator Avatar + 1 Team Avatar
  209. <StyledAvatarList
  210. listonly={listOnly}
  211. key="avatar-list-2-badges"
  212. typeAvatars="users"
  213. users={[dashboardCreator]}
  214. teams={selectedTeam ? selectedTeam : []}
  215. maxVisibleAvatars={1}
  216. avatarSize={listOnly ? 30 : 25}
  217. renderUsersFirst
  218. tooltipOptions={{disabled: !userCanEditDashboardPermissions}}
  219. />
  220. ) : (
  221. // Case where we display 1 Creator Avatar + a Badge with no. of teams selected
  222. <StyledAvatarList
  223. key="avatar-list-many-teams"
  224. listonly={listOnly}
  225. typeAvatars="users"
  226. users={Array(selectedOptions.length).fill(dashboardCreator)}
  227. maxVisibleAvatars={1}
  228. avatarSize={listOnly ? 30 : 25}
  229. tooltipOptions={{disabled: !userCanEditDashboardPermissions}}
  230. renderCollapsedAvatars={renderCollapsedAvatars}
  231. />
  232. );
  233. // Sorting function for team options
  234. const listSort = useCallback(
  235. (team: Team) => [
  236. !stagedOptions.includes(team.id), // selected teams are shown first
  237. team.slug, // sort rest alphabetically
  238. ],
  239. [stagedOptions]
  240. );
  241. const allDropdownOptions = useMemo(
  242. () => [
  243. makeCreatorOption(),
  244. {
  245. value: '_all_users_section',
  246. options: [
  247. {
  248. value: '_allUsers',
  249. label: t('All users'),
  250. disabled: !userCanEditDashboardPermissions,
  251. },
  252. ],
  253. },
  254. {
  255. value: '_teams',
  256. label: t('Teams'),
  257. options: sortBy(teamsToRender, listSort).map(makeTeamOption),
  258. showToggleAllButton: userCanEditDashboardPermissions,
  259. disabled: !userCanEditDashboardPermissions,
  260. },
  261. ],
  262. [userCanEditDashboardPermissions, teamsToRender, makeCreatorOption, listSort]
  263. );
  264. // Save and Cancel Buttons
  265. const dropdownFooterButtons = (
  266. <FilterButtons>
  267. <Button
  268. size="sm"
  269. onClick={() => {
  270. setMenuOpen(false);
  271. }}
  272. disabled={!userCanEditDashboardPermissions}
  273. >
  274. {t('Cancel')}
  275. </Button>
  276. <Button
  277. size="sm"
  278. onClick={() => {
  279. const isDefaultState =
  280. !defined(dashboard.permissions) && selectedOptions.includes('_allUsers');
  281. const newDashboardPermissions = getDashboardPermissions();
  282. if (
  283. !isDefaultState &&
  284. !isEqual(newDashboardPermissions, dashboard.permissions)
  285. ) {
  286. trackAnalytics('dashboards2.edit_access.save', {
  287. organization,
  288. editable_by: newDashboardPermissions.isEditableByEveryone
  289. ? 'all'
  290. : newDashboardPermissions.teamsWithEditAccess.length > 0
  291. ? 'team_selection'
  292. : 'owner_only',
  293. team_count: newDashboardPermissions.teamsWithEditAccess.length || undefined,
  294. });
  295. onChangeEditAccess?.(newDashboardPermissions);
  296. }
  297. setMenuOpen(!isMenuOpen);
  298. }}
  299. priority="primary"
  300. disabled={
  301. !userCanEditDashboardPermissions ||
  302. isEqual(getDashboardPermissions(), {
  303. ...dashboard.permissions,
  304. teamsWithEditAccess: dashboard.permissions?.teamsWithEditAccess?.sort(
  305. (a, b) => a - b
  306. ),
  307. })
  308. }
  309. >
  310. {t('Save Changes')}
  311. </Button>
  312. </FilterButtons>
  313. );
  314. const dropdownMenu = (
  315. <StyledCompactSelect
  316. data-test-id={'edit-access-dropdown'}
  317. size="sm"
  318. onChange={newSelectedOptions => {
  319. onSelectOptions(newSelectedOptions);
  320. }}
  321. multiple
  322. searchable
  323. options={allDropdownOptions}
  324. value={selectedOptions}
  325. triggerLabel={
  326. listOnly
  327. ? [triggerAvatars]
  328. : [
  329. <StyledFeatureBadge
  330. key="new-badge"
  331. type="new"
  332. tooltipProps={{position: 'left', delay: 1000, isHoverable: true}}
  333. />,
  334. <LabelContainer key="selector-label">{t('Edit Access:')}</LabelContainer>,
  335. triggerAvatars,
  336. ]
  337. }
  338. triggerProps={{borderless: listOnly, style: listOnly ? {padding: 2} : {}}}
  339. searchPlaceholder={t('Search Teams')}
  340. isOpen={isMenuOpen}
  341. onOpenChange={newOpenState => {
  342. if (newOpenState === true) {
  343. trackAnalytics('dashboards2.edit_access.start', {organization});
  344. }
  345. setStagedOptions(selectedOptions);
  346. setMenuOpen(!isMenuOpen);
  347. }}
  348. menuFooter={dropdownFooterButtons}
  349. onSearch={debounce(val => void onSearch(val), DEFAULT_DEBOUNCE_DURATION)}
  350. />
  351. );
  352. return (
  353. <Tooltip
  354. title={t('Only the creator of the dashboard can edit permissions')}
  355. disabled={
  356. userCanEditDashboardPermissions || isMenuOpen || isCollapsedAvatarTooltipOpen
  357. }
  358. >
  359. {dropdownMenu}
  360. </Tooltip>
  361. );
  362. }
  363. export default EditAccessSelector;
  364. const StyledCompactSelect = styled(CompactSelect)`
  365. ${InnerWrap} {
  366. align-items: center;
  367. }
  368. ${LeadingItems} {
  369. margin-top: 0;
  370. }
  371. ${CheckWrap} {
  372. padding-bottom: 0;
  373. }
  374. `;
  375. const StyledDisplayName = styled('div')`
  376. font-weight: normal;
  377. `;
  378. const StyledAvatarList = styled(AvatarList)<{listonly: boolean}>`
  379. margin-left: ${space(0.75)};
  380. margin-right: ${p => (p.listonly ? 0 : -3)}px;
  381. font-weight: normal;
  382. `;
  383. const LabelContainer = styled('div')`
  384. margin-right: ${space(1)};
  385. `;
  386. const StyledFeatureBadge = styled(FeatureBadge)`
  387. margin-left: 0px;
  388. margin-right: 6px;
  389. `;
  390. const StyledBadge = styled(Badge)<{size: number}>`
  391. color: ${p => p.theme.white};
  392. background: ${p => p.theme.purple300};
  393. padding: 0;
  394. height: ${p => p.size}px;
  395. width: ${p => p.size}px;
  396. display: flex;
  397. justify-content: center;
  398. align-items: center;
  399. margin-left: 0px;
  400. `;
  401. const FilterButtons = styled(ButtonBar)`
  402. display: grid;
  403. gap: ${space(1.5)};
  404. margin-top: ${space(0.5)};
  405. margin-bottom: ${space(0.5)};
  406. justify-content: flex-end;
  407. `;
  408. const CollapsedAvatarTooltip = styled('div')`
  409. max-height: 200px;
  410. overflow-y: auto;
  411. `;
  412. const CollapsedAvatarTooltipListItem = styled('div')`
  413. display: flex;
  414. align-items: center;
  415. gap: ${space(1)};
  416. `;
  417. const Plus = styled('span')`
  418. font-size: 10px;
  419. margin-left: 1px;
  420. margin-right: -1px;
  421. `;