From 1a4f0c3904dbb72d3bb48c1b453683fa91a5d7db Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 21 Sep 2026 22:44:10 +0000 Subject: [PATCH 1/3] ref(spike-protection): Convert SpikeProtectionHistoryTable to functional component Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../spikeProtectionHistoryTable.tsx | 95 +++++++++---------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx index 91852254beb5..ace590d246ee 100644 --- a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx +++ b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx @@ -1,4 +1,3 @@ -import {Component} from 'react'; import styled from '@emotion/styled'; import {Button, LinkButton} from '@sentry/scraps/button'; @@ -118,17 +117,24 @@ function EnableSpikeProtectionButton({ ); } -class SpikeProtectionHistoryTable extends Component { - headers = [ - t('Past Spikes'), - t('Initial Threshold'), - t('Duration'), - t('Events Dropped'), - null, // Discover Query button - ]; +const HEADERS = [ + t('Past Spikes'), + t('Initial Threshold'), + t('Duration'), + t('Events Dropped'), + null, // Discover Query button +]; - renderSpikeRow(spike: SpikeDetails) { - const {dataCategoryInfo, project, organization, subscription} = this.props; +function SpikeProtectionHistoryTable({ + dataCategoryInfo, + onEnableSpikeProtection, + organization, + project, + spikes, + subscription, + isLoading, +}: Props) { + function renderSpikeRow(spike: SpikeDetails) { // ms -> s, rounds up to get duration in minutes // rounding up to match the formatted date and time values const millisecondsPerSecond = 1000; @@ -197,8 +203,7 @@ class SpikeProtectionHistoryTable extends Component { ); } - renderEmptyMessage() { - const {organization} = this.props; + function renderEmptyMessage() { return ( {t('No Significant Spikes')} @@ -215,8 +220,7 @@ class SpikeProtectionHistoryTable extends Component { ); } - renderDisabledMessage() { - const {project, subscription, onEnableSpikeProtection} = this.props; + function renderDisabledMessage() { return ( {t('Spike Protection Disabled')} @@ -232,9 +236,7 @@ class SpikeProtectionHistoryTable extends Component { ); } - renderTable() { - const {spikes, project, isLoading} = this.props; - + function renderTable() { if (isLoading ?? false) { return ( @@ -244,11 +246,11 @@ class SpikeProtectionHistoryTable extends Component { } if (!isSpikeProtectionEnabled(project)) { - return this.renderDisabledMessage(); + return renderDisabledMessage(); } if (spikes.length === 0) { - return this.renderEmptyMessage(); + return renderEmptyMessage(); } return ( @@ -256,43 +258,40 @@ class SpikeProtectionHistoryTable extends Component { columns={SPIKE_COLUMNS} header={ - {this.headers.map((header, i) => ( + {HEADERS.map((header, i) => ( {header} ))} } > - {spikes.map(spike => this.renderSpikeRow(spike))} + {spikes.map(spike => renderSpikeRow(spike))} ); } - render() { - const {organization} = this.props; - return ( -
- - - {t('Spike Protection')} - <PageHeadingQuestionTooltip - docsUrl={SPIKE_PROTECTION_DOCS_LINK} - title={t( - 'Sentry applies a dynamic rate limit to your account designed to protect you from short-term spikes.' - )} - /> - - } - to={`/settings/${organization.slug}/spike-protection/`} - > - {t('Spike Protection Settings')} - - - {this.renderTable()} -
- ); - } + return ( +
+ + + {t('Spike Protection')} + <PageHeadingQuestionTooltip + docsUrl={SPIKE_PROTECTION_DOCS_LINK} + title={t( + 'Sentry applies a dynamic rate limit to your account designed to protect you from short-term spikes.' + )} + /> + + } + to={`/settings/${organization.slug}/spike-protection/`} + > + {t('Spike Protection Settings')} + + + {renderTable()} +
+ ); } export default withSubscription(withOrganization(SpikeProtectionHistoryTable)); From 6e7e9e98764e92c169e2d53b211f29db7fbff315 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 23 Sep 2026 14:58:30 -0700 Subject: [PATCH 2/3] ref(spike-protection): Split history table helpers into components The render helpers were closures over the component's props, recreated on every render. SpikeRow and SpikeHistoryContent now live at module scope with explicit props; the empty and disabled messages are inlined into SpikeHistoryContent since each had a single caller. The styled Title mixed typography with flex layout, so it is replaced with Flex + Text. variant="secondary" keeps the gray500 color it had. --- .../spikeProtectionHistoryTable.tsx | 262 +++++++++--------- 1 file changed, 134 insertions(+), 128 deletions(-) diff --git a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx index ace590d246ee..10dcb1242536 100644 --- a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx +++ b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx @@ -4,6 +4,7 @@ import {Button, LinkButton} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import type {TableColumnConfig} from '@sentry/scraps/table'; +import {Text} from '@sentry/scraps/text'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {DiscoverButton} from 'sentry/components/discoverButton'; @@ -125,7 +126,88 @@ const HEADERS = [ null, // Discover Query button ]; -function SpikeProtectionHistoryTable({ +function SpikeRow({ + dataCategoryInfo, + organization, + project, + spike, + subscription, +}: { + dataCategoryInfo: DataCategoryInfo; + organization: Organization; + project: ProjectSummaryWithOptions; + spike: SpikeDetails; + subscription: Subscription; +}) { + // ms -> s, rounds up to get duration in minutes + // rounding up to match the formatted date and time values + const millisecondsPerSecond = 1000; + const secondsPerMinute = 60; + const duration = spike.end + ? Math.ceil( + (new Date(spike.end).valueOf() - new Date(spike.start).valueOf()) / + (millisecondsPerSecond * secondsPerMinute) + ) * secondsPerMinute + : null; + return ( + + + + + + {defined(spike.threshold) + ? formatUsageWithUnits( + spike.threshold, + dataCategoryInfo.plural, + getFormatUsageOptions(dataCategoryInfo.plural) + ) + : '-'} + + + {duration ? getExactDuration(duration, true) : t('Ongoing')} + + + {spike.dropped + ? formatUsageWithUnits( + spike.dropped, + dataCategoryInfo.plural, + getFormatUsageOptions(dataCategoryInfo.plural) + ) + : '-'} + + + } + data-test-id="spike-protection-discover-button" + onClick={() => + trackSpendVisibilityAnaltyics(SpendVisibilityEvents.SP_DISCOVER_CLICKED, { + organization, + subscription, + view: 'project_stats', + }) + } + to={{ + pathname: makeDiscoverPathname({ + organization, + path: '/homepage/', + }), + query: { + project: [project.id], + start: decodeScalar(spike.start), + end: decodeScalar(spike.end), + }, + }} + > + {getDiscoverDeprecation(organization) + ? t('Open in Explore') + : t('Open in Discover')} + + + + ); +} + +function SpikeHistoryContent({ dataCategoryInfo, onEnableSpikeProtection, organization, @@ -134,93 +216,15 @@ function SpikeProtectionHistoryTable({ subscription, isLoading, }: Props) { - function renderSpikeRow(spike: SpikeDetails) { - // ms -> s, rounds up to get duration in minutes - // rounding up to match the formatted date and time values - const millisecondsPerSecond = 1000; - const secondsPerMinute = 60; - const duration = spike.end - ? Math.ceil( - (new Date(spike.end).valueOf() - new Date(spike.start).valueOf()) / - (millisecondsPerSecond * secondsPerMinute) - ) * secondsPerMinute - : null; + if (isLoading) { return ( - - - - - - {defined(spike.threshold) - ? formatUsageWithUnits( - spike.threshold, - dataCategoryInfo.plural, - getFormatUsageOptions(dataCategoryInfo.plural) - ) - : '-'} - - - {duration ? getExactDuration(duration, true) : t('Ongoing')} - - - {spike.dropped - ? formatUsageWithUnits( - spike.dropped, - dataCategoryInfo.plural, - getFormatUsageOptions(dataCategoryInfo.plural) - ) - : '-'} - - - } - data-test-id="spike-protection-discover-button" - onClick={() => - trackSpendVisibilityAnaltyics(SpendVisibilityEvents.SP_DISCOVER_CLICKED, { - organization, - subscription, - view: 'project_stats', - }) - } - to={{ - pathname: makeDiscoverPathname({ - organization, - path: '/homepage/', - }), - query: { - project: [project.id], - start: decodeScalar(spike.start), - end: decodeScalar(spike.end), - }, - }} - > - {getDiscoverDeprecation(organization) - ? t('Open in Explore') - : t('Open in Discover')} - - - - ); - } - - function renderEmptyMessage() { - return ( - - {t('No Significant Spikes')} -

