diff --git a/.eslintrc.yml b/.eslintrc.yml index 6e8cabb5eb1..f01ba0cc623 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -30,6 +30,7 @@ settings: - ['indico/modules/events/reviewing', './indico/modules/events/client/js/reviewing'] - ['indico/modules/events/editing', './indico/modules/events/editing/client/js'] - ['indico/modules/events/management', './indico/modules/events/management/client/js'] + - ['indico/modules/events/persons', './indico/modules/events/persons/client/js'] - ['indico/modules/events', './indico/modules/events/client/js'] - ['indico', './indico/web/client/js'] extensions: [.js, .jsx, .json] diff --git a/CHANGES.rst b/CHANGES.rst index f034befce03..e1a8451e14d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,11 @@ Bugfixes on someone's behalf (:pr:`5574`) - Disallow nonsensical retention periods and visibility durations (:pr:`5576`) +Internal Changes +^^^^^^^^^^^^^^^^ + +- Refactor email-sending dialog using React (:pr:`5547`) + Version 3.2.1 ------------- diff --git a/indico/modules/events/management/client/js/index.js b/indico/modules/events/management/client/js/index.js index 7e451cd9e3b..b4e5325db90 100644 --- a/indico/modules/events/management/client/js/index.js +++ b/indico/modules/events/management/client/js/index.js @@ -17,6 +17,7 @@ import 'indico/modules/events/util/static_filters'; import './badges'; +import {EmailButton} from 'indico/modules/events/persons/Email'; import {$T} from 'indico/utils/i18n'; import {natSortCompare} from 'indico/utils/sort'; @@ -24,6 +25,29 @@ import {SingleEventMove, EventPublish} from './EventMove'; import {SeriesManagement} from './SeriesManagement'; (function(global) { + global.setupEmailButton = function setupEmailButton(field, trigger) { + const element = document.querySelector(field); + const {eventId, roleId, personSelector, userSelector} = element.dataset; + const extraParams = {}; + if (element.dataset.noAccount !== undefined) { + extraParams.noAccount = true; + } + if (element.dataset.notInvitedOnly !== undefined) { + extraParams.notInvitedOnly = true; + } + ReactDOM.render( + , + element + ); + }; + global.setupEventManagementActionMenu = function setupEventManagementActionMenu() { const moveContainer = document.querySelector('#event-action-move-container'); if (moveContainer) { diff --git a/indico/modules/events/management/templates/_base_person_list.html b/indico/modules/events/management/templates/_base_person_list.html index 5426b5fd5de..c633688e556 100644 --- a/indico/modules/events/management/templates/_base_person_list.html +++ b/indico/modules/events/management/templates/_base_person_list.html @@ -37,18 +37,18 @@
-
diff --git a/indico/modules/events/persons/blueprint.py b/indico/modules/events/persons/blueprint.py index e617b78b213..aaa0edf4ce8 100644 --- a/indico/modules/events/persons/blueprint.py +++ b/indico/modules/events/persons/blueprint.py @@ -5,7 +5,8 @@ # modify it under the terms of the MIT License; see the # LICENSE file for more details. -from indico.modules.events.persons.controllers import (RHDeleteUnusedEventPerson, RHEmailEventPersons, +from indico.modules.events.persons.controllers import (RHAPIEmailEventPersonsMetadata, RHAPIEmailEventPersonsSend, + RHDeleteUnusedEventPerson, RHEmailEventPersonsPreview, RHEventPersonSearch, RHGrantModificationRights, RHGrantSubmissionRights, RHManagePersonLists, RHPersonsList, RHRevokeSubmissionRights, RHUpdateEventPerson) @@ -16,7 +17,12 @@ url_prefix='/event//manage') _bp.add_url_rule('/persons/', 'person_list', RHPersonsList) -_bp.add_url_rule('/persons/email', 'email_event_persons', RHEmailEventPersons, methods=('POST',)) +_bp.add_url_rule('/api/persons/email/send', 'api_email_event_persons_send', RHAPIEmailEventPersonsSend, + methods=('POST',)) +_bp.add_url_rule('/api/persons/email/metadata', 'api_email_event_persons_metadata', RHAPIEmailEventPersonsMetadata, + methods=('POST',)) +_bp.add_url_rule('/api/persons/email/preview', 'email_event_persons_preview', RHEmailEventPersonsPreview, + methods=('POST',)) _bp.add_url_rule('/persons/grant-submission', 'grant_submission_rights', RHGrantSubmissionRights, methods=('POST',)) _bp.add_url_rule('/persons/grant-modification', 'grant_modification_rights', RHGrantModificationRights, methods=('POST',)) diff --git a/indico/modules/events/persons/client/js/Email.jsx b/indico/modules/events/persons/client/js/Email.jsx new file mode 100644 index 00000000000..b0cff368815 --- /dev/null +++ b/indico/modules/events/persons/client/js/Email.jsx @@ -0,0 +1,319 @@ +// This file is part of Indico. +// Copyright (C) 2002 - 2022 CERN +// +// Indico is free software; you can redistribute it and/or +// modify it under the terms of the MIT License; see the +// LICENSE file for more details. + +import emailMetadataURL from 'indico-url:persons.api_email_event_persons_metadata'; +import emailSendURL from 'indico-url:persons.api_email_event_persons_send'; +import emailPreviewURL from 'indico-url:persons.email_event_persons_preview'; + +import PropTypes from 'prop-types'; +import React, {useEffect, useState} from 'react'; +import {FormSpy} from 'react-final-form'; +import {Form, Button, Message, Dimmer, Loader, Popup, Input, Icon} from 'semantic-ui-react'; + +import PlaceholderInfo from 'indico/react/components/PlaceholderInfo'; +import TextEditor, {FinalTextEditor} from 'indico/react/components/TextEditor'; +import {FinalCheckbox, FinalDropdown, FinalInput, handleSubmitError} from 'indico/react/forms'; +import {FinalModalForm} from 'indico/react/forms/final-form'; +import {useIndicoAxios} from 'indico/react/hooks'; +import {Param, Plural, PluralTranslate, Singular, Translate} from 'indico/react/i18n'; +import {indicoAxios} from 'indico/utils/axios'; +import {snakifyKeys} from 'indico/utils/case'; + +import './Email.module.scss'; + +const getIds = selector => + Array.from(document.querySelectorAll(selector)) + .filter(e => e.offsetWidth > 0 || e.offsetHeight > 0) + .map(e => +e.value); + +export function EmailButton({ + eventId, + roleId, + personSelector, + userSelector, + triggerSelector, + noAccount, + notInvitedOnly, +}) { + const [open, setOpen] = useState(false); + const personIds = getIds(personSelector); + const userIds = getIds(userSelector); + + useEffect(() => { + if (!triggerSelector) { + return; + } + const handler = () => setOpen(true); + const element = document.querySelector(triggerSelector); + element.addEventListener('click', handler); + return () => element.removeEventListener('click', handler); + }, [triggerSelector]); + + return ( + <> + {!triggerSelector && ( + setOpen(true)}> + Send email + + )} + {open && ( + setOpen(false)} + /> + )} + + ); +} + +EmailButton.propTypes = { + eventId: PropTypes.number.isRequired, + roleId: PropTypes.number, + personSelector: PropTypes.string, + userSelector: PropTypes.string, + triggerSelector: PropTypes.string, + noAccount: PropTypes.bool, + notInvitedOnly: PropTypes.bool, +}; + +EmailButton.defaultProps = { + roleId: undefined, + personSelector: undefined, + userSelector: undefined, + triggerSelector: undefined, + noAccount: false, + notInvitedOnly: false, +}; + +export function EmailForm({ + eventId, + personIds, + roleIds, + userIds, + onClose, + noAccount, + notInvitedOnly, +}) { + const [preview, setPreview] = useState(null); + const recipientData = { + personId: personIds, + roleId: roleIds, + userId: userIds, + noAccount, + notInvitedOnly, + }; + const {data, loading} = useIndicoAxios({ + url: emailMetadataURL({event_id: eventId}), + method: 'POST', + data: snakifyKeys(recipientData), + }); + const { + senders = [], + recipients = [], + subject: defaultSubject, + body: defaultBody, + placeholders = [], + } = data || {}; + + const togglePreview = async ({body, subject}) => { + if (!preview) { + body = body.getData ? body.getData() : body; + const {data} = await indicoAxios.post( + emailPreviewURL({event_id: eventId}), + snakifyKeys({ + body, + subject, + noAccount, + }) + ); + setPreview(data); + return; + } + setPreview(undefined); + }; + + const onSubmit = async data => { + const requestData = {...data, ...recipientData}; + requestData.body = requestData.body.getData ? requestData.body.getData() : requestData.body; + try { + await indicoAxios.post(emailSendURL({event_id: eventId}), snakifyKeys(requestData)); + setTimeout(() => onClose(), 5000); + } catch (err) { + return handleSubmitError(err); + } + }; + + const extraActions = ( + + + {({values}) => ( + + )} + + + ); + + const previewRender = preview && ( + <> + + + This preview is only shown for a single recipient. + + + When sending the emails, each recipient will receive an email customized with their + personal data. + + + + Subject + {preview.subject} + + + Email body + v} + onFocus={v => v} + onBlur={v => v} + disabled + /> + + + ); + + const successMessage = ( + + Your email has been sent. + + + email has been sent. + + + emails have been sent. + + + + ); + + const form = ( + <> + {previewRender} + + + ({value, text}))} + required + /> + + + Subject + + + + Email body + + + {placeholders.length > 0 && ( + + + + )} + + Recipients + navigator.clipboard.writeText(recipients.join(', '))} + link + /> + } + /> + ) + } + /> + + + + + Send copy of each email to my mailbox + + + + + ); + + return ( + <> + + + + {!loading && ( + !submitSucceeded && extraActions} + disabledUntilChange={false} + disabledAfterSubmit + unloadPrompt + > + {({submitSucceeded}) => (submitSucceeded ? successMessage : form)} + + )} + + ); +} + +EmailForm.propTypes = { + eventId: PropTypes.number.isRequired, + personIds: PropTypes.arrayOf(PropTypes.number), + userIds: PropTypes.arrayOf(PropTypes.number), + roleIds: PropTypes.arrayOf(PropTypes.number), + onClose: PropTypes.func.isRequired, + noAccount: PropTypes.bool, + notInvitedOnly: PropTypes.bool, +}; + +EmailForm.defaultProps = { + personIds: [], + userIds: [], + roleIds: [], + noAccount: false, + notInvitedOnly: false, +}; diff --git a/indico/modules/events/persons/client/js/Email.module.scss b/indico/modules/events/persons/client/js/Email.module.scss new file mode 100644 index 00000000000..4921bfdec17 --- /dev/null +++ b/indico/modules/events/persons/client/js/Email.module.scss @@ -0,0 +1,18 @@ +// This file is part of Indico. +// Copyright (C) 2002 - 2022 CERN +// +// Indico is free software; you can redistribute it and/or +// modify it under the terms of the MIT License; see the +// LICENSE file for more details. + +@import 'base/palette'; + +.preview-button { + margin-right: auto; +} + +.field-description { + color: $dark-gray; + font-size: 11px; + padding: 5px 2px 10px 2px; +} diff --git a/indico/modules/events/persons/controllers.py b/indico/modules/events/persons/controllers.py index 8573df544f9..9af4edc7d95 100644 --- a/indico/modules/events/persons/controllers.py +++ b/indico/modules/events/persons/controllers.py @@ -32,7 +32,7 @@ from indico.modules.events.models.principals import EventPrincipal from indico.modules.events.models.roles import EventRole from indico.modules.events.persons import logger, persons_settings -from indico.modules.events.persons.forms import EmailEventPersonsForm, ManagePersonListsForm +from indico.modules.events.persons.forms import ManagePersonListsForm from indico.modules.events.persons.operations import update_person from indico.modules.events.persons.schemas import EventPersonSchema, EventPersonUpdateSchema from indico.modules.events.persons.views import WPManagePersons @@ -46,8 +46,8 @@ from indico.modules.users.models.affiliations import Affiliation from indico.util.date_time import now_utc from indico.util.i18n import _, ngettext -from indico.util.marshmallow import validate_with_message -from indico.util.placeholders import replace_placeholders +from indico.util.marshmallow import not_empty, validate_with_message +from indico.util.placeholders import get_sorted_placeholders, replace_placeholders from indico.web.args import use_args, use_kwargs from indico.web.flask.templating import get_template_module from indico.web.flask.util import jsonify_data, url_for @@ -259,56 +259,41 @@ def _process(self): has_predefined_affiliations=Affiliation.query.has_rows()) -class RHEmailEventPersons(RHManageEventBase): +class RHEmailEventPersonsPreview(RHManageEventBase): + """Preview an email with EventPersons associated placeholders.""" + + @use_kwargs({ + 'body': fields.String(required=True), + 'subject': fields.String(required=True), + 'no_account': fields.Bool(load_default=False), + }) + def _process(self, body, subject, no_account): + person = self.event.persons[0] + email_body = replace_placeholders('event-persons-email', body, event=self.event, person=person, + register_link=no_account) + email_subject = replace_placeholders('event-persons-email', subject, event=self.event, person=person, + register_link=no_account) + tpl = get_template_module('events/persons/emails/custom_email.html', email_subject=email_subject, + email_body=email_body) + return jsonify(subject=tpl.get_subject(), body=tpl.get_body()) + + +class RHEmailEventPersonsBase(RHManageEventBase): """Send emails to selected EventPersons.""" - def _process_args(self): - self.no_account = request.args.get('no_account') == '1' + @use_kwargs({ + 'person_id': fields.List(fields.Integer(), load_default=[]), + 'user_id': fields.List(fields.Integer(), load_default=[]), + 'role_id': fields.List(fields.Integer(), load_default=[]), + 'not_invited_only': fields.Bool(load_default=None), + 'no_account': fields.Bool(load_default=False), + }) + def _process_args(self, person_id, user_id, role_id, not_invited_only, no_account): RHManageEventBase._process_args(self) - - def _process(self): - person_ids = request.form.getlist('person_id') - user_ids = request.form.getlist('user_id') - role_ids = request.form.getlist('role_id') - recipients = set(self._find_event_persons(person_ids, request.args.get('not_invited_only') == '1')) - recipients |= set(self._find_users(user_ids)) - recipients |= set(self._find_role_members(role_ids)) - if self.no_account: - with self.event.force_event_locale(): - tpl = get_template_module('events/persons/emails/invitation.html', event=self.event) - subject = tpl.get_subject() - body = tpl.get_html_body() - disabled_until_change = False - else: - with self.event.force_event_locale(): - tpl = get_template_module('events/persons/emails/generic.html', event=self.event) - subject = tpl.get_subject() - body = tpl.get_html_body() - disabled_until_change = True - form = EmailEventPersonsForm(person_id=person_ids, user_id=user_ids, - recipients=sorted(x.email for x in recipients), body=body, - subject=subject, register_link=self.no_account, event=self.event) - if form.validate_on_submit(): - self._send_emails(form, recipients) - num = len(recipients) - flash(ngettext('Your email has been sent.', '{} emails have been sent.', num).format(num)) - return jsonify_data() - return jsonify_form(form, disabled_until_change=disabled_until_change, submit=_('Send'), back=_('Cancel')) - - def _send_emails(self, form, recipients): - for recipient in recipients: - if self.no_account and isinstance(recipient, EventPerson): - recipient.invited_dt = now_utc() - email_body = replace_placeholders('event-persons-email', form.body.data, person=recipient, - event=self.event, register_link=self.no_account) - email_subject = replace_placeholders('event-persons-email', form.subject.data, person=recipient, - event=self.event, register_link=self.no_account) - tpl = get_template_module('emails/custom.html', subject=email_subject, body=email_body) - bcc = [session.user.email] if form.copy_for_sender.data else [] - with self.event.force_event_locale(): - email = make_email(to_list=recipient.email, bcc_list=bcc, from_address=form.from_address.data, - template=tpl, html=True) - send_email(email, self.event, 'Event Persons') + self.recipients = set(self._find_event_persons(person_id, not_invited_only)) + self.recipients |= set(self._find_users(user_id)) + self.recipients |= set(self._find_role_members(role_id)) + self.no_account = no_account def _find_event_persons(self, person_ids, not_invited_only): if not person_ids: @@ -333,6 +318,50 @@ def _find_role_members(self, role_ids): return itertools.chain.from_iterable(role.members for role in query) +class RHAPIEmailEventPersonsMetadata(RHEmailEventPersonsBase): + def _process(self): + with self.event.force_event_locale(): + if self.no_account: + tpl = get_template_module('events/persons/emails/invitation.html', event=self.event) + else: + tpl = get_template_module('events/persons/emails/generic.html', event=self.event) + body = tpl.get_html_body() + subject = tpl.get_subject() + placeholders = get_sorted_placeholders('event-persons-email', event=None, person=None, + register_link=self.no_account) + return jsonify({ + 'senders': list(self.event.get_allowed_sender_emails().items()), + 'recipients': sorted(x.email for x in self.recipients), + 'body': body, + 'subject': subject, + 'placeholders': [p.serialize(event=None, person=None) for p in placeholders], + }) + + +class RHAPIEmailEventPersonsSend(RHEmailEventPersonsBase): + @use_kwargs({ + 'from_address': fields.String(required=True, validate=not_empty), + 'body': fields.String(required=True, validate=not_empty), + 'subject': fields.String(required=True, validate=not_empty), + 'copy_for_sender': fields.Bool(load_default=False), + }) + def _process(self, from_address, body, subject, copy_for_sender): + for recipient in self.recipients: + if self.no_account and isinstance(recipient, EventPerson): + recipient.invited_dt = now_utc() + email_body = replace_placeholders('event-persons-email', body, person=recipient, + event=self.event, register_link=self.no_account) + email_subject = replace_placeholders('event-persons-email', subject, person=recipient, + event=self.event, register_link=self.no_account) + tpl = get_template_module('emails/custom.html', subject=email_subject, body=email_body) + bcc = [session.user.email] if copy_for_sender else [] + with self.event.force_event_locale(): + email = make_email(to_list=recipient.email, bcc_list=bcc, from_address=from_address, + template=tpl, html=True) + send_email(email, self.event, 'Event Persons') + return '', 204 + + class RHGrantSubmissionRights(RHManageEventBase): """Grant submission rights to all contribution speakers.""" diff --git a/indico/modules/events/persons/forms.py b/indico/modules/events/persons/forms.py index f9bc31362e8..eecf5c566fc 100644 --- a/indico/modules/events/persons/forms.py +++ b/indico/modules/events/persons/forms.py @@ -5,40 +5,12 @@ # modify it under the terms of the MIT License; see the # LICENSE file for more details. -from flask import request -from wtforms.fields import BooleanField, HiddenField, SelectField, StringField, TextAreaField -from wtforms.validators import DataRequired +from wtforms.fields import BooleanField from indico.core.auth import multipass from indico.util.i18n import _ -from indico.util.placeholders import render_placeholder_info from indico.web.forms.base import IndicoForm -from indico.web.forms.fields import HiddenFieldList, IndicoEmailRecipientsField -from indico.web.forms.widgets import CKEditorWidget, SwitchWidget - - -class EmailEventPersonsForm(IndicoForm): - from_address = SelectField(_('From'), [DataRequired()]) - subject = StringField(_('Subject'), [DataRequired()]) - body = TextAreaField(_('Email body'), [DataRequired()], widget=CKEditorWidget()) - recipients = IndicoEmailRecipientsField(_('Recipients')) - copy_for_sender = BooleanField(_('Send copy to me'), widget=SwitchWidget(), - description=_('Send copy of each email to my mailbox')) - person_id = HiddenFieldList() - user_id = HiddenFieldList() - role_id = HiddenFieldList() - submitted = HiddenField() - - def __init__(self, *args, **kwargs): - register_link = kwargs.pop('register_link') - event = kwargs.pop('event') - super().__init__(*args, **kwargs) - self.from_address.choices = list(event.get_allowed_sender_emails().items()) - self.body.description = render_placeholder_info('event-persons-email', event=None, person=None, - register_link=register_link) - - def is_submitted(self): - return super().is_submitted() and 'submitted' in request.form +from indico.web.forms.widgets import SwitchWidget class ManagePersonListsForm(IndicoForm): diff --git a/indico/modules/events/persons/templates/emails/custom_email.html b/indico/modules/events/persons/templates/emails/custom_email.html new file mode 100644 index 00000000000..2131be3c816 --- /dev/null +++ b/indico/modules/events/persons/templates/emails/custom_email.html @@ -0,0 +1,8 @@ +{% extends 'emails/base.html' %} +{% block subject %}{{ email_subject | safe }}{% endblock %} +{% block header %}{% endblock %} +{% block footer %}{% endblock %} + +{% block body %} + {{ email_body | sanitize_html }} +{% endblock %} diff --git a/indico/modules/events/persons/templates/management/person_list.html b/indico/modules/events/persons/templates/management/person_list.html index 3c3dd7c29d5..6a354961001 100644 --- a/indico/modules/events/persons/templates/management/person_list.html +++ b/indico/modules/events/persons/templates/management/person_list.html @@ -53,13 +53,13 @@
-
@@ -102,6 +102,17 @@ {% endif %}
+
+ {% if has_uninvited %} +
+ {% endif %}
  • + id="invitation-email-all-trigger"> {% trans %}To all users with no account{% endtrans %}
  • @@ -125,13 +131,7 @@ + id="invitation-email-not-invited-trigger"> {% trans %}To users not invited yet{% endtrans %} @@ -283,5 +283,10 @@ hasNoRegistrationFilter: true, hasNoBuiltinRolesFilter: true, }); + setupEmailButton('#persons-email', '#persons-email-trigger'); + setupEmailButton('#invitation-email-all', '#invitation-email-all-trigger'); + {% if has_uninvited %} + setupEmailButton('#invitation-email-not-invited', '#invitation-email-all-trigger'); + {% endif %} {% endblock %} diff --git a/indico/modules/events/roles/templates/_roles.html b/indico/modules/events/roles/templates/_roles.html index 773577749b1..4b24d50d784 100644 --- a/indico/modules/events/roles/templates/_roles.html +++ b/indico/modules/events/roles/templates/_roles.html @@ -40,13 +40,19 @@
    {% if email_button %} - + + {% if role.members %} +
    +
    + + {% endif %} {% endif %} @@ -190,6 +200,8 @@ FinalModalForm.propTypes = { header: PropTypes.node.isRequired, /** Whether the submit button should remain disabled as long as the form is in pristine state. */ disabledUntilChange: PropTypes.bool, + /** Whether to disable the submit button after the form is successfully submitted once. */ + disabledAfterSubmit: PropTypes.bool, /** * Whether to ask the user to confirm when unloading the page or closing the dialog using * anything but the explicit cancel button. @@ -229,6 +241,7 @@ FinalModalForm.defaultProps = { validate: undefined, size: 'tiny', // default to something reasonably small - let people explicitly go larger! disabledUntilChange: true, + disabledAfterSubmit: false, unloadPrompt: false, unloadPromptRouter: false, alignTop: false, diff --git a/jsconfig.json b/jsconfig.json index 28fb44adf1f..23c052722c6 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -14,6 +14,7 @@ "indico/modules/events/reviewing/*": ["./indico/modules/events/client/js/reviewing/*"], "indico/modules/events/editing/*": ["./indico/modules/events/editing/client/js/*"], "indico/modules/events/management/*": ["./indico/modules/events/management/client/js/*"], + "indico/modules/events/persons/*": ["./indico/modules/events/persons/client/js/*"], "indico/modules/events/*": ["./indico/modules/events/client/js/*"], "indico/*": ["./indico/web/client/js/*"] }