accountNotificationFineTuningV2.tsx 9.9 KB

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