Browse Source

fix how we determine when to show the slack link alert and remove unneeded code (#60185)

There's a bug where we show the alert for Slack being enabled for some
orgs but not others pretty much all the time:

<img width="1013" alt="Screenshot 2023-11-17 at 9 08 08 AM"
src="https://github.com/getsentry/sentry/assets/8533851/bf180e12-ad1b-4bd5-91ea-6081efbf853a">

This PR fixes that and also deletes some unused code we don't need
anymore (notifications v2 cleanup).
Stephen Cefali 1 year ago
parent
commit
35544959dc

+ 0 - 30
static/app/views/settings/account/notifications/notificationSettingsByOrganization.spec.tsx

@@ -1,30 +0,0 @@
-import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen} from 'sentry-test/reactTestingLibrary';
-
-import NotificationSettingsByOrganization from 'sentry/views/settings/account/notifications/notificationSettingsByOrganization';
-
-describe('NotificationSettingsByOrganization', function () {
-  it('should render', function () {
-    const settings = {
-      alerts: {
-        user: {me: {email: 'always', slack: 'always'}},
-        organization: {1: {email: 'always', slack: 'always'}},
-      },
-    };
-
-    const {organization, routerContext} = initializeOrg();
-
-    render(
-      <NotificationSettingsByOrganization
-        notificationType="alerts"
-        notificationSettings={settings}
-        organizations={[organization]}
-        onChange={jest.fn()}
-        onSubmitSuccess={jest.fn()}
-      />,
-      {context: routerContext}
-    );
-
-    expect(screen.getByRole('textbox', {name: 'org-slug'})).toBeInTheDocument();
-  });
-});

+ 0 - 59
static/app/views/settings/account/notifications/notificationSettingsByOrganization.tsx

@@ -1,59 +0,0 @@
-import Form from 'sentry/components/forms/form';
-import Panel from 'sentry/components/panels/panel';
-import {t} from 'sentry/locale';
-import {OrganizationSummary} from 'sentry/types';
-import withOrganizations from 'sentry/utils/withOrganizations';
-import {
-  NotificationSettingsByProviderObject,
-  NotificationSettingsObject,
-} from 'sentry/views/settings/account/notifications/constants';
-import {StyledJsonForm} from 'sentry/views/settings/account/notifications/notificationSettingsByProjects';
-import {
-  getParentData,
-  getParentField,
-} from 'sentry/views/settings/account/notifications/utils';
-
-type Props = {
-  notificationSettings: NotificationSettingsObject;
-  notificationType: string;
-  onChange: (
-    changedData: NotificationSettingsByProviderObject,
-    parentId: string
-  ) => NotificationSettingsObject;
-  onSubmitSuccess: () => void;
-  organizations: OrganizationSummary[];
-};
-
-function NotificationSettingsByOrganization({
-  notificationType,
-  notificationSettings,
-  onChange,
-  onSubmitSuccess,
-  organizations,
-}: Props) {
-  return (
-    <Panel>
-      <Form
-        saveOnBlur
-        apiMethod="PUT"
-        apiEndpoint="/users/me/notification-settings/"
-        initialData={getParentData(notificationType, notificationSettings, organizations)}
-        onSubmitSuccess={onSubmitSuccess}
-      >
-        <StyledJsonForm
-          title={t('Organizations')}
-          fields={organizations.map(organization => {
-            return getParentField(
-              notificationType,
-              notificationSettings,
-              organization,
-              onChange
-            );
-          })}
-        />
-      </Form>
-    </Panel>
-  );
-}
-
-export default withOrganizations(NotificationSettingsByOrganization);

+ 0 - 93
static/app/views/settings/account/notifications/notificationSettingsByProjects.spec.tsx

