notificationSettingsByEntity.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import {Fragment, useState} from 'react';
  2. import type {WithRouterProps} from 'react-router';
  3. import {components} from 'react-select';
  4. import styled from '@emotion/styled';
  5. import {Button} from 'sentry/components/button';
  6. import SelectControl from 'sentry/components/forms/controls/selectControl';
  7. import JsonForm from 'sentry/components/forms/jsonForm';
  8. import IdBadge from 'sentry/components/idBadge';
  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 {Organization, Project} from 'sentry/types';
  17. import {useApiQuery} from 'sentry/utils/queryClient';
  18. import withSentryRouter from 'sentry/utils/withSentryRouter';
  19. import {NotificationOptionsObject} from 'sentry/views/settings/account/notifications/constants';
  20. import {NOTIFICATION_SETTING_FIELDS_V2} from 'sentry/views/settings/account/notifications/fields2';
  21. import {OrganizationSelectHeader} from 'sentry/views/settings/account/notifications/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. export type NotificationSettingsByProjectsBaseProps = {
  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. };
  46. type Props = {
  47. organizations: Organization[];
  48. } & NotificationSettingsByProjectsBaseProps &
  49. WithRouterProps;
  50. function NotificationSettingsByEntity(props: Props) {
  51. const {
  52. entityType,
  53. handleAddNotificationOption,
  54. handleRemoveNotificationOption,
  55. notificationOptions,
  56. notificationType,
  57. organizations,
  58. router,
  59. location,
  60. } = props;
  61. const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
  62. const [selectedValue, setSelectedValue] = useState<Value | null>(null);
  63. const customerDomain = ConfigStore.get('customerDomain');
  64. const orgFromSubdomain = organizations.find(
  65. ({slug}) => slug === customerDomain?.subdomain
  66. )?.id;
  67. const orgId =
  68. location?.query?.organizationId ?? orgFromSubdomain ?? organizations[0]?.id;
  69. const orgSlug =
  70. organizations.find(({id}) => id === orgId)?.slug || organizations[0]?.slug;
  71. // loads all the projects for an org
  72. const {data: projects} = useApiQuery<Project[]>(
  73. [
  74. `/organizations/${orgSlug}/projects/`,
  75. {
  76. query: {
  77. all_projects: '1',
  78. collapse: 'latestDeploys',
  79. },
  80. },
  81. ],
  82. {staleTime: Infinity}
  83. );
  84. // always loading all projects even though we only need it sometimes
  85. const entities = entityType === 'project' ? projects || [] : organizations;
  86. const handleOrgChange = (organizationId: string) => {
  87. router.replace({
  88. ...location,
  89. query: {organizationId},
  90. });
  91. };
  92. const handleAdd = () => {
  93. // should never happen
  94. if (!selectedEntityId || !selectedValue) {
  95. return;
  96. }
  97. const data = {
  98. type: notificationType,
  99. scopeType: entityType,
  100. scopeIdentifier: selectedEntityId,
  101. value: selectedValue,
  102. };
  103. setSelectedEntityId(null);
  104. setSelectedValue(null);
  105. handleAddNotificationOption(data);
  106. };
  107. const renderOverrides = () => {
  108. const matchedOptions = notificationOptions.filter(
  109. option => option.type === notificationType && option.scopeType === entityType
  110. );
  111. return matchedOptions.map(option => {
  112. const entity = (entities as any[]).find(
  113. ({id}) => id.toString() === option.scopeIdentifier.toString()
  114. );
  115. if (!entity) {
  116. return null;
  117. }
  118. const handleDelete = async (id: string) => {
  119. await handleRemoveNotificationOption(id);
  120. };
  121. const idBadgeProps =
  122. entityType === 'project'
  123. ? {project: entity as Project}
  124. : {
  125. organization: entity as Organization,
  126. };
  127. return (
  128. <Item key={entity.id}>
  129. <IdBadge
  130. {...idBadgeProps}
  131. avatarSize={20}
  132. displayName={entity.slug}
  133. avatarProps={{consistentWidth: true}}
  134. disableLink
  135. />
  136. {getLabelForValue(option.value)}
  137. <Button
  138. aria-label={t('Delete')}
  139. size="sm"
  140. priority="default"
  141. icon={<IconDelete />}
  142. onClick={() => handleDelete(option.id)}
  143. />
  144. </Item>
  145. );
  146. });
  147. };
  148. const customValueContainer = containerProps => {
  149. // if no value set, we want to return the default component that is rendered
  150. const entity = entityById[selectedEntityId || ''];
  151. if (!entity) {
  152. return <components.ValueContainer {...containerProps} />;
  153. }
  154. const idBadgeProps =
  155. entityType === 'project'
  156. ? {project: entity as Project}
  157. : {
  158. organization: entity as Organization,
  159. };
  160. return (
  161. <components.ValueContainer {...containerProps}>
  162. <IdBadge
  163. {...idBadgeProps}
  164. avatarSize={20}
  165. displayName={entity.slug}
  166. avatarProps={{consistentWidth: true}}
  167. disableLink
  168. />
  169. </components.ValueContainer>
  170. );
  171. };
  172. const handleSelectProject = ({value}: {value: string}) => {
  173. setSelectedEntityId(value);
  174. };
  175. const handleSelectValue = ({value}: {value: string}) => {
  176. setSelectedValue(value as Value);
  177. };
  178. // create maps by the project id for constant time lookups
  179. const entityById: Record<string, Organization | Project> = Object.fromEntries(
  180. entities.map(entity => [entity.id, entity])
  181. );
  182. const entityOptions: {label: string; value: Value}[] = (entities as any[])
  183. .filter(({id}: Project | Organization) => {
  184. const match = notificationOptions.find(
  185. option =>
  186. option.scopeType === entityType &&
  187. option.scopeIdentifier.toString() === id.toString() &&
  188. option.type === notificationType
  189. );
  190. return !match;
  191. })
  192. .map(({slug, id}) => ({label: slug, value: id}));
  193. const customOptionProject = entityProps => {
  194. const entity = entityById[entityProps.value];
  195. // Should never happen for a dropdown item
  196. if (!entity) {
  197. return null;
  198. }
  199. const idBadgeProps =
  200. entityType === 'project'
  201. ? {project: entity as Project}
  202. : {
  203. organization: entity as Organization,
  204. };
  205. return (
  206. <components.Option {...entityProps}>
  207. <IdBadge
  208. {...idBadgeProps}
  209. avatarSize={20}
  210. displayName={entity.slug}
  211. avatarProps={{consistentWidth: true}}
  212. disableLink
  213. />
  214. </components.Option>
  215. );
  216. };
  217. const valueOptions = NOTIFICATION_SETTING_FIELDS_V2[notificationType].choices;
  218. return (
  219. <Fragment>
  220. <Panel>
  221. <StyledPanelHeader>
  222. <OrganizationSelectHeader
  223. organizations={organizations}
  224. organizationId={orgId}
  225. handleOrgChange={handleOrgChange}
  226. />
  227. </StyledPanelHeader>
  228. <Item>
  229. {/* TODO: enable search for sentry projects */}
  230. <SelectControl
  231. placeholder={
  232. entityType === 'project'
  233. ? t('Sentry project\u2026')
  234. : t('Sentry organization\u2026')
  235. }
  236. name={entityType}
  237. options={entityOptions}
  238. components={{
  239. Option: customOptionProject,
  240. ValueContainer: customValueContainer,
  241. }}
  242. onChange={handleSelectProject}
  243. value={selectedEntityId}
  244. />
  245. <SelectControl
  246. placeholder={t('Select\u2026')}
  247. value={selectedValue}
  248. name="value"
  249. choices={valueOptions}
  250. onChange={handleSelectValue}
  251. />
  252. <AddProjectWrapper>
  253. <Button
  254. disabled={!selectedEntityId || !selectedValue}
  255. size="sm"
  256. priority="primary"
  257. onClick={handleAdd}
  258. icon={<IconAdd />}
  259. aria-label={t('Add override')}
  260. />
  261. </AddProjectWrapper>
  262. </Item>
  263. <PanelBody>{renderOverrides()}</PanelBody>
  264. </Panel>
  265. </Fragment>
  266. );
  267. }
  268. // loading all projects and orgs
  269. export default withSentryRouter(NotificationSettingsByEntity);
  270. const StyledPanelHeader = styled(PanelHeader)`
  271. flex-wrap: wrap;
  272. gap: ${space(1)};
  273. & > form:last-child {
  274. flex-grow: 1;
  275. }
  276. `;
  277. export const StyledJsonForm = styled(JsonForm)`
  278. ${Panel} {
  279. border: 0;
  280. margin-bottom: 0;
  281. }
  282. `;
  283. const AddProjectWrapper = styled('div')``;
  284. const Item = styled('div')`
  285. min-height: 60px;
  286. padding: ${space(2)};
  287. display: grid;
  288. grid-column-gap: ${space(1)};
  289. align-items: center;
  290. grid-template-columns: 2.5fr 1fr min-content;
  291. `;