notificationSettingsByEntity.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import {useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import keyBy from 'lodash/keyBy';
  4. import {Button} from 'sentry/components/button';
  5. import SelectControl from 'sentry/components/forms/controls/selectControl';
  6. import IdBadge from 'sentry/components/idBadge';
  7. import LoadingError from 'sentry/components/loadingError';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import Panel from 'sentry/components/panels/panel';
  10. import PanelBody from 'sentry/components/panels/panelBody';
  11. import PanelHeader from 'sentry/components/panels/panelHeader';
  12. import {IconAdd, IconDelete} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import ConfigStore from 'sentry/stores/configStore';
  15. import {space} from 'sentry/styles/space';
  16. import type {Organization, Project} from 'sentry/types';
  17. import {useApiQuery} from 'sentry/utils/queryClient';
  18. import useRouter from 'sentry/utils/useRouter';
  19. import type {NotificationOptionsObject} from './constants';
  20. import {NOTIFICATION_SETTING_FIELDS_V2} from './fields2';
  21. import {OrganizationSelectHeader} from './organizationSelectHeader';
  22. type Value = 'always' | 'never' | 'subscribe_only' | 'committed_only';
  23. const getLabelForValue = (value: Value) => {
  24. switch (value) {
  25. case 'always':
  26. return t('On');
  27. case 'never':
  28. return t('Off');
  29. case 'subscribe_only':
  30. return t('Subscribed Only');
  31. case 'committed_only':
  32. return t('Committed Only');
  33. default:
  34. return '';
  35. }
  36. };
  37. interface NotificationSettingsByEntityProps {
  38. entityType: 'project' | 'organization';
  39. handleAddNotificationOption: (
  40. notificationOption: Omit<NotificationOptionsObject, 'id'>
  41. ) => void;
  42. handleRemoveNotificationOption: (id: string) => void;
  43. notificationOptions: NotificationOptionsObject[];
  44. notificationType: string;
  45. organizations: Organization[];
  46. }
  47. function NotificationSettingsByEntity({
  48. entityType,
  49. handleAddNotificationOption,
  50. handleRemoveNotificationOption,
  51. notificationOptions,
  52. notificationType,
  53. organizations,
  54. }: NotificationSettingsByEntityProps) {
  55. const router = useRouter();
  56. const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
  57. const [selectedValue, setSelectedValue] = useState<Value | null>(null);
  58. const customerDomain = ConfigStore.get('customerDomain');
  59. const orgFromSubdomain = organizations.find(
  60. ({slug}) => slug === customerDomain?.subdomain
  61. )?.id;
  62. const orgId =
  63. router.location?.query?.organizationId ?? orgFromSubdomain ?? organizations[0]?.id;
  64. const orgSlug =
  65. organizations.find(({id}) => id === orgId)?.slug || organizations[0]?.slug;
  66. // loads all the projects for an org
  67. const {
  68. data: projects,
  69. isLoading,
  70. isSuccess,
  71. isError,
  72. refetch,
  73. } = useApiQuery<Project[]>(
  74. [
  75. `/organizations/${orgSlug}/projects/`,
  76. {
  77. query: {
  78. all_projects: '1',
  79. collapse: 'latestDeploys',
  80. },
  81. },
  82. ],
  83. {staleTime: Infinity}
  84. );
  85. // always loading all projects even though we only need it sometimes
  86. const entities = entityType === 'project' ? projects || [] : organizations;
  87. // create maps by the project id for constant time lookups
  88. const entityById = keyBy<Organization | Project>(entities, 'id');
  89. const handleOrgChange = (organizationId: string) => {
  90. router.replace({
  91. ...router.location,
  92. query: {organizationId},
  93. });
  94. };
  95. const handleAdd = () => {
  96. // should never happen
  97. if (!selectedEntityId || !selectedValue) {
  98. return;
  99. }
  100. const data = {
  101. type: notificationType,
  102. scopeType: entityType,
  103. scopeIdentifier: selectedEntityId,
  104. value: selectedValue,
  105. };
  106. setSelectedEntityId(null);
  107. setSelectedValue(null);
  108. handleAddNotificationOption(data);
  109. };
  110. const renderOverrides = () => {
  111. const matchedOptions = notificationOptions.filter(
  112. option => option.type === notificationType && option.scopeType === entityType
  113. );
  114. return matchedOptions.map(option => {
  115. const entity = entityById[`${option.scopeIdentifier}`];
  116. if (!entity) {
  117. return null;
  118. }
  119. const idBadgeProps =
  120. entityType === 'project'
  121. ? {project: entity as Project}
  122. : {organization: entity as Organization};
  123. return (
  124. <Item key={entity.id}>
  125. <div style={{marginLeft: space(2)}}>
  126. <IdBadge
  127. {...idBadgeProps}
  128. avatarSize={20}
  129. displayName={entity.slug}
  130. avatarProps={{consistentWidth: true}}
  131. disableLink
  132. />
  133. </div>
  134. <div style={{marginLeft: space(2)}}>{getLabelForValue(option.value)}</div>
  135. <RemoveButtonWrapper>
  136. <Button
  137. aria-label={t('Delete')}
  138. size="sm"
  139. priority="default"
  140. icon={<IconDelete />}
  141. onClick={() => handleRemoveNotificationOption(option.id)}
  142. />
  143. </RemoveButtonWrapper>
  144. </Item>
  145. );
  146. });
  147. };
  148. const entityOptions = entities
  149. .filter(({id}) => {
  150. const match = notificationOptions.find(
  151. option =>
  152. option.scopeType === entityType &&
  153. option.scopeIdentifier.toString() === id.toString() &&
  154. option.type === notificationType
  155. );
  156. return !match;
  157. })
  158. .map(obj => {
  159. const entity = entityById[obj.id];
  160. const idBadgeProps =
  161. entityType === 'project'
  162. ? {project: entity as Project}
  163. : {organization: entity as Organization};
  164. return {
  165. label: entityType === 'project' ? obj.slug : obj.name,
  166. value: obj.id,
  167. leadingItems: (
  168. <IdBadge
  169. {...idBadgeProps}
  170. avatarSize={20}
  171. avatarProps={{consistentWidth: true}}
  172. disableLink
  173. hideName
  174. />
  175. ),
  176. };
  177. })
  178. .sort((a, b) => a.label.localeCompare(b.label));
  179. // Group options when displaying projects
  180. const groupedEntityOptions =
  181. entityType === 'project'
  182. ? [
  183. {
  184. label: t('My Projects'),
  185. options: entityOptions.filter(
  186. project => (entityById[project.value] as Project).isMember
  187. ),
  188. },
  189. {
  190. label: t('All Projects'),
  191. options: entityOptions.filter(
  192. project => !(entityById[project.value] as Project).isMember
  193. ),
  194. },
  195. ]
  196. : entityOptions;
  197. const valueOptions = NOTIFICATION_SETTING_FIELDS_V2[notificationType].choices;
  198. return (
  199. <MinHeight>
  200. <Panel>
  201. <StyledPanelHeader>
  202. {entityType === 'project' ? (
  203. <OrganizationSelectHeader
  204. organizations={organizations}
  205. organizationId={orgId}
  206. handleOrgChange={handleOrgChange}
  207. />
  208. ) : (
  209. t('Settings for Organizations')
  210. )}
  211. </StyledPanelHeader>
  212. <ControlItem>
  213. {/* TODO: enable search for sentry projects */}
  214. <SelectControl
  215. placeholder={
  216. entityType === 'project'
  217. ? t('Project\u2026')
  218. : t('Sentry Organization\u2026')
  219. }
  220. name={entityType}
  221. options={groupedEntityOptions}
  222. onChange={({value}: {value: string}) => {
  223. setSelectedEntityId(value);
  224. }}
  225. value={selectedEntityId}
  226. />
  227. <SelectControl
  228. placeholder={t('Value\u2026')}
  229. value={selectedValue}
  230. name="value"
  231. choices={valueOptions}
  232. onChange={({value}: {value: string}) => {
  233. setSelectedValue(value as Value);
  234. }}
  235. />
  236. <Button
  237. disabled={!selectedEntityId || !selectedValue}
  238. size="md"
  239. priority="primary"
  240. onClick={handleAdd}
  241. icon={<IconAdd />}
  242. aria-label={t('Add override')}
  243. />
  244. </ControlItem>
  245. {isLoading && (
  246. <PanelBody>
  247. <LoadingIndicator />
  248. </PanelBody>
  249. )}
  250. {isError && (
  251. <PanelBody>
  252. <LoadingError onRetry={refetch} />
  253. </PanelBody>
  254. )}
  255. {isSuccess && <StyledPanelBody>{renderOverrides()}</StyledPanelBody>}
  256. </Panel>
  257. </MinHeight>
  258. );
  259. }
  260. export default NotificationSettingsByEntity;
  261. const MinHeight = styled('div')`
  262. min-height: 400px;
  263. `;
  264. const StyledPanelHeader = styled(PanelHeader)`
  265. flex-wrap: wrap;
  266. gap: ${space(1)};
  267. & > form:last-child {
  268. flex-grow: 1;
  269. }
  270. `;
  271. const StyledPanelBody = styled(PanelBody)`
  272. & > div:not(:last-child) {
  273. border-bottom: 1px solid ${p => p.theme.innerBorder};
  274. }
  275. `;
  276. const Item = styled('div')`
  277. display: grid;
  278. grid-column-gap: ${space(1)};
  279. grid-template-columns: 2.5fr 1fr min-content;
  280. align-items: center;
  281. padding: ${space(1.5)} ${space(2)};
  282. `;
  283. const ControlItem = styled(Item)`
  284. border-bottom: 1px solid ${p => p.theme.innerBorder};
  285. `;
  286. const RemoveButtonWrapper = styled('div')`
  287. margin: 0 ${space(0.5)};
  288. `;