@@ -1,93 +0,0 @@
-import {Organization} from 'sentry-fixture/organization';
-
-import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen} from 'sentry-test/reactTestingLibrary';
-
-import ConfigStore from 'sentry/stores/configStore';
-import {Project} from 'sentry/types';
-import NotificationSettingsByProjects from 'sentry/views/settings/account/notifications/notificationSettingsByProjects';
-
-const renderComponent = (projects: Project[]) => {
-  const {organization} = initializeOrg();
-
-  MockApiClient.addMockResponse({
-    url: `/projects/`,
-    method: 'GET',
-    body: projects,
-  });
-
-  const notificationSettings = {
-    alerts: {
-      user: {me: {email: 'always', slack: 'always'}},
-      project: Object.fromEntries(
-        projects.map(project => [project.id, {email: 'never', slack: 'never'}])
-      ),
-    },
-  };
-
-  render(
-    <NotificationSettingsByProjects
-      notificationType="alerts"
-      notificationSettings={notificationSettings}
-      onChange={jest.fn()}
-      onSubmitSuccess={jest.fn()}
-      organizations={[organization]}
-    />
-  );
-};
-
-describe('NotificationSettingsByProjects', function () {
-  afterEach(() => {
-    MockApiClient.clearMockResponses();
-    jest.clearAllMocks();
-  });
-
-  it('should render when there are no projects', function () {
-    renderComponent([]);
-    expect(screen.getByTestId('empty-message')).toHaveTextContent('No projects found');
-    expect(screen.queryByPlaceholderText('Search Projects')).not.toBeInTheDocument();
-  });
-
-  it('should show search bar when there are enough projects', function () {
-    const organization = Organization();
-    const projects = [...Array(3).keys()].map(id =>
-      TestStubs.Project({organization, id})
-    );
-
-    renderComponent(projects);
-    expect(screen.getByPlaceholderText('Search Projects')).toBeInTheDocument();
-  });
-
-  it('should default to the subdomain org', async function () {
-    const organization = Organization();
-    const otherOrganization = Organization({
-      id: '2',
-      slug: 'other-org',
-      name: 'other org',
-    });
-    ConfigStore.set('customerDomain', {
-      ...ConfigStore.get('customerDomain')!,
-      subdomain: otherOrganization.slug,
-    });
-    const projectsMock = MockApiClient.addMockResponse({
-      url: '/projects/',
-      query: {
-        organizationId: otherOrganization.id,
-      },
-      method: 'GET',
-      body: [],
-    });
-
-    render(
-      <NotificationSettingsByProjects
-        notificationType="alerts"
-        notificationSettings={{}}
-        onChange={jest.fn()}
-        onSubmitSuccess={jest.fn()}
-        organizations={[organization, otherOrganization]}
-      />
-    );
-    expect(await screen.findByText(otherOrganization.name)).toBeInTheDocument();
-    expect(projectsMock).toHaveBeenCalledTimes(1);
-  });
-});

+ 0 - 202
static/app/views/settings/account/notifications/notificationSettingsByProjects.tsx

