Browse Source

feat(notifications): adds ui for overages (#31330)

This PR creates the UI quota notifications
Stephen Cefali 3 years ago
parent
commit
b74ca7a543

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

@@ -169,7 +169,7 @@ class AccountNotificationFineTuning extends AsyncView<Props, State> {
     const {params} = this.props;
     const {params} = this.props;
     const {fineTuneType} = params;
     const {fineTuneType} = params;
 
 
-    if (['alerts', 'deploy', 'workflow', 'approval'].includes(fineTuneType)) {
+    if (['alerts', 'deploy', 'workflow', 'approval', 'quota'].includes(fineTuneType)) {
       return <NotificationSettingsByType notificationType={fineTuneType} />;
       return <NotificationSettingsByType notificationType={fineTuneType} />;
     }
     }
 
 

+ 2 - 0
static/app/views/settings/account/notifications/constants.tsx

@@ -4,6 +4,7 @@ export const ALL_PROVIDERS = {
   email: 'default',
   email: 'default',
   slack: 'never',
   slack: 'never',
 };
 };
+export const ALL_PROVIDER_NAMES = Object.keys(ALL_PROVIDERS);
 
 
 /**
 /**
  * These values are stolen from the DB.
  * These values are stolen from the DB.
@@ -30,6 +31,7 @@ export const NOTIFICATION_SETTINGS_TYPES = [
   'workflow',
   'workflow',
   'deploy',
   'deploy',
   'approval',
   'approval',
+  'quota',
   'reports',
   'reports',
   'email',
   'email',
 ];
 ];

+ 9 - 0
static/app/views/settings/account/notifications/fields.tsx

@@ -77,6 +77,15 @@ export const ACCOUNT_NOTIFICATION_FIELDS: Record<string, FineTuneField> = {
     // No choices here because it's going to have dynamic content
     // No choices here because it's going to have dynamic content
     // Component will create choices,
     // Component will create choices,
   },
   },
+  quota: {
+    title: t('Quota Notifications'),
+    description: t(
+      'Control the notifications you receive for error, transaction, and attachment quota limits.'
+    ),
+    type: 'select',
+    // No choices here because it's going to have dynamic content
+    // Component will create choices,
+  },
   email: {
   email: {
     title: t('Email Routing'),
     title: t('Email Routing'),
     description: t(
     description: t(

+ 69 - 15
static/app/views/settings/account/notifications/fields2.tsx

@@ -1,19 +1,9 @@
-import * as React from 'react';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {t, tct} from 'sentry/locale';
+import {getDocsLinkForEventType} from 'sentry/views/settings/account/notifications/utils';
+import {Field} from 'sentry/views/settings/components/forms/type';
 
 
-import {t} from 'sentry/locale';
-
-export type NotificationSettingField = {
-  name: string;
-  type: 'select' | 'blank' | 'boolean';
-  label: string;
-  choices?: string[][];
-  defaultValue?: string;
-  defaultFieldName?: string;
-  help?: string;
-  confirm?: {[key: string]: React.ReactNode | string};
-};
-
-export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingField> = {
+export const NOTIFICATION_SETTING_FIELDS: Record<string, Field> = {
   alerts: {
   alerts: {
     name: 'alerts',
     name: 'alerts',
     type: 'select',
     type: 'select',
@@ -66,6 +56,16 @@ export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingFiel
     ],
     ],
     help: t('Notifications from teammates that require review or approval.'),
     help: t('Notifications from teammates that require review or approval.'),
   },
   },
+  quota: {
+    name: 'quota',
+    type: 'select',
+    label: t('Quota'),
+    choices: [
+      ['always', t('On')],
+      ['never', t('Off')],
+    ],
+    help: t('Error, transaction, and attachment quota limits.'),
+  },
   reports: {
   reports: {
     name: 'weekly reports',
     name: 'weekly reports',
     type: 'blank',
     type: 'blank',
@@ -91,3 +91,57 @@ export const NOTIFICATION_SETTING_FIELDS: Record<string, NotificationSettingFiel
     help: t('You’ll receive notifications about any changes that happen afterwards.'),
     help: t('You’ll receive notifications about any changes that happen afterwards.'),
   },
   },
 };
 };
+
+// partial field definition for quota sub-categories
+export const QUOTA_FIELDS = [
+  {
+    name: 'quotaWarnings',
+    label: t('Set Quota Limit'),
+    help: t(
+      'Receive notifications when your organization exceeeds the following limits.'
+    ),
+    choices: [
+      ['always', t('100% and 80%')],
+      ['never', t('100%')],
+    ] as const,
+  },
+  {
+    name: 'quotaErrors',
+    label: t('Errors'),
+    help: tct('Receive notifications about your error quotas. [learnMore:Learn more]', {
+      learnMore: <ExternalLink href={getDocsLinkForEventType('error')} />,
+    }),
+    choices: [
+      ['always', t('On')],
+      ['never', t('Off')],
+    ] as const,
+  },
+  {
+    name: 'quotaTransactions',
+    label: t('Transactions'),
+    help: tct(
+      'Receive notifications about your transaction quota. [learnMore:Learn more]',
+      {
+        learnMore: <ExternalLink href={getDocsLinkForEventType('transaction')} />,
+      }
+    ),
+    choices: [
+      ['always', t('On')],
+      ['never', t('Off')],
+    ] as const,
+  },
+  {
+    name: 'quotaAttachments',
+    label: t('Attachments'),
+    help: tct(
+      'Receive notifications about your attachment quota. [learnMore:Learn more]',
+      {
+        learnMore: <ExternalLink href={getDocsLinkForEventType('attachment')} />,
+      }
+    ),
+    choices: [
+      ['always', t('On')],
+      ['never', t('Off')],
+    ] as const,
+  },
+];

+ 11 - 2
static/app/views/settings/account/notifications/notificationSettings.tsx

@@ -91,11 +91,20 @@ class NotificationSettings extends AsyncComponent<Props, State> {
     return updatedNotificationSettings;
     return updatedNotificationSettings;
   };
   };
 
 
+  get notificationSettingsType() {
+    const hasFeatureFlag =
+      this.props.organizations.filter(org =>
+        org.features?.includes('slack-overage-notifications')
+      ).length > 0;
+    // filter out quotas if the feature flag isn't set
+    return NOTIFICATION_SETTINGS_TYPES.filter(type => type !== 'quota' || hasFeatureFlag);
+  }
+
   getInitialData(): {[key: string]: string} {
   getInitialData(): {[key: string]: string} {
     const {notificationSettings} = this.state;
     const {notificationSettings} = this.state;
 
 
     return Object.fromEntries(
     return Object.fromEntries(
-      NOTIFICATION_SETTINGS_TYPES.map(notificationType => [
+      this.notificationSettingsType.map(notificationType => [
         notificationType,
         notificationType,
         decideDefault(notificationType, notificationSettings),
         decideDefault(notificationType, notificationSettings),
       ])
       ])
@@ -106,7 +115,7 @@ class NotificationSettings extends AsyncComponent<Props, State> {
     const {notificationSettings} = this.state;
     const {notificationSettings} = this.state;
 
 
     const fields: FieldObject[] = [];
     const fields: FieldObject[] = [];
-    for (const notificationType of NOTIFICATION_SETTINGS_TYPES) {
+    for (const notificationType of this.notificationSettingsType) {
       const field = Object.assign({}, NOTIFICATION_SETTING_FIELDS[notificationType], {
       const field = Object.assign({}, NOTIFICATION_SETTING_FIELDS[notificationType], {
         getData: data => this.getStateToPutForDefault(data, notificationType),
         getData: data => this.getStateToPutForDefault(data, notificationType),
         help: (
         help: (

+ 80 - 7
static/app/views/settings/account/notifications/notificationSettingsByType.tsx

@@ -7,13 +7,17 @@ import {OrganizationIntegration} from 'sentry/types/integrations';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import withOrganizations from 'sentry/utils/withOrganizations';
 import withOrganizations from 'sentry/utils/withOrganizations';
 import {
 import {
+  ALL_PROVIDER_NAMES,
   CONFIRMATION_MESSAGE,
   CONFIRMATION_MESSAGE,
   NotificationSettingsByProviderObject,
   NotificationSettingsByProviderObject,
   NotificationSettingsObject,
   NotificationSettingsObject,
 } from 'sentry/views/settings/account/notifications/constants';
 } from 'sentry/views/settings/account/notifications/constants';
 import FeedbackAlert from 'sentry/views/settings/account/notifications/feedbackAlert';
 import FeedbackAlert from 'sentry/views/settings/account/notifications/feedbackAlert';
 import {ACCOUNT_NOTIFICATION_FIELDS} from 'sentry/views/settings/account/notifications/fields';
 import {ACCOUNT_NOTIFICATION_FIELDS} from 'sentry/views/settings/account/notifications/fields';
-import {NOTIFICATION_SETTING_FIELDS} from 'sentry/views/settings/account/notifications/fields2';
+import {
+  NOTIFICATION_SETTING_FIELDS,
+  QUOTA_FIELDS,
+} from 'sentry/views/settings/account/notifications/fields2';
 import NotificationSettingsByOrganization from 'sentry/views/settings/account/notifications/notificationSettingsByOrganization';
 import NotificationSettingsByOrganization from 'sentry/views/settings/account/notifications/notificationSettingsByOrganization';
 import NotificationSettingsByProjects from 'sentry/views/settings/account/notifications/notificationSettingsByProjects';
 import NotificationSettingsByProjects from 'sentry/views/settings/account/notifications/notificationSettingsByProjects';
 import {Identity} from 'sentry/views/settings/account/notifications/types';
 import {Identity} from 'sentry/views/settings/account/notifications/types';
@@ -33,7 +37,7 @@ import {
 } from 'sentry/views/settings/account/notifications/utils';
 } from 'sentry/views/settings/account/notifications/utils';
 import Form from 'sentry/views/settings/components/forms/form';
 import Form from 'sentry/views/settings/components/forms/form';
 import JsonForm from 'sentry/views/settings/components/forms/jsonForm';
 import JsonForm from 'sentry/views/settings/components/forms/jsonForm';
-import {FieldObject} from 'sentry/views/settings/components/forms/type';
+import {Field} from 'sentry/views/settings/components/forms/type';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 
 
@@ -48,6 +52,19 @@ type State = {
   organizationIntegrations: OrganizationIntegration[];
   organizationIntegrations: OrganizationIntegration[];
 } & AsyncComponent['state'];
 } & AsyncComponent['state'];
 
 
+const typeMappedChildren = {
+  quota: ['quotaErrors', 'quotaTransactions', 'quotaAttachments', 'quotaWarnings'],
+};
+
+const getQueryParams = (notificationType: string) => {
+  // if we need multiple settings on this page
+  // then omit the type so we can load all settings
+  if (notificationType in typeMappedChildren) {
+    return null;
+  }
+  return {type: notificationType};
+};
+
 class NotificationSettingsByType extends AsyncComponent<Props, State> {
 class NotificationSettingsByType extends AsyncComponent<Props, State> {
   getDefaultState(): State {
   getDefaultState(): State {
     return {
     return {
@@ -64,7 +81,7 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
       [
       [
         'notificationSettings',
         'notificationSettings',
         `/users/me/notification-settings/`,
         `/users/me/notification-settings/`,
-        {query: {type: notificationType}},
+        {query: getQueryParams(notificationType)},
       ],
       ],
       ['identities', `/users/me/identities/`, {query: {provider: 'slack'}}],
       ['identities', `/users/me/identities/`, {query: {provider: 'slack'}}],
       [
       [
@@ -114,6 +131,39 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
     return updatedNotificationSettings;
     return updatedNotificationSettings;
   };
   };
 
 
+  getStateToPutForDependentSetting = (
+    changedData: NotificationSettingsByProviderObject,
+    notificationType: string
+  ) => {
+    const value = changedData[notificationType];
+    const {notificationSettings} = this.state;
+
+    // parent setting will control the which providers we send to
+    // just set every provider to the same value for the child/dependent setting
+    const userSettings = ALL_PROVIDER_NAMES.reduce((accum, provider) => {
+      accum[provider] = value;
+      return accum;
+    }, {});
+
+    // setting is a user-only setting
+    const updatedNotificationSettings = {
+      [notificationType]: {
+        user: {
+          me: userSettings,
+        },
+      },
+    };
+
+    this.setState({
+      notificationSettings: mergeNotificationSettings(
+        notificationSettings,
+        updatedNotificationSettings
+      ),
+    });
+
+    return updatedNotificationSettings;
+  };
+
   getStateToPutForDefault = (
   getStateToPutForDefault = (
     changedData: NotificationSettingsByProviderObject
     changedData: NotificationSettingsByProviderObject
   ): NotificationSettingsObject => {
   ): NotificationSettingsObject => {
@@ -174,10 +224,14 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
         getCurrentProviders(notificationType, notificationSettings)
         getCurrentProviders(notificationType, notificationSettings)
       );
       );
     }
     }
+    const childTypes: string[] = typeMappedChildren[notificationType] || [];
+    childTypes.forEach(childType => {
+      initialData[childType] = getCurrentDefault(childType, notificationSettings);
+    });
     return initialData;
     return initialData;
   }
   }
 
 
-  getFields(): FieldObject[] {
+  getFields(): Field[] {
     const {notificationType} = this.props;
     const {notificationType} = this.props;
     const {notificationSettings} = this.state;
     const {notificationSettings} = this.state;
 
 
@@ -185,7 +239,7 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
       ? t('This is the default for all projects.')
       ? t('This is the default for all projects.')
       : t('This is the default for all organizations.');
       : t('This is the default for all organizations.');
 
 
-    const defaultField = Object.assign(
+    const defaultField: Field = Object.assign(
       {},
       {},
       NOTIFICATION_SETTING_FIELDS[notificationType],
       NOTIFICATION_SETTING_FIELDS[notificationType],
       {
       {
@@ -197,7 +251,7 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
       defaultField.confirm = {never: CONFIRMATION_MESSAGE};
       defaultField.confirm = {never: CONFIRMATION_MESSAGE};
     }
     }
 
 
-    const fields = [defaultField];
+    const fields: Field[] = [defaultField];
     if (!isEverythingDisabled(notificationType, notificationSettings)) {
     if (!isEverythingDisabled(notificationType, notificationSettings)) {
       fields.push(
       fields.push(
         Object.assign(
         Object.assign(
@@ -209,7 +263,26 @@ class NotificationSettingsByType extends AsyncComponent<Props, State> {
         )
         )
       );
       );
     }
     }
-    return fields as FieldObject[];
+
+    // if a quota notification is not disabled, add in our dependent fields
+    if (
+      notificationType === 'quota' &&
+      !isEverythingDisabled(notificationType, notificationSettings)
+    ) {
+      fields.push(
+        ...QUOTA_FIELDS.map(field => ({
+          ...field,
+          type: 'select' as const,
+          getData: data =>
+            this.getStateToPutForDependentSetting(
+              data as NotificationSettingsByProviderObject,
+              field.name
+            ),
+        }))
+      );
+    }
+
+    return fields;
   }
   }
 
 
   getUnlinkedOrgs = (): OrganizationSummary[] => {
   getUnlinkedOrgs = (): OrganizationSummary[] => {

+ 27 - 8
static/app/views/settings/account/notifications/utils.tsx

@@ -285,7 +285,7 @@ export const getStateToPutForProvider = (
   notificationSettings: NotificationSettingsObject,
   notificationSettings: NotificationSettingsObject,
   changedData: NotificationSettingsByProviderObject
   changedData: NotificationSettingsByProviderObject
 ): NotificationSettingsObject => {
 ): NotificationSettingsObject => {
-  const providerList: string[] = changedData.provider.split('+');
+  const providerList: string[] = changedData.provider?.split('+') || [];
   const fallbackValue = getFallBackValue(notificationType);
   const fallbackValue = getFallBackValue(notificationType);
 
 
   // If the user has no settings, we need to create them.
   // If the user has no settings, we need to create them.
@@ -397,20 +397,39 @@ export const getParentField = (
 ): FieldObject => {
 ): FieldObject => {
   const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
   const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
 
 
-  return Object.assign({}, defaultFields, {
-    label: <ParentLabel parent={parent} notificationType={notificationType} />,
-    getData: data => onChange(data, parent.id),
-    name: parent.id,
-    choices: defaultFields.choices?.concat([
+  let choices = defaultFields.choices;
+  if (Array.isArray(choices)) {
+    choices = choices.concat([
       [
       [
         'default',
         'default',
         `${t('Default')} (${getChoiceString(
         `${t('Default')} (${getChoiceString(
-          defaultFields.choices,
+          choices,
           getCurrentDefault(notificationType, notificationSettings)
           getCurrentDefault(notificationType, notificationSettings)
         )})`,
         )})`,
       ],
       ],
-    ]),
+    ]);
+  }
+
+  return Object.assign({}, defaultFields, {
+    label: <ParentLabel parent={parent} notificationType={notificationType} />,
+    getData: data => onChange(data, parent.id),
+    name: parent.id,
+    choices,
     defaultValue: 'default',
     defaultValue: 'default',
     help: undefined,
     help: undefined,
   }) as any;
   }) as any;
 };
 };
+
+/**
+ * Returns a link to docs on explaining how to manage quotas for that event type
+ */
+export function getDocsLinkForEventType(event: 'error' | 'transaction' | 'attachment') {
+  switch (event) {
+    case 'transaction':
+      return 'https://docs.sentry.io/product/performance/transaction-summary/';
+    case 'attachment':
+      return 'https://docs.sentry.io/product/accounts/quotas/#attachment-limits';
+    default:
+      return 'https://docs.sentry.io/product/accounts/quotas/manage-event-stream-guide/#common-workflows-for-managing-your-event-stream';
+  }
+}

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

@@ -52,7 +52,7 @@ type BaseField = {
   updatesForm?: boolean;
   updatesForm?: boolean;
   /** Does editing this field need to clear all other fields? */
   /** Does editing this field need to clear all other fields? */
   resetsForm?: boolean;
   resetsForm?: boolean;
-  confirm?: {[key: string]: string};
+  confirm?: {[key: string]: React.ReactNode};
   autosize?: boolean;
   autosize?: boolean;
   maxRows?: number;
   maxRows?: number;
   extraHelp?: string;
   extraHelp?: string;

+ 23 - 3
tests/js/spec/views/settings/account/notifications/notificationSettings.spec.tsx

@@ -4,8 +4,11 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
 import {NotificationSettingsObject} from 'sentry/views/settings/account/notifications/constants';
 import {NotificationSettingsObject} from 'sentry/views/settings/account/notifications/constants';
 import NotificationSettings from 'sentry/views/settings/account/notifications/notificationSettings';
 import NotificationSettings from 'sentry/views/settings/account/notifications/notificationSettings';
 
 
-const createWrapper = (notificationSettings: NotificationSettingsObject) => {
-  const {routerContext} = initializeOrg();
+const createWrapper = (
+  notificationSettings: NotificationSettingsObject,
+  orgProps?: any
+) => {
+  const {routerContext, organization} = initializeOrg({organization: orgProps} as any);
   MockApiClient.addMockResponse({
   MockApiClient.addMockResponse({
     url: '/users/me/notification-settings/',
     url: '/users/me/notification-settings/',
     method: 'GET',
     method: 'GET',
@@ -22,7 +25,10 @@ const createWrapper = (notificationSettings: NotificationSettingsObject) => {
     },
     },
   });
   });
 
 
-  return mountWithTheme(<NotificationSettings />, routerContext);
+  return mountWithTheme(
+    <NotificationSettings organizations={[organization]} />,
+    routerContext
+  );
 };
 };
 
 
 describe('NotificationSettings', function () {
 describe('NotificationSettings', function () {
@@ -37,4 +43,18 @@ describe('NotificationSettings', function () {
     const fields = wrapper.find('Field');
     const fields = wrapper.find('Field');
     expect(fields).toHaveLength(8);
     expect(fields).toHaveLength(8);
   });
   });
+  it('renders quota section with feature flag', function () {
+    const wrapper = createWrapper(
+      {
+        alerts: {user: {me: {email: 'never', slack: 'never'}}},
+        deploy: {user: {me: {email: 'never', slack: 'never'}}},
+        workflow: {user: {me: {email: 'never', slack: 'never'}}},
+      },
+      {features: ['slack-overage-notifications']}
+    );
+
+    // There are 9 notification setting Selects/Toggles.
+    const fields = wrapper.find('Field');
+    expect(fields).toHaveLength(9);
+  });
 });
 });