Browse Source

feat(notifications): Notification Settings Page (#26149)

Marcos Gaeta 3 years ago
parent
commit
b94323b862

+ 2 - 2
static/app/views/settings/account/accountNotificationFineTuning.tsx

@@ -13,7 +13,7 @@ import {
   ACCOUNT_NOTIFICATION_FIELDS,
   FineTuneField,
 } from 'app/views/settings/account/notifications/fields';
-import NotificationSettings from 'app/views/settings/account/notifications/notificationSettings';
+import NotificationSettingsByType from 'app/views/settings/account/notifications/notificationSettingsByType';
 import {
   groupByOrganization,
   isGroupedByProject,
@@ -174,7 +174,7 @@ class AccountNotificationFineTuning extends AsyncView<Props, State> {
         organization.features.includes('notification-platform')
       )
     ) {
-      return <NotificationSettings notificationType={fineTuneType} />;
+      return <NotificationSettingsByType notificationType={fineTuneType} />;
     }
 
     const {notifications, projects, fineTuneData, projectsPageLinks} = this.state;

+ 18 - 2
static/app/views/settings/account/accountNotifications.tsx

@@ -6,7 +6,10 @@ import {PanelFooter} from 'app/components/panels';
 import accountNotificationFields from 'app/data/forms/accountNotificationSettings';
 import {IconChevron, IconMail} from 'app/icons';
 import {t} from 'app/locale';
+import {OrganizationSummary} from 'app/types';
+import withOrganizations from 'app/utils/withOrganizations';
 import AsyncView from 'app/views/asyncView';
+import NotificationSettings from 'app/views/settings/account/notifications/notificationSettings';
 import Form from 'app/views/settings/components/forms/form';
 import JsonForm from 'app/views/settings/components/forms/jsonForm';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
@@ -34,13 +37,15 @@ const FINE_TUNE_FOOTERS = {
   },
 };
 
-type Props = AsyncView['props'] & {};
+type Props = AsyncView['props'] & {
+  organizations: OrganizationSummary[];
+};
 
 type State = AsyncView['state'] & {
   data: Record<string, unknown> | null;
 };
 
-export default class AccountNotifications extends AsyncView<Props, State> {
+class AccountNotifications extends AsyncView<Props, State> {
   getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
     return [['data', '/users/me/notifications/']];
   }
@@ -50,6 +55,15 @@ export default class AccountNotifications extends AsyncView<Props, State> {
   }
 
   renderBody() {
+    const {organizations} = this.props;
+    if (
+      organizations.some(organization =>
+        organization.features.includes('notification-platform')
+      )
+    ) {
+      return <NotificationSettings />;
+    }
+
     return (
       <div>
         <SettingsPageHeader title="Notifications" />
@@ -100,3 +114,5 @@ const FineTuningFooter = ({path, text}: FooterProps) => (
     </FineTuneLink>
   </PanelFooter>
 );
+
+export default withOrganizations(AccountNotifications);

+ 19 - 0
static/app/views/settings/account/notifications/feedbackAlert.tsx

@@ -0,0 +1,19 @@
+import styled from '@emotion/styled';
+
+import Alert from 'app/components/alert';
+import {IconInfo} from 'app/icons';
+import {tct} from 'app/locale';
+
+const FeedbackAlert = () => (
+  <StyledAlert type="info" icon={<IconInfo />}>
+    {tct('Got feedback? Email [email:ecosystem-feedback@sentry.io].', {
+      email: <a href="mailto:ecosystem-feedback@sentry.io" />,
+    })}
+  </StyledAlert>
+);
+
+const StyledAlert = styled(Alert)`
+  margin: 20px 0px;
+`;
+
+export default FeedbackAlert;

+ 29 - 1
static/app/views/settings/account/notifications/fields2.tsx

@@ -2,11 +2,12 @@ import {t} from 'app/locale';
 
 export type NotificationSettingField = {
   name: string;
-  type: 'select';
+  type: 'select' | 'blank' | 'boolean';
   label: string;
   choices?: string[][];
   defaultValue?: string;
   defaultFieldName?: string;
+  help?: string;
 };
 
 export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingField> = {
@@ -18,6 +19,7 @@ export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingFiel
       ['always', t('On')],
       ['never', t('Off')],
     ],
+    help: t('Enable this to receive notifications sent from project alerts.'),
   },
   deploy: {
     name: 'deploy',
@@ -28,6 +30,7 @@ export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingFiel
       ['committed_only', t('Only Committed Issues')],
       ['never', t('Off')],
     ],
+    help: t('Release, environment, and commit overviews.'),
   },
   provider: {
     name: 'provider',
@@ -48,5 +51,30 @@ export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingFiel
       ['subscribe_only', t('Only Subscribed Issues')],
       ['never', t('Off')],
     ],
+    help: t('Changes in issue assignment, resolution status, and comments.'),
+  },
+  reports: {
+    name: 'weekly reports',
+    type: 'blank',
+    label: t('Weekly Reports'),
+    help: t('A summary of the past week for an organization.'),
+  },
+  email: {
+    name: 'email routing',
+    type: 'blank',
+    label: t('Email Routing'),
+    help: t('Select which email address should receive notifications per project.'),
+  },
+  personalActivityNotifications: {
+    name: 'personalActivityNotifications',
+    type: 'boolean',
+    label: t('Notify Me About My Own Activity'),
+    help: t('Enable this to receive notifications about your own actions on Sentry.'),
+  },
+  selfAssignOnResolve: {
+    name: 'selfAssignOnResolve',
+    type: 'boolean',
+    label: t("Claim Unassigned Issues I've Resolved"),
+    help: t("You'll receive notifications about any changes that happen afterwards."),
   },
 };

