editAccessSelector.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import {useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import isEqual from 'lodash/isEqual';
  4. import AvatarList from 'sentry/components/avatar/avatarList';
  5. import TeamAvatar from 'sentry/components/avatar/teamAvatar';
  6. import Badge from 'sentry/components/badge/badge';
  7. import {Button} from 'sentry/components/button';
  8. import ButtonBar from 'sentry/components/buttonBar';
  9. import {CompactSelect} from 'sentry/components/compactSelect';
  10. import {CheckWrap} from 'sentry/components/compactSelect/styles';
  11. import UserBadge from 'sentry/components/idBadge/userBadge';
  12. import {InnerWrap, LeadingItems} from 'sentry/components/menuListItem';
  13. import {Tooltip} from 'sentry/components/tooltip';
  14. import {t, tct} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {Team} from 'sentry/types/organization';
  17. import type {User} from 'sentry/types/user';
  18. import {defined} from 'sentry/utils';
  19. import {useTeamsById} from 'sentry/utils/useTeamsById';
  20. import {useUser} from 'sentry/utils/useUser';
  21. import type {DashboardDetails, DashboardPermissions} from 'sentry/views/dashboards/types';
  22. interface EditAccessSelectorProps {
  23. dashboard: DashboardDetails;
  24. onChangeEditAccess?: (newDashboardPermissions: DashboardPermissions) => void;
  25. }
  26. /**
  27. * Dropdown multiselect button to enable selective Dashboard editing access to
  28. * specific users and teams
  29. */
  30. function EditAccessSelector({dashboard, onChangeEditAccess}: EditAccessSelectorProps) {
  31. const currentUser: User = useUser();
  32. const dashboardCreator: User | undefined = dashboard.createdBy;
  33. const isCurrentUserDashboardOwner = dashboardCreator?.id === currentUser.id;
  34. const {teams} = useTeamsById();
  35. const teamIds: string[] = Object.values(teams).map(team => team.id);
  36. const [selectedOptions, setSelectedOptions] = useState<string[]>(getSelectedOptions());
  37. const [isMenuOpen, setMenuOpen] = useState<boolean>(false);
  38. const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
  39. // Handles state change when dropdown options are selected
  40. const onSelectOptions = newSelectedOptions => {
  41. let newSelectedValues = newSelectedOptions.map(
  42. (option: {value: string}) => option.value
  43. );
  44. const areAllTeamsSelected = teamIds.every(teamId =>
  45. newSelectedValues.includes(teamId)
  46. );
  47. if (
  48. !selectedOptions.includes('_allUsers') &&
  49. newSelectedValues.includes('_allUsers')
  50. ) {
  51. newSelectedValues = ['_creator', '_allUsers', ...teamIds];
  52. } else if (
  53. selectedOptions.includes('_allUsers') &&
  54. !newSelectedValues.includes('_allUsers')
  55. ) {
  56. newSelectedValues = ['_creator'];
  57. } else {
  58. areAllTeamsSelected
  59. ? // selecting all teams deselects 'all users'
  60. (newSelectedValues = ['_creator', '_allUsers', ...teamIds])
  61. : // deselecting any team deselects 'all users'
  62. (newSelectedValues = newSelectedValues.filter(value => value !== '_allUsers'));
  63. }
  64. setSelectedOptions(newSelectedValues);
  65. };
  66. // Creates a permissions object based on the options selected
  67. function getDashboardPermissions() {
  68. return {
  69. isEditableByEveryone: selectedOptions.includes('_allUsers'),
  70. teamsWithEditAccess: selectedOptions.includes('_allUsers')
  71. ? []
  72. : selectedOptions
  73. .filter(option => option !== '_creator')
  74. .map(teamId => parseInt(teamId, 10)),
  75. };
  76. }
  77. // Gets selected options for the dropdown from dashboard object
  78. function getSelectedOptions(): string[] {
  79. if (!defined(dashboard.permissions) || dashboard.permissions.isEditableByEveryone) {
  80. return ['_creator', '_allUsers', ...teamIds];
  81. }
  82. const permittedTeamIds =
  83. dashboard.permissions.teamsWithEditAccess?.map(teamId => String(teamId)) ?? [];
  84. return ['_creator', ...permittedTeamIds];
  85. }
  86. // Dashboard creator option in the dropdown
  87. const makeCreatorOption = () => ({
  88. value: '_creator',
  89. label: (
  90. <UserBadge
  91. avatarSize={18}
  92. user={dashboardCreator}
  93. displayName={
  94. <StyledDisplayName>
  95. {dashboardCreator?.id === currentUser.id
  96. ? tct('You ([email])', {email: currentUser.email})
  97. : dashboardCreator?.email ||
  98. tct('You ([email])', {email: currentUser.email})}
  99. </StyledDisplayName>
  100. }
  101. displayEmail={t('Creator')}
  102. />
  103. ),
  104. textValue: `creator_${currentUser.email}`,
  105. disabled: true,
  106. });
  107. // Single team option in the dropdown
  108. const makeTeamOption = (team: Team) => ({
  109. value: team.id,
  110. label: `#${team.slug}`,
  111. leadingItems: <TeamAvatar team={team} size={18} />,
  112. });
  113. // Avatars/Badges in the Edit Access Selector Button
  114. const triggerAvatars =
  115. selectedOptions.includes('_allUsers') || !dashboardCreator ? (
  116. <StyledBadge key="_all" text={'All'} />
  117. ) : selectedOptions.length === 2 ? (
  118. // Case where we display 1 Creator Avatar + 1 Team Avatar
  119. <StyledAvatarList
  120. key="avatar-list"
  121. typeAvatars="users"
  122. users={[dashboardCreator]}
  123. teams={[teams.find(team => team.id === selectedOptions[1])!]}
  124. maxVisibleAvatars={1}
  125. avatarSize={25}
  126. />
  127. ) : (
  128. // Case where we display 1 Creator Avatar + a Badge with no. of teams selected
  129. <StyledAvatarList
  130. key="avatar-list"
  131. typeAvatars="users"
  132. users={Array(selectedOptions.length).fill(dashboardCreator)}
  133. maxVisibleAvatars={1}
  134. avatarSize={25}
  135. />
  136. );
  137. const allDropdownOptions = [
  138. makeCreatorOption(),
  139. {
  140. value: '_all_users_section',
  141. options: [
  142. {
  143. value: '_allUsers',
  144. label: t('All users'),
  145. disabled: !isCurrentUserDashboardOwner,
  146. },
  147. ],
  148. },
  149. {
  150. value: '_teams',
  151. label: t('Teams'),
  152. options: teams.map(makeTeamOption),
  153. showToggleAllButton: isCurrentUserDashboardOwner,
  154. disabled: !isCurrentUserDashboardOwner,
  155. },
  156. ];
  157. // Save and Cancel Buttons
  158. const dropdownFooterButtons = (
  159. <FilterButtons>
  160. <Button
  161. size="sm"
  162. onClick={() => {
  163. setMenuOpen(false);
  164. if (isMenuOpen) {
  165. setSelectedOptions(getSelectedOptions());
  166. setHasUnsavedChanges(false);
  167. }
  168. }}
  169. disabled={!isCurrentUserDashboardOwner}
  170. >
  171. {t('Cancel')}
  172. </Button>
  173. <Button
  174. size="sm"
  175. onClick={() => {
  176. const isDefaultState =
  177. !defined(dashboard.permissions) && selectedOptions.includes('_allUsers');
  178. const newDashboardPermissions = getDashboardPermissions();
  179. if (
  180. !isDefaultState &&
  181. !isEqual(newDashboardPermissions, dashboard.permissions)
  182. ) {
  183. onChangeEditAccess?.(newDashboardPermissions);
  184. }
  185. setMenuOpen(!isMenuOpen);
  186. }}
  187. priority="primary"
  188. disabled={!isCurrentUserDashboardOwner || !hasUnsavedChanges}
  189. >
  190. {t('Save Changes')}
  191. </Button>
  192. </FilterButtons>
  193. );
  194. const dropdownMenu = (
  195. <StyledCompactSelect
  196. size="sm"
  197. onChange={newSelectedOptions => {
  198. onSelectOptions(newSelectedOptions);
  199. setHasUnsavedChanges(true);
  200. }}
  201. multiple
  202. searchable
  203. options={allDropdownOptions}
  204. value={selectedOptions}
  205. triggerLabel={[t('Edit Access:'), triggerAvatars]}
  206. searchPlaceholder={t('Search Teams')}
  207. isOpen={isMenuOpen}
  208. onOpenChange={() => {
  209. setMenuOpen(!isMenuOpen);
  210. if (isMenuOpen) {
  211. setSelectedOptions(getSelectedOptions());
  212. setHasUnsavedChanges(false);
  213. }
  214. }}
  215. menuFooter={dropdownFooterButtons}
  216. />
  217. );
  218. return isCurrentUserDashboardOwner ? (
  219. dropdownMenu
  220. ) : (
  221. <Tooltip title={t('Only the creator of the dashboard can edit permissions')}>
  222. {dropdownMenu}
  223. </Tooltip>
  224. );
  225. }
  226. export default EditAccessSelector;
  227. const StyledCompactSelect = styled(CompactSelect)`
  228. ${InnerWrap} {
  229. align-items: center;
  230. }
  231. ${LeadingItems} {
  232. margin-top: 0;
  233. }
  234. ${CheckWrap} {
  235. padding-bottom: 0;
  236. }
  237. `;
  238. const StyledDisplayName = styled('div')`
  239. font-weight: normal;
  240. `;
  241. const StyledAvatarList = styled(AvatarList)`
  242. margin-left: 10px;
  243. `;
  244. const StyledBadge = styled(Badge)`
  245. color: ${p => p.theme.white};
  246. background: ${p => p.theme.purple300};
  247. margin-right: 3px;
  248. padding: 0;
  249. height: 20px;
  250. width: 20px;
  251. `;
  252. const FilterButtons = styled(ButtonBar)`
  253. display: grid;
  254. gap: ${space(1.5)};
  255. margin-top: ${space(0.5)};
  256. margin-bottom: ${space(0.5)};
  257. justify-content: flex-end;
  258. `;