@@ -1,202 +0,0 @@
-import {Fragment} from 'react';
-import type {WithRouterProps} from 'react-router';
-import styled from '@emotion/styled';
-
-import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
-import EmptyMessage from 'sentry/components/emptyMessage';
-import Form from 'sentry/components/forms/form';
-import JsonForm from 'sentry/components/forms/jsonForm';
-import Pagination from 'sentry/components/pagination';
-import Panel from 'sentry/components/panels/panel';
-import PanelBody from 'sentry/components/panels/panelBody';
-import PanelHeader from 'sentry/components/panels/panelHeader';
-import {t} from 'sentry/locale';
-import ConfigStore from 'sentry/stores/configStore';
-import {space} from 'sentry/styles/space';
-import {Organization, Project} from 'sentry/types';
-import {sortProjects} from 'sentry/utils';
-import parseLinkHeader from 'sentry/utils/parseLinkHeader';
-import withSentryRouter from 'sentry/utils/withSentryRouter';
-import {
-  MIN_PROJECTS_FOR_SEARCH,
-  NotificationSettingsByProviderObject,
-  NotificationSettingsObject,
-} from 'sentry/views/settings/account/notifications/constants';
-import {OrganizationSelectHeader} from 'sentry/views/settings/account/notifications/organizationSelectHeader';
-import {
-  getParentData,
-  getParentField,
-  groupByOrganization,
-} from 'sentry/views/settings/account/notifications/utils';
-import {RenderSearch} from 'sentry/views/settings/components/defaultSearchBar';
-
-export type NotificationSettingsByProjectsBaseProps = {
-  notificationSettings: NotificationSettingsObject;
-  notificationType: string;
-  onChange: (
-    changedData: NotificationSettingsByProviderObject,
-    parentId: string
-  ) => NotificationSettingsObject;
-  onSubmitSuccess: () => void;
-};
-
-type Props = {
-  organizations: Organization[];
-} & NotificationSettingsByProjectsBaseProps &
-  DeprecatedAsyncComponent['props'] &
-  WithRouterProps;
-
-type State = {
-  projects: Project[];
-} & DeprecatedAsyncComponent['state'];
-
-class NotificationSettingsByProjects extends DeprecatedAsyncComponent<Props, State> {
-  getDefaultState(): State {
-    return {
-      ...super.getDefaultState(),
-      projects: [],
-    };
-  }
-
-  getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
-    const organizationId = this.getOrganizationId();
-    return [
-      [
-        'projects',
-        `/projects/`,
-        {
-          query: {
-            organizationId,
-            cursor: this.props.location.query.cursor,
-          },
-        },
-      ],
-    ];
-  }
-
-  getOrganizationId(): string | undefined {
-    const {location, organizations} = this.props;
-    const customerDomain = ConfigStore.get('customerDomain');
-    const orgFromSubdomain = organizations.find(
-      ({slug}) => slug === customerDomain?.subdomain
-    )?.id;
-    return location?.query?.organizationId ?? orgFromSubdomain ?? organizations[0]?.id;
-  }
-
-  /**
-   * Check the notification settings for how many projects there are.
-   */
-  getProjectCount = (): number => {
-    const {notificationType, notificationSettings} = this.props;
-
-    return Object.values(notificationSettings[notificationType]?.project || {}).length;
-  };
-
-  /**
-   * The UI expects projects to be grouped by organization but can also use
-   * this function to make a single group with all organizations.
-   */
-  getGroupedProjects = (): {[key: string]: Project[]} => {
-    const {projects: stateProjects} = this.state;
-
-    return Object.fromEntries(
-      Object.values(groupByOrganization(sortProjects(stateProjects))).map(
-        ({organization, projects}) => [`${organization.name} Projects`, projects]
-      )
-    );
-  };
-
-  handleOrgChange = (organizationId: string) => {
-    this.props.router.replace({
-      ...this.props.location,
-      query: {organizationId},
-    });
-  };
-
-  renderBody() {
-    const {notificationType, notificationSettings, onChange, onSubmitSuccess} =
-      this.props;
-    const {projects, projectsPageLinks} = this.state;
-
-    const canSearch = this.getProjectCount() >= MIN_PROJECTS_FOR_SEARCH;
-    const paginationObject = parseLinkHeader(projectsPageLinks ?? '');
-    const hasMore = paginationObject?.next?.results;
-    const hasPrevious = paginationObject?.previous?.results;
-
-    const renderSearch: RenderSearch = ({defaultSearchBar}) => defaultSearchBar;
-    const orgId = this.getOrganizationId();
-    return (
-      <Fragment>
-        <Panel>
-          <StyledPanelHeader>
-            <OrganizationSelectHeader
-              organizations={this.props.organizations}
-              organizationId={orgId}
-              handleOrgChange={this.handleOrgChange}
-            />
-            {canSearch &&
-              this.renderSearchInput({
-                stateKey: 'projects',
-                url: `/projects/?organizationId=${orgId}`,
-                placeholder: t('Search Projects'),
-                children: renderSearch,
-              })}
-          </StyledPanelHeader>
-          <PanelBody>
-            <Form
-              saveOnBlur
-              apiMethod="PUT"
-              apiEndpoint="/users/me/notification-settings/"
-              initialData={getParentData(
-                notificationType,
-                notificationSettings,
-                projects
-              )}
-              onSubmitSuccess={onSubmitSuccess}
-            >
-              {projects.length === 0 ? (
-                <EmptyMessage>{t('No projects found')}</EmptyMessage>
-              ) : (
-                Object.entries(this.getGroupedProjects()).map(([groupTitle, parents]) => (
-                  <StyledJsonForm
-                    collapsible
-                    key={groupTitle}
-                    // title={groupTitle}
-                    fields={parents.map(parent =>
-                      getParentField(
-                        notificationType,
-                        notificationSettings,
-                        parent,
-                        onChange
-                      )
-                    )}
-                  />
-                ))
-              )}
-            </Form>
-          </PanelBody>
-        </Panel>
-        {canSearch && (hasMore || hasPrevious) && (
-          <Pagination pageLinks={projectsPageLinks} />
-        )}
-      </Fragment>
-    );
-  }
-}
-
-export default withSentryRouter(NotificationSettingsByProjects);
-
-const StyledPanelHeader = styled(PanelHeader)`
-  flex-wrap: wrap;
-  gap: ${space(1)};
-  & > form:last-child {
-    flex-grow: 1;
-  }
-`;
-
-export const StyledJsonForm = styled(JsonForm)`
-  ${Panel} {
-    border: 0;
-    margin-bottom: 0;
-  }
-`;