+ 80 - 352
static/app/views/settings/account/notifications/notificationSettings.tsx

@@ -1,25 +1,18 @@
 import React from 'react';
-import styled from '@emotion/styled';
 
-import Alert from 'app/components/alert';
+import AlertLink from 'app/components/alertLink';
 import AsyncComponent from 'app/components/asyncComponent';
-import Avatar from 'app/components/avatar';
-import {IconInfo} from 'app/icons';
-import {t, tct} from 'app/locale';
-import space from 'app/styles/space';
-import {Organization, Project} from 'app/types';
-import withOrganizations from 'app/utils/withOrganizations';
-import {ACCOUNT_NOTIFICATION_FIELDS} from 'app/views/settings/account/notifications/fields';
+import Link from 'app/components/links/link';
+import {IconMail} from 'app/icons';
+import {t} from 'app/locale';
+import FeedbackAlert from 'app/views/settings/account/notifications/feedbackAlert';
 import {NOTIFICATION_SETTING_FIELDS} from 'app/views/settings/account/notifications/fields2';
 import {
-  backfillMissingProvidersWithFallback,
-  getChoiceString,
-  getFallBackValue,
-  groupByOrganization,
-  isGroupedByProject,
+  decideDefault,
+  getParentIds,
+  getStateToPutForDefault,
   mergeNotificationSettings,
   NotificationSettingsObject,
-  providerListToString,
 } from 'app/views/settings/account/notifications/utils';
 import Form from 'app/views/settings/components/forms/form';
 import JsonForm from 'app/views/settings/components/forms/jsonForm';
@@ -27,14 +20,18 @@ import {FieldObject} from 'app/views/settings/components/forms/type';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
 import TextBlock from 'app/views/settings/components/text/textBlock';
 
