notificationSettingsByEntity.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  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} from './fields2';
  21. import {OrganizationSelectHeader} from './organizationSelectHeader';
  22. type Value = 'always' | 'never' | 'subscribe_only' | 'committed_only';
  23. interface NotificationSettingsByEntityProps {
  24. entityType: 'project' | 'organization';
  25. handleAddNotificationOption: (
  26. notificationOption: Omit<NotificationOptionsObject, 'id'>
  27. ) => void;
  28. handleEditNotificationOption: (notificationOption: NotificationOptionsObject) => void;
  29. handleRemoveNotificationOption: (id: string) => void;
  30. notificationOptions: NotificationOptionsObject[];
  31. notificationType: string;
  32. organizations: Organization[];
  33. }
  34. function NotificationSettingsByEntity({
  35. entityType,
  36. handleAddNotificationOption,
  37. handleEditNotificationOption,
  38. handleRemoveNotificationOption,
  39. notificationOptions,
  40. notificationType,
  41. organizations,
  42. }: NotificationSettingsByEntityProps) {
  43. const router = useRouter();
  44. const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
  45. const [selectedValue, setSelectedValue] = useState<Value | null>(null);
  46. const customerDomain = ConfigStore.get('customerDomain');
  47. const orgFromSubdomain = organizations.find(
  48. ({slug}) => slug === customerDomain?.subdomain
  49. )?.id;
  50. const orgId =
  51. router.location?.query?.organizationId ?? orgFromSubdomain ?? organizations[0]?.id;
  52. const orgSlug =
  53. organizations.find(({id}) => id === orgId)?.slug || organizations[0]?.slug;
  54. // loads all the projects for an org
  55. const {
  56. data: projects,
  57. isLoading,
  58. isSuccess,
  59. isError,
  60. refetch,
  61. } = useApiQuery<Project[]>(
  62. [
  63. `/organizations/${orgSlug}/projects/`,
  64. {
  65. query: {
  66. all_projects: '1',
  67. collapse: ['latestDeploys', 'unusedFeatures'],
  68. },
  69. },
  70. ],
  71. {staleTime: Infinity}
  72. );
  73. // always loading all projects even though we only need it sometimes
  74. const entities = entityType === 'project' ? projects || [] : organizations;
  75. // create maps by the project id for constant time lookups
  76. const entityById = keyBy<Organization | Project>(entities, 'id');
  77. const handleOrgChange = (organizationId: string) => {
  78. router.replace({
  79. ...router.location,
  80. query: {organizationId},
  81. });
  82. };
  83. const handleAdd = () => {
  84. // should never happen
  85. if (!selectedEntityId || !selectedValue) {
  86. return;
  87. }
  88. const data = {
  89. type: notificationType,
  90. scopeType: entityType,
  91. scopeIdentifier: selectedEntityId,
  92. value: selectedValue,
  93. };
  94. setSelectedEntityId(null);
  95. setSelectedValue(null);
  96. handleAddNotificationOption(data);
  97. };
  98. const valueOptions = NOTIFICATION_SETTING_FIELDS[notificationType].choices;
  99. const renderOverrides = () => {
  100. const matchedOptions = notificationOptions.filter(
  101. option => option.type === notificationType && option.scopeType === entityType
  102. );
  103. return matchedOptions.map(option => {
  104. const entity = entityById[`${option.scopeIdentifier}`];
  105. if (!entity) {
  106. return null;
  107. }
  108. const idBadgeProps =
  109. entityType === 'project'
  110. ? {project: entity as Project}
  111. : {organization: entity as Organization};
  112. return (
  113. <Item key={entity.id}>
  114. <div style={{marginLeft: space(2)}}>
  115. <IdBadge
  116. {...idBadgeProps}
  117. avatarSize={20}
  118. displayName={entity.slug}
  119. avatarProps={{consistentWidth: true}}
  120. disableLink
  121. />
  122. </div>
  123. <SelectControl
  124. placeholder={t('Value\u2026')}
  125. value={option.value}
  126. name={`${entity.id}-value`}
  127. choices={valueOptions}
  128. onChange={({value}: {value: string}) => {
  129. handleEditNotificationOption({
  130. ...option,
  131. value: value as Value,
  132. });
  133. }}
  134. />
  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. return (
  198. <MinHeight>
  199. <Panel>
  200. <StyledPanelHeader>
  201. {entityType === 'project' ? (
  202. <OrganizationSelectHeader
  203. organizations={organizations}
  204. organizationId={orgId}
  205. handleOrgChange={handleOrgChange}
  206. />
  207. ) : (
  208. t('Settings for Organizations')
  209. )}
  210. </StyledPanelHeader>
  211. <ControlItem>
  212. {/* TODO: enable search for sentry projects */}
  213. <SelectControl
  214. placeholder={
  215. entityType === 'project'
  216. ? t('Project\u2026')
  217. : t('Sentry Organization\u2026')
  218. }
  219. name={entityType}
  220. options={groupedEntityOptions}
  221. onChange={({value}: {value: string}) => {
  222. setSelectedEntityId(value);
  223. }}
  224. value={selectedEntityId}
  225. />
  226. <SelectControl
  227. placeholder={t('Value\u2026')}
  228. value={selectedValue}
  229. name="value"
  230. choices={valueOptions}
  231. onChange={({value}: {value: string}) => {
  232. setSelectedValue(value as Value);
  233. }}
  234. />
  235. <Button
  236. disabled={!selectedEntityId || !selectedValue}
  237. priority="primary"
  238. onClick={handleAdd}
  239. icon={<IconAdd />}
  240. aria-label={t('Add override')}
  241. />
  242. </ControlItem>
  243. {isLoading && (
  244. <PanelBody>
  245. <LoadingIndicator />
  246. </PanelBody>
  247. )}
  248. {isError && (
  249. <PanelBody>
  250. <LoadingError onRetry={refetch} />
  251. </PanelBody>
  252. )}
  253. {isSuccess && <StyledPanelBody>{renderOverrides()}</StyledPanelBody>}
  254. </Panel>
  255. </MinHeight>
  256. );
  257. }
  258. export default NotificationSettingsByEntity;
  259. const MinHeight = styled('div')`
  260. min-height: 400px;
  261. `;
  262. const StyledPanelHeader = styled(PanelHeader)`
  263. flex-wrap: wrap;
  264. gap: ${space(1)};
  265. & > form:last-child {
  266. flex-grow: 1;
  267. }
  268. `;
  269. const StyledPanelBody = styled(PanelBody)`
  270. & > div:not(:last-child) {
  271. border-bottom: 1px solid ${p => p.theme.innerBorder};
  272. }
  273. `;
  274. const Item = styled('div')`
  275. display: grid;
  276. grid-column-gap: ${space(1)};
  277. grid-template-columns: 2.5fr 1fr min-content;
  278. align-items: center;
  279. padding: ${space(1.5)} ${space(2)};
  280. `;
  281. const ControlItem = styled(Item)`
  282. border-bottom: 1px solid ${p => p.theme.innerBorder};
  283. `;
  284. const RemoveButtonWrapper = styled('div')`
  285. margin: 0 ${space(0.5)};
  286. `;