Skip to content
Merged
9 changes: 9 additions & 0 deletions static/app/types/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ type ComponentHooks = {
'component:insights-date-range-query-limit-footer': () => React.ComponentType;
'component:insights-upsell-page': () => React.ComponentType<InsightsUpsellHook>;
'component:member-list-header': () => React.ComponentType<MemberListHeaderProps>;
'component:metric-alert-quota-icon': React.ComponentType;
'component:metric-alert-quota-message': React.ComponentType;
'component:org-stats-banner': () => React.ComponentType<DashboardHeadersProps>;
'component:org-stats-profiling-banner': () => React.ComponentType;
'component:organization-header': () => React.ComponentType<OrganizationHeaderProps>;
Expand Down Expand Up @@ -327,6 +329,13 @@ type ReactHooks = {
) => React.ContextType<typeof RouteAnalyticsContext>;
'react-hook:use-button-tracking': (props: ButtonProps) => () => void;
'react-hook:use-get-max-retention-days': () => number | undefined;
'react-hook:use-metric-detector-limit': () => {
detectorCount: number;
detectorLimit: number;
hasReachedLimit: boolean;
isError: boolean;
isLoading: boolean;
};
};

/**
Expand Down
41 changes: 27 additions & 14 deletions static/app/views/alerts/wizard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Feature from 'sentry/components/acl/feature';
import FeatureDisabled from 'sentry/components/acl/featureDisabled';
import {ExternalLink} from 'sentry/components/core/link';
import CreateAlertButton from 'sentry/components/createAlertButton';
import Hook from 'sentry/components/hook';
import {Hovercard} from 'sentry/components/hovercard';
import * as Layout from 'sentry/components/layouts/thirds';
import List from 'sentry/components/list';
Expand All @@ -14,6 +15,7 @@ import PanelBody from 'sentry/components/panels/panelBody';
import PanelHeader from 'sentry/components/panels/panelHeader';
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
import {t} from 'sentry/locale';
import HookStore from 'sentry/stores/hookStore';
import {space} from 'sentry/styles/space';
import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
import type {Organization} from 'sentry/types/organization';
Expand All @@ -23,7 +25,7 @@ import {makeAlertsPathname} from 'sentry/views/alerts/pathnames';
import {Dataset} from 'sentry/views/alerts/rules/metric/types';
import {AlertRuleType} from 'sentry/views/alerts/types';

import type {AlertType, WizardRuleTemplate} from './options';
import type {AlertType, MetricAlertType, WizardRuleTemplate} from './options';
import {
AlertWizardAlertNames,
AlertWizardExtraContent,
Expand All @@ -45,6 +47,11 @@ type AlertWizardProps = RouteComponentProps<RouteParams> & {
const DEFAULT_ALERT_OPTION = 'issues';

function AlertWizard({organization, params, location, projectId}: AlertWizardProps) {
const useMetricDetectorLimit =
HookStore.get('react-hook:use-metric-detector-limit')[0] ?? (() => null);
const quota = useMetricDetectorLimit();
const canCreateMetricAlert = !quota?.hasReachedLimit;

const [alertOption, setAlertOption] = useState<AlertType>(
location.query.alert_option in AlertWizardAlertNames
? location.query.alert_option
Expand All @@ -56,11 +63,13 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
setAlertOption(option);
};

let metricRuleTemplate: Readonly<WizardRuleTemplate> | undefined =
alertOption in AlertWizardRuleTemplates
? AlertWizardRuleTemplates[alertOption as MetricAlertType]
: undefined;
const isMetricAlert = !!metricRuleTemplate;

function renderCreateAlertButton() {
let metricRuleTemplate: Readonly<WizardRuleTemplate> | undefined =
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
AlertWizardRuleTemplates[alertOption];
const isMetricAlert = !!metricRuleTemplate;
const isTransactionDataset = metricRuleTemplate?.dataset === Dataset.TRANSACTIONS;

// If theres anything using the legacy sessions dataset, we need to convert it to metrics
Expand Down Expand Up @@ -114,7 +123,7 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
<CreateAlertButton
organization={organization}
projectSlug={projectSlug}
disabled={!hasFeature}
disabled={!hasFeature || (isMetricAlert && !canCreateMetricAlert)}
priority="primary"
to={{
pathname: makeAlertsPathname({
Expand Down Expand Up @@ -169,14 +178,17 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
<div key={categoryHeading}>
<CategoryTitle>{categoryHeading} </CategoryTitle>
<WizardGroupedOptions
choices={options.map((alertType: any) => {
return [
alertType,
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
AlertWizardAlertNames[alertType],
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
AlertWizardExtraContent[alertType],
];
choices={options.map((alertType: MetricAlertType) => {
const optionIsMetricAlert = alertType in AlertWizardRuleTemplates;

return {
id: alertType,
name: AlertWizardAlertNames[alertType],
badge: AlertWizardExtraContent[alertType],
trailingContent: optionIsMetricAlert ? (
<Hook name="component:metric-alert-quota-icon" />
) : null,
};
})}
onChange={option => handleChangeAlertOption(option as AlertType)}
value={alertOption}
Expand Down Expand Up @@ -209,6 +221,7 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
</PanelBody>
</div>
<WizardFooter>{renderCreateAlertButton()}</WizardFooter>
{isMetricAlert && <Hook name="component:metric-alert-quota-message" />}
</WizardPanelBody>
</WizardPanel>
</WizardBody>
Expand Down
12 changes: 6 additions & 6 deletions static/app/views/alerts/wizard/radioPanelGroup.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ describe('RadioGroupPanel', function () {
label="test"
value="choice_one"
choices={[
['choice_one', 'Choice One'],
['choice_two', 'Choice Two'],
['choice_three', 'Choice Three'],
{id: 'choice_one', name: 'Choice One'},
{id: 'choice_two', name: 'Choice Two'},
{id: 'choice_three', name: 'Choice Three'},
]}
onChange={mock}
/>
Expand All @@ -32,9 +32,9 @@ describe('RadioGroupPanel', function () {
label="test"
value="choice_one"
choices={[
['choice_one', 'Choice One'],
['choice_two', 'Choice Two', 'extra content'],
['choice_three', 'Choice Three'],
{id: 'choice_one', name: 'Choice One'},
{id: 'choice_two', name: 'Choice Two', trailingContent: 'extra content'},
{id: 'choice_three', name: 'Choice Three'},
]}
onChange={mock}
/>
Expand Down
40 changes: 22 additions & 18 deletions static/app/views/alerts/wizard/radioPanelGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import styled from '@emotion/styled';

import {Flex} from 'sentry/components/core/layout';
import {Radio} from 'sentry/components/core/radio';
import {space} from 'sentry/styles/space';

type RadioPanelGroupProps<C extends string> = {
/**
* An array of [id, name]
*/
choices: Array<[C, React.ReactNode, React.ReactNode?]>;
choices: Array<{
id: C;
name: React.ReactNode;
badge?: React.ReactNode;
trailingContent?: React.ReactNode;
}>;
label: string;
onChange: (id: C, e: React.FormEvent<HTMLInputElement>) => void;
value: string | null;
Expand All @@ -25,17 +31,20 @@ function RadioPanelGroup<C extends string>({
}: Props<C>) {
return (
<Container {...props} role="radiogroup" aria-labelledby={label}>
{(choices || []).map(([id, name, extraContent], index) => (
{(choices || []).map(({id, name, badge, trailingContent}, index) => (
<RadioPanel key={index}>
<RadioLineItem role="radio" index={index} aria-checked={value === id}>
<Radio
size="sm"
aria-label={id}
checked={value === id}
onChange={(e: React.FormEvent<HTMLInputElement>) => onChange(id, e)}
/>
<div>{name}</div>
{extraContent}
<Flex align="center" gap="sm">
<Radio
size="sm"
aria-label={id}
checked={value === id}
onChange={(e: React.FormEvent<HTMLInputElement>) => onChange(id, e)}
/>
{name}
{badge}
</Flex>
{trailingContent && <div>{trailingContent}</div>}
</RadioLineItem>
</RadioPanel>
))}
Expand All @@ -56,10 +65,10 @@ const Container = styled('div')`
const RadioLineItem = styled('label')<{
index: number;
}>`
display: grid;
gap: ${space(0.25)} ${space(1)};
grid-template-columns: max-content auto max-content;
display: flex;
gap: ${p => p.theme.space.sm};
align-items: center;
justify-content: space-between;
cursor: pointer;
outline: none;
font-weight: ${p => p.theme.fontWeight.normal};
Expand All @@ -74,11 +83,6 @@ const RadioLineItem = styled('label')<{
color: ${p => p.theme.textColor};
}

svg {
display: none;
opacity: 0;
}

&[aria-checked='true'] {
color: ${p => p.theme.textColor};
}
Expand Down
1 change: 1 addition & 0 deletions static/gsApp/__fixtures__/plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function PlanFixture(fields: Partial<Plan>): Plan {
availableReservedBudgetTypes: {},
contractInterval: 'monthly',
dashboardLimit: 10,
metricDetectorLimit: 20,
description: '',
features: [],
hasOnDemandModes: false,
Expand Down
116 changes: 116 additions & 0 deletions static/gsApp/components/metricAlertQuotaMessage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {OrganizationFixture} from 'sentry-fixture/organization';

import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import {openUpsellModal} from 'getsentry/actionCreators/modal';
import SubscriptionStore from 'getsentry/stores/subscriptionStore';

import {MetricAlertQuotaMessage} from './metricAlertQuotaMessage';

jest.mock('getsentry/actionCreators/modal', () => ({
openUpsellModal: jest.fn(),
}));

describe('MetricAlertQuotaMessage', () => {
const organization = OrganizationFixture({
features: ['workflow-engine-metric-detector-limit'],
});

beforeEach(() => {
jest.clearAllMocks();
});

it('renders nothing when subscription has unlimited quota', async () => {
const subscription = SubscriptionFixture({
organization,
planDetails: {
...SubscriptionFixture({
organization,
}).planDetails,
metricDetectorLimit: -1,
},
});

SubscriptionStore.set(organization.slug, subscription);

const {container} = render(<MetricAlertQuotaMessage />, {
organization,
});

await waitFor(() => {
expect(container).toBeEmptyDOMElement();
});
});

it('renders approaching detectorLimit message with upgrade action', async () => {
const subscription = SubscriptionFixture({
organization,
planDetails: {
...SubscriptionFixture({
organization,
}).planDetails,
metricDetectorLimit: 10,
},
});

SubscriptionStore.set(organization.slug, subscription);

MockApiClient.addMockResponse({
url: '/organizations/org-slug/detectors/',
headers: {'X-Hits': '9'},
body: [],
});

render(<MetricAlertQuotaMessage />, {organization});

expect(await screen.findByText(/used 9 of 10 metric monitors/i)).toBeInTheDocument();

const upgrade = screen.getByRole('button', {name: /upgrade your plan/i});
await userEvent.click(upgrade);

expect(openUpsellModal).toHaveBeenCalledWith({
organization,
source: 'metric-alert-quota',
});
});

it('renders reached detectorLimit message with remove and upgrade actions when at detectorLimit', async () => {
const subscription = SubscriptionFixture({
organization,
planDetails: {
...SubscriptionFixture({
organization,
}).planDetails,
metricDetectorLimit: 10,
},
});

SubscriptionStore.set(organization.slug, subscription);

MockApiClient.addMockResponse({
url: '/organizations/org-slug/detectors/',
headers: {'X-Hits': '10'},
body: [],
});

render(<MetricAlertQuotaMessage />, {organization});

expect(
await screen.findByText(/reached your plan's limit on metric monitors/i)
).toBeInTheDocument();

// Remove link and upgrade button are present
expect(
screen.getByRole('link', {name: /remove existing monitors/i})
).toBeInTheDocument();

const upgrade = screen.getByRole('button', {name: /upgrade your plan/i});
await userEvent.click(upgrade);

expect(openUpsellModal).toHaveBeenCalledWith({
organization,
source: 'metric-alert-quota',
});
});
});
Loading
Loading