notificationSettingsByEntity.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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. let organization = organizations.find(({id}) => id === orgId);
  54. if (!organization) {
  55. organization = organizations[0];
  56. }
  57. const orgSlug = organization.slug;
  58. // loads all the projects for an org
  59. const {
  60. data: projects,
  61. isLoading,
  62. isSuccess,
  63. isError,
  64. refetch,
  65. } = useApiQuery<Project[]>(
  66. [
  67. `/organizations/${orgSlug}/projects/`,
  68. {
  69. host: organization.links.regionUrl,
  70. query: {
  71. all_projects: '1',
  72. collapse: ['latestDeploys', 'unusedFeatures'],
  73. },
  74. },
  75. ],
  76. {staleTime: Infinity}
  77. );
  78. // always loading all projects even though we only need it sometimes
  79. const entities = entityType === 'project' ? projects || [] : organizations;
  80. // create maps by the project id for constant time lookups
  81. const entityById = keyBy<Organization | Project>(entities, 'id');
  82. const handleOrgChange = (organizationId: string) => {
  83. router.replace({
  84. ...router.location,
  85. query: {organizationId},
  86. });
  87. };
  88. const handleAdd = () => {
  89. // should never happen
  90. if (!selectedEntityId || !selectedValue) {
  91. return;
  92. }
  93. const data = {
  94. type: notificationType,
  95. scopeType: entityType,
  96. scopeIdentifier: selectedEntityId,
  97. value: selectedValue,
  98. };
  99. setSelectedEntityId(null);
  100. setSelectedValue(null);
  101. handleAddNotificationOption(data);
  102. };
  103. const valueOptions = NOTIFICATION_SETTING_FIELDS[notificationType].choices;
  104. const renderOverrides = () => {
  105. const matchedOptions = notificationOptions.filter(
  106. option => option.type === notificationType && option.scopeType === entityType
  107. );
  108. return matchedOptions.map(option => {
  109. const entity = entityById[`${option.scopeIdentifier}`];
  110. if (!entity) {
  111. return null;
  112. }
  113. const idBadgeProps =
  114. entityType === 'project'
  115. ? {project: entity as Project}
  116. : {organization: entity as Organization};
  117. return (
  118. <Item key={entity.id}>
  119. <div style={{marginLeft: space(2)}}>
  120. <IdBadge
  121. {...idBadgeProps}
  122. avatarSize={20}
  123. displayName={entity.slug}
  124. avatarProps={{consistentWidth: true}}
  125. disableLink
  126. />
  127. </div>
  128. <SelectControl
  129. placeholder={t('Value\u2026')}
  130. value={option.value}
  131. name={`${entity.id}-value`}
  132. choices={valueOptions}
  133. onChange={({value}: {value: string}) => {
  134. handleEditNotificationOption({
  135. ...option,
  136. value: value as Value,
  137. });
  138. }}
  139. />
  140. <RemoveButtonWrapper>
  141. <Button
  142. aria-label={t('Delete')}
  143. size="sm"
  144. priority="default"
  145. icon={<IconDelete />}
  146. onClick={() => handleRemoveNotificationOption(option.id)}
  147. />
  148. </RemoveButtonWrapper>
  149. </Item>
  150. );
  151. });
  152. };
  153. const entityOptions = entities
  154. .filter(({id}) => {
  155. const match = notificationOptions.find(
  156. option =>
  157. option.scopeType === entityType &&
  158. option.scopeIdentifier.toString() === id.toString() &&
  159. option.type === notificationType
  160. );
  161. return !match;
  162. })
  163. .map(obj => {
  164. const entity = entityById[obj.id];
  165. const idBadgeProps =
  166. entityType === 'project'
  167. ? {project: entity as Project}
  168. : {organization: entity as Organization};
  169. return {
  170. label: entityType === 'project' ? obj.slug : obj.name,
  171. value: obj.id,
  172. leadingItems: (
  173. <IdBadge
  174. {...idBadgeProps}
  175. avatarSize={20}
  176. avatarProps={{consistentWidth: true}}
  177. disableLink
  178. hideName
  179. />
  180. ),
  181. };
  182. })
  183. .sort((a, b) => a.label.localeCompare(b.label));
  184. // Group options when displaying projects
  185. const groupedEntityOptions =
  186. entityType === 'project'
  187. ? [
  188. {
  189. label: t('My Projects'),
  190. options: entityOptions.filter(
  191. project => (entityById[project.value] as Project).isMember
  192. ),
  193. },
  194. {
  195. label: t('All Projects'),
  196. options: entityOptions.filter(
  197. project => !(entityById[project.value] as Project).isMember
  198. ),
  199. },
  200. ]
  201. : entityOptions;
  202. return (
  203. <MinHeight>
  204. <Panel>
  205. <StyledPanelHeader>
  206. {entityType === 'project' ? (
  207. <OrganizationSelectHeader
  208. organizations={organizations}
  209. organizationId={orgId}
  210. handleOrgChange={handleOrgChange}
  211. />
  212. ) : (
  213. t('Settings for Organizations')
  214. )}
  215. </StyledPanelHeader>
  216. <ControlItem>
  217. {/* TODO: enable search for sentry projects */}
  218. <SelectControl
  219. placeholder={
  220. entityType === 'project'
  221. ? t('Project\u2026')
  222. : t('Sentry Organization\u2026')
  223. }
  224. name={entityType}
  225. options={groupedEntityOptions}
  226. onChange={({value}: {value: string}) => {
  227. setSelectedEntityId(value);
  228. }}
  229. value={selectedEntityId}
  230. />
  231. <SelectControl
  232. placeholder={t('Value\u2026')}
  233. value={selectedValue}
  234. name="value"
  235. choices={valueOptions}
  236. onChange={({value}: {value: string}) => {
  237. setSelectedValue(value as Value);
  238. }}
  239. />
  240. <Button
  241. disabled={!selectedEntityId || !selectedValue}
  242. priority="primary"
  243. onClick={handleAdd}
  244. icon={<IconAdd />}
  245. aria-label={t('Add override')}
  246. />
  247. </ControlItem>
  248. {isLoading && (
  249. <PanelBody>
  250. <LoadingIndicator />
  251. </PanelBody>
  252. )}
  253. {isError && (
  254. <PanelBody>
  255. <LoadingError onRetry={refetch} />
  256. </PanelBody>
  257. )}
  258. {isSuccess && <StyledPanelBody>{renderOverrides()}</StyledPanelBody>}
  259. </Panel>
  260. </MinHeight>
  261. );
  262. }
  263. export default NotificationSettingsByEntity;
  264. const MinHeight = styled('div')`
  265. min-height: 400px;
  266. `;
  267. const StyledPanelHeader = styled(PanelHeader)`
  268. flex-wrap: wrap;
  269. gap: ${space(1)};
  270. & > form:last-child {
  271. flex-grow: 1;
  272. }
  273. `;
  274. const StyledPanelBody = styled(PanelBody)`
  275. & > div:not(:last-child) {
  276. border-bottom: 1px solid ${p => p.theme.innerBorder};
  277. }
  278. `;
  279. const Item = styled('div')`
  280. display: grid;
  281. grid-column-gap: ${space(1)};
  282. grid-template-columns: 2.5fr 1fr min-content;
  283. align-items: center;
  284. padding: ${space(1.5)} ${space(2)};
  285. `;
  286. const ControlItem = styled(Item)`
  287. border-bottom: 1px solid ${p => p.theme.innerBorder};
  288. `;
  289. const RemoveButtonWrapper = styled('div')`
  290. margin: 0 ${space(0.5)};
  291. `;