accountNotificationFineTuning.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import EmptyMessage from 'sentry/components/emptyMessage';
  4. import SelectField from 'sentry/components/forms/fields/selectField';
  5. import Form from 'sentry/components/forms/form';
  6. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  7. import LoadingError from 'sentry/components/loadingError';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import Pagination from 'sentry/components/pagination';
  10. import Panel from 'sentry/components/panels/panel';
  11. import PanelBody from 'sentry/components/panels/panelBody';
  12. import PanelHeader from 'sentry/components/panels/panelHeader';
  13. import SearchBar from 'sentry/components/searchBar';
  14. import {t} from 'sentry/locale';
  15. import ConfigStore from 'sentry/stores/configStore';
  16. import OrganizationsStore from 'sentry/stores/organizationsStore';
  17. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  18. import {space} from 'sentry/styles/space';
  19. import type {Organization} from 'sentry/types/organization';
  20. import type {Project} from 'sentry/types/project';
  21. import type {UserEmail} from 'sentry/types/user';
  22. import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient';
  23. import {useLocation} from 'sentry/utils/useLocation';
  24. import {useNavigate} from 'sentry/utils/useNavigate';
  25. import {useParams} from 'sentry/utils/useParams';
  26. import withOrganizations from 'sentry/utils/withOrganizations';
  27. import type {FineTuneField} from 'sentry/views/settings/account/notifications/fields';
  28. import {ACCOUNT_NOTIFICATION_FIELDS} from 'sentry/views/settings/account/notifications/fields';
  29. import NotificationSettingsByType from 'sentry/views/settings/account/notifications/notificationSettingsByType';
  30. import {OrganizationSelectHeader} from 'sentry/views/settings/account/notifications/organizationSelectHeader';
  31. import {
  32. getNotificationTypeFromPathname,
  33. groupByOrganization,
  34. isGroupedByProject,
  35. } from 'sentry/views/settings/account/notifications/utils';
  36. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  37. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  38. const PanelBodyLineItem = styled(PanelBody)`
  39. font-size: 1rem;
  40. &:not(:last-child) {
  41. border-bottom: 1px solid ${p => p.theme.innerBorder};
  42. }
  43. `;
  44. const accountNotifications = [
  45. 'alerts',
  46. 'deploy',
  47. 'workflow',
  48. 'approval',
  49. 'quota',
  50. 'spikeProtection',
  51. 'reports',
  52. 'brokenMonitors',
  53. ];
  54. type ANBPProps = {
  55. field: FineTuneField;
  56. projects: Project[];
  57. };
  58. function AccountNotificationsByProject({projects, field}: ANBPProps) {
  59. const projectsByOrg = groupByOrganization(projects);
  60. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  61. const {title, description, ...fieldConfig} = field;
  62. // Display as select box in this view regardless of the type specified in the config
  63. const data = Object.values(projectsByOrg).map(org => ({
  64. name: org.organization.name,
  65. projects: org.projects.map(project => ({
  66. ...fieldConfig,
  67. // `name` key refers to field name
  68. // we use project.id because slugs are not unique across orgs
  69. name: project.id,
  70. label: (
  71. <ProjectBadge
  72. project={project}
  73. avatarSize={20}
  74. avatarProps={{consistentWidth: true}}
  75. disableLink
  76. />
  77. ),
  78. })),
  79. }));
  80. return (
  81. <Fragment>
  82. {data.map(({name, projects: projectFields}) => (
  83. <div key={name}>
  84. {projectFields.map(f => (
  85. <PanelBodyLineItem key={f.name}>
  86. <SelectField
  87. defaultValue={f.defaultValue}
  88. name={f.name}
  89. options={f.options}
  90. label={f.label}
  91. />
  92. </PanelBodyLineItem>
  93. ))}
  94. </div>
  95. ))}
  96. </Fragment>
  97. );
  98. }
  99. type ANBOProps = {
  100. field: FineTuneField;
  101. };
  102. function AccountNotificationsByOrganization({field}: ANBOProps) {
  103. const {organizations} = useLegacyStore(OrganizationsStore);
  104. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  105. const {title, description, ...fieldConfig} = field;
  106. // Display as select box in this view regardless of the type specified in the config
  107. const data = organizations.map(org => ({
  108. ...fieldConfig,
  109. // `name` key refers to field name
  110. // we use org.id to remain consistent project.id use (which is required because slugs are not unique across orgs)
  111. name: org.id,
  112. label: org.slug,
  113. }));
  114. return (
  115. <Fragment>
  116. {data.map(f => (
  117. <PanelBodyLineItem key={f.name}>
  118. <SelectField
  119. defaultValue={f.defaultValue}
  120. name={f.name}
  121. options={f.options}
  122. label={f.label}
  123. />
  124. </PanelBodyLineItem>
  125. ))}
  126. </Fragment>
  127. );
  128. }
  129. interface AccountNotificationFineTuningProps {
  130. organizations: Organization[];
  131. }
  132. function AccountNotificationFineTuning({
  133. organizations,
  134. }: AccountNotificationFineTuningProps) {
  135. const navigate = useNavigate();
  136. const location = useLocation();
  137. const params = useParams<{fineTuneType: string}>();
  138. const {fineTuneType: pathnameType} = params;
  139. const fineTuneType = getNotificationTypeFromPathname(pathnameType);
  140. const config = useLegacyStore(ConfigStore);
  141. // Get org id from:
  142. // - query param
  143. // - subdomain
  144. // - default to first org
  145. const organizationId =
  146. (location?.query?.organizationId as string | undefined) ??
  147. organizations.find(({slug}) => slug === config?.customerDomain?.subdomain)?.id ??
  148. organizations[0]?.id;
  149. const {
  150. data: notifications,
  151. isPending: isPendingNotifications,
  152. isError: isErrorNotifications,
  153. } = useApiQuery<Record<string, any>>(['/users/me/notifications/'], {staleTime: 0});
  154. const projectsEnabled = isGroupedByProject(fineTuneType);
  155. const {
  156. data: projects,
  157. isPending: isPendingProjects,
  158. isError: isErrorProjects,
  159. getResponseHeader: getProjectsResponseHeader,
  160. } = useApiQuery<Project[]>(
  161. [
  162. '/projects/',
  163. {
  164. query: {
  165. organizationId,
  166. cursor: location.query.cursor,
  167. query: location.query.query,
  168. },
  169. },
  170. ],
  171. {
  172. staleTime: 0,
  173. enabled: projectsEnabled,
  174. }
  175. );
  176. const isLoadingProjects = projectsEnabled ? isPendingProjects : false;
  177. // Extra data specific to email notifications
  178. const isEmail = fineTuneType === 'email';
  179. const {
  180. data: emails = [],
  181. isPending: isPendingEmails,
  182. isError: isErrorEmails,
  183. } = useApiQuery<UserEmail[]>(['/users/me/emails/'], {
  184. staleTime: 0,
  185. enabled: isEmail,
  186. });
  187. const {
  188. data: emailsByProject,
  189. isPending: isPendingEmailsByProject,
  190. isError: isErrorEmailsByProject,
  191. refetch: refetchEmailsByProject,
  192. } = useApiQuery<Record<string, any>>(['/users/me/notifications/email/'], {
  193. staleTime: 0,
  194. enabled: isEmail,
  195. placeholderData: keepPreviousData,
  196. });
  197. if (accountNotifications.includes(fineTuneType)) {
  198. return <NotificationSettingsByType notificationType={fineTuneType} />;
  199. }
  200. const isProject = isGroupedByProject(fineTuneType) && organizations.length > 0;
  201. const field = ACCOUNT_NOTIFICATION_FIELDS[fineTuneType];
  202. // TODO(isabella): once GA, remove this
  203. if (
  204. fineTuneType === 'quota' &&
  205. organizations.some(org => org.features?.includes('spend-visibility-notifications'))
  206. ) {
  207. field.title = t('Spend Notifications');
  208. field.description = t(
  209. 'Control the notifications you receive for organization spend.'
  210. );
  211. }
  212. if (isEmail) {
  213. // Vrified email addresses
  214. const emailChoices: UserEmail[] = emails
  215. .filter(({isVerified}) => isVerified)
  216. .sort((a, b) => {
  217. // Sort by primary -> email
  218. if (a.isPrimary) {
  219. return -1;
  220. }
  221. if (b.isPrimary) {
  222. return 1;
  223. }
  224. return a.email < b.email ? -1 : 1;
  225. });
  226. field.options = emailChoices.map(({email}) => ({value: email, label: email}));
  227. }
  228. if (
  229. isErrorProjects ||
  230. isErrorNotifications ||
  231. isErrorEmails ||
  232. isErrorEmailsByProject
  233. ) {
  234. return <LoadingError />;
  235. }
  236. if (!notifications || (!emailsByProject && fineTuneType === 'email')) {
  237. return null;
  238. }
  239. const hasProjects = !!projects?.length;
  240. const mainContent =
  241. isLoadingProjects ||
  242. isPendingNotifications ||
  243. (isEmail && (isPendingEmailsByProject || isPendingEmails)) ? (
  244. <LoadingIndicator />
  245. ) : (
  246. <Fragment>
  247. {isProject && hasProjects && (
  248. <AccountNotificationsByProject projects={projects!} field={field} />
  249. )}
  250. {isProject && !hasProjects && (
  251. <EmptyMessage>{t('No projects found')}</EmptyMessage>
  252. )}
  253. {!isProject && <AccountNotificationsByOrganization field={field} />}
  254. </Fragment>
  255. );
  256. return (
  257. <div>
  258. <SettingsPageHeader title={field.title} />
  259. {field.description && <TextBlock>{field.description}</TextBlock>}
  260. <Panel>
  261. <StyledPanelHeader hasButtons={isProject}>
  262. {isProject ? (
  263. <Fragment>
  264. <OrganizationSelectHeader
  265. organizations={organizations}
  266. organizationId={organizationId}
  267. handleOrgChange={(newOrgId: string) => {
  268. navigate(
  269. {
  270. ...location,
  271. query: {organizationId: newOrgId},
  272. },
  273. {replace: true}
  274. );
  275. }}
  276. />
  277. <SearchBar
  278. placeholder={t('Search Projects')}
  279. query={location.query.query as string | undefined}
  280. onSearch={value => {
  281. navigate(
  282. {
  283. ...location,
  284. query: {...location.query, query: value, cursor: undefined},
  285. },
  286. {replace: true}
  287. );
  288. }}
  289. />
  290. </Fragment>
  291. ) : (
  292. <Heading>{t('Organizations')}</Heading>
  293. )}
  294. </StyledPanelHeader>
  295. <PanelBody>
  296. {/* Only email needs the form to change the emmail */}
  297. {fineTuneType === 'email' && emailsByProject && !isPendingEmailsByProject ? (
  298. <Form
  299. saveOnBlur
  300. apiMethod="PUT"
  301. apiEndpoint="/users/me/notifications/email/"
  302. initialData={emailsByProject}
  303. onSubmitSuccess={() => {
  304. refetchEmailsByProject();
  305. }}
  306. >
  307. {mainContent}
  308. </Form>
  309. ) : (
  310. mainContent
  311. )}
  312. </PanelBody>
  313. </Panel>
  314. {projects && <Pagination pageLinks={getProjectsResponseHeader?.('Link')} />}
  315. </div>
  316. );
  317. }
  318. const Heading = styled('div')`
  319. flex: 1;
  320. `;
  321. const StyledPanelHeader = styled(PanelHeader)`
  322. flex-wrap: wrap;
  323. gap: ${space(1)};
  324. & > form:last-child {
  325. flex-grow: 1;
  326. }
  327. `;
  328. export default withOrganizations(AccountNotificationFineTuning);