Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added staff-to-learner email with link in learner chip #2693

Merged
merged 1 commit into from
Mar 7, 2017

Conversation

gsidebo
Copy link
Contributor

@gsidebo gsidebo commented Feb 27, 2017

What are the relevant tickets?

Closes #2641

What's this PR do?

Implements the API endpoint to send a staff-to-learner email, and adds the link on the learner chip to launch the email dialog

How should this be manually tested?

From the /learners page, hover over a learner's name, click the email link, send an email, and repeat for a couple of other learners. Make sure MAILGUN_RECIPIENT_OVERRIDE is set to your email address

Where should the reviewer start?

LearnerResult.js and LearnerChip.js

@codecov-io
Copy link

codecov-io commented Feb 27, 2017

Codecov Report

Merging #2693 into master will decrease coverage by -0.05%.
The diff coverage is 91.73%.

@@            Coverage Diff            @@
##           master   #2693      +/-   ##
=========================================
- Coverage   93.54%   93.5%   -0.05%     
=========================================
  Files         359     359              
  Lines       14742   14789      +47     
  Branches      650     651       +1     
=========================================
+ Hits        13790   13828      +38     
- Misses        899     905       +6     
- Partials       53      56       +3
Impacted Files Coverage Δ
static/js/test_constants.js 100% <ø> (ø)
static/js/components/email/hoc.js 95.55% <ø> (-0.1%)
mail/urls.py 100% <ø> (ø)
static/js/reducers/email_test.js 100% <100%> (ø)
static/js/lib/api_test.js 99.67% <100%> (ø)
static/js/components/email/constants.js 100% <100%> (ø)
static/js/components/LearnerChip_test.js 100% <100%> (ø)
static/js/containers/LearnerSearchPage.js 83.33% <100%> (ø)
mail/views.py 100% <100%> (ø)
static/js/components/LearnerSearch.js 90.56% <100%> (+0.56%)
... and 15 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 8addbb1...e13528c. Read the comment docs.

@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 February 27, 2017 05:39 Inactive
Copy link
Contributor

@singingwolfboy singingwolfboy left a comment

Choose a reason for hiding this comment

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

This is not intended to be a full review of this pull request: I am not assigning myself to it. I just wanted to point out an accessibility problem that I found.

<Icon name="person"/>
<span>View Profile</span>
</a>
<a onClick={openLearnerEmailComposer} className="mm-minor-action">
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be a <button> element, for accessibility reasons. See #1222.

@@ -32,6 +32,7 @@

a:hover {
text-decoration: none !important;
cursor: pointer;
Copy link
Contributor

Choose a reason for hiding this comment

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

If this is only because of the <a onClick> element referenced above, then after changing it to a <button> element, this line should be removed. (Or is there some other reason why this was added?)

@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 February 27, 2017 18:18 Inactive
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 February 27, 2017 18:18 Inactive
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 February 27, 2017 20:51 Inactive
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 February 27, 2017 21:45 Inactive
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 February 28, 2017 15:58 Inactive
@gsidebo
Copy link
Contributor Author

gsidebo commented Feb 28, 2017

@roberthouse54 here are some screenshots. let me know what you think:

ss 2017-02-28 at 11 06 29

ss 2017-02-28 at 11 04 34

ss 2017-02-28 at 11 04 42

@odlbot odlbot had a problem deploying to micromasters-ci-pr-2693 March 1, 2017 00:14 Failure
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 March 1, 2017 02:05 Inactive
// Execute the openLearnerEmailComposer function passed down to the LearnerChip
result.find("LearnerChip").props().openLearnerEmailComposer();
assert.isTrue(openLearnerEmailComposerStub.called);
assert.isTrue(openLearnerEmailComposerStub.calledWith(USER_PROFILE_RESPONSE));
Copy link
Contributor

Choose a reason for hiding this comment

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

I recommend using sinon.assert.called() and sinon.assert.calledWith() instead of assert.isTrue() for these cases. You get a much clearer failure message if the assertion fails. http://sinonjs.org/releases/v1.17.7/assertions/

}

