123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- import {Fragment} from 'react';
- import type {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 ProjectBadge from 'sentry/components/idBadge/projectBadge';
- 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 OrganizationsStore from 'sentry/stores/organizationsStore';
- import {useLegacyStore} from 'sentry/stores/useLegacyStore';
- import {space} from 'sentry/styles/space';
- import type {Organization} from 'sentry/types/organization';
- import type {Project} from 'sentry/types/project';
- import type {UserEmail} from 'sentry/types/user';
- import parseLinkHeader from 'sentry/utils/parseLinkHeader';
- import withOrganizations from 'sentry/utils/withOrganizations';
- import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
- import type {FineTuneField} from 'sentry/views/settings/account/notifications/fields';
- import {ACCOUNT_NOTIFICATION_FIELDS} from 'sentry/views/settings/account/notifications/fields';
- import NotificationSettingsByType from 'sentry/views/settings/account/notifications/notificationSettingsByType';
- 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',
- 'reports',
- 'brokenMonitors',
- ];
- 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: (
- <ProjectBadge
- project={project}
- avatarSize={20}
- avatarProps={{consistentWidth: true}}
- disableLink
- />
- ),
- })),
- }));
- 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;
- };
- function AccountNotificationsByOrganization({field}: ANBOProps) {
- const {organizations} = useLegacyStore(OrganizationsStore);
- // 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>
- );
- }
- type Props = DeprecatedAsyncView['props'] &
- RouteComponentProps<{fineTuneType: string}, {}> & {
- organizations: Organization[];
- };
- type State = DeprecatedAsyncView['state'] & {
- emails: UserEmail[] | null;
- emailsByProject: 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/'],
- ];
- if (isGroupedByProject(fineTuneType)) {
- const organizationId = this.getOrganizationId();
- endpoints.push(['projects', `/projects/`, {query: {organizationId}}]);
- }
- // special logic for email
- if (fineTuneType === 'email') {
- endpoints.push(['emails', '/users/me/emails/']);
- endpoints.push(['emailsByProject', `/users/me/notifications/email/`]);
- }
- 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 <NotificationSettingsByType notificationType={fineTuneType} />;
- }
- const {notifications, projects, emailsByProject, projectsPageLinks} = this.state;
- const isProject = isGroupedByProject(fineTuneType) && organizations.length > 0;
- const field = ACCOUNT_NOTIFICATION_FIELDS[fineTuneType];
- // TODO(isabella): once GA, remove this
- if (
- fineTuneType === 'quota' &&
- organizations.some(org => org.features?.includes('spend-visibility-notifications'))
- ) {
- field.title = t('Spend Notifications');
- field.description = t(
- 'Control the notifications you receive for organization spend.'
- );
- }
- 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 || (!emailsByProject && fineTuneType === 'email')) {
- return null;
- }
- const orgId = this.getOrganizationId();
- const paginationObject = parseLinkHeader(projectsPageLinks ?? '');
- const hasMore = paginationObject?.next?.results;
- const hasPrevious = paginationObject?.previous?.results;
- const mainContent = (
- <Fragment>
- {isProject && hasProjects && (
- <AccountNotificationsByProject projects={projects!} field={field} />
- )}
- {isProject && !hasProjects && (
- <EmptyMessage>{t('No projects found')}</EmptyMessage>
- )}
- {!isProject && <AccountNotificationsByOrganization field={field} />}
- </Fragment>
- );
- return (
- <div>
- <SettingsPageHeader title={title} />
- {description && <TextBlock>{description}</TextBlock>}
- <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>
- {/* Only email needs the form to change the emmail */}
- {fineTuneType === 'email' && emailsByProject ? (
- <Form
- saveOnBlur
- apiMethod="PUT"
- apiEndpoint="/users/me/notifications/email/"
- initialData={emailsByProject}
- >
- {mainContent}
- </Form>
- ) : (
- mainContent
- )}
- </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);
|