accountNotificationFineTuning.tsx 9.1 KB

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