notificationSettingsByEntity.tsx 9.1 KB

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