diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx
index f95d735561e1..7f2f9b22767a 100644
--- a/static/app/types/project.tsx
+++ b/static/app/types/project.tsx
@@ -131,6 +131,8 @@ export interface DetailedProject extends ProjectSummary {
preprodDistributionPrCommentsEnabledByCustomer?: boolean;
preprodSizeEnabledByCustomer?: boolean;
preprodSizeEnabledQuery?: string | null;
+ preprodSizePrCommentsEnabled?: boolean;
+ preprodSizePrCommentsRules?: unknown[];
preprodSizeStatusChecksEnabled?: boolean;
preprodSizeStatusChecksRules?: unknown[];
preprodSnapshotPrCommentsEnabled?: boolean;
diff --git a/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx b/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx
index 58c3d41ef5ea..d74a2b2485da 100644
--- a/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx
+++ b/static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx
@@ -78,6 +78,14 @@ export type PreprodBuildEventParameters = {
'preprod.releases.snapshots.tab-clicked': {
organization: Organization;
};
+ 'preprod.settings.pr_comment_rule_created': PreprodSettingsEvent;
+ 'preprod.settings.pr_comment_rule_deleted': PreprodSettingsEvent;
+ 'preprod.settings.pr_comment_rule_updated': PreprodSettingsEvent & {
+ artifact_type: ArtifactType;
+ measurement: string;
+ metric: string;
+ value: number;
+ };
'preprod.settings.status_check_rule_created': PreprodSettingsEvent;
'preprod.settings.status_check_rule_deleted': PreprodSettingsEvent;
'preprod.settings.status_check_rule_updated': PreprodSettingsEvent & {
@@ -169,4 +177,7 @@ export const preprodBuildEventMap: Record
+
+
+
)}
{tab === 'distribution' && (
diff --git a/static/app/views/settings/project/preprod/prCommentRules.spec.tsx b/static/app/views/settings/project/preprod/prCommentRules.spec.tsx
new file mode 100644
index 000000000000..e9233228e93c
--- /dev/null
+++ b/static/app/views/settings/project/preprod/prCommentRules.spec.tsx
@@ -0,0 +1,238 @@
+import {DetailedProjectFixture} from 'sentry-fixture/project';
+import {RepositoryFixture} from 'sentry-fixture/repository';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {PrCommentRules} from 'sentry/views/settings/project/preprod/prCommentRules';
+
+describe('PrCommentRules', () => {
+ const {organization} = initializeOrg();
+ const initialRouterConfig = {
+ location: {
+ pathname: `/settings/projects/test-project/preprod/`,
+ },
+ route: '/settings/projects/:projectId/preprod/',
+ };
+
+ function mockRepositories(repositories = [RepositoryFixture()]) {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/repos/`,
+ body: repositories,
+ });
+ }
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('renders disabled by default when the project has no preprod options', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({options: {}});
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).not.toBeChecked();
+ expect(
+ screen.getByText('Enable PR comments above to configure rules.')
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', {name: 'Create PR Comment Rule'})
+ ).not.toBeInTheDocument();
+ });
+
+ it('reflects an enabled project from the explicit field', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizePrCommentsEnabled: true,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).toBeChecked();
+ expect(
+ screen.getByText('No PR comment rules configured. Create one to get started.')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {name: 'Create PR Comment Rule'})
+ ).toBeInTheDocument();
+ });
+
+ it('reflects an enabled project from the option key when the explicit field is absent', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {'sentry:preprod_size_pr_comments_enabled': true},
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).toBeChecked();
+ expect(
+ screen.getByRole('button', {name: 'Create PR Comment Rule'})
+ ).toBeInTheDocument();
+ });
+
+ it('reflects a disabled project from the option key when the explicit field is absent', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {'sentry:preprod_size_pr_comments_enabled': false},
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ ).not.toBeChecked();
+ expect(
+ screen.getByText('Enable PR comments above to configure rules.')
+ ).toBeInTheDocument();
+ });
+
+ it('enables PR comments when toggled on', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({options: {}});
+ const projectEndpoint = `/projects/${organization.slug}/${project.slug}/`;
+ MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ body: project,
+ });
+ const mock = MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ method: 'PUT',
+ body: {},
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ await userEvent.click(
+ await screen.findByRole('checkbox', {name: 'Toggle PR comments'})
+ );
+
+ await waitFor(() =>
+ expect(mock).toHaveBeenCalledWith(
+ projectEndpoint,
+ expect.objectContaining({
+ method: 'PUT',
+ data: {preprodSizePrCommentsEnabled: true},
+ })
+ )
+ );
+ });
+
+ it('creates a rule when the create button is clicked', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizePrCommentsEnabled: true,
+ });
+ const projectEndpoint = `/projects/${organization.slug}/${project.slug}/`;
+ MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ body: project,
+ });
+ const mock = MockApiClient.addMockResponse({
+ url: projectEndpoint,
+ method: 'PUT',
+ body: {},
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ await userEvent.click(
+ await screen.findByRole('button', {name: 'Create PR Comment Rule'})
+ );
+
+ await waitFor(() =>
+ expect(mock).toHaveBeenCalledWith(
+ projectEndpoint,
+ expect.objectContaining({
+ method: 'PUT',
+ data: {
+ preprodSizePrCommentsRules: [
+ expect.objectContaining({
+ metric: 'install_size',
+ measurement: 'absolute',
+ value: 0,
+ artifactType: 'main_artifact',
+ }),
+ ],
+ },
+ })
+ )
+ );
+ });
+
+ it('shows the connect-a-repository empty state when there are no repositories', async () => {
+ mockRepositories([]);
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizePrCommentsEnabled: true,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByText(
+ 'Connect at least one repository to get Size Analysis PR comments'
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('checkbox', {name: 'Toggle PR comments'})
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/settings/project/preprod/prCommentRules.tsx b/static/app/views/settings/project/preprod/prCommentRules.tsx
new file mode 100644
index 000000000000..e1a350511045
--- /dev/null
+++ b/static/app/views/settings/project/preprod/prCommentRules.tsx
@@ -0,0 +1,81 @@
+import {t} from 'sentry/locale';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
+
+import {SizeRulesPanel} from './sizeRulesPanel';
+import {DEFAULT_ARTIFACT_TYPE} from './types';
+
+export function PrCommentRules() {
+ const organization = useOrganization();
+ const {project} = useProjectSettingsOutlet();
+
+ return (
+ (
+
+ Will no longer comment on PRs when {ruleDescription}{' '}
+ surpasses {valueWithUnit}
+
+ ),
+ searchSource: 'preprod_pr_comment_filters',
+ },
+ },
+ analytics: {
+ onCreate: () =>
+ trackAnalytics('preprod.settings.pr_comment_rule_created', {
+ organization,
+ project_slug: project.slug,
+ }),
+ onUpdate: rule =>
+ trackAnalytics('preprod.settings.pr_comment_rule_updated', {
+ organization,
+ project_slug: project.slug,
+ metric: rule.metric,
+ measurement: rule.measurement,
+ artifact_type: rule.artifactType ?? DEFAULT_ARTIFACT_TYPE,
+ value: rule.value,
+ }),
+ onDelete: () =>
+ trackAnalytics('preprod.settings.pr_comment_rule_deleted', {
+ organization,
+ project_slug: project.slug,
+ }),
+ },
+ }}
+ />
+ );
+}
diff --git a/static/app/views/settings/project/preprod/sizeRulesPanel.tsx b/static/app/views/settings/project/preprod/sizeRulesPanel.tsx
new file mode 100644
index 000000000000..f5d079328403
--- /dev/null
+++ b/static/app/views/settings/project/preprod/sizeRulesPanel.tsx
@@ -0,0 +1,221 @@
+import {Fragment, useCallback, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import seerConfigBugSvg from 'sentry-images/spot/seer-config-bug-1.svg';
+
+import {Button, LinkButton} from '@sentry/scraps/button';
+import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
+import {Switch} from '@sentry/scraps/switch';
+import {Heading, Text} from '@sentry/scraps/text';
+
+import {Panel} from 'sentry/components/panels/panel';
+import {PanelBody} from 'sentry/components/panels/panelBody';
+import {PanelHeader} from 'sentry/components/panels/panelHeader';
+import {IconAdd} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import {useOrganization} from 'sentry/utils/useOrganization';
+import {useRepositories} from 'sentry/utils/useRepositories';
+import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
+
+import type {RuleFormCopy} from './statusCheckRuleForm';
+import {StatusCheckRuleItem} from './statusCheckRuleItem';
+import type {StatusCheckRule} from './types';
+import {type SizeRulesConfig, useSizeRules} from './useSizeRules';
+
+interface SizeRulesPanelConfig {
+ analytics: {
+ onCreate: () => void;
+ onDelete: () => void;
+ onUpdate: (rule: StatusCheckRule) => void;
+ };
+ copy: {
+ addRuleButtonLabel: string;
+ connectRepoText: string;
+ disabledHintText: string;
+ emptyRulesText: string;
+ enabledDescription: string;
+ enabledLabel: string;
+ form: RuleFormCopy;
+ panelTitle: string;
+ toggleAriaLabel: string;
+ };
+ rules: SizeRulesConfig;
+}
+
+interface Props {
+ config: SizeRulesPanelConfig;
+}
+
+export function SizeRulesPanel({config: panelConfig}: Props) {
+ const {copy, analytics} = panelConfig;
+ const organization = useOrganization();
+ const {project} = useProjectSettingsOutlet();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const {data: repositories, isPending: isLoadingRepos} = useRepositories({
+ orgSlug: organization.slug,
+ });
+ const {config, setEnabled, addRule, updateRule, deleteRule, createEmptyRule} =
+ useSizeRules(project, panelConfig.rules);
+
+ const [newRuleId, setNewRuleId] = useState(null);
+
+ const expandedRuleIds = useMemo(() => {
+ const expanded = location.query.expanded;
+ if (!expanded) {
+ return new Set();
+ }
+ return new Set(Array.isArray(expanded) ? expanded : [expanded]);
+ }, [location.query.expanded]);
+
+ const updateExpandedInUrl = useCallback(
+ (expandedIds: string[]) => {
+ navigate(
+ {
+ query: {
+ ...location.query,
+ expanded: expandedIds,
+ },
+ },
+ {replace: true}
+ );
+ },
+ [location.query, navigate]
+ );
+
+ const handleAddRule = () => {
+ const newRule = createEmptyRule();
+ addRule(newRule);
+ analytics.onCreate();
+ setNewRuleId(newRule.id);
+ updateExpandedInUrl([...expandedRuleIds, newRule.id]);
+ };
+
+ const handleToggleExpanded = (ruleId: string, isExpanded: boolean) => {
+ const newExpanded = new Set(expandedRuleIds);
+ if (isExpanded) {
+ newExpanded.add(ruleId);
+ } else {
+ newExpanded.delete(ruleId);
+ if (ruleId === newRuleId) {
+ setNewRuleId(null);
+ }
+ }
+ updateExpandedInUrl([...newExpanded]);
+ };
+
+ const hasRepositories = !isLoadingRepos && repositories && repositories.length > 0;
+
+ return (
+
+ {copy.panelTitle}
+
+ {hasRepositories ? (
+
+
+
+
+ {copy.enabledLabel}
+
+
+ {copy.enabledDescription}
+
+
+ setEnabled(!config.enabled)}
+ aria-label={copy.toggleAriaLabel}
+ />
+
+
+ {config.enabled ? (
+
+ {config.rules.length > 0 ? (
+
+ {config.rules.map(rule => (
+
+ handleToggleExpanded(rule.id, isExpanded)
+ }
+ onSave={updated => {
+ updateRule(rule.id, updated);
+ analytics.onUpdate(updated);
+ if (rule.id === newRuleId) {
+ setNewRuleId(null);
+ }
+ }}
+ onDelete={() => {
+ analytics.onDelete();
+ deleteRule(rule.id);
+ if (rule.id === newRuleId) {
+ setNewRuleId(null);
+ }
+ const newExpanded = new Set(expandedRuleIds);
+ newExpanded.delete(rule.id);
+ updateExpandedInUrl([...newExpanded]);
+ }}
+ />
+ ))}
+
+ ) : (
+
+
+ {copy.emptyRulesText}
+
+
+ )}
+
+
+ } onClick={handleAddRule}>
+ {copy.addRuleButtonLabel}
+
+
+
+ ) : (
+
+
+ {copy.disabledHintText}
+
+
+ )}
+
+ ) : (
+
+
+ {t('Get the most out of Size Analysis')}
+ {copy.connectRepoText}
+
+ {t('Add Repo')}
+
+
+
+
+ )}
+
+
+ );
+}
+
+const ImageContainer = styled('div')`
+ width: 220px;
+ height: 220px;
+ background-image: url(${seerConfigBugSvg});
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ flex-shrink: 0;
+`;
diff --git a/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx b/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx
index d1dc34a73c2d..f139b8d07d6f 100644
--- a/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx
+++ b/static/app/views/settings/project/preprod/statusCheckRuleForm.tsx
@@ -1,4 +1,4 @@
-import {useState} from 'react';
+import {type ReactNode, useState} from 'react';
import styled from '@emotion/styled';
import {Button} from '@sentry/scraps/button';
@@ -27,13 +27,38 @@ import {
STATUS_CHECK_ALLOWED_FILTER_KEYS,
} from './types';
+export interface RuleFormCopy {
+ deleteConfirmHeader: string;
+ deleteConfirmMessage: (ruleDescription: string, valueWithUnit: string) => ReactNode;
+ headerLabel: string;
+ searchSource: string;
+}
+
+const DEFAULT_COPY: RuleFormCopy = {
+ headerLabel: t('Fail Status Check When'),
+ deleteConfirmHeader: t('Are you sure you want to delete this status check rule?'),
+ deleteConfirmMessage: (ruleDescription, valueWithUnit) => (
+
+ Will no longer fail status checks when {ruleDescription} surpasses{' '}
+ {valueWithUnit}
+
+ ),
+ searchSource: 'preprod_status_check_filters',
+};
+
interface Props {
onDelete: () => void;
onSave: (rule: StatusCheckRule) => void;
rule: StatusCheckRule;
+ copy?: RuleFormCopy;
}
-export function StatusCheckRuleForm({rule, onSave, onDelete}: Props) {
+export function StatusCheckRuleForm({
+ rule,
+ onSave,
+ onDelete,
+ copy = DEFAULT_COPY,
+}: Props) {
const {project} = useProjectSettingsOutlet();
const [metric, setMetric] = useState(rule.metric);
const [measurement, setMeasurement] = useState(rule.measurement);
@@ -78,15 +103,10 @@ export function StatusCheckRuleForm({rule, onSave, onDelete}: Props) {
openConfirmModal({
header: (
- {t('Are you sure you want to delete this status check rule?')}
+ {copy.deleteConfirmHeader}
),
- message: (
-
- Will no longer fail status checks when {ruleDescription}{' '}
- surpasses {valueWithUnit}
-
- ),
+ message: copy.deleteConfirmMessage(ruleDescription, valueWithUnit),
confirmText: t('Delete Rule'),
priority: 'danger',
onConfirm: onDelete,
@@ -95,7 +115,7 @@ export function StatusCheckRuleForm({rule, onSave, onDelete}: Props) {
return (
- {t('Fail Status Check When')}
+ {copy.headerLabel}
handleQueryChange(query)}
- searchSource="preprod_status_check_filters"
+ searchSource={copy.searchSource}
portalTarget={document.body}
disallowFreeText
disallowHas
diff --git a/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx b/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx
index 1a08457265ec..296f300e797d 100644
--- a/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx
+++ b/static/app/views/settings/project/preprod/statusCheckRuleItem.tsx
@@ -11,7 +11,7 @@ import {
} from 'sentry/components/searchSyntax/parser';
import {IconChevron} from 'sentry/icons';
-import {StatusCheckRuleForm} from './statusCheckRuleForm';
+import {type RuleFormCopy, StatusCheckRuleForm} from './statusCheckRuleForm';
import type {StatusCheckFilter, StatusCheckRule} from './types';
import {
bytesToMB,
@@ -27,6 +27,7 @@ interface Props {
onSave: (rule: StatusCheckRule) => void;
onToggleExpanded: (isExpanded: boolean) => void;
rule: StatusCheckRule;
+ formCopy?: RuleFormCopy;
}
function FilterSummary({filters}: {filters: StatusCheckFilter[]}) {
@@ -61,6 +62,7 @@ export function StatusCheckRuleItem({
onDelete,
onToggleExpanded,
isExpanded,
+ formCopy,
}: Props) {
const filters = parseFiltersForDisplay(rule.filterQuery);
@@ -91,6 +93,7 @@ export function StatusCheckRuleItem({
rule={rule}
onSave={onSave}
onDelete={onDelete}
+ copy={formCopy}
/>
)}
diff --git a/static/app/views/settings/project/preprod/statusCheckRules.spec.tsx b/static/app/views/settings/project/preprod/statusCheckRules.spec.tsx
new file mode 100644
index 000000000000..70a6621f6952
--- /dev/null
+++ b/static/app/views/settings/project/preprod/statusCheckRules.spec.tsx
@@ -0,0 +1,99 @@
+import {DetailedProjectFixture} from 'sentry-fixture/project';
+import {RepositoryFixture} from 'sentry-fixture/repository';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {StatusCheckRules} from 'sentry/views/settings/project/preprod/statusCheckRules';
+
+describe('StatusCheckRules', () => {
+ const {organization} = initializeOrg();
+ const initialRouterConfig = {
+ location: {
+ pathname: `/settings/projects/test-project/preprod/`,
+ },
+ route: '/settings/projects/:projectId/preprod/',
+ };
+
+ function mockRepositories(repositories = [RepositoryFixture()]) {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/repos/`,
+ body: repositories,
+ });
+ }
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('renders enabled by default when the project has no preprod options', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({options: {}});
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle status checks'})
+ ).toBeChecked();
+ expect(
+ screen.getByText('No status check rules configured. Create one to get started.')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {name: 'Create Status Check Rule'})
+ ).toBeInTheDocument();
+ });
+
+ it('reflects an enabled project from the explicit field', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {},
+ preprodSizeStatusChecksEnabled: true,
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle status checks'})
+ ).toBeChecked();
+ });
+
+ it('reflects a disabled project from the option key when the explicit field is absent', async () => {
+ mockRepositories();
+ const project = DetailedProjectFixture({
+ options: {'sentry:preprod_size_status_checks_enabled': false},
+ });
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/`,
+ body: project,
+ });
+
+ render(, {
+ organization,
+ outletContext: {project},
+ initialRouterConfig,
+ });
+
+ expect(
+ await screen.findByRole('checkbox', {name: 'Toggle status checks'})
+ ).not.toBeChecked();
+ expect(
+ screen.getByText('Enable status checks above to configure rules.')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/settings/project/preprod/statusCheckRules.tsx b/static/app/views/settings/project/preprod/statusCheckRules.tsx
index 2c0ba172b9bb..364cf24e976b 100644
--- a/static/app/views/settings/project/preprod/statusCheckRules.tsx
+++ b/static/app/views/settings/project/preprod/statusCheckRules.tsx
@@ -1,222 +1,83 @@
-import {Fragment, useCallback, useMemo, useState} from 'react';
-import styled from '@emotion/styled';
-
-import seerConfigBugSvg from 'sentry-images/spot/seer-config-bug-1.svg';
-
-import {Button, LinkButton} from '@sentry/scraps/button';
-import {Container, Flex, Stack} from '@sentry/scraps/layout';
-import {Switch} from '@sentry/scraps/switch';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {Panel} from 'sentry/components/panels/panel';
-import {PanelBody} from 'sentry/components/panels/panelBody';
-import {PanelHeader} from 'sentry/components/panels/panelHeader';
-import {IconAdd} from 'sentry/icons';
import {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
-import {useLocation} from 'sentry/utils/useLocation';
-import {useNavigate} from 'sentry/utils/useNavigate';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {useRepositories} from 'sentry/utils/useRepositories';
import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
-import {StatusCheckRuleItem} from './statusCheckRuleItem';
+import {SizeRulesPanel} from './sizeRulesPanel';
import {DEFAULT_ARTIFACT_TYPE} from './types';
-import {useStatusCheckRules} from './useStatusCheckRules';
export function StatusCheckRules() {
const organization = useOrganization();
const {project} = useProjectSettingsOutlet();
- const location = useLocation();
- const navigate = useNavigate();
- const {data: repositories, isPending: isLoadingRepos} = useRepositories({
- orgSlug: organization.slug,
- });
- const {config, setEnabled, addRule, updateRule, deleteRule, createEmptyRule} =
- useStatusCheckRules(project);
-
- const [newRuleId, setNewRuleId] = useState(null);
-
- const expandedRuleIds = useMemo(() => {
- const expanded = location.query.expanded;
- if (!expanded) {
- return new Set();
- }
- return new Set(Array.isArray(expanded) ? expanded : [expanded]);
- }, [location.query.expanded]);
- const handleAddRule = () => {
- const newRule = createEmptyRule();
- addRule(newRule);
- trackAnalytics('preprod.settings.status_check_rule_created', {
- organization,
- project_slug: project.slug,
- });
- setNewRuleId(newRule.id);
- updateExpandedInUrl([...expandedRuleIds, newRule.id]);
- };
-
- const updateExpandedInUrl = useCallback(
- (expandedIds: string[]) => {
- navigate(
- {
- query: {
- ...location.query,
- expanded: expandedIds,
+ return (
+ {
- const newExpanded = new Set(expandedRuleIds);
- if (isExpanded) {
- newExpanded.add(ruleId);
- } else {
- newExpanded.delete(ruleId);
- if (ruleId === newRuleId) {
- setNewRuleId(null);
- }
- }
- updateExpandedInUrl([...newExpanded]);
- };
-
- const hasRepositories = !isLoadingRepos && repositories && repositories.length > 0;
-
- return (
-
- {t('Size Analysis - Status Checks')}
-
- {hasRepositories ? (
-
-
-
-
- {t('Status Checks Enabled')}
-
-
- {t("Sentry will post status checks based on your build's app size.")}
-
-
- setEnabled(!config.enabled)}
- aria-label={t('Toggle status checks')}
- />
-
-
- {config.enabled ? (
-
- {config.rules.length > 0 ? (
-
- {config.rules.map(rule => (
-
- handleToggleExpanded(rule.id, isExpanded)
- }
- onSave={updated => {
- updateRule(rule.id, updated);
- trackAnalytics('preprod.settings.status_check_rule_updated', {
- organization,
- project_slug: project.slug,
- metric: updated.metric,
- measurement: updated.measurement,
- artifact_type: updated.artifactType ?? DEFAULT_ARTIFACT_TYPE,
- value: updated.value,
- });
- if (rule.id === newRuleId) {
- setNewRuleId(null);
- }
- }}
- onDelete={() => {
- trackAnalytics('preprod.settings.status_check_rule_deleted', {
- organization,
- project_slug: project.slug,
- });
- deleteRule(rule.id);
- if (rule.id === newRuleId) {
- setNewRuleId(null);
- }
- const newExpanded = new Set(expandedRuleIds);
- newExpanded.delete(rule.id);
- updateExpandedInUrl([...newExpanded]);
- }}
- />
- ))}
-
- ) : (
-
-
- {t('No status check rules configured. Create one to get started.')}
-
-
- )}
-
-
- } onClick={handleAddRule}>
- {t('Create Status Check Rule')}
-
-
-
- ) : (
-
-
- {t('Enable status checks above to configure rules.')}
-
-
- )}
-
- ) : (
-
-
- {t('Get the most out of Size Analysis')}
-
- {t('Connect at least one repository to get Size Analysis status checks')}
-
-
- {t('Add Repo')}
-
-
-
-
- )}
-
-
+ copy: {
+ panelTitle: t('Size Analysis - Status Checks'),
+ enabledLabel: t('Status Checks Enabled'),
+ enabledDescription: t(
+ "Sentry will post status checks based on your build's app size."
+ ),
+ toggleAriaLabel: t('Toggle status checks'),
+ emptyRulesText: t(
+ 'No status check rules configured. Create one to get started.'
+ ),
+ disabledHintText: t('Enable status checks above to configure rules.'),
+ addRuleButtonLabel: t('Create Status Check Rule'),
+ connectRepoText: t(
+ 'Connect at least one repository to get Size Analysis status checks'
+ ),
+ form: {
+ headerLabel: t('Fail Status Check When'),
+ deleteConfirmHeader: t(
+ 'Are you sure you want to delete this status check rule?'
+ ),
+ deleteConfirmMessage: (ruleDescription, valueWithUnit) => (
+
+ Will no longer fail status checks when {ruleDescription}{' '}
+ surpasses {valueWithUnit}
+
+ ),
+ searchSource: 'preprod_status_check_filters',
+ },
+ },
+ analytics: {
+ onCreate: () =>
+ trackAnalytics('preprod.settings.status_check_rule_created', {
+ organization,
+ project_slug: project.slug,
+ }),
+ onUpdate: rule =>
+ trackAnalytics('preprod.settings.status_check_rule_updated', {
+ organization,
+ project_slug: project.slug,
+ metric: rule.metric,
+ measurement: rule.measurement,
+ artifact_type: rule.artifactType ?? DEFAULT_ARTIFACT_TYPE,
+ value: rule.value,
+ }),
+ onDelete: () =>
+ trackAnalytics('preprod.settings.status_check_rule_deleted', {
+ organization,
+ project_slug: project.slug,
+ }),
+ },
+ }}
+ />
);
}
-
-const AddRuleButton = styled(Button)`
- align-self: flex-start;
-`;
-
-const EmptyStateContainer = styled('div')`
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- padding: 56px 48px;
- gap: ${p => p.theme.space.xl};
-`;
-
-const ImageContainer = styled('div')`
- width: 220px;
- height: 220px;
- background-image: url(${seerConfigBugSvg});
- background-size: contain;
- background-position: center;
- background-repeat: no-repeat;
- flex-shrink: 0;
-`;
diff --git a/static/app/views/settings/project/preprod/useStatusCheckRules.ts b/static/app/views/settings/project/preprod/useSizeRules.ts
similarity index 54%
rename from static/app/views/settings/project/preprod/useStatusCheckRules.ts
rename to static/app/views/settings/project/preprod/useSizeRules.ts
index e8816dc9938f..9c1bd7bbccde 100644
--- a/static/app/views/settings/project/preprod/useStatusCheckRules.ts
+++ b/static/app/views/settings/project/preprod/useSizeRules.ts
@@ -20,11 +20,29 @@ import {
type StatusCheckRule,
} from './types';
-const ENABLED_KEY = 'sentry:preprod_size_status_checks_enabled';
-const RULES_KEY = 'sentry:preprod_size_status_checks_rules';
-
-const DEFAULT_METRIC = DEFAULT_METRIC_TYPE;
-const DEFAULT_MEASUREMENT = DEFAULT_MEASUREMENT_TYPE;
+type EnabledField = 'preprodSizeStatusChecksEnabled' | 'preprodSizePrCommentsEnabled';
+
+type RulesField = 'preprodSizeStatusChecksRules' | 'preprodSizePrCommentsRules';
+
+export interface SizeRulesConfig {
+ /** Whether the feature is enabled when neither field nor option is set. */
+ defaultEnabled: boolean;
+ /** Top-level project field that mirrors the enabled flag (optimistic update). */
+ enabledField: EnabledField;
+ /** Project option key that stores the enabled flag (server response). */
+ enabledOptionKey: string;
+ /** Top-level project field that mirrors the rules array (optimistic update). */
+ rulesField: RulesField;
+ /** Project option key that stores the rules array (server response). */
+ rulesOptionKey: string;
+ toasts: {
+ created: string;
+ deleted: string;
+ disabled: string;
+ enabled: string;
+ saved: string;
+ };
+}
function parseRules(raw: unknown): StatusCheckRule[] {
if (!Array.isArray(raw)) {
@@ -33,8 +51,8 @@ function parseRules(raw: unknown): StatusCheckRule[] {
return raw
.filter((r): r is Record => !!r && typeof r.id === 'string')
.map(r => {
- const metric = toMetricType(r.metric, DEFAULT_METRIC);
- const measurement = toMeasurementType(r.measurement, DEFAULT_MEASUREMENT);
+ const metric = toMetricType(r.metric, DEFAULT_METRIC_TYPE);
+ const measurement = toMeasurementType(r.measurement, DEFAULT_MEASUREMENT_TYPE);
const artifactType = toArtifactType(r.artifactType);
return {
id: r.id as string,
@@ -47,14 +65,18 @@ function parseRules(raw: unknown): StatusCheckRule[] {
});
}
-export function useStatusCheckRules(project: DetailedProject) {
+export function useSizeRules(project: DetailedProject, config: SizeRulesConfig) {
const updateProject = useUpdateProject(project);
- // Check top-level field first (optimistic update), fallback to options (server response)
+ // Check top-level field first (optimistic update), fallback to options (server
+ // response), then to the configured default.
+ const enabledOption = project.options?.[config.enabledOptionKey];
const enabled =
- project.preprodSizeStatusChecksEnabled ?? project.options?.[ENABLED_KEY] !== false;
+ project[config.enabledField] ??
+ (enabledOption === undefined ? config.defaultEnabled : enabledOption === true);
- const rulesRaw = project.preprodSizeStatusChecksRules ?? project.options?.[RULES_KEY];
+ const rulesRaw =
+ (project[config.rulesField] as unknown) ?? project.options?.[config.rulesOptionKey];
const rules = useMemo(() => {
if (Array.isArray(rulesRaw)) {
return parseRules(rulesRaw);
@@ -69,33 +91,28 @@ export function useStatusCheckRules(project: DetailedProject) {
}
}, [rulesRaw]);
- const config = {enabled, rules};
+ const settingConfig = {enabled, rules};
const setEnabled = useCallback(
(value: boolean) => {
addLoadingMessage(t('Saving...'));
- updateProject.mutate(
- {preprodSizeStatusChecksEnabled: value},
- {
- onSuccess: () => {
- addSuccessMessage(
- value ? t('Status checks enabled.') : t('Status checks disabled.')
- );
- },
- onError: () => {
- addErrorMessage(t('Failed to save changes. Please try again.'));
- },
- }
- );
+ updateProject.mutate({[config.enabledField]: value} as Partial, {
+ onSuccess: () => {
+ addSuccessMessage(value ? config.toasts.enabled : config.toasts.disabled);
+ },
+ onError: () => {
+ addErrorMessage(t('Failed to save changes. Please try again.'));
+ },
+ });
},
- [updateProject]
+ [updateProject, config.enabledField, config.toasts]
);
const saveRules = useCallback(
(newRules: StatusCheckRule[], successMessage?: string) => {
addLoadingMessage(t('Saving...'));
updateProject.mutate(
- {preprodSizeStatusChecksRules: newRules as unknown[]},
+ {[config.rulesField]: newRules as unknown[]} as Partial,
{
onSuccess: () => {
if (successMessage) {
@@ -108,37 +125,37 @@ export function useStatusCheckRules(project: DetailedProject) {
}
);
},
- [updateProject]
+ [updateProject, config.rulesField]
);
const addRule = useCallback(
(rule: StatusCheckRule) => {
- saveRules([...rules, rule], t('Status check rule created.'));
+ saveRules([...rules, rule], config.toasts.created);
},
- [rules, saveRules]
+ [rules, saveRules, config.toasts.created]
);
const updateRule = useCallback(
(id: string, updates: Partial) => {
const newRules = rules.map(r => (r.id === id ? {...r, ...updates} : r));
- saveRules(newRules, t('Status check rule saved.'));
+ saveRules(newRules, config.toasts.saved);
},
- [rules, saveRules]
+ [rules, saveRules, config.toasts.saved]
);
const deleteRule = useCallback(
(id: string) => {
const newRules = rules.filter(r => r.id !== id);
- saveRules(newRules, t('Status check rule deleted.'));
+ saveRules(newRules, config.toasts.deleted);
},
- [rules, saveRules]
+ [rules, saveRules, config.toasts.deleted]
);
const createEmptyRule = useCallback((): StatusCheckRule => {
return {
id: uniqueId(),
- metric: DEFAULT_METRIC,
- measurement: DEFAULT_MEASUREMENT,
+ metric: DEFAULT_METRIC_TYPE,
+ measurement: DEFAULT_MEASUREMENT_TYPE,
value: 0,
filterQuery: '',
artifactType: DEFAULT_ARTIFACT_TYPE,
@@ -146,7 +163,7 @@ export function useStatusCheckRules(project: DetailedProject) {
}, []);
return {
- config,
+ config: settingConfig,
setEnabled,
addRule,
updateRule,