-type Props = {
-  notificationType: string;
-  organizations: Organization[];
-} & AsyncComponent['props'];
+const NOTIFICATION_SETTINGS_TYPES = ['alerts', 'deploy', 'workflow', 'reports', 'email'];
+
+const SELF_NOTIFICATION_SETTINGS_TYPES = [
+  'personalActivityNotifications',
+  'selfAssignOnResolve',
+];
+
+type Props = AsyncComponent['props'];
 
 type State = {
   notificationSettings: NotificationSettingsObject;
-  projects: Project[];
+  legacyData: {[key: string]: string};
 } & AsyncComponent['state'];
 
 class NotificationSettings extends AsyncComponent<Props, State> {
@@ -42,385 +39,116 @@ class NotificationSettings extends AsyncComponent<Props, State> {
     return {
       ...super.getDefaultState(),
       notificationSettings: {},
-      projects: [],
+      legacyData: {},
     };
   }
 
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
-    const {notificationType} = this.props;
-
-    const query = {type: notificationType};
-    const endpoints: ReturnType<AsyncComponent['getEndpoints']> = [
-      ['notificationSettings', `/users/me/notification-settings/`, {query}],
+    return [
+      ['notificationSettings', `/users/me/notification-settings/`],
+      ['legacyData', '/users/me/notifications/'],
     ];
-    if (this.isGroupedByProject()) {
-      endpoints.push(['projects', '/projects/']);
-    }
-    return endpoints;
-  }
-
-  /* Helper methods that help interpret state. */
-
-  isGroupedByProject() {
-    /** We can infer the parent type by the `notificationType` key. */
-    const {notificationType} = this.props;
-    return isGroupedByProject(notificationType);
-  }
-
-  getParentKey = (): string => {
-    return this.isGroupedByProject() ? 'project' : 'organization';
-  };
-
-  getParents(): Organization[] | Project[] {
-    /** Use the `notificationType` key to decide which parent objects to use */
-    const {organizations} = this.props;
-    const {projects} = this.state;
-
-    return this.isGroupedByProject() ? projects : organizations;
   }
 
-  getUserDefaultValues = (): {[key: string]: string} => {
-    /**
-     * Get the mapping of providers to values that describe a user's parent-
-     * independent notification preferences. The data from the API uses the user
-     * ID rather than "me" so we assume the first ID is the user's.
-     */
-    const {notificationType} = this.props;
-    const {notificationSettings} = this.state;
-
-    return (
-      Object.values(notificationSettings[notificationType]?.user || {}).pop() || {
-        email: getFallBackValue(notificationType),
-      }
-    );
-  };
-
-  getParentValues = (parentId: string): {[key: string]: string} => {
-    const {notificationType} = this.props;
-    const {notificationSettings} = this.state;
-
-    return (
-      notificationSettings[notificationType]?.[this.getParentKey()]?.[parentId] || {
-        email: 'default',
-      }
-    );
-  };
-
-  getParentData = (): {[key: string]: string} => {
-    /** Get a mapping of all parent IDs to the notification setting for the current providers. */
-    const provider = this.getCurrentProviders()[0];
-
-    return Object.fromEntries(
-      this.getParents().map(parent => [
-        parent.id,
-        this.getParentValues(parent.id)[provider],
-      ])
-    );
-  };
-
-  getCurrentProviders = (): string[] => {
-    /** Get the list of providers currently active on this page. Note: this can be empty. */
-    const userData = this.getUserDefaultValues();
-
-    return Object.entries(userData)
-      .filter(([_, value]) => !['never'].includes(value))
-      .map(([provider, _]) => provider);
-  };
-
-  /* Methods responsible for updating state and hitting the API. */
-
-  getStateToPutForProvider = changedData => {
-    /**
-     * I don't need to update the provider for EVERY once of the user's projects
-     * and organizations, just the user and parents that have explicit settings.
-     */
-    const {notificationType} = this.props;
-    const {notificationSettings} = this.state;
-
-    const providerList: string[] = changedData.provider.split('+');
-    const fallbackValue = getFallBackValue(notificationType);
-
-    let updatedNotificationSettings;
-    if (Object.keys(notificationSettings).length) {
-      updatedNotificationSettings = {
-        [notificationType]: Object.fromEntries(
-          Object.entries(
-            notificationSettings[notificationType]
-          ).map(([scopeType, scopeTypeData]) => [
-            scopeType,
-            Object.fromEntries(
-              Object.entries(scopeTypeData).map(([scopeId, scopeIdData]) => [
-                scopeId,
-                backfillMissingProvidersWithFallback(
-                  scopeIdData,
-                  providerList,
-                  fallbackValue,
-                  scopeType
-                ),
-              ])
-            ),
-          ])
-        ),
-      };
-    } else {
-      // If the user has no settings, we need to create them.
-      updatedNotificationSettings = {
-        [notificationType]: {
-          user: {
-            me: Object.fromEntries(
-              providerList.map(provider => [provider, fallbackValue])
-            ),
-          },
-        },
-      };
-    }
-
-    this.setState({
-      notificationSettings: mergeNotificationSettings(
-        notificationSettings,
-        updatedNotificationSettings
-      ),
-    });
-
-    return updatedNotificationSettings;
-  };
-
-  getStateToPutForDefault = (changedData: {[key: string]: string}) => {
+  getStateToPutForDefault = (
+    changedData: {[key: string]: string},
+    notificationType: string
+  ) => {
     /**
      * Update the current providers' parent-independent notification settings
      * with the new value. If the new value is "never", then also update all
      * parent-specific notification settings to "default". If the previous value
      * was "never", then assume providerList should be "email" only.
      */
-    const {notificationType} = this.props;
-    const {notificationSettings} = this.state;
-
-    const newValue = Object.values(changedData)[0];
-    let providerList = this.getCurrentProviders();
-    if (!providerList.length) {
-      providerList = ['email'];
-    }
 
-    const updatedNotificationSettings = {
-      [notificationType]: {
-        user: {
-          me: Object.fromEntries(providerList.map(provider => [provider, newValue])),
-        },
-      },
-    };
-
-    if (newValue === 'never') {
-      updatedNotificationSettings[notificationType][
-        this.getParentKey()
-      ] = Object.fromEntries(
-        this.getParents().map(parent => [
-          parent.id,
-          Object.fromEntries(providerList.map(provider => [provider, 'default'])),
-        ])
-      );
-    }
-
-    this.setState({
-      notificationSettings: mergeNotificationSettings(
-        notificationSettings,
-        updatedNotificationSettings
-      ),
-    });
-
-    return updatedNotificationSettings;
-  };
-
-  getStateToPutForParent = (changedData: {[key: string]: string}, parentId: string) => {
-    /** Get the diff of the Notification Settings for this parent ID. */
-    const {notificationType} = this.props;
     const {notificationSettings} = this.state;
 
-    const currentProviders = this.getCurrentProviders();
-    const newValue = Object.values(changedData)[0];
+    const updatedNotificationSettings = getStateToPutForDefault(
+      notificationType,
+      notificationSettings,
+      changedData,
+      getParentIds(notificationType, notificationSettings)
+    );
 
-    const updatedNotificationSettings = {
-      [notificationType]: {
-        [this.getParentKey()]: {
-          [parentId]: Object.fromEntries(
-            currentProviders.map(provider => [provider, newValue])
-          ),
-        },
-      },
-    };
     this.setState({
       notificationSettings: mergeNotificationSettings(
         notificationSettings,
         updatedNotificationSettings
       ),
     });
-    return updatedNotificationSettings;
-  };
-
-  /* Methods responsible for rendering the page. */
-
-  getGroupedParents = (): {[key: string]: Organization[] | Project[]} => {
-    /**
-     * The UI expects projects to be grouped by organization but can also use
-     * this function to make a single group with all organizations.
-     */
-    const {organizations} = this.props;
-    const {projects: stateProjects} = this.state;
 
-    return this.isGroupedByProject()
-      ? Object.fromEntries(
-          Object.values(
-            groupByOrganization(stateProjects)
-          ).map(({organization, projects}) => [`${organization.name} Projects`, projects])
-        )
-      : {organizations};
-  };
-
-  getParentField = (parent: Organization | Project): FieldObject => {
-    const {notificationType} = this.props;
-
-    const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
-    const currentDefault = Object.values(this.getUserDefaultValues())[0];
-
-    return Object.assign({}, defaultFields, {
-      label: (
-        <FieldLabel>
-          <Avatar
-            {...{[this.isGroupedByProject() ? 'project' : 'organization']: parent}}
-          />
-          <span>{parent.slug}</span>
-        </FieldLabel>
-      ),
-      getData: data => this.getStateToPutForParent(data, parent.id),
-      name: parent.id,
-      choices: defaultFields.choices?.concat([
-        [
-          'default',
-          `${t('Default')} (${getChoiceString(defaultFields.choices, currentDefault)})`,
-        ],
-      ]),
-      defaultValue: 'default',
-    }) as any;
+    return updatedNotificationSettings;
   };
 
   getInitialData(): {[key: string]: string} {
-    const {notificationType} = this.props;
-
-    const providerList = this.getCurrentProviders();
-    const initialData = {
-      [notificationType]: providerList.length
-        ? this.getUserDefaultValues()[providerList[0]]
-        : 'never',
-    };
+    const {notificationSettings} = this.state;
 
-    if (!this.isEverythingDisabled()) {
-      initialData.provider = providerListToString(providerList);
-    }
-    return initialData;
+    return Object.fromEntries(
+      NOTIFICATION_SETTINGS_TYPES.map(notificationType => [
+        notificationType,
+        decideDefault(notificationType, notificationSettings),
+      ])
+    );
   }
 
-  getFields(): FieldObject[] {
-    const {notificationType} = this.props;
-
-    const fields = [
-      Object.assign(
-        {
-          help: t('This is the default for all projects.'),
-          getData: data => this.getStateToPutForDefault(data),
-        },
-        NOTIFICATION_SETTING_FIELDS[notificationType]
-      ),
-    ];
-    if (!this.isEverythingDisabled()) {
-      fields.push(
-        Object.assign(
-          {
-            help: t('Where personal notifications will be sent.'),
-            getData: data => this.getStateToPutForProvider(data),
-          },
-          NOTIFICATION_SETTING_FIELDS.provider
-        )
-      );
-    }
-    return fields as FieldObject[];
+  getFields(notificationType: string): FieldObject[] {
+    return [
+      Object.assign({}, NOTIFICATION_SETTING_FIELDS[notificationType], {
+        getData: data => this.getStateToPutForDefault(data, notificationType),
+        help: (
+          <React.Fragment>
+            {NOTIFICATION_SETTING_FIELDS[notificationType].help}
+            &nbsp;
+            <Link to={`/settings/account/notifications/${notificationType}`}>
+              Fine tune
+            </Link>
+          </React.Fragment>
+        ),
+      }),
+    ] as FieldObject[];
   }
 
-  isEverythingDisabled = (): boolean => {
-    /**
-     * For a given notificationType, are the parent-independent setting "never"
-     * for all providers and are the parent-specific settings "default" or
-     * "never". If so, the API is telling us that the user has opted out of
-     * all notifications.
-     */
-    const {notificationType} = this.props;
-    const {notificationSettings} = this.state;
-
-    return (
-      // For user, all providers are "never".
-      Object.values(this.getUserDefaultValues()).every(value => value === 'never') &&
-      // Every leaf value is either "never" or "default".
-      Object.values(
-        notificationSettings[notificationType]?.[this.getParentKey()] || {}
-      ).every(settingsByProvider =>
-        Object.values(settingsByProvider).every(value =>
-          ['never', 'default'].includes(value)
-        )
-      )
-    );
-  };
-
   renderBody() {
-    const {notificationType} = this.props;
-    const {title, description} = ACCOUNT_NOTIFICATION_FIELDS[notificationType];
+    const {legacyData} = this.state;
 
     return (
       <React.Fragment>
-        <SettingsPageHeader title={title} />
-        {description && <TextBlock>{description}</TextBlock>}
-        <FeedbackAlert type="info" icon={<IconInfo />}>
-          {tct('Got feedback? Email [email:ecosystem-feedback@sentry.io].', {
-            email: <a href="mailto:ecosystem-feedback@sentry.io" />,
-          })}
-        </FeedbackAlert>
+        <SettingsPageHeader title="Notifications" />
+        <TextBlock>Control alerts that you receive.</TextBlock>
+        <FeedbackAlert />
         <Form
           saveOnBlur
           apiMethod="PUT"
           apiEndpoint="/users/me/notification-settings/"
           initialData={this.getInitialData()}
+        >
+          {NOTIFICATION_SETTINGS_TYPES.map(notificationType => (
+            <JsonForm
+              key={notificationType}
+              title={NOTIFICATION_SETTING_FIELDS[notificationType].name}
+              fields={this.getFields(notificationType)}
+            />
+          ))}
+        </Form>
+        <Form
+          initialData={legacyData}
+          saveOnBlur
+          apiMethod="PUT"
+          apiEndpoint="/users/me/notifications/"
         >
           <JsonForm
-            title={this.isGroupedByProject() ? t('All Projects') : t('All Organizations')}
-            fields={this.getFields()}
+            title={t('My Activity')}
+            fields={SELF_NOTIFICATION_SETTINGS_TYPES.map(
+              type => NOTIFICATION_SETTING_FIELDS[type] as FieldObject
+            )}
           />
         </Form>
-        {!this.isEverythingDisabled() && (
-          <Form
-            saveOnBlur
-            apiMethod="PUT"
-            apiEndpoint="/users/me/notification-settings/"
-            initialData={this.getParentData()}
-          >
-            {Object.entries(this.getGroupedParents()).map(([groupTitle, parents]) => (
-              <JsonForm
-                key={groupTitle}
-                title={groupTitle}
-                fields={parents.map(parent => this.getParentField(parent))}
-              />
-            ))}
-          </Form>
-        )}
+        <AlertLink to="/settings/account/emails" icon={<IconMail />}>
+          {t('Looking to add or remove an email address? Use the emails panel.')}
+        </AlertLink>
       </React.Fragment>
     );
   }
 }
 
-const FieldLabel = styled('div')`
-  display: flex;
-  gap: ${space(0.5)};
-  line-height: 16px;
-`;
-
-const FeedbackAlert = styled(Alert)`
-  margin: 20px 0px;
-`;
-
-export default withOrganizations(NotificationSettings);
+export default NotificationSettings;

+ 325 - 0
static/app/views/settings/account/notifications/notificationSettingsByType.tsx

@@ -0,0 +1,325 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import AsyncComponent from 'app/components/asyncComponent';
+import Avatar from 'app/components/avatar';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {OrganizationSummary, Project} from 'app/types';
+import withOrganizations from 'app/utils/withOrganizations';
+import FeedbackAlert from 'app/views/settings/account/notifications/feedbackAlert';
+import {ACCOUNT_NOTIFICATION_FIELDS} from 'app/views/settings/account/notifications/fields';
+import {NOTIFICATION_SETTING_FIELDS} from 'app/views/settings/account/notifications/fields2';
+import {
+  getChoiceString,
+  getParentIds,
+  getParentKey,
+  getStateToPutForDefault,
+  getStateToPutForParent,
+  getStateToPutForProvider,
+  getUserDefaultValues,
+  groupByOrganization,
+  isEverythingDisabled,
+  isGroupedByProject,
+  mergeNotificationSettings,
+  NotificationSettingsObject,
+  providerListToString,
+} from 'app/views/settings/account/notifications/utils';
+import Form from 'app/views/settings/components/forms/form';
+import JsonForm from 'app/views/settings/components/forms/jsonForm';
+import {FieldObject} from 'app/views/settings/components/forms/type';
+import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
+import TextBlock from 'app/views/settings/components/text/textBlock';
+
+type Props = {
+  notificationType: string;
+  organizations: OrganizationSummary[];
+} & AsyncComponent['props'];
+
+type State = {
+  notificationSettings: NotificationSettingsObject;
+  projects: Project[];
+} & AsyncComponent['state'];
+
+class NotificationSettingsByType extends AsyncComponent<Props, State> {
+  getDefaultState(): State {
+    return {
+      ...super.getDefaultState(),
+      notificationSettings: {},
+      projects: [],
+    };
+  }
+
+  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    const {notificationType} = this.props;
+
+    const query = {type: notificationType};
+    const endpoints: ReturnType<AsyncComponent['getEndpoints']> = [
+      ['notificationSettings', `/users/me/notification-settings/`, {query}],
+    ];
+    if (isGroupedByProject(notificationType)) {
+      endpoints.push(['projects', '/projects/']);
+    }
+    return endpoints;
+  }
+
+  /* Helper methods that help interpret state. */
+
+  getParents(): OrganizationSummary[] | Project[] {
+    /** Use the `notificationType` key to decide which parent objects to use. */
+    const {notificationType, organizations} = this.props;
+    const {projects} = this.state;
+
+    return isGroupedByProject(notificationType) ? projects : organizations;
+  }
+
+  getParentValues = (parentId: string): {[key: string]: string} => {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    return (
+      notificationSettings[notificationType]?.[getParentKey(notificationType)]?.[
+        parentId
+      ] || {
+        email: 'default',
+      }
+    );
+  };
+
+  getParentData = (): {[key: string]: string} => {
+    /** Get a mapping of all parent IDs to the notification setting for the current providers. */
+    const provider = this.getCurrentProviders()[0];
+
+    return Object.fromEntries(
+      this.getParents().map(parent => [
+        parent.id,
+        this.getParentValues(parent.id)[provider],
+      ])
+    );
+  };
+
+  getCurrentProviders = (): string[] => {
+    /** Get the list of providers currently active on this page. Note: this can be empty. */
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    return Object.entries(getUserDefaultValues(notificationType, notificationSettings))
+      .filter(([_, value]) => !['never'].includes(value))
+      .map(([provider, _]) => provider);
+  };
+
+  /* Methods responsible for updating state and hitting the API. */
+
+  getStateToPutForProvider = changedData => {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const updatedNotificationSettings = getStateToPutForProvider(
+      notificationType,
+      notificationSettings,
+      changedData
+    );
+
+    this.setState({
+      notificationSettings: mergeNotificationSettings(
+        notificationSettings,
+        updatedNotificationSettings
+      ),
+    });
+
+    return updatedNotificationSettings;
+  };
+
+  getStateToPutForDefault = (changedData: {[key: string]: string}) => {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const updatedNotificationSettings = getStateToPutForDefault(
+      notificationType,
+      notificationSettings,
+      changedData,
+      getParentIds(notificationType, notificationSettings)
+    );
+
+    this.setState({
+      notificationSettings: mergeNotificationSettings(
+        notificationSettings,
+        updatedNotificationSettings
+      ),
+    });
+
+    return updatedNotificationSettings;
+  };
+
+  getStateToPutForParent = (changedData: {[key: string]: string}, parentId: string) => {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const updatedNotificationSettings = getStateToPutForParent(
+      notificationType,
+      notificationSettings,
+      changedData,
+      parentId
+    );
+
+    this.setState({
+      notificationSettings: mergeNotificationSettings(
+        notificationSettings,
+        updatedNotificationSettings
+      ),
+    });
+    return updatedNotificationSettings;
+  };
+
+  /* Methods responsible for rendering the page. */
+
+  getGroupedParents = (): {[key: string]: OrganizationSummary[] | Project[]} => {
+    /**
+     * The UI expects projects to be grouped by organization but can also use
+     * this function to make a single group with all organizations.
+     */
+    const {notificationType, organizations} = this.props;
+    const {projects: stateProjects} = this.state;
+
+    return isGroupedByProject(notificationType)
+      ? Object.fromEntries(
+          Object.values(
+            groupByOrganization(stateProjects)
+          ).map(({organization, projects}) => [`${organization.name} Projects`, projects])
+        )
+      : {organizations};
+  };
+
+  getCurrentDefault = (): string => {
+    /** Calculate the currently selected provider. */
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const providersList = this.getCurrentProviders();
+    return providersList.length
+      ? getUserDefaultValues(notificationType, notificationSettings)[providersList[0]]
+      : 'never';
+  };
+
+  getParentField = (parent: OrganizationSummary | Project): FieldObject => {
+    /** Render each parent and add a default option to the the field choices. */
+    const {notificationType} = this.props;
+
+    const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
+
+    return Object.assign({}, defaultFields, {
+      label: (
+        <FieldLabel>
+          <Avatar
+            {...{
+              [isGroupedByProject(notificationType) ? 'project' : 'organization']: parent,
+            }}
+          />
+          <span>{parent.slug}</span>
+        </FieldLabel>
+      ),
+      getData: data => this.getStateToPutForParent(data, parent.id),
+      name: parent.id,
+      choices: defaultFields.choices?.concat([
+        [
+          'default',
+          `${t('Default')} (${getChoiceString(
+            defaultFields.choices,
+            this.getCurrentDefault()
+          )})`,
+        ],
+      ]),
+      defaultValue: 'default',
+      help: undefined,
+    }) as any;
+  };
+
+  getInitialData(): {[key: string]: string} {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const initialData = {[notificationType]: this.getCurrentDefault()};
+    if (!isEverythingDisabled(notificationType, notificationSettings)) {
+      initialData.provider = providerListToString(this.getCurrentProviders());
+    }
+    return initialData;
+  }
+
+  getFields(): FieldObject[] {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const fields = [
+      Object.assign({}, NOTIFICATION_SETTING_FIELDS[notificationType], {
+        help: t('This is the default for all projects.'),
+        getData: data => this.getStateToPutForDefault(data),
+      }),
+    ];
+    if (!isEverythingDisabled(notificationType, notificationSettings)) {
+      fields.push(
+        Object.assign(
+          {
+            help: t('Where personal notifications will be sent.'),
+            getData: data => this.getStateToPutForProvider(data),
+          },
+          NOTIFICATION_SETTING_FIELDS.provider
+        )
+      );
+    }
+    return fields as FieldObject[];
+  }
+
+  renderBody() {
+    const {notificationType} = this.props;
+    const {notificationSettings} = this.state;
+
+    const {title, description} = ACCOUNT_NOTIFICATION_FIELDS[notificationType];
+
+    return (
+      <React.Fragment>
+        <SettingsPageHeader title={title} />
+        {description && <TextBlock>{description}</TextBlock>}
+        <FeedbackAlert />
+        <Form
+          saveOnBlur
+          apiMethod="PUT"
+          apiEndpoint="/users/me/notification-settings/"
+          initialData={this.getInitialData()}
+        >
+          <JsonForm
+            title={
+              isGroupedByProject(notificationType)
+                ? t('All Projects')
+                : t('All Organizations')
+            }
+            fields={this.getFields()}
+          />
+        </Form>
+        {!isEverythingDisabled(notificationType, notificationSettings) && (
+          <Form
+            saveOnBlur
+            apiMethod="PUT"
+            apiEndpoint="/users/me/notification-settings/"
+            initialData={this.getParentData()}
+          >
+            {Object.entries(this.getGroupedParents()).map(([groupTitle, parents]) => (
+              <JsonForm
+                key={groupTitle}
+                title={groupTitle}
+                fields={parents.map(parent => this.getParentField(parent))}
+              />
+            ))}
+          </Form>
+        )}
+      </React.Fragment>
+    );
+  }
+}
+
+const FieldLabel = styled('div')`
+  display: flex;
+  gap: ${space(0.5)};
+  line-height: 16px;
+`;
+
+export default withOrganizations(NotificationSettingsByType);

+ 247 - 34
static/app/views/settings/account/notifications/utils.tsx

@@ -1,18 +1,27 @@
 import set from 'lodash/set';
 
-import {Organization, Project} from 'app/types';
+import {OrganizationSummary, Project} from 'app/types';
+
+const ALL_PROVIDERS = {
+  email: 'default',
+  slack: 'never',
+};
 
 export type NotificationSettingsObject = {
   [key: string]: {[key: string]: {[key: string]: {[key: string]: string}}};
 };
 
 // Which fine tuning parts are grouped by project
-export const isGroupedByProject = (type: string): boolean =>
-  ['alerts', 'email', 'workflow'].includes(type);
+export const isGroupedByProject = (notificationType: string): boolean =>
+  ['alerts', 'email', 'workflow'].includes(notificationType);
+
+export const getParentKey = (notificationType: string): string => {
+  return isGroupedByProject(notificationType) ? 'project' : 'organization';
+};
 
 export const groupByOrganization = (projects: Project[]) => {
   return projects.reduce<
-    Record<string, {organization: Organization; projects: Project[]}>
+    Record<string, {organization: OrganizationSummary; projects: Project[]}>
   >((acc, project) => {
     const orgSlug = project.organization.slug;
     if (acc.hasOwnProperty(orgSlug)) {
@@ -63,38 +72,30 @@ export const backfillMissingProvidersWithFallback = (
   scopeType: string
 ): {[key: string]: string} => {
   /**
-   * Transform `data` to include only providers expected in `providerList`.
-   * Everything not in that list is set to "never". Missing values will be
-   * backfilled either with a current value from `data` or `fallbackValue` if
-   * none are present. When wiping out a provider, set the parent-independent
-   * setting to "never" and all parent-specific settings to "default".
-   *
-   * For example:
-   * f({}, ["email"], "sometimes", "user") = {"email": "sometimes"}
-   *
-   * f({"email": "always", pagerduty: "always"}, ["email", "slack"], "sometimes", "user) =
-   * {"email": "always", "slack": "always", "pagerduty": "never"}
+   * Transform `data`, a mapping of providers to values, so that all providers
+   * in `providerList` are "on" in the resulting object. The "on" value is
+   * determined by checking `data` for non-"never" values and falling back to
+   * the value `fallbackValue`. The "off" value is either "default" or "never"
+   * depending on whether `scopeType` is "parent" or "user" respectively.
    */
-  const entries: string[][] = [];
-  let fallback = fallbackValue;
-  for (const [provider, previousValue] of Object.entries(data)) {
-    fallback = previousValue;
-    let value;
-    if (providerList.includes(provider)) {
-      value = previousValue;
-    } else if (scopeType === 'user') {
-      value = 'never';
-    } else {
-      value = 'default';
-    }
-
-    entries.push([provider, value]);
-  }
 
-  for (const provider of providerList) {
-    entries.push([provider, fallback]);
-  }
-  return Object.fromEntries(entries);
+  // First pass: determine the fallback value.
+  const fallback = Object.values(data).reduce(
+    (previousValue, currentValue) =>
+      currentValue === 'never' ? previousValue : currentValue,
+    fallbackValue
+  );
+  // Second pass: fill in values for every provider.
+  return Object.fromEntries(
+    Object.keys(ALL_PROVIDERS).map(provider => [
+      provider,
+      providerList.includes(provider)
+        ? fallback
+        : scopeType === 'user'
+        ? 'never'
+        : 'default',
+    ])
+  );
 };
 
 export const mergeNotificationSettings = (
@@ -114,3 +115,215 @@ export const mergeNotificationSettings = (
 
   return output;
 };
+
+export const getUserDefaultValues = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject
+): {[key: string]: string} => {
+  /**
+   * Get the mapping of providers to values that describe a user's parent-
+   * independent notification preferences. The data from the API uses the user
+   * ID rather than "me" so we assume the first ID is the user's.
+   */
+  return (
+    Object.values(notificationSettings[notificationType]?.user || {}).pop() ||
+    Object.fromEntries(
+      Object.entries(ALL_PROVIDERS).map(([provider, value]) => [
+        provider,
+        value === 'default' ? getFallBackValue(notificationType) : value,
+      ])
+    )
+  );
+};
+
+export const getCurrentProviders = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject
+): string[] => {
+  /** Get the list of providers currently active on this page. Note: this can be empty. */
+  const userData = getUserDefaultValues(notificationType, notificationSettings);
+
+  return Object.entries(userData)
+    .filter(([_, value]) => !['never'].includes(value))
+    .map(([provider, _]) => provider);
+};
+
+export const decideDefault = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject
+): string => {
+  /**
+   * For a given notificationType, are the parent-independent setting "never"
+   * for all providers and are the parent-specific settings "default" or
+   * "never". If so, the API is telling us that the user has opted out of
+   * all notifications.
+   */
+  // These values are stolen from the DB.
+  const mapping = {
+    default: 0,
+    never: 10,
+    always: 20,
+    subscribe_only: 30,
+    committed_only: 40,
+  };
+  const compare = (a: string, b: string): number => mapping[a] - mapping[b];
+
+  const parentIndependentSetting =
+    Object.values(getUserDefaultValues(notificationType, notificationSettings))
+      .sort(compare)
+      .pop() || 'never';
+
+  if (parentIndependentSetting !== 'never') {
+    return parentIndependentSetting;
+  }
+
+  const parentSpecificSetting =
+    Object.values(
+      notificationSettings[notificationType]?.[getParentKey(notificationType)] || {}
+    )
+      .flatMap(settingsByProvider => Object.values(settingsByProvider))
+      .sort(compare)
+      .pop() || 'default';
+
+  return parentSpecificSetting === 'default' ? 'never' : parentSpecificSetting;
+};
+
+export const isEverythingDisabled = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject
+): boolean => {
+  /**
+   * For a given notificationType, are the parent-independent setting "never"
+   * for all providers and are the parent-specific settings "default" or
+   * "never"? If so, the API is telling us that the user has opted out of
+   * all notifications.
+   */
+  return ['never', 'default'].includes(
+    decideDefault(notificationType, notificationSettings)
+  );
+};
+
+export const getParentIds = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject
+): string[] => {
+  /**
+   * Extract either the list of project or organization IDs from the
+   * notification settings in state. This assumes that the notification settings
+   * object is fully backfilled with settings for every parent.
+   */
+  return Object.keys(
+    notificationSettings[notificationType]?.[getParentKey(notificationType)] || {}
+  );
+};
+
+export const getStateToPutForProvider = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject,
+  changedData: {[key: string]: string}
+) => {
+  /**
+   * I don't need to update the provider for EVERY once of the user's projects
+   * and organizations, just the user and parents that have explicit settings.
+   */
+  const providerList: string[] = changedData.provider.split('+');
+  const fallbackValue = getFallBackValue(notificationType);
+
+  let updatedNotificationSettings;
+  if (Object.keys(notificationSettings).length) {
+    updatedNotificationSettings = {
+      [notificationType]: Object.fromEntries(
+        Object.entries(
+          notificationSettings[notificationType]
+        ).map(([scopeType, scopeTypeData]) => [
+          scopeType,
+          Object.fromEntries(
+            Object.entries(scopeTypeData).map(([scopeId, scopeIdData]) => [
+              scopeId,
+              backfillMissingProvidersWithFallback(
+                scopeIdData,
+                providerList,
+                fallbackValue,
+                scopeType
+              ),
+            ])
+          ),
+        ])
+      ),
+    };
+  } else {
+    // If the user has no settings, we need to create them.
+    updatedNotificationSettings = {
+      [notificationType]: {
+        user: {
+          me: Object.fromEntries(providerList.map(provider => [provider, fallbackValue])),
+        },
+      },
+    };
+  }
+
+  return updatedNotificationSettings;
+};
+
+export const getStateToPutForDefault = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject,
+  changedData: {[key: string]: string},
+  parentIds: string[]
+) => {
+  /**
+   * Update the current providers' parent-independent notification settings
+   * with the new value. If the new value is "never", then also update all
+   * parent-specific notification settings to "default". If the previous value
+   * was "never", then assume providerList should be "email" only.
+   */
+
+  const newValue = Object.values(changedData)[0];
+  let providerList = getCurrentProviders(notificationType, notificationSettings);
+  if (!providerList.length) {
+    providerList = ['email'];
+  }
+
+  const updatedNotificationSettings = {
+    [notificationType]: {
+      user: {
+        me: Object.fromEntries(providerList.map(provider => [provider, newValue])),
+      },
+    },
+  };
+
+  if (newValue === 'never') {
+    updatedNotificationSettings[notificationType][
+      getParentKey(notificationType)
+    ] = Object.fromEntries(
+      parentIds.map(parentId => [
+        parentId,
+        Object.fromEntries(providerList.map(provider => [provider, 'default'])),
+      ])
+    );
+  }
+
+  return updatedNotificationSettings;
+};
+
+export const getStateToPutForParent = (
+  notificationType: string,
+  notificationSettings: NotificationSettingsObject,
+  changedData: {[key: string]: string},
+  parentId: string
+) => {
+  /** Get the diff of the Notification Settings for this parent ID. */
+
+  const providerList = getCurrentProviders(notificationType, notificationSettings);
+  const newValue = Object.values(changedData)[0];
+
+  return {
+    [notificationType]: {
+      [getParentKey(notificationType)]: {
+        [parentId]: Object.fromEntries(
+          providerList.map(provider => [provider, newValue])
+        ),
+      },
+    },
+  };
+};

