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 @@ -118,7 +118,9 @@ export function AutomationBuilderDrawerForm({

const handleSubmit = useCallback<OnSubmitCallback>(
async (data, onSubmitSuccess, onSubmitError, _event, formModel) => {
const errors = validateAutomationBuilderState(state);
const errors = validateAutomationBuilderState(state, data as AutomationFormData, {
validateConnectedMonitors: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be nice to have a comment as to why we don't validate in the drawer; (seems like because we're enforcing on to be there, but it's a little unclear why we don't want to validate that)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, will add. We don't validate it in the drawer because we actually do want to create it without any connections (because when creating inline for a new monitor, the monitor hasn't been created yet)

});
setAutomationBuilderErrors(errors);

if (Object.keys(errors).length > 0) {
Expand Down
18 changes: 17 additions & 1 deletion static/app/views/automations/components/automationFormData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import {actionNodesMap} from 'sentry/views/automations/components/actionNodes';
import type {AutomationBuilderState} from 'sentry/views/automations/components/automationBuilderContext';
import {dataConditionNodesMap} from 'sentry/views/automations/components/dataConditionNodes';
import {CONNECTED_MONITORS_ERROR_ID} from 'sentry/views/automations/components/editConnectedMonitors';

export interface AutomationFormData {
detectorIds: string[];
Expand Down Expand Up @@ -106,8 +107,23 @@ export interface ValidateDataConditionProps {
condition: DataCondition;
}

export function validateAutomationBuilderState(state: AutomationBuilderState) {
export function validateAutomationBuilderState(
state: AutomationBuilderState,
data: AutomationFormData,
{validateConnectedMonitors = true}: {validateConnectedMonitors?: boolean} = {}
) {
const errors: Record<string, string> = {};

if (
validateConnectedMonitors &&
!data.detectorIds?.length &&
!data.projectIds?.length
) {
errors[CONNECTED_MONITORS_ERROR_ID] = t(
'Select at least one project or monitor to create an alert.'
);
}

// validate trigger conditions
for (const condition of state.triggers.conditions || []) {
const validationResult = dataConditionNodesMap
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Fragment, useCallback, useContext, useEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';

import {Alert} from '@sentry/scraps/alert';
import {Button, LinkButton} from '@sentry/scraps/button';
import {Container, Flex, Stack} from '@sentry/scraps/layout';

Expand All @@ -22,6 +23,7 @@ import type {Detector} from 'sentry/types/workflowEngine/detectors';
import {useQueryClient} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useProjects} from 'sentry/utils/useProjects';
import {AutomationBuilderErrorContext} from 'sentry/views/automations/components/automationBuilderErrorContext';
import {ConnectedMonitorsList} from 'sentry/views/automations/components/connectedMonitorsList';
import {useConnectedDetectors} from 'sentry/views/automations/hooks/useConnectedDetectors';
import {DetectorSearch} from 'sentry/views/detectors/components/detectorSearch';
Expand Down Expand Up @@ -284,13 +286,16 @@ function SpecificMonitorsSection({
);
}

export const CONNECTED_MONITORS_ERROR_ID = 'connectedMonitors';

function EditConnectedMonitorsContent({
initialMode,
connectedIds,
setConnectedIds,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not related to this pr and a bit of a nit.. but might be nice to get this method from context or something so it's clearer where the connected id's are being stored.

}: ContentProps) {
const [monitorMode, setMonitorMode] = useState<MonitorMode>(initialMode);
const {form} = useContext(FormContext);
const errorContext = useContext(AutomationBuilderErrorContext);

const handleModeChange = useCallback(
(newMode: MonitorMode) => {
Expand All @@ -302,11 +307,23 @@ function EditConnectedMonitorsContent({
);
const handleProjectChange = useCallback(
(projectIds: string[]) => {
if (!projectIds.length) {
if (projectIds.length) {
errorContext?.removeError(CONNECTED_MONITORS_ERROR_ID);
} else {
setConnectedIds([]);
}
},
[setConnectedIds]
[setConnectedIds, errorContext]
);

const handleSetConnectedIds = useCallback(
(ids: Automation['detectorIds']) => {
setConnectedIds(ids);
if (ids.length) {
errorContext?.removeError(CONNECTED_MONITORS_ERROR_ID);
}
},
[setConnectedIds, errorContext]
);

return (
Expand All @@ -332,9 +349,14 @@ function EditConnectedMonitorsContent({
) : (
<SpecificMonitorsSection
connectedIds={connectedIds}
setConnectedIds={setConnectedIds}
setConnectedIds={handleSetConnectedIds}
/>
)}
{errorContext?.errors[CONNECTED_MONITORS_ERROR_ID] && (
<Alert variant="danger">
{errorContext.errors[CONNECTED_MONITORS_ERROR_ID]}
</Alert>
)}
</Stack>
</FormSection>
</WorkflowEngineContainer>
Expand Down
7 changes: 4 additions & 3 deletions static/app/views/automations/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,14 @@ function AutomationEditForm({automation}: {automation: Automation}) {

const handleFormSubmit = useCallback<OnSubmitCallback>(
async (data, onSubmitSuccess, onSubmitError, _event, formModel) => {
const errors = validateAutomationBuilderState(state);
const automationFormData = data as AutomationFormData;
const errors = validateAutomationBuilderState(state, automationFormData);
setAutomationBuilderErrors(errors);

if (Object.keys(errors).length > 0) {
const analyticsPayload = getAutomationAnalyticsPayload(
getNewAutomationData({
data: data as AutomationFormData,
data: automationFormData,
state,
})
);
Expand All @@ -167,7 +168,7 @@ function AutomationEditForm({automation}: {automation: Automation}) {
formModel.setFormSaving();

const formData = await resolveDetectorIdsForProjects({
formData: data as AutomationFormData,
formData: automationFormData,
onSubmitError,
organization,
projectIds: data.projectIds,
Expand Down
80 changes: 77 additions & 3 deletions static/app/views/automations/new.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {AutomationFixture} from 'sentry-fixture/automations';
import {IssueStreamDetectorFixture} from 'sentry-fixture/detectors';
import {MemberFixture} from 'sentry-fixture/member';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {PageFiltersFixture} from 'sentry-fixture/pageFilters';
import {ProjectFixture} from 'sentry-fixture/project';
import {UserFixture} from 'sentry-fixture/user';
import {
Expand All @@ -13,6 +14,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar
import {selectEvent} from 'sentry-test/selectEvent';

import * as indicators from 'sentry/actionCreators/indicator';
import {PageFiltersStore} from 'sentry/components/pageFilters/store';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import type {Action} from 'sentry/types/workflowEngine/actions';
import {ActionGroup, ActionType} from 'sentry/types/workflowEngine/actions';
Expand Down Expand Up @@ -209,7 +211,12 @@ describe('AutomationNewSettings', () => {
body: created,
});

const {router} = render(<AutomationNewSettings />, {organization});
const {router} = render(<AutomationNewSettings />, {
organization,
initialRouterConfig: {
location: {pathname: '/', query: {connectedIds: '123'}},
},
});

// Add an action filter (tagged event)
await selectEvent.select(
Expand Down Expand Up @@ -291,7 +298,7 @@ describe('AutomationNewSettings', () => {
},
],
config: {frequency: 0},
detectorIds: [],
detectorIds: ['123'],
enabled: true,
},
})
Expand Down Expand Up @@ -329,7 +336,12 @@ describe('AutomationNewSettings', () => {
body: created,
});

render(<AutomationNewSettings />, {organization});
render(<AutomationNewSettings />, {
organization,
initialRouterConfig: {
location: {pathname: '/', query: {connectedIds: '123'}},
},
});

const addAction = async (label: string) => {
await selectEvent.select(screen.getByRole('textbox', {name: 'Add action'}), label);
Expand Down Expand Up @@ -593,6 +605,68 @@ describe('AutomationNewSettings', () => {
});
});

it('shows validation error when submitting with no projects or monitors selected', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also add that it's cleared if something has been added?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes good catch!

ProjectsStore.loadInitialData([]);

const post = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/workflows/`,
method: 'POST',
body: AutomationFixture(),
});

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

// Add an action so the builder validation passes
await selectEvent.select(screen.getByRole('textbox', {name: 'Add action'}), 'Slack');
await userEvent.type(screen.getByRole('textbox', {name: 'Target'}), '#alerts');

// Submit with no projects or monitors selected
await userEvent.click(screen.getByRole('button', {name: 'Create Alert'}));

// Should show validation error
expect(
await screen.findByText(
'Select at least one project or monitor to create an alert.'
)
).toBeInTheDocument();

// Should not have submitted
expect(post).not.toHaveBeenCalled();
});

it('pre-selects a project from page filters on first load', async () => {
const project = ProjectFixture({id: '2', slug: 'my-project', isMember: true});
ProjectsStore.loadInitialData([project]);
PageFiltersStore.onInitializeUrlState(
PageFiltersFixture({projects: [Number(project.id)]})
);

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

// The project selector should show the pre-selected project
expect(await screen.findByText('my-project')).toBeInTheDocument();
});

it('pre-selects the first member project when "my projects" is selected', async () => {
const memberProject = ProjectFixture({
id: '3',
slug: 'member-project',
isMember: true,
});
const otherProject = ProjectFixture({
id: '4',
slug: 'other-project',
isMember: false,
});
ProjectsStore.loadInitialData([otherProject, memberProject]);
PageFiltersStore.onInitializeUrlState(PageFiltersFixture({projects: []}));

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

// Should pre-select the member project
expect(await screen.findByText('member-project')).toBeInTheDocument();
});

it('surfaces error details when test notification fails', async () => {
jest.spyOn(indicators, 'addErrorMessage');

Expand Down
Loading
Loading