&.medium {
height: auto !important;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do you need !important here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this was copied from the original style. not sure i want to find out why it's there as a part of this PR

Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha. @roberthouse54, can you take a look at this? I've created #2732 to address this and other issues.

sender_user = request.user
recipient_user = self.get_object().user
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
Copy link
Contributor

Choose a reason for hiding this comment

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

Please add a test to cover an invalid serialization error.

@@ -20,7 +20,7 @@ def test_urls(self):
assert reverse('checkout') == '/api/v0/checkout/'
assert reverse('user_program_enrollments') == '/api/v0/enrolledprograms/'
assert reverse('user_course_enrollments') == '/api/v0/course_enrollments/'
assert reverse('search_result_mail_api') == '/api/v0/mail/'
assert reverse('search_result_mail_api') == '/api/v0/mail/search/'
Copy link
Contributor

Choose a reason for hiding this comment

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

It probably also makes sense to add a test for learner_mail_api

@@ -31,6 +31,23 @@ export const sendEmailSuccess = createAction(SEND_EMAIL_SUCCESS);
export const SEND_EMAIL_FAILURE = 'SEND_EMAIL_FAILURE';
export const sendEmailFailure = createAction(SEND_EMAIL_FAILURE);

export function sendEmail(
emailType: string,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is emailType an arbitrary string, or does it have to be one of a short list of strings? If the latter, please specify that list in the Flow type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it can theoretically be any string. static/js/components/email/constants.js has a short list of strings that we can use for known email types, but this argument to sendEmail just depends on the keys passed into the withEmailDialog higher-order component (see: static/js/containers/DashboardPage.js:732). those keys can be anything

result.find(".learner-name").props().onMouseEnter();
}).then(() => {
// Execute the openLearnerEmailComposer function passed down to the LearnerChip
result.find("LearnerChip").props().openLearnerEmailComposer();
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you stick in an assertion, to be sure that we find the LearnerChip? Something like this:

const chip = result.find("LearnerChip");
assert.lengthOf(chip, 1);
chip.props().openLearnerEmailCompose();

The way this is currently written, if the LearnerChip is not rendered, you'll get a misleading error message about calling .props() on nothing. This will make that failure case more clear.

@@ -31,5 +29,5 @@ export type EmailConfig = {
title: string,
renderSubheading: (subheading: string) => React$Element<*>,
emailOpenParams: (args: any) => Object,
emailSendAction: (emailState: EmailState) => Dispatcher<EmailSendResponse>
emailSendParams: (emailState: EmailState) => Array<any>
Copy link
Contributor

Choose a reason for hiding this comment

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

We should avoid using the any type as much as possible. What type or types should this be?

Copy link
Contributor Author

@gsidebo gsidebo Mar 1, 2017

Choose a reason for hiding this comment

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

it can literally be anything. for one implementation, that array contains strings and a complex ES query object. for another implementation, it contains strings and an integer ID

it('returns expected values when a POST to send a learner email succeeds', () => {
fetchJSONStub.returns(Promise.resolve(MAIL_RESPONSE));
return sendLearnerMail('subject', 'body', learnerStudentId).then(mailResp => {
assert.ok(fetchJSONStub.calledWith(`/api/v0/mail/learner/${learnerStudentId}/`, {
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be better to use sinon.assert.calledWith().

@@ -517,6 +518,20 @@ export function getDisplayName(WrappedComponent: ReactClass<*>) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

export const wrapWithProps = (addedProps: Object, WrappedComponent: ReactClass<*>) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: can you add Flow type information that this function returns a ReactClass?

self.client.force_login(self.staff_user)
mock_mailgun_client.send_individual_email.return_value = Mock(
spec=Response,
status_code=status.HTTP_200_OK,
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you add another test simulating a failure from Mailgun?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we don't currently have tests that simulate a mailgun failure for any of the other email views. it will involve patching requests.post or something like that. i made a quick attempt at it and failed. it's non-trivial and i would very strongly prefer creating a separate issue for that work. if you have any objections let me know

Copy link
Contributor

Choose a reason for hiding this comment

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

You don't need to patch the requests module, just do the same mocking that you're doing here, but have it return 400 Bad Request status code. It's worth verifying that, if Mailgun returns a non-successful HTTP response, then our code should also return a non-successful HTTP response.

@singingwolfboy
Copy link
Contributor

There's a usability problem with this feature, although fixing it might be outside the scope of this pull request. If you open the "Send a Message" dialog, type in a message, close the dialog without sending the message, and reopen it, the message you've typed in is gone. These sorts of dialogs are easy to close accidentally, just by clicking outside of the dialog. I could also imagine an instructor writing a message to a student, closing the dialog to check the student's grade in the program, and then reopening the dialog expecting the message to be saved.

Can you save the contents of the message when the dialog is closed, and repopulate the dialog with that message when it's reopened? The saved contents would only get cleared after the email is successfully sent, which would help deal with Mailgun failures. If Mailgun is experiencing a temporary failure, the instructor doesn't have to type out the whole message again -- just reopen the dialog and click send again!

@@ -18,12 +20,16 @@ const LearnerChip = ({ profile }: {profile: Profile}): React$Element<*> => (
{ mstr(getEmployer(profile)) }
</span>
<a href={`/learner/${profile.username}`} className="mm-minor-action">
<Icon name="person" />
<span>View profile</span>
<Icon name="person"/>
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this link is already described by the text View Profile, please add aria-hidden=true on this <Icon>.

</Card>
);
<button onClick={openLearnerEmailComposer} className="mm-minor-action">
<Icon name="email"/>
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this button is already described by the text Send a Message, please add aria-hidden=true on this <Icon>.

@singingwolfboy
Copy link
Contributor

singingwolfboy commented Mar 1, 2017

Also, I don't know if we care about viewing this dialog on mobile, but the layout is wrapping when it doesn't need to.
screen shot 2017-03-01 at 10 08 35 am

@@ -58,7 +58,7 @@ def setUpTestData(cls):
}

def setUp(self):
super(MailViewsTests, self).setUp()
super(SearchResultMailViewsTests, self).setUp()
Copy link
Contributor

Choose a reason for hiding this comment

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

Not reviewing, but you can make this just super().setUp() in Python 3

@gsidebo
Copy link
Contributor Author

gsidebo commented Mar 1, 2017

@singingwolfboy regarding the clearing of the dialog when you cancel out of it, this has been a 'feature' as long as we've had the email dialogs. i agree that the input should be saved. i'll open a separate issue for it

@gsidebo gsidebo force-pushed the 2641_staff_to_learner_email branch from ce0059c to a42cd00 Compare March 1, 2017 18:55
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 March 1, 2017 18:56 Inactive
@gsidebo
Copy link
Contributor Author

gsidebo commented Mar 1, 2017

@singingwolfboy should be all set

@gsidebo gsidebo force-pushed the 2641_staff_to_learner_email branch from 0b51ee8 to 3ba0cb0 Compare March 3, 2017 18:15
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 March 3, 2017 18:16 Inactive
@noisecapella
Copy link
Contributor

I see the email link on the learner chip, should there also be one on the learner profile page?

mail/views.py Outdated
authentication.SessionAuthentication,
authentication.TokenAuthentication,
)
permission_classes = (permissions.IsAuthenticated, UserCanMessageLearnersPermission, )
Copy link
Contributor

Choose a reason for hiding this comment

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

We need to also verify that the staff is staff of a program that the user is enrolled in. We don't have an extra permission in the search view because we add a filter on the programs the staff is enrolled in via Elasticsearch, but here we should check this in a permission class

@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 March 3, 2017 18:42 Inactive
@@ -252,6 +252,72 @@ def test_course_team_email_unpaid(self):
assert resp.status_code == status.HTTP_403_FORBIDDEN


class LearnerMailViewTests(APITestCase, MockedESTestCase):
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you add a test where a user who is not staff attempts to use this API to send an email?

@noisecapella
Copy link
Contributor

Functionality looks great

constructor(props: Object) {
super(props);
this.WrappedLearnerResult = wrapWithProps(
{openLearnerEmailComposer: this.props.openLearnerEmailComposer},
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think it's a good idea to set this in the constructor. What if openLearnerEmailComposer changes later?

Copy link
Contributor Author

@gsidebo gsidebo Mar 3, 2017

Choose a reason for hiding this comment

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

I did this because there was a visually-noticeable performance penalty if the component wrapping is performed in, say, the render method. It's also important to note that this method can't change (at least not through any means I'm aware of). It's handed down from the email HOC, which is only executed once on the container component, and the function is not part of the state, so it can't change via an action/reducer.

I agree that this is kind of a code smell and it isn't by-the-book React, but I don't quite know how else to handle this without repeatedly re-wrapping this component unnecessarily.

Copy link
Contributor

Choose a reason for hiding this comment

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

It would be nice to investigate what the performance penalty was, but feel free to leave as is for this PR

])
};

export const LEARNER_EMAIL_CONFIG: EmailConfig = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you add tests which cover these functions, either via unit tests and mocks or with an integration test?

@gsidebo
Copy link
Contributor Author

gsidebo commented Mar 3, 2017

@noisecapella the profile page link will be implemented as part of #2196. Sorry, I had it in mind that I would just implement the learner chip link in this issue and the profile page link in that other issue, but forgot to update the descriptions. They are updated now

@gsidebo gsidebo force-pushed the 2641_staff_to_learner_email branch from 8a2bf99 to 74b9b2a Compare March 6, 2017 16:25
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 March 6, 2017 16:25 Inactive
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 March 7, 2017 17:01 Inactive
@gsidebo
Copy link
Contributor Author

gsidebo commented Mar 7, 2017

@noisecapella that should do it

@gsidebo gsidebo force-pushed the 2641_staff_to_learner_email branch from 42840fb to 42f1765 Compare March 7, 2017 18:50
@odlbot odlbot temporarily deployed to micromasters-ci-pr-2693 March 7, 2017 18:50 Inactive
Copy link
Contributor

@noisecapella noisecapella left a comment

Choose a reason for hiding this comment

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

Looks great 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants