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} + + + )} + + + + + + ) : ( + + + {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,