accountNotificationFineTuning.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import {Fragment} from 'react';
  2. import type {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import EmptyMessage from 'sentry/components/emptyMessage';
  5. import SelectField from 'sentry/components/forms/fields/selectField';
  6. import Form from 'sentry/components/forms/form';
  7. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  8. import Pagination from 'sentry/components/pagination';
  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 {t} from 'sentry/locale';
  13. import ConfigStore from 'sentry/stores/configStore';
  14. import OrganizationsStore from 'sentry/stores/organizationsStore';
  15. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  16. import {space} from 'sentry/styles/space';
  17. import type {Organization, Project, UserEmail} from 'sentry/types';
  18. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  19. import withOrganizations from 'sentry/utils/withOrganizations';
  20. import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
  21. import type {FineTuneField} from 'sentry/views/settings/account/notifications/fields';
  22. import {ACCOUNT_NOTIFICATION_FIELDS} from 'sentry/views/settings/account/notifications/fields';
  23. import NotificationSettingsByType from 'sentry/views/settings/account/notifications/notificationSettingsByType';
  24. import {OrganizationSelectHeader} from 'sentry/views/settings/account/notifications/organizationSelectHeader';
  25. import {
  26. getNotificationTypeFromPathname,
  27. groupByOrganization,
  28. isGroupedByProject,
  29. } from 'sentry/views/settings/account/notifications/utils';
  30. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  31. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  32. const PanelBodyLineItem = styled(PanelBody)`
  33. font-size: 1rem;
  34. &:not(:last-child) {
  35. border-bottom: 1px solid ${p => p.theme.innerBorder};
  36. }
  37. `;
  38. const accountNotifications = [
  39. 'alerts',
  40. 'deploy',
  41. 'workflow',
  42. 'approval',
  43. 'quota',
  44. 'spikeProtection',
  45. 'reports',
  46. ];
  47. type ANBPProps = {
  48. field: FineTuneField;
  49. projects: Project[];
  50. };
  51. function AccountNotificationsByProject({projects, field}: ANBPProps) {
  52. const projectsByOrg = groupByOrganization(projects);
  53. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  54. const {title, description, ...fieldConfig} = field;
  55. // Display as select box in this view regardless of the type specified in the config
  56. const data = Object.values(projectsByOrg).map(org => ({
  57. name: org.organization.name,
  58. projects: org.projects.map(project => ({
  59. ...fieldConfig,
  60. // `name` key refers to field name
  61. // we use project.id because slugs are not unique across orgs
  62. name: project.id,
  63. label: (
  64. <ProjectBadge
  65. project={project}
  66. avatarSize={20}
  67. avatarProps={{consistentWidth: true}}
  68. disableLink
  69. />
  70. ),
  71. })),
  72. }));
  73. return (
  74. <Fragment>
  75. {data.map(({name, projects: projectFields}) => (
  76. <div key={name}>
  77. {projectFields.map(f => (
  78. <PanelBodyLineItem key={f.name}>
  79. <SelectField
  80. defaultValue={f.defaultValue}
  81. name={f.name}
  82. options={f.options}
  83. label={f.label}
  84. />
  85. </PanelBodyLineItem>
  86. ))}
  87. </div>
  88. ))}
  89. </Fragment>
  90. );
  91. }
  92. type ANBOProps = {
  93. field: FineTuneField;
  94. };
  95. function AccountNotificationsByOrganization({field}: ANBOProps) {
  96. const {organizations} = useLegacyStore(OrganizationsStore);
  97. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  98. const {title, description, ...fieldConfig} = field;
  99. // Display as select box in this view regardless of the type specified in the config
  100. const data = organizations.map(org => ({
  101. ...fieldConfig,
  102. // `name` key refers to field name
  103. // we use org.id to remain consistent project.id use (which is required because slugs are not unique across orgs)
  104. name: org.id,
  105. label: org.slug,
  106. }));
  107. return (
  108. <Fragment>
  109. {data.map(f => (
  110. <PanelBodyLineItem key={f.name}>
  111. <SelectField
  112. defaultValue={f.defaultValue}
  113. name={f.name}
  114. options={f.options}
  115. label={f.label}
  116. />
  117. </PanelBodyLineItem>
  118. ))}
  119. </Fragment>
  120. );
  121. }
  122. type Props = DeprecatedAsyncView['props'] &
  123. RouteComponentProps<{fineTuneType: string}, {}> & {
  124. organizations: Organization[];
  125. };
  126. type State = DeprecatedAsyncView['state'] & {
  127. emails: UserEmail[] | null;
  128. emailsByProject: Record<string, any> | null;
  129. notifications: Record<string, any> | null;
  130. projects: Project[] | null;
  131. };
  132. class AccountNotificationFineTuning extends DeprecatedAsyncView<Props, State> {
  133. getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
  134. const {fineTuneType: pathnameType} = this.props.params;
  135. const fineTuneType = getNotificationTypeFromPathname(pathnameType);
  136. const endpoints: ReturnType<DeprecatedAsyncView['getEndpoints']> = [
  137. ['notifications', '/users/me/notifications/'],
  138. ];
  139. if (isGroupedByProject(fineTuneType)) {
  140. const organizationId = this.getOrganizationId();
  141. endpoints.push(['projects', `/projects/`, {query: {organizationId}}]);
  142. }
  143. // special logic for email
  144. if (fineTuneType === 'email') {
  145. endpoints.push(['emails', '/users/me/emails/']);
  146. endpoints.push(['emailsByProject', `/users/me/notifications/email/`]);
  147. }
  148. return endpoints;
  149. }
  150. // Return a sorted list of user's verified emails
  151. get emailChoices() {
  152. return (
  153. this.state.emails
  154. ?.filter(({isVerified}) => isVerified)
  155. ?.sort((a, b) => {
  156. // Sort by primary -> email
  157. if (a.isPrimary) {
  158. return -1;
  159. }
  160. if (b.isPrimary) {
  161. return 1;
  162. }
  163. return a.email < b.email ? -1 : 1;
  164. }) ?? []
  165. );
  166. }
  167. handleOrgChange = (organizationId: string) => {
  168. this.props.router.replace({
  169. ...this.props.location,
  170. query: {organizationId},
  171. });
  172. };
  173. getOrganizationId(): string | undefined {
  174. const {location, organizations} = this.props;
  175. const customerDomain = ConfigStore.get('customerDomain');
  176. const orgFromSubdomain = organizations.find(
  177. ({slug}) => slug === customerDomain?.subdomain
  178. )?.id;
  179. return location?.query?.organizationId ?? orgFromSubdomain ?? organizations[0]?.id;
  180. }
  181. renderBody() {
  182. const {params, organizations} = this.props;
  183. const {fineTuneType: pathnameType} = params;
  184. const fineTuneType = getNotificationTypeFromPathname(pathnameType);
  185. if (accountNotifications.includes(fineTuneType)) {
  186. return <NotificationSettingsByType notificationType={fineTuneType} />;
  187. }
  188. const {notifications, projects, emailsByProject, projectsPageLinks} = this.state;
  189. const isProject = isGroupedByProject(fineTuneType) && organizations.length > 0;
  190. const field = ACCOUNT_NOTIFICATION_FIELDS[fineTuneType];
  191. // TODO(isabella): once GA, remove this
  192. if (
  193. fineTuneType === 'quota' &&
  194. organizations.some(org => org.features?.includes('spend-visibility-notifications'))
  195. ) {
  196. field.title = t('Spend Notifications');
  197. field.description = t(
  198. 'Control the notifications you receive for organization spend.'
  199. );
  200. }
  201. const {title, description} = field;
  202. const [stateKey] = isProject ? this.getEndpoints()[2] : [];
  203. const hasProjects = !!projects?.length;
  204. if (fineTuneType === 'email') {
  205. // Fetch verified email addresses
  206. field.options = this.emailChoices.map(({email}) => ({value: email, label: email}));
  207. }
  208. if (!notifications || (!emailsByProject && fineTuneType === 'email')) {
  209. return null;
  210. }
  211. const orgId = this.getOrganizationId();
  212. const paginationObject = parseLinkHeader(projectsPageLinks ?? '');
  213. const hasMore = paginationObject?.next?.results;
  214. const hasPrevious = paginationObject?.previous?.results;
  215. const mainContent = (
  216. <Fragment>
  217. {isProject && hasProjects && (
  218. <AccountNotificationsByProject projects={projects!} field={field} />
  219. )}
  220. {isProject && !hasProjects && (
  221. <EmptyMessage>{t('No projects found')}</EmptyMessage>
  222. )}
  223. {!isProject && <AccountNotificationsByOrganization field={field} />}
  224. </Fragment>
  225. );
  226. return (
  227. <div>
  228. <SettingsPageHeader title={title} />
  229. {description && <TextBlock>{description}</TextBlock>}
  230. <Panel>
  231. <StyledPanelHeader hasButtons={isProject}>
  232. {isProject ? (
  233. <Fragment>
  234. <OrganizationSelectHeader
  235. organizations={organizations}
  236. organizationId={orgId}
  237. handleOrgChange={this.handleOrgChange}
  238. />
  239. {this.renderSearchInput({
  240. placeholder: t('Search Projects'),
  241. url: `/projects/?organizationId=${orgId}`,
  242. stateKey,
  243. })}
  244. </Fragment>
  245. ) : (
  246. <Heading>{t('Organizations')}</Heading>
  247. )}
  248. </StyledPanelHeader>
  249. <PanelBody>
  250. {/* Only email needs the form to change the emmail */}
  251. {fineTuneType === 'email' && emailsByProject ? (
  252. <Form
  253. saveOnBlur
  254. apiMethod="PUT"
  255. apiEndpoint="/users/me/notifications/email/"
  256. initialData={emailsByProject}
  257. >
  258. {mainContent}
  259. </Form>
  260. ) : (
  261. mainContent
  262. )}
  263. </PanelBody>
  264. </Panel>
  265. {projects && (hasMore || hasPrevious) && (
  266. <Pagination pageLinks={projectsPageLinks} />
  267. )}
  268. </div>
  269. );
  270. }
  271. }
  272. const Heading = styled('div')`
  273. flex: 1;
  274. `;
  275. const StyledPanelHeader = styled(PanelHeader)`
  276. flex-wrap: wrap;
  277. gap: ${space(1)};
  278. & > form:last-child {
  279. flex-grow: 1;
  280. }
  281. `;
  282. export default withOrganizations(AccountNotificationFineTuning);