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 %}
@@ -111,12 +122,7 @@
+ 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 %}
({
+export const getConfig = ({
+ images = true,
+ imageUploadURL = null,
+ fullScreen = true,
+ showToolbar = true,
+} = {}) => ({
removePlugins: images && imageUploadURL ? [] : ['ImageInsert', 'ImageUpload'],
fontFamily: {
options: [
@@ -14,7 +19,7 @@ export const getConfig = ({images = true, imageUploadURL = null, fullScreen = tr
'Monospace/"Liberation Mono", monospace',
],
},
- toolbar: {
+ toolbar: showToolbar && {
shouldNotGroupWhenFull: false,
items: [
'heading',
diff --git a/indico/web/client/js/react/components/PlaceholderInfo.jsx b/indico/web/client/js/react/components/PlaceholderInfo.jsx
new file mode 100644
index 00000000000..09c07d5c55d
--- /dev/null
+++ b/indico/web/client/js/react/components/PlaceholderInfo.jsx
@@ -0,0 +1,113 @@
+// 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 PropTypes from 'prop-types';
+import React from 'react';
+import {Accordion} from 'semantic-ui-react';
+
+import {Translate} from 'indico/react/i18n';
+
+import './PlaceholderInfo.module.scss';
+
+const placeholderShape = {
+ name: PropTypes.string.isRequired,
+ required: PropTypes.bool.isRequired,
+ description: PropTypes.string,
+ advanced: PropTypes.bool.isRequired,
+ parametrized: PropTypes.bool.isRequired,
+ params: PropTypes.arrayOf(
+ PropTypes.shape({
+ param: PropTypes.string,
+ description: PropTypes.string.isRequired,
+ })
+ ),
+};
+
+function SinglePlaceholderInfo({name, description, required}) {
+ return (
+
+ {`{${name}}`}
- {description}
+ {required && (
+
+ {'('}
+ required
+ {')'}
+
+ )}
+
+ );
+}
+
+SinglePlaceholderInfo.propTypes = {
+ name: PropTypes.string.isRequired,
+ required: PropTypes.bool.isRequired,
+ description: PropTypes.string.isRequired,
+};
+
+function ParametrizedPlaceholderInfo({name, required, params}) {
+ return params.map(({param, description}) => (
+
+ ));
+}
+
+ParametrizedPlaceholderInfo.propTypes = placeholderShape;
+
+function PlaceholderInfoBox({placeholders}) {
+ return (
+
+ {placeholders
+ .filter(p => p.description)
+ .map(placeholder => (
+
+ ))}
+ {placeholders
+ .filter(p => p.parametrized)
+ .map(placeholder => (
+
+ ))}
+
+ );
+}
+
+PlaceholderInfoBox.propTypes = {
+ placeholders: PropTypes.arrayOf(PropTypes.shape(placeholderShape)).isRequired,
+};
+
+export default function PlaceholderInfo({placeholders}) {
+ const simplePlaceholders = placeholders.filter(p => !p.advanced);
+ const advancedPlaceholders = placeholders.filter(p => p.advanced);
+
+ const panels = [
+ {
+ key: 'simple',
+ title: Translate.string('Available placeholders'),
+ content: {
+ content: ,
+ },
+ },
+ ];
+ if (advancedPlaceholders.length > 0) {
+ panels.push({
+ key: 'advanced',
+ title: Translate.string('Advanced placeholders'),
+ content: {
+ content: ,
+ },
+ });
+ }
+
+ return ;
+}
+
+PlaceholderInfo.propTypes = {
+ placeholders: PropTypes.arrayOf(PropTypes.shape(placeholderShape)).isRequired,
+};
diff --git a/indico/web/client/js/react/components/PlaceholderInfo.module.scss b/indico/web/client/js/react/components/PlaceholderInfo.module.scss
new file mode 100644
index 00000000000..1f24e074c72
--- /dev/null
+++ b/indico/web/client/js/react/components/PlaceholderInfo.module.scss
@@ -0,0 +1,24 @@
+// 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';
+
+.placeholder-info {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ code {
+ font-style: normal;
+ font-size: 0.9em;
+ }
+
+ .required {
+ font-style: italic;
+ margin-left: 0.2em;
+ }
+}
diff --git a/indico/web/client/js/react/forms/fields.jsx b/indico/web/client/js/react/forms/fields.jsx
index ed5b5dcca96..128910c472c 100644
--- a/indico/web/client/js/react/forms/fields.jsx
+++ b/indico/web/client/js/react/forms/fields.jsx
@@ -589,6 +589,7 @@ export function FinalSubmitButton({
form,
disabledUntilChange,
disabledIfInvalid,
+ disabledAfterSubmit,
activeSubmitButton,
color,
onClick,
@@ -598,19 +599,28 @@ export function FinalSubmitButton({
style,
children,
}) {
- const {validating, hasValidationErrors, pristine, submitting, submitError} = useFormState({
+ const {
+ validating,
+ hasValidationErrors,
+ pristine,
+ submitting,
+ submitError,
+ submitSucceeded,
+ } = useFormState({
subscription: {
validating: true,
hasValidationErrors: true,
pristine: true,
submitting: true,
submitError: true,
+ submitSucceeded: true,
},
});
const disabled =
validating ||
(disabledIfInvalid && hasValidationErrors) ||
(disabledUntilChange && pristine) ||
+ (disabledAfterSubmit && submitSucceeded) ||
submitting;
return (
@@ -645,6 +655,7 @@ FinalSubmitButton.propTypes = {
form: PropTypes.string,
disabledUntilChange: PropTypes.bool,
disabledIfInvalid: PropTypes.bool,
+ disabledAfterSubmit: PropTypes.bool,
activeSubmitButton: PropTypes.bool,
color: PropTypes.string,
onClick: PropTypes.func,
@@ -660,6 +671,7 @@ FinalSubmitButton.defaultProps = {
form: null,
disabledUntilChange: true,
disabledIfInvalid: true,
+ disabledAfterSubmit: false,
activeSubmitButton: true,
color: null,
onClick: null,
diff --git a/indico/web/client/js/react/forms/final-form.jsx b/indico/web/client/js/react/forms/final-form.jsx
index 1b9d4b3a2b4..a17fcf3f6a6 100644
--- a/indico/web/client/js/react/forms/final-form.jsx
+++ b/indico/web/client/js/react/forms/final-form.jsx
@@ -89,6 +89,7 @@ export function FinalModalForm({
children,
extraActions,
disabledUntilChange,
+ disabledAfterSubmit,
unloadPrompt,
unloadPromptRouter,
alignTop,
@@ -111,7 +112,7 @@ export function FinalModalForm({
return (
{fprops => (
- {fprops.dirty ? Cancel : Close}
+ {fprops.dirty && !(fprops.submitSucceeded && disabledAfterSubmit) ? (
+ Cancel
+ ) : (
+ Close
+ )}
@@ -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/*"]
}