Skip to content

Commit

Permalink
feat(onboarding): Add alert rule configuration onboarding (#17357)
Browse files Browse the repository at this point in the history
  • Loading branch information
evanpurkhiser committed Feb 28, 2020
1 parent 7a78dcf commit 7e11627
Show file tree
Hide file tree
Showing 13 changed files with 1,098 additions and 86 deletions.
4 changes: 3 additions & 1 deletion src/sentry/api/endpoints/organization_onboarding_tasks.py
Expand Up @@ -30,8 +30,10 @@ def post(self, request, organization):
organization=organization,
user=request.user,
task=task_id,
values={"status": OnboardingTaskStatus.SKIPPED, "date_completed": timezone.now()},
values={"status": status, "date_completed": timezone.now()},
)

if rows_affected or created:
try_mark_onboarding_complete(organization.id)

return Response(status=204)
18 changes: 18 additions & 0 deletions src/sentry/receivers/onboarding.py
Expand Up @@ -26,6 +26,7 @@
member_joined,
plugin_enabled,
project_created,
alert_rule_created,
)
from sentry.utils.javascript import has_sourcemap

Expand Down Expand Up @@ -308,6 +309,23 @@ def record_plugin_enabled(plugin, project, user, **kwargs):
)


@alert_rule_created.connect(weak=False)
def record_alert_rule_created(user, project, rule, **kwargs):
rows_affected, created = OrganizationOnboardingTask.objects.create_or_update(
organization_id=project.organization_id,
task=OnboardingTask.ALERT_RULE,
values={
"status": OnboardingTaskStatus.COMPLETE,
"user": user,
"project_id": project.id,
"date_completed": timezone.now(),
},
)

if rows_affected or created:
try_mark_onboarding_complete(project.organization_id)


@issue_tracker_used.connect(weak=False)
def record_issue_tracker_used(plugin, project, user, **kwargs):
rows_affected, created = OrganizationOnboardingTask.objects.create_or_update(
Expand Down
34 changes: 34 additions & 0 deletions src/sentry/static/sentry/app/actionCreators/onboardingTasks.tsx
@@ -0,0 +1,34 @@
import {Client} from 'app/api';
import {Organization, OnboardingTask} from 'app/types';
import OrganizationActions from 'app/actions/organizationActions';

/**
* Update an onboarding task.
*
* If no API client is provided the task will not be updated on the server side
* and will only update in the organization store.
*/
export async function updateOnboardingTask(
api: Client | null,
organization: Organization,
updatedTask: Pick<OnboardingTask, 'task' | 'status' | 'data'>
) {
if (api !== null) {
await api.requestPromise(`/organizations/${organization.slug}/onboarding-tasks/`, {
method: 'POST',
data: updatedTask,
});
}

const hasSkippedTask = organization.onboardingTasks.find(
task => task.task === updatedTask.task
);

const onboardingTasks = hasSkippedTask
? organization.onboardingTasks.map(task =>
task.task === updatedTask.task ? {...task, ...updatedTask} : task
)
: [...organization.onboardingTasks, updatedTask];

OrganizationActions.update({onboardingTasks});
}
Expand Up @@ -72,7 +72,7 @@ class TodoItem extends React.Component<Props, State> {
}

if (task.actionType === 'app') {
navigateTo(task.location, router);
navigateTo(`${task.location}?onboardingTask`, router);
}
};

Expand Down
Expand Up @@ -7,67 +7,46 @@ import withApi from 'app/utils/withApi';
import withOrganization from 'app/utils/withOrganization';
import {Client} from 'app/api';
import {Organization, OnboardingTask, OnboardingTaskKey} from 'app/types';
import {updateOnboardingTask} from 'app/actionCreators/onboardingTasks';

type Props = {
api: Client;
organization: Organization;
};

type State = {
tasks: OnboardingTask[];
seeAll: boolean;
};

