-
Notifications
You must be signed in to change notification settings - Fork 17
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
Conversation
Codecov Report
@@ 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
Continue to review full report at Codecov.
|
There was a problem hiding this 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.
static/js/components/LearnerChip.js
Outdated
<Icon name="person"/> | ||
<span>View Profile</span> | ||
</a> | ||
<a onClick={openLearnerEmailComposer} className="mm-minor-action"> |
There was a problem hiding this comment.
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.
static/scss/user-chip.scss
Outdated
@@ -32,6 +32,7 @@ | |||
|
|||
a:hover { | |||
text-decoration: none !important; | |||
cursor: pointer; |
There was a problem hiding this comment.
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?)
3d1cf5e
to
a9bac77
Compare
a9bac77
to
e68b921
Compare
@roberthouse54 here are some screenshots. let me know what you think: |
4ed9fdf
to
ce0059c
Compare
// 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)); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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/' |
There was a problem hiding this comment.
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
static/js/actions/email.js
Outdated
@@ -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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
static/js/flow/emailTypes.js
Outdated
@@ -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> |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
static/js/lib/api_test.js
Outdated
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}/`, { |
There was a problem hiding this comment.
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()
.
static/js/util/util.js
Outdated
@@ -517,6 +518,20 @@ export function getDisplayName(WrappedComponent: ReactClass<*>) { | |||
return WrappedComponent.displayName || WrappedComponent.name || 'Component'; | |||
} | |||
|
|||
export const wrapWithProps = (addedProps: Object, WrappedComponent: ReactClass<*>) => { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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! |
static/js/components/LearnerChip.js
Outdated
@@ -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"/> |
There was a problem hiding this comment.
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>
.
static/js/components/LearnerChip.js
Outdated
</Card> | ||
); | ||
<button onClick={openLearnerEmailComposer} className="mm-minor-action"> | ||
<Icon name="email"/> |
There was a problem hiding this comment.
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>
.
mail/views_test.py
Outdated
@@ -58,7 +58,7 @@ def setUpTestData(cls): | |||
} | |||
|
|||
def setUp(self): | |||
super(MailViewsTests, self).setUp() | |||
super(SearchResultMailViewsTests, self).setUp() |
There was a problem hiding this comment.
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
@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 |
ce0059c
to
a42cd00
Compare
@singingwolfboy should be all set |
0b51ee8
to
3ba0cb0
Compare
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, ) |
There was a problem hiding this comment.
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
@@ -252,6 +252,72 @@ def test_course_team_email_unpaid(self): | |||
assert resp.status_code == status.HTTP_403_FORBIDDEN | |||
|
|||
|
|||
class LearnerMailViewTests(APITestCase, MockedESTestCase): |
There was a problem hiding this comment.
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?
Functionality looks great |
constructor(props: Object) { | ||
super(props); | ||
this.WrappedLearnerResult = wrapWithProps( | ||
{openLearnerEmailComposer: this.props.openLearnerEmailComposer}, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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?
@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 |
8a2bf99
to
74b9b2a
Compare
@noisecapella that should do it |
42840fb
to
42f1765
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great 👍
42f1765
to
e13528c
Compare
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 addressWhere should the reviewer start?
LearnerResult.js and LearnerChip.js