accountNotificationFineTuningV2.tsx 9.7 KB

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