class TodoList extends React.Component<Props, State> {
state: State = {
tasks: [],
seeAll: false, // Show all tasks, included those completed
};

componentDidMount() {
const {organization} = this.props;
const taskDescriptors = getOnboardingTasks(organization);
const serverTasks = organization.onboardingTasks;

// Map server task state (i.e. completed status) with tasks objects
const tasks = taskDescriptors.map(
desc =>
({
...desc,
...serverTasks.find(serverTask => serverTask.task === desc.task),
} as OnboardingTask)
);

// eslint-disable-next-line react/no-did-mount-set-state
this.setState({tasks});
}

skipTask = async (skippedTask: OnboardingTaskKey) => {
const {organization, api} = this.props;

await api.requestPromise(`/organizations/${organization.slug}/onboarding-tasks/`, {
method: 'POST',
data: {task: skippedTask, status: 'skipped'},
});

this.setState({
tasks: this.state.tasks.map(task =>
task.task === skippedTask ? {...task, status: 'skipped'} : task
),
});
};

render() {
const allDisplayedTasks = this.state.tasks.filter(task => task.display);

const todoListTasks = allDisplayedTasks.map(task => (
<TodoItem key={task.task} task={task} onSkip={this.skipTask} />
));

return <StyledTodoList>{todoListTasks}</StyledTodoList>;
}
function getMergedTasks(organization: Organization) {
const taskDescriptors = getOnboardingTasks(organization);
const serverTasks = organization.onboardingTasks;

// Map server task state (i.e. completed status) with tasks objects
return taskDescriptors.map(
desc =>
({
...desc,
...serverTasks.find(serverTask => serverTask.task === desc.task),
} as OnboardingTask)
);
}

const TodoList = ({api, organization}: Props) => (
<StyledTodoList>
{getMergedTasks(organization)
.filter(task => task.display)
.map(task => (
<TodoItem
key={task.task}
task={task}
onSkip={(skippedTask: OnboardingTaskKey) =>
updateOnboardingTask(api, organization, {
task: skippedTask,
status: 'skipped',
})
}
/>
))}
</StyledTodoList>
);

const StyledTodoList = styled('ul')`
padding-left: 0;
list-style: none;
Expand Down
6 changes: 3 additions & 3 deletions src/sentry/static/sentry/app/types/index.tsx
Expand Up @@ -924,9 +924,9 @@ export type OnboardingTaskDescriptor = {
export type OnboardingTaskStatus = {
task: OnboardingTaskKey;
status: 'skipped' | 'pending' | 'complete';
user: string | null;
dateCompleted: string;
data: object;
user?: string | null;
dateCompleted?: string;
data?: object;
};

export type OnboardingTask = OnboardingTaskStatus & OnboardingTaskDescriptor;
Expand Down
Expand Up @@ -5,7 +5,7 @@ import classNames from 'classnames';
import styled from '@emotion/styled';

import {ALL_ENVIRONMENTS_KEY} from 'app/constants';
import {Environment, Organization, Project} from 'app/types';
import {Environment, Organization, Project, OnboardingTaskKey} from 'app/types';
import {
IssueAlertRule,
IssueAlertRuleActionTemplate,
Expand Down Expand Up @@ -35,6 +35,7 @@ import recreateRoute from 'app/utils/recreateRoute';
import space from 'app/styles/space';
import withOrganization from 'app/utils/withOrganization';
import withProject from 'app/utils/withProject';
import {updateOnboardingTask} from 'app/actionCreators/onboardingTasks';

import RuleNodeList from './ruleNodeList';

Expand Down Expand Up @@ -139,16 +140,24 @@ class IssueRuleEditor extends AsyncView<Props, State> {
data: rule,
});
this.setState({detailedError: null, rule: resp});

addSuccessMessage(isNew ? t('Created alert rule') : t('Updated alert rule'));
browserHistory.replace(recreateRoute('', {...this.props, stepBack: -2}));
} catch (err) {
this.setState({
detailedError: err.responseJSON || {__all__: 'Unknown error'},
loading: false,
});
addErrorMessage(t('An error occurred'));
return;
}

// The onboarding task will be completed on the server side when the alert
// is created
updateOnboardingTask(null, organization, {
task: OnboardingTaskKey.ALERT_RULE,
status: 'complete',
});

addSuccessMessage(isNew ? t('Created alert rule') : t('Updated alert rule'));
browserHistory.replace(recreateRoute('', {...this.props, stepBack: -2}));
};

handleDeleteRule = async () => {
Expand Down
@@ -0,0 +1,101 @@
import React from 'react';
import {Location} from 'history';
import styled from '@emotion/styled';

import Button from 'app/components/button';
import Hovercard from 'app/components/hovercard';
import {t} from 'app/locale';
import space from 'app/styles/space';
import withApi from 'app/utils/withApi';
import {Client} from 'app/api';
import {Organization, OnboardingTaskKey} from 'app/types';
import {updateOnboardingTask} from 'app/actionCreators/onboardingTasks';

type Props = {
api: Client;
organization: Organization;
location: Location;
children: React.ReactNode;
};

type State = {
dismissed: boolean;
};

class OnboardingHovercard extends React.Component<Props, State> {
state: State = {
dismissed: false,
};

get shouldShowHovercard() {
const {organization} = this.props;
const {dismissed} = this.state;

const hasCompletedTask = organization.onboardingTasks.find(
task => task.task === OnboardingTaskKey.ALERT_RULE && task.status === 'complete'
);

const query = this.props.location?.query || {};

return (
!hasCompletedTask &&
!dismissed &&
Object.prototype.hasOwnProperty.call(query, 'onboardingTask')
);
}

skipTask = () => {
const {api, organization} = this.props;

updateOnboardingTask(api, organization, {
task: OnboardingTaskKey.ALERT_RULE,
status: 'complete',
data: {accepted_defaults: true},
});

this.setState({dismissed: true});
};

render() {
const {children, organization, location, ...props} = this.props;

if (!this.shouldShowHovercard) {
return children;
}

const hovercardBody = (
<HovercardBody>
<h1>{t('Configure custom alerting')}</h1>

<p>
{t(
`Add custom alert rules to configure under what conditions
you receive notifications from Sentry.`
)}
</p>

<Button size="xsmall" onClick={this.skipTask}>
{t('The default rule looks good!')}
</Button>
</HovercardBody>
);

return (
<Hovercard show position="left" body={hovercardBody} {...props}>
{children}
</Hovercard>
);
}
}

const HovercardBody = styled('div')`
h1 {
font-size: ${p => p.theme.fontSizeLarge};
margin-bottom: ${space(1.5)};
}
p {
font-size: ${p => p.theme.fontSizeMedium};
}
`;

export default withApi(OnboardingHovercard);

0 comments on commit 7e11627

Please sign in to comment.