- {t( - 'Spike Protection is enabled for this project, but there are no significant spikes that lasted 2hrs or longer.' - )} -
- {tct('Please see the [auditLogLink: audit log] for all detected spikes.', { - auditLogLink: , - })} -

-
+ + + ); } - function renderDisabledMessage() { + if (!isSpikeProtectionEnabled(project)) { return ( {t('Spike Protection Disabled')} @@ -236,76 +240,78 @@ function SpikeProtectionHistoryTable({ ); } - function renderTable() { - if (isLoading ?? false) { - return ( - - - - ); - } - - if (!isSpikeProtectionEnabled(project)) { - return renderDisabledMessage(); - } - - if (spikes.length === 0) { - return renderEmptyMessage(); - } - + if (spikes.length === 0) { return ( - - {HEADERS.map((header, i) => ( - {header} - ))} - - } - > - {spikes.map(spike => renderSpikeRow(spike))} - + + {t('No Significant Spikes')} +

+ {t( + 'Spike Protection is enabled for this project, but there are no significant spikes that lasted 2hrs or longer.' + )} +
+ {tct('Please see the [auditLogLink: audit log] for all detected spikes.', { + auditLogLink: , + })} +

+
); } + return ( + + {HEADERS.map((header, i) => ( + {header} + ))} + + } + > + {spikes.map(spike => ( + + ))} + + ); +} + +function SpikeProtectionHistoryTable(props: Props) { return (
- - {t('Spike Protection')} + <Flex flex="1" align="center" gap="sm"> + <Text bold size="lg" variant="secondary"> + {t('Spike Protection')} + </Text> <PageHeadingQuestionTooltip docsUrl={SPIKE_PROTECTION_DOCS_LINK} title={t( 'Sentry applies a dynamic rate limit to your account designed to protect you from short-term spikes.' )} /> - + } - to={`/settings/${organization.slug}/spike-protection/`} + to={`/settings/${props.organization.slug}/spike-protection/`} > {t('Spike Protection Settings')} - {renderTable()} +
); } export default withSubscription(withOrganization(SpikeProtectionHistoryTable)); -const Title = styled('div')` - font-weight: bold; - font-size: ${p => p.theme.font.size.lg}; - color: ${p => p.theme.colors.gray500}; - display: flex; - flex: 1; - align-items: center; - gap: ${p => p.theme.space.sm}; -`; - const EmptySpikeHistory = styled(Panel)` width: 100%; display: flex; From d84b2c333da770806a231c76f9445ea5aaed1fcf Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 23 Sep 2026 15:16:54 -0700 Subject: [PATCH 3/3] ref(spike-protection): Replace HoCs with hooks in history table organization and subscription were only injected by withOrganization and withSubscription, so the components that need them now read them with useOrganization and useSubscription instead of threading props down. The subscription is only used for analytics, so the table no longer waits on it before rendering; the spec's findBy calls become getBy. Drops the default export to satisfy no-default-exports. --- .../enhancedUsageStatsOrganization.tsx | 2 +- .../spikeProtectionHistoryTable.spec.tsx | 12 +++---- .../spikeProtectionHistoryTable.tsx | 35 +++++++------------ 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/static/gsApp/overrides/spendVisibility/enhancedUsageStatsOrganization.tsx b/static/gsApp/overrides/spendVisibility/enhancedUsageStatsOrganization.tsx index bcbce349cd53..012ba1967f68 100644 --- a/static/gsApp/overrides/spendVisibility/enhancedUsageStatsOrganization.tsx +++ b/static/gsApp/overrides/spendVisibility/enhancedUsageStatsOrganization.tsx @@ -37,7 +37,7 @@ import {withSubscription} from 'getsentry/components/withSubscription'; import {type Subscription} from 'getsentry/types'; import {SPIKE_PROTECTION_OPTION_DISABLED} from 'getsentry/views/spikeProtection/constants'; import {SpikeProtectionRangeLimitation} from 'getsentry/views/spikeProtection/spikeProtectionCallouts'; -import SpikeProtectionHistoryTable from 'getsentry/views/spikeProtection/spikeProtectionHistoryTable'; +import {SpikeProtectionHistoryTable} from 'getsentry/views/spikeProtection/spikeProtectionHistoryTable'; import SpikeProtectionUsageChart from 'getsentry/views/spikeProtection/spikeProtectionUsageChart'; import type { Spike, diff --git a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.spec.tsx b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.spec.tsx index 2e183fb17821..a890701cba37 100644 --- a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.spec.tsx +++ b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.spec.tsx @@ -8,7 +8,7 @@ import {DATA_CATEGORY_INFO} from 'sentry/constants'; import {DataCategoryExact} from 'sentry/types/core'; import {SubscriptionStore} from 'getsentry/stores/subscriptionStore'; -import SpikeProtectionHistoryTable from 'getsentry/views/spikeProtection/spikeProtectionHistoryTable'; +import {SpikeProtectionHistoryTable} from 'getsentry/views/spikeProtection/spikeProtectionHistoryTable'; import type {SpikeDetails} from 'getsentry/views/spikeProtection/types'; import {SPIKE_PROTECTION_OPTION_DISABLED} from './constants'; @@ -36,7 +36,7 @@ describe('SpikeProtectionHistoryTable', () => { }); }); - it('renders an empty state when no spikes are provided', async () => { + it('renders an empty state when no spikes are provided', () => { render( { {organization} ); - const emptyState = await screen.findByTestId('spike-history-empty'); + const emptyState = screen.getByTestId('spike-history-empty'); expect(emptyState).toBeInTheDocument(); const emptyMessage = screen.getByText(/No Significant Spikes/); expect(emptyMessage).toBeInTheDocument(); @@ -101,7 +101,7 @@ describe('SpikeProtectionHistoryTable', () => { />, {organization} ); - await screen.findByTestId('spike-protection-history-table'); + screen.getByTestId('spike-protection-history-table'); screen.getByText('2wk'); screen.getByText('1.3M'); screen.getByText('500K'); @@ -125,7 +125,7 @@ describe('SpikeProtectionHistoryTable', () => { ); }); - it('renders ongoing stored spike', async () => { + it('renders ongoing stored spike', () => { const storedSpikes: SpikeDetails[] = [ { start: new Date(2022, 0, 2, 6, 0, 0, 0).toISOString(), @@ -145,7 +145,7 @@ describe('SpikeProtectionHistoryTable', () => { {organization} ); - await screen.findByTestId('spike-protection-history-table'); + screen.getByTestId('spike-protection-history-table'); screen.getByText('Ongoing'); screen.getByText('200K'); screen.getByText('Jan 2, 2022 - present'); diff --git a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx index 10dcb1242536..da27e78090d5 100644 --- a/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx +++ b/static/gsApp/views/spikeProtection/spikeProtectionHistoryTable.tsx @@ -17,14 +17,12 @@ import {IconSettings} from 'sentry/icons'; import {IconTelescope} from 'sentry/icons/iconTelescope'; import {t, tct} from 'sentry/locale'; import type {DataCategoryInfo} from 'sentry/types/core'; -import type {Organization} from 'sentry/types/organization'; import type {ProjectSummaryWithOptions} from 'sentry/types/project'; import {defined} from 'sentry/utils/defined'; import {getExactDuration} from 'sentry/utils/duration/getExactDuration'; import {decodeScalar} from 'sentry/utils/queryString'; import {useApi} from 'sentry/utils/useApi'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {withOrganization} from 'sentry/utils/withOrganization'; import {makeDiscoverPathname} from 'sentry/views/discover/pathnames'; import {getDiscoverDeprecation} from 'sentry/views/discover/utils'; import { @@ -32,8 +30,7 @@ import { getFormatUsageOptions, } from 'sentry/views/organizationStats/utils'; -import {withSubscription} from 'getsentry/components/withSubscription'; -import type {Subscription} from 'getsentry/types'; +import {useSubscription} from 'getsentry/hooks/useSubscription'; import { SpendVisibilityEvents, trackSpendVisibilityAnaltyics, @@ -50,10 +47,8 @@ import {isSpikeProtectionEnabled} from './spikeProtectionProjectToggle'; type Props = { dataCategoryInfo: DataCategoryInfo; onEnableSpikeProtection: () => void; - organization: Organization; project: ProjectSummaryWithOptions; spikes: SpikeDetails[]; - subscription: Subscription; isLoading?: boolean; }; @@ -68,15 +63,14 @@ const SPIKE_COLUMNS: TableColumnConfig[] = [ function EnableSpikeProtectionButton({ onEnableSpikeProtection, project, - subscription, ...props }: { onEnableSpikeProtection: () => void; project: ProjectSummaryWithOptions; - subscription: Subscription; }) { const api = useApi(); const organization = useOrganization(); + const subscription = useSubscription(); const endpoint = `/organizations/${organization.slug}/spike-protections/`; async function enableSpikeProtection() { @@ -93,7 +87,7 @@ function EnableSpikeProtectionButton({ ); trackSpendVisibilityAnaltyics(SpendVisibilityEvents.SP_PROJECT_TOGGLED, { organization, - subscription, + subscription: subscription ?? undefined, project_id: project.id, value: true, view: 'project_stats', @@ -128,17 +122,15 @@ const HEADERS = [ function SpikeRow({ dataCategoryInfo, - organization, project, spike, - subscription, }: { dataCategoryInfo: DataCategoryInfo; - organization: Organization; project: ProjectSummaryWithOptions; spike: SpikeDetails; - subscription: Subscription; }) { + const organization = useOrganization(); + const subscription = useSubscription(); // ms -> s, rounds up to get duration in minutes // rounding up to match the formatted date and time values const millisecondsPerSecond = 1000; @@ -182,7 +174,7 @@ function SpikeRow({ onClick={() => trackSpendVisibilityAnaltyics(SpendVisibilityEvents.SP_DISCOVER_CLICKED, { organization, - subscription, + subscription: subscription ?? undefined, view: 'project_stats', }) } @@ -210,12 +202,12 @@ function SpikeRow({ function SpikeHistoryContent({ dataCategoryInfo, onEnableSpikeProtection, - organization, project, spikes, - subscription, isLoading, }: Props) { + const organization = useOrganization(); + if (isLoading) { return ( @@ -232,7 +224,6 @@ function SpikeHistoryContent({
@@ -272,17 +263,17 @@ function SpikeHistoryContent({ ))} ); } -function SpikeProtectionHistoryTable(props: Props) { +export function SpikeProtectionHistoryTable(props: Props) { + const organization = useOrganization(); + return (
@@ -300,7 +291,7 @@ function SpikeProtectionHistoryTable(props: Props) { } - to={`/settings/${props.organization.slug}/spike-protection/`} + to={`/settings/${organization.slug}/spike-protection/`} > {t('Spike Protection Settings')} @@ -310,8 +301,6 @@ function SpikeProtectionHistoryTable(props: Props) { ); } -export default withSubscription(withOrganization(SpikeProtectionHistoryTable)); - const EmptySpikeHistory = styled(Panel)` width: 100%; display: flex;