+ 53 - 32
static/app/views/settings/account/notifications/notificationSettingsByType.tsx

@@ -1,10 +1,12 @@
 import {Fragment} from 'react';
 import styled from '@emotion/styled';
+import {Observer} from 'mobx-react';
 
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import Form from 'sentry/components/forms/form';
 import JsonForm from 'sentry/components/forms/jsonForm';
+import FormModel from 'sentry/components/forms/model';
 import {Field} from 'sentry/components/forms/types';
 import Panel from 'sentry/components/panels/panel';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
@@ -21,6 +23,7 @@ import {
   DefaultSettings,
   NotificationOptionsObject,
   NotificationProvidersObject,
+  SupportedProviders,
 } from './constants';
 import {ACCOUNT_NOTIFICATION_FIELDS} from './fields';
 import {NOTIFICATION_SETTING_FIELDS_V2, QUOTA_FIELDS} from './fields2';
@@ -63,7 +66,7 @@ const getQueryParams = (notificationType: string) => {
 };
 
 class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State> {
-  // topLevelOptionFormModel = new TopLevelOptionFormModel(this.props.notificationType);
+  providerModel = new FormModel();
 
   getDefaultState(): State {
     return {
@@ -150,21 +153,30 @@ class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State
     };
   }
 
-  getProviderInitialData(): {[key: string]: string[]} {
+  isProviderSupported = (provider: SupportedProviders) => {
+    // email is always possible
+    if (provider === 'email') {
+      return true;
+    }
+    return this.getLinkedOrgs(provider).length > 0;
+  };
+
+  getProviders(): SupportedProviders[] {
     const {notificationType} = this.props;
     const {notificationProviders, defaultSettings} = this.state;
 
     const relevantProviderSettings = notificationProviders.filter(
       option => option.scopeType === 'user' && option.type === notificationType
     );
-    // user has no settings saved so use default
-    if (relevantProviderSettings.length === 0 && defaultSettings) {
-      return {provider: defaultSettings.providerDefaults};
-    }
-    const providers = relevantProviderSettings
-      .filter(option => option.value === 'always')
-      .map(option => option.provider);
-    return {provider: providers};
+
+    const providers =
+      // if user has no settings saved so use default
+      relevantProviderSettings.length === 0 && defaultSettings
+        ? defaultSettings?.providerDefaults
+        : relevantProviderSettings
+            .filter(option => option.value === 'always')
+            .map(option => option.provider);
+    return providers.filter(this.isProviderSupported);
   }
 
   getFields(): Field[] {
@@ -217,18 +229,10 @@ class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State
 
   getProviderFields(): Field[] {
     const {notificationType} = this.props;
-    const {organizationIntegrations} = this.state;
     // get the choices but only the ones that are available to the user
     const choices = (
-      NOTIFICATION_SETTING_FIELDS_V2.provider.choices as [string, string][]
-    ).filter(([providerSlug]) => {
-      if (providerSlug === 'email') {
-        return true;
-      }
-      return organizationIntegrations.some(
-        organizationIntegration => organizationIntegration.provider.slug === providerSlug
-      );
-    });
+      NOTIFICATION_SETTING_FIELDS_V2.provider.choices as [SupportedProviders, string][]
+    ).filter(([providerSlug]) => this.isProviderSupported(providerSlug));
 
     const defaultField = Object.assign({}, NOTIFICATION_SETTING_FIELDS_V2.provider, {
       choices,
@@ -246,14 +250,18 @@ class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State
     return fields;
   }
 
-  getUnlinkedOrgs = (): OrganizationSummary[] => {
+  getLinkedOrgs = (provider: SupportedProviders): OrganizationSummary[] => {
     const {organizations} = this.props;
     const {identities, organizationIntegrations} = this.state;
     const integrationExternalIDsByOrganizationID = Object.fromEntries(
-      organizationIntegrations.map(organizationIntegration => [
-        organizationIntegration.organizationId,
-        organizationIntegration.externalId,
-      ])
+      organizationIntegrations
+        .filter(
+          organizationIntegration => organizationIntegration.provider.key === provider
+        )
+        .map(organizationIntegration => [
+          organizationIntegration.organizationId,
+          organizationIntegration.externalId,
+        ])
     );
 
     const identitiesByExternalId = Object.fromEntries(
@@ -263,10 +271,16 @@ class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State
     return organizations.filter(organization => {
       const externalID = integrationExternalIDsByOrganizationID[organization.id];
       const identity = identitiesByExternalId[externalID];
-      return identity === undefined || identity === null;
+      return !!identity;
     });
   };
 
+  getUnlinkedOrgs = (provider: SupportedProviders): OrganizationSummary[] => {
+    const linkedOrgs = this.getLinkedOrgs(provider);
+    const {organizations} = this.props;
+    return organizations.filter(organization => !linkedOrgs.includes(organization));
+  };
+
   handleRemoveNotificationOption = async (id: string) => {
     await this.api.requestPromise(`/users/me/notification-options/${id}/`, {
       method: 'DELETE',
@@ -327,8 +341,7 @@ class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State
   renderBody() {
     const {notificationType} = this.props;
     const {notificationOptions} = this.state;
-    const hasSlack = true;
-    const unlinkedOrgs = this.getUnlinkedOrgs();
+    const unlinkedSlackOrgs = this.getUnlinkedOrgs('slack');
     const {title, description} = ACCOUNT_NOTIFICATION_FIELDS[notificationType];
     const entityType = isGroupedByProject(notificationType) ? 'project' : 'organization';
     return (
@@ -336,9 +349,16 @@ class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State
         <SentryDocumentTitle title={title} />
         <SettingsPageHeader title={title} />
         {description && <TextBlock>{description}</TextBlock>}
-        {hasSlack && unlinkedOrgs.length > 0 && (
-          <UnlinkedAlert organizations={unlinkedOrgs} />
-        )}
+        <Observer>
+          {() => {
+            return this.providerModel
+              .getValue('provider')
+              ?.toString()
+              .includes('slack') && unlinkedSlackOrgs.length > 0 ? (
+              <UnlinkedAlert organizations={unlinkedSlackOrgs} />
+            ) : null;
+          }}
+        </Observer>
         <Form
           saveOnBlur
           apiMethod="PUT"
@@ -360,7 +380,8 @@ class NotificationSettingsByTypeV2 extends DeprecatedAsyncComponent<Props, State
             saveOnBlur
             apiMethod="PUT"
             apiEndpoint="/users/me/notification-providers/"
-            initialData={this.getProviderInitialData()}
+            initialData={{provider: this.getProviders()}}
+            model={this.providerModel}
           >
             <BottomJsonForm fields={this.getProviderFields()} />
           </Form>

+ 0 - 212
static/app/views/settings/account/notifications/utils.spec.tsx

@@ -1,212 +0,0 @@
-import {
-  backfillMissingProvidersWithFallback,
-  decideDefault,
-  getUserDefaultValues,
-} from 'sentry/views/settings/account/notifications/utils';
-
-describe('Notification Settings Utils', () => {
-  describe('getUserDefaultValues', () => {
-    describe('when notificationsSettings are empty', () => {
-      it('should return fallback values', () => {
-        expect(getUserDefaultValues('deploy', {})).toEqual({
-          email: 'committed_only',
-          slack: 'never',
-          msteams: 'never',
-        });
-      });
-    });
-    describe('when notificationsSettings are not empty', () => {
-      it('should return the parent-independent notificationSettings', () => {
-        expect(
-          getUserDefaultValues('alerts', {
-            alerts: {
-              user: {
-                me: {
-                  email: 'never',
-                  slack: 'never',
-                },
-              },
-            },
-          })
-        ).toEqual({
-          email: 'never',
-          slack: 'never',
-        });
-      });
-    });
-  });
-
-  describe('decideDefault', () => {
-    describe('when there are no parent-specific settings', () => {
-      describe('when the parent-independent settings match', () => {
-        it('should return always when the settings are always', () => {
-          expect(
-            decideDefault('alerts', {
-              alerts: {
-                user: {
-                  me: {
-                    email: 'always',
-                    slack: 'always',
-                  },
-                },
-              },
-            })
-          ).toEqual('always');
-        });
-
-        it('should return never when the settings are never', () => {
-          expect(
-            decideDefault('alerts', {
-              alerts: {
-                user: {
-                  me: {
-                    email: 'never',
-                    slack: 'never',
-                  },
-                },
-              },
-            })
-          ).toEqual('never');
-        });
-      });
-      describe('when the parent-independent settings do not match', () => {
-        it('should return the always when the other value is never', () => {
-          expect(
-            decideDefault('alerts', {
-              alerts: {
-                user: {
-                  me: {
-                    email: 'always',
-                    slack: 'never',
-                  },
-                },
-              },
-            })
-          ).toEqual('always');
-        });
-      });
-    });
-
-    describe('when there are parent-specific settings', () => {
-      describe('when the parent-specific settings are "below" the parent-independent settings', () => {
-        it('should "prioritize" always over never', () => {
-          expect(
-            decideDefault('alerts', {
-              alerts: {
-                user: {
-                  me: {
-                    email: 'never',
-                    slack: 'never',
-                  },
-                },
-                project: {
-                  1: {
-                    email: 'always',
-                    slack: 'always',
-                  },
-                },
-              },
-            })
-          ).toEqual('always');
-        });
-        it('should "prioritize" sometimes over always', () => {
-          expect(
-            decideDefault('alerts', {
-              alerts: {
-                user: {
-                  me: {
-                    email: 'never',
-                    slack: 'never',
-                  },
-                },
-                project: {
-                  1: {
-                    email: 'subscribe_only',
-                    slack: 'subscribe_only',
-                  },
-                },
-              },
-            })
-          ).toEqual('subscribe_only');
-        });
-      });
-      describe('when the parent-specific settings are "above" the parent-independent settings', () => {
-        it('should return the parent-specific settings', () => {
-          expect(
-            decideDefault('alerts', {
-              alerts: {
-                user: {
-                  me: {
-                    email: 'always',
-                    slack: 'always',
-                  },
-                },
-                project: {
-                  1: {
-                    email: 'never',
-                    slack: 'never',
-                  },
-                },
-              },
-            })
-          ).toEqual('always');
-        });
-      });
-    });
-  });
-
-  describe('backfillMissingProvidersWithFallback', () => {
-    describe('when scopeType is user', () => {
-      it('should add missing provider with the fallback value', () => {
-        expect(
-          backfillMissingProvidersWithFallback({}, ['email'], 'sometimes', 'user')
-        ).toEqual({email: 'sometimes', slack: 'never', msteams: 'never'});
-      });
-
-      it('should turn on all providers with the fallback value', () => {
-        expect(
-          backfillMissingProvidersWithFallback(
-            {email: 'never', slack: 'never', msteams: 'never'},
-            ['email', 'slack', 'msteams'],
-            'sometimes',
-            'user'
-          )
-        ).toEqual({email: 'sometimes', slack: 'sometimes', msteams: 'sometimes'});
-      });
-
-      it('should move the existing setting when providers are swapped', () => {
-        expect(
-          backfillMissingProvidersWithFallback(
-            {email: 'always', slack: 'never', msteams: 'never'},
-            ['slack', 'msteams'],
-            '',
-            'user'
-          )
-        ).toEqual({email: 'never', slack: 'always', msteams: 'always'});
-      });
-
-      it('should turn off all providers when providers is empty', () => {
-        expect(
-          backfillMissingProvidersWithFallback(
-            {email: 'always', slack: 'always', msteams: 'always'},
-            [],
-            '',
-            'user'
-          )
-        ).toEqual({email: 'never', slack: 'never', msteams: 'never'});
-      });
-    });
-    describe('when scopeType is organization', () => {
-      it('should retain OFF organization scope preference when provider list changes', () => {
-        expect(
-          backfillMissingProvidersWithFallback(
-            {email: 'never', slack: 'never', msteams: 'never'},
-            ['slack'],
-            'sometimes',
-            'organization'
-          )
-        ).toEqual({email: 'never', slack: 'never', msteams: 'never'});
-      });
-    });
-  });
-});

+ 1 - 406
static/app/views/settings/account/notifications/utils.tsx

@@ -1,18 +1,5 @@
-import set from 'lodash/set';
-
-import {FieldObject} from 'sentry/components/forms/types';
-import {t} from 'sentry/locale';
 import {OrganizationSummary, Project} from 'sentry/types';
-import {
-  ALL_PROVIDERS,
-  MIN_PROJECTS_FOR_CONFIRMATION,
-  NOTIFICATION_SETTINGS_PATHNAMES,
-  NotificationSettingsByProviderObject,
-  NotificationSettingsObject,
-  VALUE_MAPPING,
-} from 'sentry/views/settings/account/notifications/constants';
-import {NOTIFICATION_SETTING_FIELDS} from 'sentry/views/settings/account/notifications/fields2';
-import ParentLabel from 'sentry/views/settings/account/notifications/parentLabel';
+import {NOTIFICATION_SETTINGS_PATHNAMES} from 'sentry/views/settings/account/notifications/constants';
 
 /**
  * Which fine-tuning parts are grouped by project
@@ -45,398 +32,6 @@ export const groupByOrganization = (
   }, {});
 };
 
-export const getFallBackValue = (notificationType: string): string => {
-  switch (notificationType) {
-    case 'alerts':
-      return 'always';
-    case 'deploy':
-      return 'committed_only';
-    case 'workflow':
-      return 'subscribe_only';
-    case 'approval':
-      return 'always';
-    case 'quota':
-      return 'always';
-    case 'spikeProtection':
-      return 'always';
-    case 'reports':
-      return 'always';
-    default:
-      // These are the expected potential settings with fallback of ''
-      // issue, quotaErrors, quotaTransactions, quotaAttachments,
-      // quotaReplays, quotaWarnings, quotaSpendAllocations
-      return '';
-  }
-};
-
-export const providerListToString = (providers: string[]): string => {
-  return providers.sort().join('+');
-};
-
-export const getChoiceString = (choices: string[][], key: string): string => {
-  if (!choices) {
-    return 'default';
-  }
-  const found = choices.find(row => row[0] === key);
-  if (!found) {
-    throw new Error(`Could not find ${key}`);
-  }
-
-  return found[1];
-};
-
-const isDataAllNever = (data: {[key: string]: string}): boolean =>
-  !!Object.keys(data).length && Object.values(data).every(value => value === 'never');
-
-const getNonNeverValue = (data: {[key: string]: string}): string | null =>
-  Object.values(data).reduce(
-    (previousValue: string | null, currentValue) =>
-      currentValue === 'never' ? previousValue : currentValue,
-    null
-  );
-
-/**
- * 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.
- */
-export const backfillMissingProvidersWithFallback = (
-  data: {[key: string]: string},
-  providerList: string[],
-  fallbackValue: string,
-  scopeType: string
-): NotificationSettingsByProviderObject => {
-  // First pass: What was this scope's previous value?
-  let existingValue;
-  if (scopeType === 'user') {
-    existingValue = isDataAllNever(data)
-      ? fallbackValue
-      : getNonNeverValue(data) || fallbackValue;
-  } else {
-    existingValue = isDataAllNever(data) ? 'never' : getNonNeverValue(data) || 'default';
-  }
-
-  // Second pass: Fill in values for every provider.
-  return Object.fromEntries(
-    Object.keys(ALL_PROVIDERS).map(provider => [
-      provider,
-      providerList.includes(provider) ? existingValue : 'never',
-    ])
-  );
-};
-
-/**
- * Deeply merge N notification settings objects (usually just 2).
- */
-export const mergeNotificationSettings = (
-  ...objects: NotificationSettingsObject[]
-): NotificationSettingsObject => {
-  const output: NotificationSettingsObject = {};
-
-  objects.forEach(settingsByType =>
-    Object.entries(settingsByType).forEach(([type, settingsByScopeType]) =>
-      Object.entries(settingsByScopeType).forEach(([scopeType, settingsByScopeId]) =>
-        Object.entries(settingsByScopeId).forEach(([scopeId, settingsByProvider]) => {
-          set(output, [type, scopeType, scopeId].join('.'), settingsByProvider);
-        })
-      )
-    )
-  );
-
-  return output;
-};
-
-/**
- * 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.
- */
-export const getUserDefaultValues = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject
-): NotificationSettingsByProviderObject => {
-  return (
-    Object.values(notificationSettings[notificationType]?.user || {}).pop() ||
-    Object.fromEntries(
-      Object.entries(ALL_PROVIDERS).map(([provider, value]) => [
-        provider,
-        value === 'default' ? getFallBackValue(notificationType) : value,
-      ])
-    )
-  );
-};
-
-/**
- * Get the list of providers currently active on this page. Note: this can be empty.
- */
-export const getCurrentProviders = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject
-): string[] => {
-  const userData = getUserDefaultValues(notificationType, notificationSettings);
-
-  return Object.entries(userData)
-    .filter(([_, value]) => !['never'].includes(value))
-    .map(([provider, _]) => provider);
-};
-
-/**
- * Calculate the currently selected provider.
- */
-export const getCurrentDefault = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject
-): string => {
-  const providersList = getCurrentProviders(notificationType, notificationSettings);
-  return providersList.length
-    ? getUserDefaultValues(notificationType, notificationSettings)[providersList[0]]
-    : 'never';
-};
-
-/**
- * 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.
- */
-export const decideDefault = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject
-): string => {
-  const compare = (a: string, b: string): number => VALUE_MAPPING[a] - VALUE_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;
-};
-
-/**
- * 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.
- */
-export const isEverythingDisabled = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject
-): boolean =>
-  ['never', 'default'].includes(decideDefault(notificationType, notificationSettings));
-
-/**
- * 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.
- */
-export const getParentIds = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject
-): string[] =>
-  Object.keys(
-    notificationSettings[notificationType]?.[getParentKey(notificationType)] || {}
-  );
-
-export const getParentValues = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject,
-  parentId: string
-): NotificationSettingsByProviderObject =>
-  notificationSettings[notificationType]?.[getParentKey(notificationType)]?.[
-    parentId
-  ] || {
-    email: 'default',
-  };
-
-/**
- * Get a mapping of all parent IDs to the notification setting for the current
- * providers.
- */
-export const getParentData = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject,
-  parents: OrganizationSummary[] | Project[]
-): NotificationSettingsByProviderObject => {
-  const provider = getCurrentProviders(notificationType, notificationSettings)[0];
-
-  return Object.fromEntries(
-    parents.map(parent => [
-      parent.id,
-      getParentValues(notificationType, notificationSettings, parent.id)[provider],
-    ])
-  );
-};
-
-/**
- * Are there are more than N project or organization settings?
- */
-export const isSufficientlyComplex = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject
-): boolean =>
-  getParentIds(notificationType, notificationSettings).length >
-  MIN_PROJECTS_FOR_CONFIRMATION;
-
-/**
- * This is triggered when we change the Delivery Method select. Don't update the
- * provider for EVERY one of the user's projects and organizations, just the user
- * and parents that have explicit settings.
- */
-export const getStateToPutForProvider = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject,
-  changedData: NotificationSettingsByProviderObject
-): NotificationSettingsObject => {
-  const providerList: string[] = changedData.provider
-    ? Object.values(changedData.provider)
-    : [];
-  const fallbackValue = getFallBackValue(notificationType);
-
-  // If the user has no settings, we need to create them.
-  if (!Object.keys(notificationSettings).length) {
-    return {
-      [notificationType]: {
-        user: {
-          me: Object.fromEntries(providerList.map(provider => [provider, fallbackValue])),
-        },
-      },
-    };
-  }
-
-  return {
-    [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
-              ),
-            ])
-          ),
-        ]
-      )
-    ),
-  };
-};
-
-/**
- * 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.
- */
-export const getStateToPutForDefault = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject,
-  changedData: NotificationSettingsByProviderObject,
-  parentIds: string[]
-): NotificationSettingsObject => {
-  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;
-};
-
-/**
- * Get the diff of the Notification Settings for this parent ID.
- */
-export const getStateToPutForParent = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject,
-  changedData: NotificationSettingsByProviderObject,
-  parentId: string
-): NotificationSettingsObject => {
-  const providerList = getCurrentProviders(notificationType, notificationSettings);
-  const newValue = Object.values(changedData)[0];
-
-  return {
-    [notificationType]: {
-      [getParentKey(notificationType)]: {
-        [parentId]: Object.fromEntries(
-          providerList.map(provider => [provider, newValue])
-        ),
-      },
-    },
-  };
-};
-
-/**
- * Render each parent and add a default option to the the field choices.
- */
-export const getParentField = (
-  notificationType: string,
-  notificationSettings: NotificationSettingsObject,
-  parent: OrganizationSummary | Project,
-  onChange: (
-    changedData: NotificationSettingsByProviderObject,
-    parentId: string
-  ) => NotificationSettingsObject
-): FieldObject => {
-  const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
-
-  let choices = defaultFields.choices;
-  if (Array.isArray(choices)) {
-    choices = choices.concat([
-      [
-        'default',
-        `${t('Default')} (${getChoiceString(
-          choices,
-          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',
-    help: undefined,
-  }) as any;
-};
-
 /**
  * Returns a link to docs on explaining how to manage quotas for that event type
  */