Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function AutomationBuilderSelect(props: ComponentProps<typeof Select>) {
}

const StyledSelect = styled(Select)`
width: 180px;
min-width: 180px;
padding: 0;
> div {
padding-left: 0;
Expand All @@ -20,7 +20,6 @@ export const selectControlStyles = {
control: (provided: any) => ({
...provided,
minHeight: '32px',
height: '32px',
Comment thread
leeandher marked this conversation as resolved.
padding: 0,
}),
};
3 changes: 3 additions & 0 deletions static/app/types/workflowEngine/dataConditions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export enum DataConditionType {
EVENT_UNIQUE_USER_FREQUENCY = 'event_unique_user_frequency',
PERCENT_SESSIONS = 'percent_sessions',
ANOMALY_DETECTION = 'anomaly_detection',

// activity trigger conditions
SEER_ACTIVITY_TRIGGER = 'seer_activity_trigger',
}

export enum DataConditionGroupLogicType {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import {DataConditionFixture} from 'sentry-fixture/automations';

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

import {DataConditionType} from 'sentry/types/workflowEngine/dataConditions';
import {
SeerActivityTriggerDetails,
SeerActivityTriggerNode,
validateSeerActivityTriggerCondition,
} from 'sentry/views/automations/components/actionFilters/seerActivityTrigger';
import {AutomationBuilderErrorContext} from 'sentry/views/automations/components/automationBuilderErrorContext';
import {DataConditionNodeContext} from 'sentry/views/automations/components/dataConditionNodes';

describe('SeerActivityTriggerDetails', () => {
it('renders single-stage text', () => {
render(
<SeerActivityTriggerDetails
condition={DataConditionFixture({
type: DataConditionType.SEER_ACTIVITY_TRIGGER,
comparison: ['pr_created'],
})}
/>
);

expect(
screen.getByText("Seer reaches the 'Pull request created' stage")
).toBeInTheDocument();
});

it('renders multi-stage text', () => {
render(
<SeerActivityTriggerDetails
condition={DataConditionFixture({
type: DataConditionType.SEER_ACTIVITY_TRIGGER,
comparison: ['rca_started', 'coding_completed'],
})}
/>
);

expect(
screen.getByText(
'Seer reaches any of these stages: Root cause analysis started, Coding completed'
)
).toBeInTheDocument();
});

it('handles empty comparison gracefully', () => {
render(
<SeerActivityTriggerDetails
condition={DataConditionFixture({
type: DataConditionType.SEER_ACTIVITY_TRIGGER,
comparison: [],
})}
/>
);

expect(screen.getByText('Seer reaches any of these stages:')).toBeInTheDocument();
});
});

describe('SeerActivityTriggerNode', () => {
const dataCondition = DataConditionFixture({
id: 'seer-1',
type: DataConditionType.SEER_ACTIVITY_TRIGGER,
comparison: ['rca_started', 'coding_completed'],
});
const errorContext = {
errors: {},
mutationErrors: undefined,
setErrors: jest.fn(),
removeError: jest.fn(),
};
const dataConditionNodeContext = {
condition: dataCondition,
condition_id: dataCondition.id,
onUpdate: jest.fn(),
};

it('renders the label and select', () => {
render(
<AutomationBuilderErrorContext.Provider value={errorContext}>
<DataConditionNodeContext.Provider value={dataConditionNodeContext}>
<SeerActivityTriggerNode />
</DataConditionNodeContext.Provider>
</AutomationBuilderErrorContext.Provider>
);

expect(
screen.getByText('Seer runs on an issue and reaches the stage...')
).toBeInTheDocument();
expect(
screen.getByRole('textbox', {name: 'Seer activity stages'})
).toBeInTheDocument();
});

it('calls onUpdate and removeError when a stage is selected', async () => {
render(
<AutomationBuilderErrorContext.Provider value={errorContext}>
<DataConditionNodeContext.Provider value={dataConditionNodeContext}>
<SeerActivityTriggerNode />
</DataConditionNodeContext.Provider>
</AutomationBuilderErrorContext.Provider>
);
await userEvent.click(screen.getByRole('textbox', {name: 'Seer activity stages'}));
await userEvent.click(
screen.getByRole('menuitemcheckbox', {name: 'Pull request created'})
);
await waitFor(() => {
expect(dataConditionNodeContext.onUpdate).toHaveBeenCalledWith({
comparison: dataCondition.comparison.concat('pr_created'),
});
});
expect(errorContext.removeError).toHaveBeenCalledWith('seer-1');
});

it('renders pre-selected stages', () => {
render(
<AutomationBuilderErrorContext.Provider value={errorContext}>
<DataConditionNodeContext.Provider value={dataConditionNodeContext}>
<SeerActivityTriggerNode />
</DataConditionNodeContext.Provider>
</AutomationBuilderErrorContext.Provider>
);

expect(screen.getByText('Root cause analysis started')).toBeInTheDocument();
expect(screen.getByText('Coding completed')).toBeInTheDocument();
});
});

describe('validateSeerActivityTriggerCondition', () => {
it('returns error when comparison is invalid', () => {
expect(
validateSeerActivityTriggerCondition({
condition: DataConditionFixture({
type: DataConditionType.SEER_ACTIVITY_TRIGGER,
comparison: [],
}),
})
).toBe('You must select at least one Seer stage.');

expect(
validateSeerActivityTriggerCondition({
condition: DataConditionFixture({
type: DataConditionType.SEER_ACTIVITY_TRIGGER,
comparison: undefined,
}),
})
).toBe('You must select at least one Seer stage.');
});

it('returns undefined for valid comparison', () => {
expect(
validateSeerActivityTriggerCondition({
condition: DataConditionFixture({
type: DataConditionType.SEER_ACTIVITY_TRIGGER,
comparison: ['rca_started'],
}),
})
).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {Flex} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';

import {AutomationBuilderSelect} from 'sentry/components/workflowEngine/form/automationBuilderSelect';
import {t, tct} from 'sentry/locale';
import type {SelectValue} from 'sentry/types/core';
import type {DataCondition} from 'sentry/types/workflowEngine/dataConditions';
import {useAutomationBuilderErrorContext} from 'sentry/views/automations/components/automationBuilderErrorContext';
import type {ValidateDataConditionProps} from 'sentry/views/automations/components/automationFormData';
import {useDataConditionNodeContext} from 'sentry/views/automations/components/dataConditionNodes';

const SEER_ACTIVITY_STAGE_CHOICES: Array<{label: string; value: string}> = [
{value: 'rca_started', label: t('Root cause analysis started')},
{value: 'rca_completed', label: t('Root cause analysis completed')},
{value: 'solution_started', label: t('Solution search started')},
{value: 'solution_completed', label: t('Solution search completed')},
{value: 'coding_started', label: t('Coding started')},
{value: 'coding_completed', label: t('Coding completed')},
{value: 'pr_created', label: t('Pull request created')},
];

export function SeerActivityTriggerDetails({condition}: {condition: DataCondition}) {
const stages: string[] = Array.isArray(condition.comparison)
? condition.comparison
: [];
// The stages should all appear in SEER_ACTIVITY_STAGE_CHOICES, but for type safety we call
// call .filter(Boolean) to get rid of invalid stages when we render.
const labels = stages
.map(s => SEER_ACTIVITY_STAGE_CHOICES.find(c => c.value === s)?.label)
.filter(Boolean);
Comment thread
leeandher marked this conversation as resolved.
const details =
labels.length === 1
? tct("Seer reaches the '[stage]' stage", {stage: labels[0] ?? ''})
: tct('Seer reaches any of these stages: [stages]', {stages: labels.join(', ')});

return <span>{details}</span>;
}

export function SeerActivityTriggerNode() {
const {condition, condition_id, onUpdate} = useDataConditionNodeContext();
const {removeError} = useAutomationBuilderErrorContext();
const value: string[] = Array.isArray(condition.comparison) ? condition.comparison : [];

return (
<Flex direction="column" gap="sm">
<Text>{t('Seer runs on an issue and reaches the stage...')}</Text>
<AutomationBuilderSelect
multiple
name={`${condition_id}.comparison`}
aria-label={t('Seer activity stages')}
placeholder={t('Select a stage...')}
value={value}
options={SEER_ACTIVITY_STAGE_CHOICES}
onChange={(options: Array<SelectValue<string>>) => {
onUpdate({comparison: options.map(o => o.value)});
removeError(condition.id);
}}
/>
</Flex>
);
}

export function validateSeerActivityTriggerCondition({
condition,
}: ValidateDataConditionProps): string | undefined {
if (!Array.isArray(condition.comparison) || condition.comparison.length === 0) {
return t('You must select at least one Seer stage.');
}
return undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {act, renderHook} from 'sentry-test/reactTestingLibrary';

import {DataConditionType} from 'sentry/types/workflowEngine/dataConditions';
import {dataConditionNodesMap} from 'sentry/views/automations/components/dataConditionNodes';

import {useAutomationBuilderReducer} from './automationBuilderContext';

describe('useAutomationBuilderReducer', () => {
it('falls back to true for when conditions without a defaultComparison', () => {
const {result} = renderHook(useAutomationBuilderReducer);

act(() => {
result.current.actions.addWhenCondition(DataConditionType.FIRST_SEEN_EVENT);
});

const conditions = result.current.state.triggers.conditions.filter(
c => c.type === DataConditionType.FIRST_SEEN_EVENT
);
const addedCondition = conditions.at(-1);

expect(addedCondition?.comparison).toBe(true);
});

it('uses defaultComparison from the node map when adding a when condition', () => {
const {result} = renderHook(useAutomationBuilderReducer);

act(() => {
result.current.actions.addWhenCondition(DataConditionType.SEER_ACTIVITY_TRIGGER);
});

const addedCondition = result.current.state.triggers.conditions.find(
c => c.type === DataConditionType.SEER_ACTIVITY_TRIGGER
);

const expectedDefault = dataConditionNodesMap.get(
DataConditionType.SEER_ACTIVITY_TRIGGER
)?.defaultComparison;

expect(expectedDefault).toBeDefined();
expect(addedCondition).toBeDefined();
expect(addedCondition?.comparison).toEqual(expectedDefault);
});

it('uses defaultComparison from the node map when adding an if condition', () => {
const {result} = renderHook(useAutomationBuilderReducer);
const groupId = result.current.state.actionFilters[0]!.id;

act(() => {
result.current.actions.addIfCondition(groupId, DataConditionType.AGE_COMPARISON);
});

const addedCondition = result.current.state.actionFilters[0]?.conditions.find(
c => c.type === DataConditionType.AGE_COMPARISON
);

const expectedDefault = dataConditionNodesMap.get(
DataConditionType.AGE_COMPARISON
)?.defaultComparison;

expect(expectedDefault).toBeDefined();
expect(addedCondition).toBeDefined();
expect(addedCondition?.comparison).toEqual(expectedDefault);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,10 @@ const initialAutomationBuilderState: AutomationBuilderState = {
id: 'when',
logicType: DataConditionGroupLogicType.ANY_SHORT_CIRCUIT,
conditions: [
createWhenCondition(DataConditionType.FIRST_SEEN_EVENT),
createWhenCondition(DataConditionType.ISSUE_RESOLVED_TRIGGER),
createWhenCondition(DataConditionType.REAPPEARED_EVENT),
createWhenCondition(DataConditionType.REGRESSION_EVENT),
createCondition(DataConditionType.FIRST_SEEN_EVENT),
createCondition(DataConditionType.ISSUE_RESOLVED_TRIGGER),
createCondition(DataConditionType.REAPPEARED_EVENT),
createCondition(DataConditionType.REGRESSION_EVENT),
],
},
actionFilters: [
Expand Down Expand Up @@ -297,15 +297,17 @@ type AutomationBuilderAction =
| UpdateIfActionAction
| UpdateIfLogicTypeAction;

function createWhenCondition(conditionType: DataConditionType): DataCondition {
function createCondition(conditionType: DataConditionType): DataCondition {
return {
id: uuid4(),
type: conditionType,
comparison: true,
// Most when conditions are booleans, so it's a safe fallback.
// If a condition's comparison is not a boolean, it MUST set a default comparison.
// For example, see DataConditionType.SEER_ACTIVITY_TRIGGER.
comparison: dataConditionNodesMap.get(conditionType)?.defaultComparison ?? true,
conditionResult: true,
};
}

function addWhenCondition(
state: AutomationBuilderState,
action: AddWhenConditionAction
Expand All @@ -314,10 +316,7 @@ function addWhenCondition(
...state,
triggers: {
...state.triggers,
conditions: [
...state.triggers.conditions,
createWhenCondition(action.conditionType),
],
conditions: [...state.triggers.conditions, createCondition(action.conditionType)],
},
};
}
Expand Down Expand Up @@ -409,16 +408,7 @@ function addIfCondition(
}
return {
...group,
conditions: [
...group.conditions,
{
id: uuid4(),
type: conditionType,
comparison:
dataConditionNodesMap.get(conditionType)?.defaultComparison || true,
conditionResult: true,
},
],
conditions: [...group.conditions, createCondition(conditionType)],
};
}),
};
Expand Down
Loading
Loading