+ 16 - 0
static/app/views/settings/components/forms/blankField.tsx

@@ -0,0 +1,16 @@
+import * as React from 'react';
+
+import Field from 'app/views/settings/components/forms/field';
+
+type Props = Field['props'];
+
+/**
+ * This class is meant to hook into `fieldFromConfig`. Like the FieldSeparator
+ * class, this doesn't have any fields of its own and is just meant to make
+ * forms more flexible.
+ */
+export default class BlankField extends React.Component<Props> {
+  render() {
+    return <Field {...this.props} />;
+  }
+}

+ 3 - 0
static/app/views/settings/components/forms/fieldFromConfig.tsx

@@ -2,6 +2,7 @@ import {Component} from 'react';
 
 import {Scope} from 'app/types';
 
+import BlankField from './blankField';
 import BooleanField from './booleanField';
 import ChoiceMapperField from './choiceMapperField';
 import EmailField from './emailField';
@@ -51,6 +52,8 @@ export default class FieldFromConfig extends Component<Props> {
         // TODO(ts) The switch on field.type is not resolving
         // the Field union for this component. The union might be 'too big'.
         return <RangeField {...(props as any)} />;
+      case 'blank':
+        return <BlankField {...props} />;
       case 'bool':
       case 'boolean':
         return <BooleanField {...props} />;

+ 1 - 0
static/app/views/settings/components/forms/type.tsx

@@ -9,6 +9,7 @@ import {RichListProps} from 'app/views/settings/components/forms/richListField';
 
 export const FieldType = [
   'array',
+  'blank',
   'bool',
   'boolean',
   'choice_mapper',

Some files were not shown because too many files changed in this diff