Browse Source

feat(notifications): adds notification fine tuning page (#54962)

This PR adds the new notification settings fine tuning page to read from
the new models. The main functional change is that the org/project
selectors at the bottom are now pure overrides. There's no way to set a
default value for them, to do that you simply delete the row in the UI.
If the default is off, we still render the overrides so a user can
explicitly enable a notification for a particular org/project.

Before:
![Screen Shot 2023-08-18 at 9 49 42
AM](https://github.com/getsentry/sentry/assets/8533851/bf2e5007-cdc3-44c1-859e-f9c4d8a4f4d8)

After:
![Screen Shot 2023-08-18 at 9 50 29
AM](https://github.com/getsentry/sentry/assets/8533851/78cc4814-ebad-4859-b26d-cfdf39d32e54)
Stephen Cefali 1 year ago
parent
commit
9f39c30e72

+ 17 - 0
fixtures/js-stubs/notificationDefaults.tsx

@@ -0,0 +1,17 @@
+export const NotificationDefaults = () => ({
+  providerDefaults: ['email', 'slack'],
+  typeDefaults: {
+    alerts: 'always',
+    approval: 'always',
+    deploy: 'committed_only',
+    quota: 'always',
+    quotaAttachments: 'always',
+    quotaErrors: 'always',
+    quotaReplays: 'always',
+    quotaSpendAllocations: 'always',
+    quotaTransactions: 'always',
+    quotaWarnings: 'always',
+    spikeProtection: 'always',
+    workflow: 'subscribe_only',
+  },
+});

+ 1 - 0
fixtures/js-stubs/types.tsx

@@ -99,6 +99,7 @@ type TestStubFixtures = {
   MetricsSessionUserCountByStatusByRelease: SimpleStub;
   MetricsTotalCountByReleaseIn24h: SimpleStub;
   MissingMembers: OverridableStubList;
+  NotificationDefaults: SimpleStub;
   OrgOwnedApps: SimpleStub;
   OrgRoleList: OverridableStub;
   Organization: OverridableStub;

+ 4 - 1
static/app/routes.tsx

@@ -298,7 +298,10 @@ function buildRoutes() {
           path=":fineTuneType/"
           name={t('Fine Tune Alerts')}
           component={make(
-            () => import('sentry/views/settings/account/accountNotificationFineTuning')
+            () =>
+              import(
+                'sentry/views/settings/account/accountNotificationFineTuningController'
+              )
           )}
         />
       </Route>

+ 28 - 0
static/app/views/settings/account/accountNotificationFineTuningController.tsx

@@ -0,0 +1,28 @@
+import {RouteComponentProps} from 'react-router';
+
+import {Organization} from 'sentry/types';
+import withOrganizations from 'sentry/utils/withOrganizations';
+
+import AccountNotificationFineTuning from './accountNotificationFineTuning';
+import AccountNotificationFineTuningV2 from './accountNotificationFineTuningV2';
+
+type Props = RouteComponentProps<{fineTuneType: string}, {}> & {
+  organizations: Organization[];
+};
+
+export function AccountNotificationFineTuningController({
+  organizations,
+  ...props
+}: Props) {
+  // check if feature is enabled for any organization
+  const hasFeature = organizations.some(org =>
+    org.features.includes('notification-settings-v2')
+  );
+  return hasFeature ? (
+    <AccountNotificationFineTuningV2 {...props} />
+  ) : (
+    <AccountNotificationFineTuning {...props} />
+  );
+}
+
+export default withOrganizations(AccountNotificationFineTuningController);

+ 315 - 0
static/app/views/settings/account/accountNotificationFineTuningV2.tsx

@@ -0,0 +1,315 @@
+import {Fragment} from 'react';
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+
+import EmptyMessage from 'sentry/components/emptyMessage';
+import SelectField from 'sentry/components/forms/fields/selectField';
+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 {fields} from 'sentry/data/forms/accountNotificationSettings';
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import {space} from 'sentry/styles/space';
+import {Organization, Project, UserEmail} from 'sentry/types';
+import parseLinkHeader from 'sentry/utils/parseLinkHeader';
+import withOrganizations from 'sentry/utils/withOrganizations';
+import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
+import {
+  ACCOUNT_NOTIFICATION_FIELDS,
+  FineTuneField,
+} from 'sentry/views/settings/account/notifications/fields';
+import NotificationSettingsByTypeV2 from 'sentry/views/settings/account/notifications/notificationSettingsByTypeV2';
+import {OrganizationSelectHeader} from 'sentry/views/settings/account/notifications/organizationSelectHeader';
+import {
+  getNotificationTypeFromPathname,
+  groupByOrganization,
+  isGroupedByProject,
+} from 'sentry/views/settings/account/notifications/utils';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+
+const PanelBodyLineItem = styled(PanelBody)`
+  font-size: 1rem;
+  &:not(:last-child) {
+    border-bottom: 1px solid ${p => p.theme.innerBorder};
+  }
+`;
+
+const accountNotifications = [
+  'alerts',
+  'deploy',
+  'workflow',
+  'approval',
+  'quota',
+  'spikeProtection',
+];
+
+type ANBPProps = {
+  field: FineTuneField;
+  projects: Project[];
+};
+
+function AccountNotificationsByProject({projects, field}: ANBPProps) {
+  const projectsByOrg = groupByOrganization(projects);
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const {title, description, ...fieldConfig} = field;
+
+  // Display as select box in this view regardless of the type specified in the config
+  const data = Object.values(projectsByOrg).map(org => ({
+    name: org.organization.name,
+    projects: org.projects.map(project => ({
+      ...fieldConfig,
+      // `name` key refers to field name
+      // we use project.id because slugs are not unique across orgs
+      name: project.id,
+      label: project.slug,
+    })),
+  }));
+
+  return (
+    <Fragment>
+      {data.map(({name, projects: projectFields}) => (
+        <div key={name}>
+          {projectFields.map(f => (
+            <PanelBodyLineItem key={f.name}>
+              <SelectField
+                defaultValue={f.defaultValue}
+                name={f.name}
+                options={f.options}
+                label={f.label}
+              />
+            </PanelBodyLineItem>
+          ))}
+        </div>
+      ))}
+    </Fragment>
+  );
+}
+
+type ANBOProps = {
+  field: FineTuneField;
+  organizations: Organization[];
+};
+
+function AccountNotificationsByOrganization({organizations, field}: ANBOProps) {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const {title, description, ...fieldConfig} = field;
+
+  // Display as select box in this view regardless of the type specified in the config
+  const data = organizations.map(org => ({
+    ...fieldConfig,
+    // `name` key refers to field name
+    // we use org.id to remain consistent project.id use (which is required because slugs are not unique across orgs)
+    name: org.id,
+    label: org.slug,
+  }));
+
+  return (
+    <Fragment>
+      {data.map(f => (
+        <PanelBodyLineItem key={f.name}>
+          <SelectField
+            defaultValue={f.defaultValue}
+            name={f.name}
+            options={f.options}
+            label={f.label}
+          />
+        </PanelBodyLineItem>
+      ))}
+    </Fragment>
+  );
+}
+
+const AccountNotificationsByOrganizationContainer = withOrganizations(
+  AccountNotificationsByOrganization
+);
+
+type Props = DeprecatedAsyncView['props'] &
+  RouteComponentProps<{fineTuneType: string}, {}> & {
+    organizations: Organization[];
+  };
+
+type State = DeprecatedAsyncView['state'] & {
+  emails: UserEmail[] | null;
+  fineTuneData: Record<string, any> | null;
+  notifications: Record<string, any> | null;
+  projects: Project[] | null;
+};
+
+class AccountNotificationFineTuning extends DeprecatedAsyncView<Props, State> {
+  getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
+    const {fineTuneType: pathnameType} = this.props.params;
+    const fineTuneType = getNotificationTypeFromPathname(pathnameType);
+    const endpoints: ReturnType<DeprecatedAsyncView['getEndpoints']> = [
+      ['notifications', '/users/me/notifications/'],
+      ['fineTuneData', `/users/me/notifications/${fineTuneType}/`],
+    ];
+
+    if (isGroupedByProject(fineTuneType)) {
+      const organizationId = this.getOrganizationId();
+      endpoints.push(['projects', `/projects/`, {query: {organizationId}}]);
+    }
+
+    endpoints.push(['emails', '/users/me/emails/']);
+    if (fineTuneType === 'email') {
+      endpoints.push(['emails', '/users/me/emails/']);
+    }
+
+    return endpoints;
+  }
+
+  // Return a sorted list of user's verified emails
+  get emailChoices() {
+    return (
+      this.state.emails
+        ?.filter(({isVerified}) => isVerified)
+        ?.sort((a, b) => {
+          // Sort by primary -> email
+          if (a.isPrimary) {
+            return -1;
+          }
+          if (b.isPrimary) {
+            return 1;
+          }
+
+          return a.email < b.email ? -1 : 1;
+        }) ?? []
+    );
+  }
+
+  handleOrgChange = (organizationId: string) => {
+    this.props.router.replace({
+      ...this.props.location,
+      query: {organizationId},
+    });
+  };
+
+  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;
+  }
+
+  renderBody() {
+    const {params, organizations} = this.props;
+    const {fineTuneType: pathnameType} = params;
+    const fineTuneType = getNotificationTypeFromPathname(pathnameType);
+
+    if (accountNotifications.includes(fineTuneType)) {
+      return <NotificationSettingsByTypeV2 notificationType={fineTuneType} />;
+    }
+
+    const {notifications, projects, fineTuneData, projectsPageLinks} = this.state;
+
+    const isProject = isGroupedByProject(fineTuneType) && organizations.length > 0;
+    const field = ACCOUNT_NOTIFICATION_FIELDS[fineTuneType];
+    const {title, description} = field;
+
+    const [stateKey] = isProject ? this.getEndpoints()[2] : [];
+    const hasProjects = !!projects?.length;
+
+    if (fineTuneType === 'email') {
+      // Fetch verified email addresses
+      field.options = this.emailChoices.map(({email}) => ({value: email, label: email}));
+    }
+
+    if (!notifications || !fineTuneData) {
+      return null;
+    }
+
+    const orgId = this.getOrganizationId();
+    const paginationObject = parseLinkHeader(projectsPageLinks ?? '');
+    const hasMore = paginationObject?.next?.results;
+    const hasPrevious = paginationObject?.previous?.results;
+
+    return (
+      <div>
+        <SettingsPageHeader title={title} />
+        {description && <TextBlock>{description}</TextBlock>}
+
+        {field &&
+          field.defaultFieldName &&
+          // not implemented yet
+          field.defaultFieldName !== 'weeklyReports' && (
+            <Form
+              saveOnBlur
+              apiMethod="PUT"
+              apiEndpoint="/users/me/notifications/"
+              initialData={notifications}
+            >
+              <JsonForm
+                title={`Default ${title}`}
+                fields={[fields[field.defaultFieldName]]}
+              />
+            </Form>
+          )}
+        <Panel>
+          <StyledPanelHeader hasButtons={isProject}>
+            {isProject ? (
+              <Fragment>
+                <OrganizationSelectHeader
+                  organizations={organizations}
+                  organizationId={orgId}
+                  handleOrgChange={this.handleOrgChange}
+                />
+                {this.renderSearchInput({
+                  placeholder: t('Search Projects'),
+                  url: `/projects/?organizationId=${orgId}`,
+                  stateKey,
+                })}
+              </Fragment>
+            ) : (
+              <Heading>{t('Organizations')}</Heading>
+            )}
+          </StyledPanelHeader>
+          <PanelBody>
+            <Form
+              saveOnBlur
+              apiMethod="PUT"
+              apiEndpoint={`/users/me/notifications-v2/${fineTuneType}/`}
+              initialData={fineTuneData}
+            >
+              {isProject && hasProjects && (
+                <AccountNotificationsByProject projects={projects!} field={field} />
+              )}
+
+              {isProject && !hasProjects && (
+                <EmptyMessage>{t('No projects found')}</EmptyMessage>
+              )}
+
+              {!isProject && (
+                <AccountNotificationsByOrganizationContainer field={field} />
+              )}
+            </Form>
+          </PanelBody>
+        </Panel>
+
+        {projects && (hasMore || hasPrevious) && (
+          <Pagination pageLinks={projectsPageLinks} />
+        )}
+      </div>
+    );
+  }
+}
+
+const Heading = styled('div')`
+  flex: 1;
+`;
+
+const StyledPanelHeader = styled(PanelHeader)`
+  flex-wrap: wrap;
+  gap: ${space(1)};
+  & > form:last-child {
+    flex-grow: 1;
+  }
+`;
+
+export default withOrganizations(AccountNotificationFineTuning);

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

@@ -21,11 +21,33 @@ export const VALUE_MAPPING = {
 export const MIN_PROJECTS_FOR_CONFIRMATION = 3;
 export const MIN_PROJECTS_FOR_SEARCH = 3;
 export const MIN_PROJECTS_FOR_PAGINATION = 100;
+export type SupportedProviders = 'email' | 'slack' | 'msteams';
+export type ProviderValue = 'always' | 'never';
 
 export type NotificationSettingsByProviderObject = {[key: string]: string};
 export type NotificationSettingsObject = {
   [key: string]: {[key: string]: {[key: string]: NotificationSettingsByProviderObject}};
 };
+interface NotificationBaseObject {
+  id: string;
+  scopeIdentifier: string;
+  scopeType: string;
+  type: string;
+}
+
+export interface NotificationOptionsObject extends NotificationBaseObject {
+  value: ProviderValue | 'subscribe_only' | 'committed_only';
+}
+
+export interface NotificationProvidersObject extends NotificationBaseObject {
+  provider: SupportedProviders;
+  value: ProviderValue;
+}
+
+export interface DefaultSettings {
+  providerDefaults: SupportedProviders[];
+  typeDefaults: Record<string, ProviderValue>;
+}
 
 export const NOTIFICATION_SETTINGS_TYPES = [
   'alerts',

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

@@ -48,6 +48,7 @@ export const NOTIFICATION_SETTING_FIELDS: Record<string, Field> = {
       ['slack', t('Slack')],
       ['msteams', t('Microsoft Teams')],
     ],
+    help: t('Where personal notifications will be sent.'),
     multiple: true,
     onChange: val => {
       // This is a little hack to prevent this field from being empty.

+ 5 - 3
static/app/views/settings/account/notifications/notificationSettings.tsx

@@ -97,14 +97,16 @@ class NotificationSettings extends DeprecatedAsyncComponent<Props, State> {
     return updatedNotificationSettings;
   };
 
+  checkFeatureFlag(flag: string) {
+    return this.props.organizations.some(org => org.features?.includes(flag));
+  }
+
   get notificationSettingsType() {
     // filter out notification settings if the feature flag isn't set
     return NOTIFICATION_SETTINGS_TYPES.filter(type => {
       const notificationFlag = NOTIFICATION_FEATURE_MAP[type];
       if (notificationFlag) {
-        return this.props.organizations.some(org =>
-          org.features?.includes(notificationFlag)
-        );
+        return this.checkFeatureFlag(notificationFlag);
       }
       return true;
     });

+ 42 - 0
static/app/views/settings/account/notifications/notificationSettingsByEntity.spec.tsx

@@ -0,0 +1,42 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import ConfigStore from 'sentry/stores/configStore';
+import NotificationSettingsByEntity from 'sentry/views/settings/account/notifications/notificationSettingsByEntity';
+
+describe('NotificationSettingsByEntity', function () {
+  afterEach(() => {
+    MockApiClient.clearMockResponses();
+    jest.clearAllMocks();
+  });
+
+  it('should default to the subdomain org', async function () {
+    const organization = TestStubs.Organization();
+    const otherOrganization = TestStubs.Organization({
+      id: '2',
+      slug: 'other-org',
+      name: 'other org',
+    });
+    ConfigStore.set('customerDomain', {
+      ...ConfigStore.get('customerDomain')!,
+      subdomain: otherOrganization.slug,
+    });
+    const projectsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${otherOrganization.slug}/projects/`,
+      method: 'GET',
+      body: [],
+    });
+
+    render(
+      <NotificationSettingsByEntity
+        organizations={[organization, otherOrganization]}
+        notificationType="alerts"
+        notificationOptions={[]}
+        handleRemoveNotificationOption={jest.fn()}
+        handleAddNotificationOption={jest.fn()}
+        entityType={'project' as const}
+      />
+    );
+    expect(await screen.findByText(otherOrganization.name)).toBeInTheDocument();
+    expect(projectsMock).toHaveBeenCalledTimes(1);
+  });
+});

+ 314 - 0
static/app/views/settings/account/notifications/notificationSettingsByEntity.tsx

@@ -0,0 +1,314 @@
+import {Fragment, useState} from 'react';
+import type {WithRouterProps} from 'react-router';
+import {components} from 'react-select';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import SelectControl from 'sentry/components/forms/controls/selectControl';
+import JsonForm from 'sentry/components/forms/jsonForm';
+import IdBadge from 'sentry/components/idBadge';
+import Panel from 'sentry/components/panels/panel';
+import PanelBody from 'sentry/components/panels/panelBody';
+import PanelHeader from 'sentry/components/panels/panelHeader';
+import {IconAdd, IconDelete} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import {space} from 'sentry/styles/space';
+import {Organization, Project} from 'sentry/types';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import withSentryRouter from 'sentry/utils/withSentryRouter';
+import {NotificationOptionsObject} from 'sentry/views/settings/account/notifications/constants';
+import {NOTIFICATION_SETTING_FIELDS} from 'sentry/views/settings/account/notifications/fields2';
+import {OrganizationSelectHeader} from 'sentry/views/settings/account/notifications/organizationSelectHeader';
+
+type Value = 'always' | 'never' | 'subscribe_only' | 'committed_only';
+
+const getLabelForValue = (value: Value) => {
+  switch (value) {
+    case 'always':
+      return t('On');
+    case 'never':
+      return t('Off');
+    case 'subscribe_only':
+      return t('Subscribed Only');
+    case 'committed_only':
+      return t('Committed Only');
+    default:
+      return '';
+  }
+};
+
+export type NotificationSettingsByProjectsBaseProps = {
+  entityType: 'project' | 'organization';
+  handleAddNotificationOption: (
+    notificationOption: Omit<NotificationOptionsObject, 'id'>
+  ) => void;
+  handleRemoveNotificationOption: (id: string) => void;
+  notificationOptions: NotificationOptionsObject[];
+  notificationType: string;
+};
+
+type Props = {
+  organizations: Organization[];
+} & NotificationSettingsByProjectsBaseProps &
+  WithRouterProps;
+
+function NotificationSettingsByEntity(props: Props) {
+  const {
+    entityType,
+    handleAddNotificationOption,
+    handleRemoveNotificationOption,
+    notificationOptions,
+    notificationType,
+    organizations,
+    router,
+    location,
+  } = props;
+  const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
+  const [selectedValue, setSelectedValue] = useState<Value | null>(null);
+
+  const customerDomain = ConfigStore.get('customerDomain');
+  const orgFromSubdomain = organizations.find(
+    ({slug}) => slug === customerDomain?.subdomain
+  )?.id;
+
+  const orgId =
+    location?.query?.organizationId ?? orgFromSubdomain ?? organizations[0]?.id;
+  const orgSlug =
+    organizations.find(({id}) => id === orgId)?.slug || organizations[0]?.slug;
+
+  // loads all the projects for an org
+  const {data: projects} = useApiQuery<Project[]>(
+    [
+      `/organizations/${orgSlug}/projects/`,
+      {
+        query: {
+          all_projects: '1',
+          collapse: 'latestDeploys',
+        },
+      },
+    ],
+    {staleTime: Infinity}
+  );
+
+  // always loading all projects even though we only need it sometimes
+  const entities = entityType === 'project' ? projects || [] : organizations;
+
+  const handleOrgChange = (organizationId: string) => {
+    router.replace({
+      ...location,
+      query: {organizationId},
+    });
+  };
+
+  const handleAdd = () => {
+    // should never happen
+    if (!selectedEntityId || !selectedValue) {
+      return;
+    }
+    const data = {
+      type: notificationType,
+      scopeType: entityType,
+      scopeIdentifier: selectedEntityId,
+      value: selectedValue,
+    };
+    setSelectedEntityId(null);
+    setSelectedValue(null);
+    handleAddNotificationOption(data);
+  };
+
+  const renderOverrides = () => {
+    const matchedOptions = notificationOptions.filter(
+      option => option.type === notificationType && option.scopeType === entityType
+    );
+    return matchedOptions.map(option => {
+      const entity = (entities as any[]).find(
+        ({id}) => id.toString() === option.scopeIdentifier.toString()
+      );
+      if (!entity) {
+        return null;
+      }
+      const handleDelete = async (id: string) => {
+        await handleRemoveNotificationOption(id);
+      };
+      const idBadgeProps =
+        entityType === 'project'
+          ? {project: entity as Project}
+          : {
+              organization: entity as Organization,
+            };
+      return (
+        <Item key={entity.id}>
+          <IdBadge
+            {...idBadgeProps}
+            avatarSize={20}
+            displayName={entity.slug}
+            avatarProps={{consistentWidth: true}}
+            disableLink
+          />
+          {getLabelForValue(option.value)}
+          <Button
+            aria-label={t('Delete')}
+            size="sm"
+            priority="default"
+            icon={<IconDelete />}
+            onClick={() => handleDelete(option.id)}
+          />
+        </Item>
+      );
+    });
+  };
+
+  const customValueContainer = containerProps => {
+    // if no value set, we want to return the default component that is rendered
+    const entity = entityById[selectedEntityId || ''];
+    if (!entity) {
+      return <components.ValueContainer {...containerProps} />;
+    }
+    const idBadgeProps =
+      entityType === 'project'
+        ? {project: entity as Project}
+        : {
+            organization: entity as Organization,
+          };
+    return (
+      <components.ValueContainer {...containerProps}>
+        <IdBadge
+          {...idBadgeProps}
+          avatarSize={20}
+          displayName={entity.slug}
+          avatarProps={{consistentWidth: true}}
+          disableLink
+        />
+      </components.ValueContainer>
+    );
+  };
+
+  const handleSelectProject = ({value}: {value: string}) => {
+    setSelectedEntityId(value);
+  };
+  const handleSelectValue = ({value}: {value: string}) => {
+    setSelectedValue(value as Value);
+  };
+
+  // create maps by the project id for constant time lookups
+  const entityById: Record<string, Organization | Project> = Object.fromEntries(
+    entities.map(entity => [entity.id, entity])
+  );
+  const entityOptions: {label: string; value: Value}[] = (entities as any[])
+    .filter(({id}: Project | Organization) => {
+      const match = notificationOptions.find(
+        option =>
+          option.scopeType === entityType &&
+          option.scopeIdentifier.toString() === id.toString() &&
+          option.type === notificationType
+      );
+      return !match;
+    })
+    .map(({slug, id}) => ({label: slug, value: id}));
+  const customOptionProject = entityProps => {
+    const entity = entityById[entityProps.value];
+    // Should never happen for a dropdown item
+    if (!entity) {
+      return null;
+    }
+    const idBadgeProps =
+      entityType === 'project'
+        ? {project: entity as Project}
+        : {
+            organization: entity as Organization,
+          };
+    return (
+      <components.Option {...entityProps}>
+        <IdBadge
+          {...idBadgeProps}
+          avatarSize={20}
+          displayName={entity.slug}
+          avatarProps={{consistentWidth: true}}
+          disableLink
+        />
+      </components.Option>
+    );
+  };
+
+  const valueOptions = NOTIFICATION_SETTING_FIELDS[notificationType].choices;
+  return (
+    <Fragment>
+      <Panel>
+        <StyledPanelHeader>
+          <OrganizationSelectHeader
+            organizations={organizations}
+            organizationId={orgId}
+            handleOrgChange={handleOrgChange}
+          />
+        </StyledPanelHeader>
+        <Item>
+          {/* TODO: enable search for sentry projects */}
+          <SelectControl
+            placeholder={
+              entityType === 'project'
+                ? t('Sentry project\u2026')
+                : t('Sentry organization\u2026')
+            }
+            name={entityType}
+            options={entityOptions}
+            components={{
+              Option: customOptionProject,
+              ValueContainer: customValueContainer,
+            }}
+            onChange={handleSelectProject}
+            value={selectedEntityId}
+          />
+          <SelectControl
+            placeholder={t('Select\u2026')}
+            value={selectedValue}
+            name="value"
+            choices={valueOptions}
+            onChange={handleSelectValue}
+          />
+          <AddProjectWrapper>
+            <Button
+              disabled={!selectedEntityId || !selectedValue}
+              size="sm"
+              priority="primary"
+              onClick={handleAdd}
+              icon={<IconAdd />}
+              aria-label={t('Add override')}
+            />
+          </AddProjectWrapper>
+        </Item>
+        <PanelBody>{renderOverrides()}</PanelBody>
+      </Panel>
+    </Fragment>
+  );
+}
+
+// loading all projects and orgs
+export default withSentryRouter(NotificationSettingsByEntity);
+
+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;
+  }
+`;
+
+const AddProjectWrapper = styled('div')``;
+
+const Item = styled('div')`
+  min-height: 60px;
+  padding: ${space(2)};
+
+  display: grid;
+  grid-column-gap: ${space(1)};
+  align-items: center;
+  grid-template-columns: 2.5fr 1fr min-content;
+`;

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