Skip to content

Commit 6021f68

Browse files
authored
feat(aci): Add validation to threshold inputs (#96375)
1 parent 38a55d6 commit 6021f68

File tree

9 files changed

+138
-130
lines changed

9 files changed

+138
-130
lines changed

static/app/components/workflowEngine/form/control/index.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default Storybook.story('Form Controls', story => {
1616

1717
<Form hideFooter>
1818
<Flex direction="column" gap="xl">
19-
<PriorityControl minimumPriority={DetectorPriorityLevel.LOW} />
19+
<PriorityControl minimumPriority={DetectorPriorityLevel.MEDIUM} />
2020
</Flex>
2121
</Form>
2222
</Fragment>

static/app/components/workflowEngine/form/control/priorityControl.spec.tsx

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,36 @@ describe('PriorityControl', function () {
1414
const formModel = new FormModel({
1515
initialData: {
1616
...DEFAULT_THRESHOLD_METRIC_FORM_DATA,
17-
[METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel]: DetectorPriorityLevel.LOW,
17+
[METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel]: DetectorPriorityLevel.MEDIUM,
1818
},
1919
});
2020
render(
2121
<Form model={formModel} hideFooter>
22-
<PriorityControl minimumPriority={DetectorPriorityLevel.LOW} />
22+
<PriorityControl minimumPriority={DetectorPriorityLevel.MEDIUM} />
2323
</Form>
2424
);
2525

2626
expect(await screen.findByText('Above 0ms')).toBeInTheDocument();
27-
expect(screen.getByLabelText('Medium threshold')).toBeInTheDocument();
2827
expect(screen.getByLabelText('High threshold')).toBeInTheDocument();
2928
});
3029

3130
it('allows configuring priority', async function () {
3231
const formModel = new FormModel({
3332
initialData: {
3433
...DEFAULT_THRESHOLD_METRIC_FORM_DATA,
35-
[METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel]: DetectorPriorityLevel.LOW,
34+
[METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel]: DetectorPriorityLevel.MEDIUM,
3635
},
3736
});
3837
render(
3938
<Form model={formModel} hideFooter>
40-
<PriorityControl minimumPriority={DetectorPriorityLevel.LOW} />
39+
<PriorityControl minimumPriority={DetectorPriorityLevel.MEDIUM} />
4140
</Form>
4241
);
43-
expect(await screen.findByRole('button', {name: 'Low'})).toBeInTheDocument();
42+
expect(await screen.findByRole('button', {name: 'Med'})).toBeInTheDocument();
4443
expect(screen.getByText('Med')).toBeInTheDocument();
4544
expect(screen.getByText('High')).toBeInTheDocument();
4645

47-
await userEvent.click(screen.getByRole('button', {name: 'Low'}));
46+
await userEvent.click(screen.getByRole('button', {name: 'Med'}));
4847
await userEvent.click(await screen.findByRole('option', {name: 'High'}));
4948
expect(formModel.getValue(METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel)).toBe(
5049
DetectorPriorityLevel.HIGH
@@ -53,25 +52,22 @@ describe('PriorityControl', function () {
5352
expect(screen.getAllByRole('button')).toHaveLength(1);
5453
});
5554

56-
it('allows configuring medium and high thresholds', async function () {
55+
it('allows configuring high thresholds', async function () {
5756
const formModel = new FormModel({
5857
initialData: {
5958
...DEFAULT_THRESHOLD_METRIC_FORM_DATA,
60-
[METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel]: DetectorPriorityLevel.LOW,
59+
[METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel]: DetectorPriorityLevel.MEDIUM,
6160
},
6261
});
6362
render(
6463
<Form model={formModel} hideFooter>
65-
<PriorityControl minimumPriority={DetectorPriorityLevel.LOW} />
64+
<PriorityControl minimumPriority={DetectorPriorityLevel.MEDIUM} />
6665
</Form>
6766
);
68-
const medium = screen.getByLabelText('Medium threshold');
69-
await userEvent.type(medium, '4');
7067

7168
const high = screen.getByLabelText('High threshold');
7269
await userEvent.type(high, '5');
7370

74-
expect(formModel.getValue(METRIC_DETECTOR_FORM_FIELDS.mediumThreshold)).toBe('4');
7571
expect(formModel.getValue(METRIC_DETECTOR_FORM_FIELDS.highThreshold)).toBe('5');
7672
});
7773

@@ -97,4 +93,39 @@ describe('PriorityControl', function () {
9793
expect(screen.getByRole('option', {name: 'High'})).toBeInTheDocument();
9894
expect(screen.queryByRole('option', {name: 'Low'})).not.toBeInTheDocument();
9995
});
96+
97+
it('validates that medium threshold is lower than high threshold', async function () {
98+
const formModel = new FormModel({
99+
initialData: {
100+
...DEFAULT_THRESHOLD_METRIC_FORM_DATA,
101+
[METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel]: DetectorPriorityLevel.MEDIUM,
102+
[METRIC_DETECTOR_FORM_FIELDS.conditionValue]: '10', // This is the medium threshold
103+
},
104+
});
105+
106+
render(
107+
<Form model={formModel} hideFooter>
108+
<PriorityControl minimumPriority={DetectorPriorityLevel.MEDIUM} />
109+
</Form>
110+
);
111+
112+
// Only high threshold field should be visible when initial priority is MEDIUM
113+
const highField = screen.getByLabelText('High threshold');
114+
expect(screen.queryByLabelText('Medium threshold')).not.toBeInTheDocument();
115+
116+
// Test invalid case: high (5) <= medium (10)
117+
await userEvent.clear(highField);
118+
await userEvent.type(highField, '5');
119+
120+
expect(formModel.getError(METRIC_DETECTOR_FORM_FIELDS.highThreshold)).toBe(
121+
'High threshold must be higher than medium threshold'
122+
);
123+
124+
// Test valid case: high (15) > medium (10)
125+
await userEvent.clear(highField);
126+
await userEvent.type(highField, '15');
127+
128+
// Validation should clear error when high > medium
129+
expect(formModel.getError(METRIC_DETECTOR_FORM_FIELDS.highThreshold)).toBeFalsy();
130+
});
100131
});

static/app/components/workflowEngine/form/control/priorityControl.tsx

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {useContext} from 'react';
22
import styled from '@emotion/styled';
3+
import toNumber from 'lodash/toNumber';
34

45
import {GroupPriorityBadge} from 'sentry/components/badge/groupPriority';
56
import {CompactSelect} from 'sentry/components/core/compactSelect';
@@ -16,11 +17,12 @@ import {
1617
DETECTOR_PRIORITY_LEVEL_TO_PRIORITY_LEVEL,
1718
DetectorPriorityLevel,
1819
} from 'sentry/types/workflowEngine/dataConditions';
20+
import type {MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metric/metricFormData';
1921
import {
2022
METRIC_DETECTOR_FORM_FIELDS,
2123
useMetricDetectorFormField,
2224
} from 'sentry/views/detectors/components/forms/metric/metricFormData';
23-
import {getStaticDetectorThresholdSuffix} from 'sentry/views/detectors/utils/metricDetectorSuffix';
25+
import {getMetricDetectorSuffix} from 'sentry/views/detectors/utils/metricDetectorSuffix';
2426

2527
const priorities = [
2628
DetectorPriorityLevel.LOW,
@@ -42,17 +44,13 @@ const conditionKindAndTypeToLabel: Record<
4244
},
4345
};
4446

45-
function ThresholdPriority() {
47+
function ThresholdPriority({thresholdSuffix}: {thresholdSuffix: string}) {
4648
const conditionType = useMetricDetectorFormField(
4749
METRIC_DETECTOR_FORM_FIELDS.conditionType
4850
);
4951
const conditionValue = useMetricDetectorFormField(
5052
METRIC_DETECTOR_FORM_FIELDS.conditionValue
5153
);
52-
const aggregate = useMetricDetectorFormField(
53-
METRIC_DETECTOR_FORM_FIELDS.aggregateFunction
54-
);
55-
const thresholdSuffix = getStaticDetectorThresholdSuffix(aggregate);
5654

5755
return (
5856
<div>
@@ -63,17 +61,13 @@ function ThresholdPriority() {
6361
);
6462
}
6563

66-
function ChangePriority() {
64+
function ChangePriority({thresholdSuffix}: {thresholdSuffix: string}) {
6765
const conditionType = useMetricDetectorFormField(
6866
METRIC_DETECTOR_FORM_FIELDS.conditionType
6967
);
7068
const conditionValue = useMetricDetectorFormField(
7169
METRIC_DETECTOR_FORM_FIELDS.conditionValue
7270
);
73-
const aggregate = useMetricDetectorFormField(
74-
METRIC_DETECTOR_FORM_FIELDS.aggregateFunction
75-
);
76-
const thresholdSuffix = getStaticDetectorThresholdSuffix(aggregate);
7771

7872
return (
7973
<div>
@@ -83,8 +77,54 @@ function ChangePriority() {
8377
);
8478
}
8579

80+
function createValidationError(field: string, message: string): [string, string] {
81+
return [field, message];
82+
}
83+
84+
function validateThresholdOrder(
85+
value: number,
86+
reference: number,
87+
conditionType: DataConditionType,
88+
isGreaterExpected: boolean
89+
): boolean {
90+
if (conditionType === DataConditionType.GREATER) {
91+
return isGreaterExpected ? value > reference : value < reference;
92+
}
93+
// For LESS condition type, logic is inverted
94+
return isGreaterExpected ? value < reference : value > reference;
95+
}
96+
97+
function validateHighThreshold({
98+
form,
99+
}: {
100+
form: MetricDetectorFormData;
101+
id: string;
102+
}): Array<[string, string]> {
103+
const highNum = toNumber(form.highThreshold);
104+
const conditionNum = toNumber(form.conditionValue);
105+
const {conditionType} = form;
106+
107+
if (!conditionType) {
108+
return [];
109+
}
110+
111+
if (
112+
Number.isFinite(highNum) &&
113+
Number.isFinite(conditionNum) &&
114+
!validateThresholdOrder(highNum, conditionNum, conditionType, true)
115+
) {
116+
const message = t(
117+
'High threshold must be %s than medium threshold',
118+
conditionType === DataConditionType.GREATER ? t('higher') : t('lower')
119+
);
120+
return [createValidationError(METRIC_DETECTOR_FORM_FIELDS.highThreshold, message)];
121+
}
122+
123+
return [];
124+
}
125+
86126
interface PriorityControlProps {
87-
minimumPriority: DetectorPriorityLevel;
127+
minimumPriority: DetectorPriorityLevel.MEDIUM | DetectorPriorityLevel.HIGH;
88128
}
89129

90130
export default function PriorityControl({minimumPriority}: PriorityControlProps) {
@@ -100,7 +140,7 @@ export default function PriorityControl({minimumPriority}: PriorityControlProps)
100140
const aggregate = useMetricDetectorFormField(
101141
METRIC_DETECTOR_FORM_FIELDS.aggregateFunction
102142
);
103-
const thresholdSuffix = getStaticDetectorThresholdSuffix(aggregate);
143+
const thresholdSuffix = getMetricDetectorSuffix(detectionType, aggregate);
104144

105145
if (detectionType === 'dynamic') {
106146
return null;
@@ -111,47 +151,31 @@ export default function PriorityControl({minimumPriority}: PriorityControlProps)
111151
<PrioritizeRow
112152
left={
113153
<Flex align="center" direction="column">
114-
{detectionType === 'static' ? <ThresholdPriority /> : <ChangePriority />}
154+
{detectionType === 'static' ? (
155+
<ThresholdPriority thresholdSuffix={thresholdSuffix} />
156+
) : (
157+
<ChangePriority thresholdSuffix={thresholdSuffix} />
158+
)}
115159
<SecondaryLabel>({t('issue created')})</SecondaryLabel>
116160
</Flex>
117161
}
118162
right={<InitialPrioritySelect minimumPriority={minimumPriority} />}
119163
/>
120-
{priorityIsConfigurable(initialPriorityLevel, DetectorPriorityLevel.MEDIUM) && (
164+
{initialPriorityLevel === DetectorPriorityLevel.MEDIUM && (
121165
<PrioritizeRow
122166
left={
123167
<Flex align="center" gap="md">
124168
<SmallNumberField
125-
alignRight
126-
inline
127-
hideLabel
128-
flexibleControlStateSize
129-
size="sm"
130-
suffix={thresholdSuffix}
131-
placeholder="0"
132-
name={METRIC_DETECTOR_FORM_FIELDS.mediumThreshold}
133-
aria-label={t('Medium threshold')}
134-
/>
135-
<div>{conditionKindAndTypeToLabel[detectionType][conditionType!]}</div>
136-
</Flex>
137-
}
138-
right={<GroupPriorityBadge showLabel priority={PriorityLevel.MEDIUM} />}
139-
/>
140-
)}
141-
{priorityIsConfigurable(initialPriorityLevel, DetectorPriorityLevel.HIGH) && (
142-
<PrioritizeRow
143-
left={
144-
<Flex align="center" gap="md">
145-
<SmallNumberField
146-
alignRight
147-
inline
169+
inline={false}
148170
hideLabel
149171
flexibleControlStateSize
150172
size="sm"
151173
suffix={thresholdSuffix}
152174
placeholder="0"
153175
name={METRIC_DETECTOR_FORM_FIELDS.highThreshold}
154176
aria-label={t('High threshold')}
177+
validate={validateHighThreshold}
178+
required
155179
/>
156180
<div>{conditionKindAndTypeToLabel[detectionType][conditionType!]}</div>
157181
</Flex>
@@ -163,13 +187,6 @@ export default function PriorityControl({minimumPriority}: PriorityControlProps)
163187
);
164188
}
165189

166-
function priorityIsConfigurable(
167-
initialPriorityLevel: DetectorPriorityLevel,
168-
targetPriority: DetectorPriorityLevel
169-
): boolean {
170-
return targetPriority > initialPriorityLevel;
171-
}
172-
173190
function PrioritizeRow({left, right}: {left: React.ReactNode; right: React.ReactNode}) {
174191
return (
175192
<Row>
@@ -258,7 +275,7 @@ const Cell = styled('div')`
258275
`;
259276

260277
const SmallNumberField = styled(NumberField)`
261-
width: 3.5rem;
278+
width: 6rem;
262279
padding: 0;
263280
& > div {
264281
padding-left: 0;

static/app/views/detectors/components/details/metric/sidebar.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ function DetectorPriorities({detector}: {detector: MetricDetector}) {
5454
typeof condition.comparison === 'number'
5555
? String(condition.comparison)
5656
: String(condition.comparison || '0');
57-
const thresholdSuffix = getMetricDetectorSuffix(detector);
57+
const thresholdSuffix = getMetricDetectorSuffix(
58+
detector.config?.detectionType || 'static',
59+
detector.dataSources[0].queryObj?.snubaQuery?.aggregate || 'count()'
60+
);
5861

5962
return `${typeLabel} ${comparisonValue}${thresholdSuffix}`;
6063
};
@@ -87,7 +90,10 @@ function DetectorResolve({detector}: {detector: MetricDetector}) {
8790
const mainCondition = conditions.find(
8891
condition => condition.conditionResult !== DetectorPriorityLevel.OK
8992
);
90-
const thresholdSuffix = getMetricDetectorSuffix(detector);
93+
const thresholdSuffix = getMetricDetectorSuffix(
94+
detector.config?.detectionType || 'static',
95+
detector.dataSources[0].queryObj?.snubaQuery?.aggregate || 'count()'
96+
);
9197

9298
const description = getResolutionDescription({
9399
detectionType,

static/app/views/detectors/components/detectorLink.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,17 @@ function DetailItem({children}: {children: React.ReactNode}) {
8181
}
8282

8383
function MetricDetectorConfigDetails({detector}: {detector: MetricDetector}) {
84-
const type = detector.config.detectionType;
84+
const detectionType = detector.config.detectionType;
8585
const conditions = detector.conditionGroup?.conditions;
8686
if (!conditions?.length) {
8787
return null;
8888
}
8989

90-
const unit = getMetricDetectorSuffix(detector);
91-
switch (type) {
90+
const unit = getMetricDetectorSuffix(
91+
detectionType,
92+
detector.dataSources[0].queryObj?.snubaQuery?.aggregate || 'count()'
93+
);
94+
switch (detectionType) {
9295
case 'static': {
9396
const text = conditions
9497
.map(condition => formatCondition({condition, unit}))
@@ -112,7 +115,7 @@ function MetricDetectorConfigDetails({detector}: {detector: MetricDetector}) {
112115
case 'dynamic':
113116
return <DetailItem>{t('Dynamic')}</DetailItem>;
114117
default:
115-
unreachable(type);
118+
unreachable(detectionType);
116119
return null;
117120
}
118121
}

static/app/views/detectors/edit.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe('DetectorEdit | Metric Detector', () => {
126126
queryType: 0,
127127
},
128128
conditionGroup: {
129-
conditions: [{comparison: 8, conditionResult: 50, type: 'gt'}],
129+
conditions: [{comparison: 8, conditionResult: 75, type: 'gt'}],
130130
logicType: 'any',
131131
},
132132
config: {detectionType: 'static', thresholdPeriod: 1},

0 commit comments

Comments
 (0)