From 312b1f2423bfd8c7092ffe5587b4983280133fe4 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Tue, 7 Apr 2026 21:24:28 +0000 Subject: [PATCH 1/7] fix: Minimum Speaker Requirement not Enforced --- src/components/presentation-speakers-form.js | 57 +++++++++++++------- src/i18n/en.json | 7 ++- src/i18n/zh.json | 10 +++- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/components/presentation-speakers-form.js b/src/components/presentation-speakers-form.js index cbbacec..aa0111a 100644 --- a/src/components/presentation-speakers-form.js +++ b/src/components/presentation-speakers-form.js @@ -18,7 +18,6 @@ import { Exclusive, Dropdown } from 'openstack-uicore-foundation/lib/components' import T from "i18n-react/dist/i18n-react"; import CPFSpeakerInput from './inputs/speaker-input' import Swal from "sweetalert2"; -import {getMarketingValue} from "./marketing-setting"; class PresentationSpeakersForm extends React.Component { constructor(props) { @@ -47,24 +46,41 @@ class PresentationSpeakersForm extends React.Component { } handleSubmit(ev) { - - const entity = {...this.props.entity}; - const { selectionPlanSettings } = this.props; ev.preventDefault(); + const { selectionPlanSettings, entity } = this.props; const validModerator = !entity.type.use_moderator || !entity.type.is_moderator_mandatory || entity.moderator; - const validSpeaker = !entity.type.use_speakers || !entity.type.are_speakers_mandatory || entity.speakers.length > 0; + const presentation = selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation").toLowerCase(); if (!validModerator) { - Swal.fire("Validation error", T.translate("edit_presentation.errors.add_moderator", - { presentation: selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation").toLowerCase()}), "warning"); - } else if (!validSpeaker) { - Swal.fire("Validation error", T.translate("edit_presentation.errors.add_speaker", - { presentation: selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation").toLowerCase(), - speaker: selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker").toLowerCase()}), "warning"); - } else { - this.props.onSubmit(this.props.entity); + Swal.fire("Validation error", T.translate("edit_presentation.errors.add_moderator", { presentation }), "warning"); + return; } + + const MIN = (entity.type.are_speakers_mandatory ? 1 : 0); + const MAX = (entity.type.are_speakers_mandatory || entity.type.use_speakers ? Infinity : 0); + const minSpeakers = entity.type?.min_speakers || MIN; + const maxSpeakers = entity.type?.max_speakers || MAX; + const validSpeaker = !entity.type.use_speakers || (entity.speakers.length <= maxSpeakers && entity.speakers.length >= minSpeakers); + if (!validSpeaker) { + const speaker = (selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker")).toLowerCase(); + const speakers = (selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || T.translate("edit_presentation.speakers")).toLowerCase(); + const TParams = { presentation, speaker, speakers, max: maxSpeakers, min: minSpeakers } + + let errorField = "add_speakers"; + if (Infinity === maxSpeakers) { + errorField = "add_min_number_speakers"; + } + else if (minSpeakers === maxSpeakers ) { + errorField = maxSpeakers === 1 ? "add_only_one_speaker" : "add_exact_number_of_speakers"; + } + + let errorText = T.translate(`edit_presentation.errors.${errorField}`, TParams); + Swal.fire("Validation error", errorText, "warning"); + return; + } + + this.props.onSubmit(this.props.entity); } handleBack(ev) { @@ -101,12 +117,13 @@ class PresentationSpeakersForm extends React.Component { handleAddSpeaker(ev) { const {speaker, currentSpeakerType} = this.state; - const {history, onAddSpeaker, onAddModerator, match} = this.props; + const {history, onAddSpeaker, onAddModerator, match, selectionPlanSettings} = this.props; + const speakerLabel = selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker").toLowerCase(); ev.preventDefault(); if(!speaker){ // speaker not set - this.setState({...this.state, error: T.translate("edit_presentation.errors.missing_speaker")}); + this.setState({...this.state, error: T.translate("edit_presentation.errors.missing_speaker", {speaker: speakerLabel})}); return; } @@ -134,14 +151,14 @@ class PresentationSpeakersForm extends React.Component { } // speaker not set - this.setState({...this.state, error: T.translate("edit_presentation.errors.missing_speaker")}); + this.setState({...this.state, error: T.translate("edit_presentation.errors.missing_speaker", {speaker: speakerLabel})}); return false; } render() { let {summit, selectionPlanSettings, entity, presentation, step} = this.props; let {speakerInput, error, speaker} = this.state; - let eventType = summit.event_types.find(t => t.id == entity.type_id); + let eventType = summit.event_types.find(t => t.id == entity.type_id); let canAddSpeakers = (eventType && eventType.max_speakers > entity.speakers.length); let canAddModerator = (eventType && eventType.max_moderators && !entity.moderator); @@ -156,7 +173,7 @@ class PresentationSpeakersForm extends React.Component { return (
-

{T.translate("edit_presentation.speaker_included", +

{T.translate("edit_presentation.speaker_included", { presentation: selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation")})}

@@ -220,8 +237,8 @@ class PresentationSpeakersForm extends React.Component { selectionPlanSettings={selectionPlanSettings} value={speakerInput} speakers={entity.speakers} - placeholder={T.translate("edit_presentation.placeholders.speakers", - {speakers: `${selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || + placeholder={T.translate("edit_presentation.placeholders.speakers", + {speakers: `${selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || T.translate('edit_presentation.speakers').toLowerCase()}` })} onChange={this.handleChangeSpeaker} diff --git a/src/i18n/en.json b/src/i18n/en.json index bc37772..ca551a6 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -205,8 +205,11 @@ "errors": { "role": "You must select a Role.", "add_moderator": "You need to add a moderator to the {presentation}.", - "add_speaker": "You need to add at least one {speaker} to the {presentation}.", - "missing_speaker": "You need to select a participant." + "add_speakers": "You need to add between {min} and {max} {speakers} to the {presentation}.", + "add_only_one_speaker": "You need to add one {speaker} to the {presentation}.", + "add_exact_number_of_speakers": "You need to add {min} {speakers} to the {presentation}.", + "add_min_number_speakers": "You need to add at least {min} {speakers} to the {presentation}.", + "missing_speaker": "You need to select a {speaker}." } }, "edit_speaker": { diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 3de4591..67f8191 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -167,12 +167,20 @@ "review_subtitle": "您的演讲已经提交并等待专题负责人审阅", "permission_denied": "没有权限", "no_edit": "您无权编辑此演示文稿", - "add_speaker_error": "您需要添加至少一名演讲者到演讲议题中来。", "chair_comments": "Chair Comments", "presentation_material": "Presentation Material", "placeholders": { "type_id" : "-- Select One --", "level" : "-- Select One --" + }, + "errors": { + "role": "您必须选择一个角色。", + "add_moderator": "您需要添加至少一名主持人到 {presentation} 中来。", + "add_speakers": "您需要添加 {min} 到 {max} 名 {speakers} 到 {presentation} 中来。", + "add_only_one_speaker": "您需要添加一名 {speaker} 到 {presentation} 中来。", + "add_exact_number_of_speakers": "您需要添加 {min} 名 {speakers} 到 {presentation} 中来。", + "add_min_number_speakers": "您需要添加至少 {min} 名 {speakers} 到 {presentation} 中来。", + "missing_speaker": "您需要选择一个 {speaker}。" } }, "edit_speaker": { From 92e553243e2f3742f7f02ea5dfa5120048c0cf0a Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Fri, 10 Apr 2026 19:35:33 +0000 Subject: [PATCH 2/7] chore: Add fixes proposed in PR comments --- src/components/presentation-speakers-form.js | 50 +++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/components/presentation-speakers-form.js b/src/components/presentation-speakers-form.js index aa0111a..c5a0f99 100644 --- a/src/components/presentation-speakers-form.js +++ b/src/components/presentation-speakers-form.js @@ -57,26 +57,41 @@ class PresentationSpeakersForm extends React.Component { return; } - const MIN = (entity.type.are_speakers_mandatory ? 1 : 0); - const MAX = (entity.type.are_speakers_mandatory || entity.type.use_speakers ? Infinity : 0); - const minSpeakers = entity.type?.min_speakers || MIN; - const maxSpeakers = entity.type?.max_speakers || MAX; - const validSpeaker = !entity.type.use_speakers || (entity.speakers.length <= maxSpeakers && entity.speakers.length >= minSpeakers); + const speakersCount = Array.isArray(entity.speakers) ? entity.speakers.length : 0; + const defaultMinSpeakers = (entity.type?.are_speakers_mandatory ? 1 : 0); + const defaultMaxSpeakers = ((entity.type?.are_speakers_mandatory || entity.type?.use_speakers) ? Infinity : 0); + const minSpeakers = entity.type?.min_speakers || defaultMinSpeakers; + const possibleMaxSpeakers = (entity.type?.max_speakers || defaultMaxSpeakers); + // Protection against invalid configuration of max_speakers < min_speakers + const maxSpeakers = possibleMaxSpeakers >= minSpeakers ? possibleMaxSpeakers : minSpeakers; + const validSpeaker = !entity.type.use_speakers || (speakersCount <= maxSpeakers && speakersCount >= minSpeakers); + if (!validSpeaker) { const speaker = (selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker")).toLowerCase(); const speakers = (selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || T.translate("edit_presentation.speakers")).toLowerCase(); - const TParams = { presentation, speaker, speakers, max: maxSpeakers, min: minSpeakers } - - let errorField = "add_speakers"; - if (Infinity === maxSpeakers) { - errorField = "add_min_number_speakers"; - } - else if (minSpeakers === maxSpeakers ) { - errorField = maxSpeakers === 1 ? "add_only_one_speaker" : "add_exact_number_of_speakers"; + const translationParams = { presentation, speaker, speakers, max: maxSpeakers, min: minSpeakers }; + + let errorField; + switch (true) { + // There is no upper limit of speakers but there is a minimum + case (Infinity === maxSpeakers): + errorField = "add_min_number_speakers"; + break; + // There should be only one speaker + case (minSpeakers === maxSpeakers && maxSpeakers === 1): + errorField = "add_only_one_speaker"; + break; + // There should be exactly a number of speakers + case (minSpeakers === maxSpeakers && maxSpeakers !== 1): + errorField = "add_exact_number_of_speakers"; + break; + // The default error message when there is an upper limit and a minimum of speakers + default: + errorField = "add_speakers"; + break; } - let errorText = T.translate(`edit_presentation.errors.${errorField}`, TParams); - Swal.fire("Validation error", errorText, "warning"); + Swal.fire("Validation error", T.translate(`edit_presentation.errors.${errorField}`, translationParams), "warning"); return; } @@ -159,7 +174,8 @@ class PresentationSpeakersForm extends React.Component { let {summit, selectionPlanSettings, entity, presentation, step} = this.props; let {speakerInput, error, speaker} = this.state; let eventType = summit.event_types.find(t => t.id == entity.type_id); - let canAddSpeakers = (eventType && eventType.max_speakers > entity.speakers.length); + let speakersCount = entity.speakers?.length ?? 0; + let canAddSpeakers = (eventType && eventType.max_speakers > speakersCount); let canAddModerator = (eventType && eventType.max_moderators && !entity.moderator); let speakerTypes = []; @@ -203,7 +219,7 @@ class PresentationSpeakersForm extends React.Component {
} - {entity.speakers.map(s => ( + {entity.speakers?.map(s => (
From a6e31adbce190aa68d3d8211c33245254088dd2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 11 Aug 2026 17:45:09 -0300 Subject: [PATCH 3/7] fix: new error msg for max speakers passed, add getSpeakersLimits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/presentation-speakers-form.js | 203 ++++++++++--------- src/i18n/en.json | 3 +- 2 files changed, 110 insertions(+), 96 deletions(-) diff --git a/src/components/presentation-speakers-form.js b/src/components/presentation-speakers-form.js index c5a0f99..695a815 100644 --- a/src/components/presentation-speakers-form.js +++ b/src/components/presentation-speakers-form.js @@ -19,6 +19,17 @@ import T from "i18n-react/dist/i18n-react"; import CPFSpeakerInput from './inputs/speaker-input' import Swal from "sweetalert2"; +const getSpeakerLimits = (type) => { + if (!type) return { min: 0, max: 0 }; + const defaultMin = type.are_speakers_mandatory ? 1 : 0; + const defaultMax = (type.are_speakers_mandatory || type.use_speakers) ? Infinity : 0; + const min = type.min_speakers ?? defaultMin; + const possibleMax = type.max_speakers ?? defaultMax; + // Protection against invalid configuration of max_speakers < min_speakers + const max = possibleMax >= min ? possibleMax : min; + return { min, max }; +}; + class PresentationSpeakersForm extends React.Component { constructor(props) { super(props); @@ -26,8 +37,8 @@ class PresentationSpeakersForm extends React.Component { this.state = { speaker: {}, speakerInput: null, - entity: {...props.entity}, - currentSpeakerType : null + entity: { ...props.entity }, + currentSpeakerType: null }; this.handleSubmit = this.handleSubmit.bind(this); @@ -39,10 +50,10 @@ class PresentationSpeakersForm extends React.Component { this.handleAddSpeaker = this.handleAddSpeaker.bind(this); } - handleChangeSpeakerType(ev){ - let {value, id} = ev.target; + handleChangeSpeakerType(ev) { + let { value, id } = ev.target; id = id.toString(); - this.setState({...this.state, currentSpeakerType: value, error: null}); + this.setState({ ...this.state, currentSpeakerType: value, error: null }); } handleSubmit(ev) { @@ -58,37 +69,37 @@ class PresentationSpeakersForm extends React.Component { } const speakersCount = Array.isArray(entity.speakers) ? entity.speakers.length : 0; - const defaultMinSpeakers = (entity.type?.are_speakers_mandatory ? 1 : 0); - const defaultMaxSpeakers = ((entity.type?.are_speakers_mandatory || entity.type?.use_speakers) ? Infinity : 0); - const minSpeakers = entity.type?.min_speakers || defaultMinSpeakers; - const possibleMaxSpeakers = (entity.type?.max_speakers || defaultMaxSpeakers); - // Protection against invalid configuration of max_speakers < min_speakers - const maxSpeakers = possibleMaxSpeakers >= minSpeakers ? possibleMaxSpeakers : minSpeakers; + const { min: minSpeakers, max: maxSpeakers } = getSpeakerLimits(entity.type); const validSpeaker = !entity.type.use_speakers || (speakersCount <= maxSpeakers && speakersCount >= minSpeakers); + const excess = speakersCount - maxSpeakers; if (!validSpeaker) { const speaker = (selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker")).toLowerCase(); const speakers = (selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || T.translate("edit_presentation.speakers")).toLowerCase(); - const translationParams = { presentation, speaker, speakers, max: maxSpeakers, min: minSpeakers }; + const translationParams = { presentation, speaker, speakers, max: maxSpeakers, min: minSpeakers, excess }; let errorField; - switch (true) { - // There is no upper limit of speakers but there is a minimum - case (Infinity === maxSpeakers): - errorField = "add_min_number_speakers"; - break; - // There should be only one speaker - case (minSpeakers === maxSpeakers && maxSpeakers === 1): - errorField = "add_only_one_speaker"; - break; - // There should be exactly a number of speakers - case (minSpeakers === maxSpeakers && maxSpeakers !== 1): - errorField = "add_exact_number_of_speakers"; - break; - // The default error message when there is an upper limit and a minimum of speakers - default: - errorField = "add_speakers"; - break; + if (speakersCount > maxSpeakers) { + errorField = "remove_speakers"; + } else { + switch (true) { + // There is no upper limit of speakers but there is a minimum + case (Infinity === maxSpeakers): + errorField = "add_min_number_speakers"; + break; + // There should be only one speaker + case (minSpeakers === maxSpeakers && maxSpeakers === 1): + errorField = "add_only_one_speaker"; + break; + // There should be exactly a number of speakers + case (minSpeakers === maxSpeakers && maxSpeakers !== 1): + errorField = "add_exact_number_of_speakers"; + break; + // The default error message when there is an upper limit and a minimum of speakers + default: + errorField = "add_speakers"; + break; + } } Swal.fire("Validation error", T.translate(`edit_presentation.errors.${errorField}`, translationParams), "warning"); @@ -104,12 +115,12 @@ class PresentationSpeakersForm extends React.Component { } handleChangeSpeaker(ev) { - let {value, id} = ev.target; - this.setState({speaker: value, speakerInput: value}); + let { value, id } = ev.target; + this.setState({ speaker: value, speakerInput: value }); } handleSpeakerClick(speakerId, speakerType, ev) { - let {history, entity, summit} = this.props; + let { history, entity, summit } = this.props; ev.preventDefault(); this.props.onSpeakerEdit(entity.selection_plan_id, entity.id, speakerId, speakerType); } @@ -125,25 +136,25 @@ class PresentationSpeakersForm extends React.Component { } handleEditSpeaker(speakerId, speakerType, ev) { - let {history, entity} = this.props; + let { history, entity } = this.props; ev.preventDefault(); this.props.onSpeakerEdit(entity.selection_plan_id, entity.id, speakerId, speakerType); } handleAddSpeaker(ev) { - const {speaker, currentSpeakerType} = this.state; - const {history, onAddSpeaker, onAddModerator, match, selectionPlanSettings} = this.props; + const { speaker, currentSpeakerType } = this.state; + const { history, onAddSpeaker, onAddModerator, match, selectionPlanSettings } = this.props; const speakerLabel = selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker").toLowerCase(); ev.preventDefault(); - if(!speaker){ + if (!speaker) { // speaker not set - this.setState({...this.state, error: T.translate("edit_presentation.errors.missing_speaker", {speaker: speakerLabel})}); + this.setState({ ...this.state, error: T.translate("edit_presentation.errors.missing_speaker", { speaker: speakerLabel }) }); return; } - if(!currentSpeakerType){ - this.setState({...this.state, error: T.translate("edit_presentation.errors.role")}); + if (!currentSpeakerType) { + this.setState({ ...this.state, error: T.translate("edit_presentation.errors.role") }); return; } @@ -154,7 +165,7 @@ class PresentationSpeakersForm extends React.Component { } else { onAddSpeaker(speaker); } - this.setState({...this.state, currentSpeakerType: null, speakerInput: null, error: null}); + this.setState({ ...this.state, currentSpeakerType: null, speakerInput: null, error: null }); return false; } @@ -166,42 +177,43 @@ class PresentationSpeakersForm extends React.Component { } // speaker not set - this.setState({...this.state, error: T.translate("edit_presentation.errors.missing_speaker", {speaker: speakerLabel})}); + this.setState({ ...this.state, error: T.translate("edit_presentation.errors.missing_speaker", { speaker: speakerLabel }) }); return false; } render() { - let {summit, selectionPlanSettings, entity, presentation, step} = this.props; - let {speakerInput, error, speaker} = this.state; + let { summit, selectionPlanSettings, entity, presentation, step } = this.props; + let { speakerInput, error, speaker } = this.state; let eventType = summit.event_types.find(t => t.id == entity.type_id); let speakersCount = entity.speakers?.length ?? 0; - let canAddSpeakers = (eventType && eventType.max_speakers > speakersCount); + let { max: maxSpeakers } = getSpeakerLimits(eventType); + let canAddSpeakers = (eventType && maxSpeakers > speakersCount); let canAddModerator = (eventType && eventType.max_moderators && !entity.moderator); let speakerTypes = []; - if(canAddSpeakers){ - speakerTypes.push({value:'speaker', label: selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.labels.speaker")}); + if (canAddSpeakers) { + speakerTypes.push({ value: 'speaker', label: selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.labels.speaker") }); } - if(canAddModerator){ - speakerTypes.push({value:'moderator', label: T.translate("edit_presentation.labels.moderator")}); + if (canAddModerator) { + speakerTypes.push({ value: 'moderator', label: T.translate("edit_presentation.labels.moderator") }); } return (

{T.translate("edit_presentation.speaker_included", - { presentation: selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation")})}

+ { presentation: selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation") })} - + -
+
{entity.moderator &&
- + {speakerTypes.length > 0 && -
-
-
-
- - -
-
- - -
-
- +
+
+
+
+ + +
+
+ + +
+
+ +
+ {error && +

{error}

} + {speaker && isNaN(speaker.id) && (speaker.value) && +

* Please select a Role and click "Add".

}
- {error && -

{error}

} - {speaker && isNaN(speaker.id) && (speaker.value) && -

* Please select a Role and click "Add".

} -
} {!canAddSpeakers && !canAddModerator && -

{T.translate("edit_presentation.max_speakers")}

+

{T.translate("edit_presentation.max_speakers")}

} @@ -297,7 +310,7 @@ class PresentationSpeakersForm extends React.Component {
-
+
diff --git a/src/i18n/en.json b/src/i18n/en.json index ca551a6..51cbe17 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -209,7 +209,8 @@ "add_only_one_speaker": "You need to add one {speaker} to the {presentation}.", "add_exact_number_of_speakers": "You need to add {min} {speakers} to the {presentation}.", "add_min_number_speakers": "You need to add at least {min} {speakers} to the {presentation}.", - "missing_speaker": "You need to select a {speaker}." + "missing_speaker": "You need to select a {speaker}.", + "remove_speakers": "You can have at most {max} {speakers} in the {presentation}. Please remove {excess}." } }, "edit_speaker": { From 47394b971e7c60342439d5e08969e5e76e936320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 11 Aug 2026 17:57:01 -0300 Subject: [PATCH 4/7] fix: add guard on CPF, rollback i18n zh changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/presentation-speakers-form.js | 2 +- src/i18n/zh.json | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/components/presentation-speakers-form.js b/src/components/presentation-speakers-form.js index 695a815..8224090 100644 --- a/src/components/presentation-speakers-form.js +++ b/src/components/presentation-speakers-form.js @@ -264,7 +264,7 @@ class PresentationSpeakersForm extends React.Component { id="speaker" selectionPlanSettings={selectionPlanSettings} value={speakerInput} - speakers={entity.speakers} + speakers={Array.isArray(entity.speakers) ? entity.speakers : []} placeholder={T.translate("edit_presentation.placeholders.speakers", { speakers: `${selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 67f8191..3de4591 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -167,20 +167,12 @@ "review_subtitle": "您的演讲已经提交并等待专题负责人审阅", "permission_denied": "没有权限", "no_edit": "您无权编辑此演示文稿", + "add_speaker_error": "您需要添加至少一名演讲者到演讲议题中来。", "chair_comments": "Chair Comments", "presentation_material": "Presentation Material", "placeholders": { "type_id" : "-- Select One --", "level" : "-- Select One --" - }, - "errors": { - "role": "您必须选择一个角色。", - "add_moderator": "您需要添加至少一名主持人到 {presentation} 中来。", - "add_speakers": "您需要添加 {min} 到 {max} 名 {speakers} 到 {presentation} 中来。", - "add_only_one_speaker": "您需要添加一名 {speaker} 到 {presentation} 中来。", - "add_exact_number_of_speakers": "您需要添加 {min} 名 {speakers} 到 {presentation} 中来。", - "add_min_number_speakers": "您需要添加至少 {min} 名 {speakers} 到 {presentation} 中来。", - "missing_speaker": "您需要选择一个 {speaker}。" } }, "edit_speaker": { From 0c2702c6512b43b427201a966b17b89ead4d2a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 13 Aug 2026 16:07:23 -0300 Subject: [PATCH 5/7] fix: fix eventy type source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/presentation-speakers-form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/presentation-speakers-form.js b/src/components/presentation-speakers-form.js index 8224090..fbaa891 100644 --- a/src/components/presentation-speakers-form.js +++ b/src/components/presentation-speakers-form.js @@ -186,7 +186,7 @@ class PresentationSpeakersForm extends React.Component { let { speakerInput, error, speaker } = this.state; let eventType = summit.event_types.find(t => t.id == entity.type_id); let speakersCount = entity.speakers?.length ?? 0; - let { max: maxSpeakers } = getSpeakerLimits(eventType); + let { max: maxSpeakers } = getSpeakerLimits(entity.type); let canAddSpeakers = (eventType && maxSpeakers > speakersCount); let canAddModerator = (eventType && eventType.max_moderators && !entity.moderator); From 6c812642cec4d72f952ed45e2212b97c5b18fcc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 13 Aug 2026 16:19:26 -0300 Subject: [PATCH 6/7] fix: split functions into speaker limits to add test cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/presentation-speakers-form.js | 37 +-------- .../__tests__/speaker-limits.test.js | 79 +++++++++++++++++++ src/components/speaker-limits/index.js | 42 ++++++++++ 3 files changed, 123 insertions(+), 35 deletions(-) create mode 100644 src/components/speaker-limits/__tests__/speaker-limits.test.js create mode 100644 src/components/speaker-limits/index.js diff --git a/src/components/presentation-speakers-form.js b/src/components/presentation-speakers-form.js index fbaa891..b5cd5d9 100644 --- a/src/components/presentation-speakers-form.js +++ b/src/components/presentation-speakers-form.js @@ -18,17 +18,7 @@ import { Exclusive, Dropdown } from 'openstack-uicore-foundation/lib/components' import T from "i18n-react/dist/i18n-react"; import CPFSpeakerInput from './inputs/speaker-input' import Swal from "sweetalert2"; - -const getSpeakerLimits = (type) => { - if (!type) return { min: 0, max: 0 }; - const defaultMin = type.are_speakers_mandatory ? 1 : 0; - const defaultMax = (type.are_speakers_mandatory || type.use_speakers) ? Infinity : 0; - const min = type.min_speakers ?? defaultMin; - const possibleMax = type.max_speakers ?? defaultMax; - // Protection against invalid configuration of max_speakers < min_speakers - const max = possibleMax >= min ? possibleMax : min; - return { min, max }; -}; +import { getSpeakerLimits, getSpeakerCountErrorField } from './speaker-limits'; class PresentationSpeakersForm extends React.Component { constructor(props) { @@ -77,30 +67,7 @@ class PresentationSpeakersForm extends React.Component { const speaker = (selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker")).toLowerCase(); const speakers = (selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || T.translate("edit_presentation.speakers")).toLowerCase(); const translationParams = { presentation, speaker, speakers, max: maxSpeakers, min: minSpeakers, excess }; - - let errorField; - if (speakersCount > maxSpeakers) { - errorField = "remove_speakers"; - } else { - switch (true) { - // There is no upper limit of speakers but there is a minimum - case (Infinity === maxSpeakers): - errorField = "add_min_number_speakers"; - break; - // There should be only one speaker - case (minSpeakers === maxSpeakers && maxSpeakers === 1): - errorField = "add_only_one_speaker"; - break; - // There should be exactly a number of speakers - case (minSpeakers === maxSpeakers && maxSpeakers !== 1): - errorField = "add_exact_number_of_speakers"; - break; - // The default error message when there is an upper limit and a minimum of speakers - default: - errorField = "add_speakers"; - break; - } - } + const errorField = getSpeakerCountErrorField(speakersCount, minSpeakers, maxSpeakers); Swal.fire("Validation error", T.translate(`edit_presentation.errors.${errorField}`, translationParams), "warning"); return; diff --git a/src/components/speaker-limits/__tests__/speaker-limits.test.js b/src/components/speaker-limits/__tests__/speaker-limits.test.js new file mode 100644 index 0000000..cb6c8cd --- /dev/null +++ b/src/components/speaker-limits/__tests__/speaker-limits.test.js @@ -0,0 +1,79 @@ +/** + * Copyright 2017 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +import { getSpeakerLimits, getSpeakerCountErrorField } from '..'; + +describe('getSpeakerLimits', () => { + it('returns zero/zero when no event type is available yet', () => { + expect(getSpeakerLimits(null)).toEqual({ min: 0, max: 0 }); + expect(getSpeakerLimits(undefined)).toEqual({ min: 0, max: 0 }); + }); + + it('defaults to at-least-one with no upper bound when speakers are mandatory and no explicit limits are configured', () => { + expect(getSpeakerLimits({ are_speakers_mandatory: true })).toEqual({ min: 1, max: Infinity }); + }); + + it('defaults to optional/unbounded when speakers are enabled but not mandatory and no explicit limits are configured', () => { + expect(getSpeakerLimits({ use_speakers: true, are_speakers_mandatory: false })).toEqual({ min: 0, max: Infinity }); + }); + + it('defaults to zero/zero when the event type does not use speakers at all', () => { + expect(getSpeakerLimits({ use_speakers: false, are_speakers_mandatory: false })).toEqual({ min: 0, max: 0 }); + }); + + it('honors an explicit min_speakers of 0 even when speakers are mandatory', () => { + // min_speakers=0 is a real, distinct configuration from "unset" - the + // nullish check must not treat 0 as missing and fall back to defaultMin=1. + expect(getSpeakerLimits({ are_speakers_mandatory: true, min_speakers: 0 })).toEqual({ min: 0, max: Infinity }); + }); + + it('honors an explicit finite range', () => { + expect(getSpeakerLimits({ use_speakers: true, min_speakers: 2, max_speakers: 5 })).toEqual({ min: 2, max: 5 }); + }); + + it('honors an explicit exact-count configuration (min equals max)', () => { + expect(getSpeakerLimits({ use_speakers: true, min_speakers: 3, max_speakers: 3 })).toEqual({ min: 3, max: 3 }); + }); + + it('clamps max_speakers up to min_speakers when the event type is misconfigured with max below min', () => { + expect(getSpeakerLimits({ use_speakers: true, min_speakers: 3, max_speakers: 1 })).toEqual({ min: 3, max: 3 }); + }); + + it('honors an explicit max_speakers of 0 when min is also 0', () => { + expect(getSpeakerLimits({ use_speakers: true, min_speakers: 0, max_speakers: 0 })).toEqual({ min: 0, max: 0 }); + }); +}); + +describe('getSpeakerCountErrorField', () => { + it('reports remove_speakers when the count exceeds the max, even for an exact-count requirement', () => { + // over-max must win over the min===max branch below - a submitter over + // the limit needs to be told to remove speakers, not to add the exact count. + expect(getSpeakerCountErrorField(5, 3, 3)).toBe('remove_speakers'); + }); + + it('reports add_min_number_speakers when there is no upper bound', () => { + expect(getSpeakerCountErrorField(0, 2, Infinity)).toBe('add_min_number_speakers'); + }); + + it('reports add_only_one_speaker when exactly one speaker is required', () => { + expect(getSpeakerCountErrorField(0, 1, 1)).toBe('add_only_one_speaker'); + }); + + it('reports add_exact_number_of_speakers when an exact count greater than one is required', () => { + expect(getSpeakerCountErrorField(1, 3, 3)).toBe('add_exact_number_of_speakers'); + }); + + it('reports add_speakers for a bounded range with distinct min and max', () => { + expect(getSpeakerCountErrorField(0, 2, 5)).toBe('add_speakers'); + }); +}); diff --git a/src/components/speaker-limits/index.js b/src/components/speaker-limits/index.js new file mode 100644 index 0000000..455f4af --- /dev/null +++ b/src/components/speaker-limits/index.js @@ -0,0 +1,42 @@ +/** + * Copyright 2017 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +export const getSpeakerLimits = (type) => { + if (!type) return { min: 0, max: 0 }; + const defaultMin = type.are_speakers_mandatory ? 1 : 0; + const defaultMax = (type.are_speakers_mandatory || type.use_speakers) ? Infinity : 0; + const min = type.min_speakers ?? defaultMin; + const possibleMax = type.max_speakers ?? defaultMax; + // Protection against invalid configuration of max_speakers < min_speakers + const max = possibleMax >= min ? possibleMax : min; + return { min, max }; +}; + +export const getSpeakerCountErrorField = (speakersCount, minSpeakers, maxSpeakers) => { + if (speakersCount > maxSpeakers) return "remove_speakers"; + + switch (true) { + // There is no upper limit of speakers but there is a minimum + case (Infinity === maxSpeakers): + return "add_min_number_speakers"; + // There should be only one speaker + case (minSpeakers === maxSpeakers && maxSpeakers === 1): + return "add_only_one_speaker"; + // There should be exactly a number of speakers + case (minSpeakers === maxSpeakers && maxSpeakers !== 1): + return "add_exact_number_of_speakers"; + // The default error message when there is an upper limit and a minimum of speakers + default: + return "add_speakers"; + } +}; From 9cd0ffc6997d04fd3e383edfab87c260ff6d7526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 13 Aug 2026 17:12:32 -0300 Subject: [PATCH 7/7] fix: remove min speakers case, validate speakers on review, remove default max and min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/presentation-review-form.js | 16 ++++++ src/components/presentation-speakers-form.js | 13 ++--- .../__tests__/speaker-limits.test.js | 54 +++++++++++++------ src/components/speaker-limits/index.js | 33 ++++++++---- src/i18n/en.json | 1 - 5 files changed, 82 insertions(+), 35 deletions(-) diff --git a/src/components/presentation-review-form.js b/src/components/presentation-review-form.js index 213f0f9..a26dfa8 100644 --- a/src/components/presentation-review-form.js +++ b/src/components/presentation-review-form.js @@ -16,7 +16,9 @@ import 'awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css' import SubmitButtons from "./presentation-submit-buttons"; import { RawHTML } from 'openstack-uicore-foundation/lib/components' import T from "i18n-react/dist/i18n-react"; +import Swal from "sweetalert2"; import {getMarketingValue} from "./marketing-setting"; +import { validateSpeakerCount } from './speaker-limits'; class PresentationReviewForm extends React.Component { constructor(props) { @@ -42,6 +44,20 @@ class PresentationReviewForm extends React.Component { handleSubmit(ev) { ev.preventDefault(); + const { entity, selectionPlanSettings } = this.props; + const speakerValidation = validateSpeakerCount(entity); + + if (!speakerValidation.valid) { + const presentation = selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation").toLowerCase(); + const speaker = (selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker")).toLowerCase(); + const speakers = (selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || T.translate("edit_presentation.speakers")).toLowerCase(); + const { errorField, min, max, excess } = speakerValidation; + const translationParams = { presentation, speaker, speakers, max, min, excess }; + + Swal.fire("Validation error", T.translate(`edit_presentation.errors.${errorField}`, translationParams), "warning"); + return; + } + this.props.onSubmit(this.props.entity); } diff --git a/src/components/presentation-speakers-form.js b/src/components/presentation-speakers-form.js index b5cd5d9..3f26c98 100644 --- a/src/components/presentation-speakers-form.js +++ b/src/components/presentation-speakers-form.js @@ -18,7 +18,7 @@ import { Exclusive, Dropdown } from 'openstack-uicore-foundation/lib/components' import T from "i18n-react/dist/i18n-react"; import CPFSpeakerInput from './inputs/speaker-input' import Swal from "sweetalert2"; -import { getSpeakerLimits, getSpeakerCountErrorField } from './speaker-limits'; +import { getSpeakerLimits, validateSpeakerCount } from './speaker-limits'; class PresentationSpeakersForm extends React.Component { constructor(props) { @@ -58,16 +58,13 @@ class PresentationSpeakersForm extends React.Component { return; } - const speakersCount = Array.isArray(entity.speakers) ? entity.speakers.length : 0; - const { min: minSpeakers, max: maxSpeakers } = getSpeakerLimits(entity.type); - const validSpeaker = !entity.type.use_speakers || (speakersCount <= maxSpeakers && speakersCount >= minSpeakers); - const excess = speakersCount - maxSpeakers; + const speakerValidation = validateSpeakerCount(entity); - if (!validSpeaker) { + if (!speakerValidation.valid) { const speaker = (selectionPlanSettings?.CFP_SPEAKERS_SINGULAR_LABEL || T.translate("edit_presentation.speaker")).toLowerCase(); const speakers = (selectionPlanSettings?.CFP_SPEAKERS_PLURAL_LABEL || T.translate("edit_presentation.speakers")).toLowerCase(); - const translationParams = { presentation, speaker, speakers, max: maxSpeakers, min: minSpeakers, excess }; - const errorField = getSpeakerCountErrorField(speakersCount, minSpeakers, maxSpeakers); + const { errorField, min, max, excess } = speakerValidation; + const translationParams = { presentation, speaker, speakers, max, min, excess }; Swal.fire("Validation error", T.translate(`edit_presentation.errors.${errorField}`, translationParams), "warning"); return; diff --git a/src/components/speaker-limits/__tests__/speaker-limits.test.js b/src/components/speaker-limits/__tests__/speaker-limits.test.js index cb6c8cd..d74fa87 100644 --- a/src/components/speaker-limits/__tests__/speaker-limits.test.js +++ b/src/components/speaker-limits/__tests__/speaker-limits.test.js @@ -11,7 +11,7 @@ * limitations under the License. **/ -import { getSpeakerLimits, getSpeakerCountErrorField } from '..'; +import { getSpeakerLimits, getSpeakerCountErrorField, validateSpeakerCount } from '..'; describe('getSpeakerLimits', () => { it('returns zero/zero when no event type is available yet', () => { @@ -19,22 +19,14 @@ describe('getSpeakerLimits', () => { expect(getSpeakerLimits(undefined)).toEqual({ min: 0, max: 0 }); }); - it('defaults to at-least-one with no upper bound when speakers are mandatory and no explicit limits are configured', () => { - expect(getSpeakerLimits({ are_speakers_mandatory: true })).toEqual({ min: 1, max: Infinity }); - }); - - it('defaults to optional/unbounded when speakers are enabled but not mandatory and no explicit limits are configured', () => { - expect(getSpeakerLimits({ use_speakers: true, are_speakers_mandatory: false })).toEqual({ min: 0, max: Infinity }); - }); - it('defaults to zero/zero when the event type does not use speakers at all', () => { - expect(getSpeakerLimits({ use_speakers: false, are_speakers_mandatory: false })).toEqual({ min: 0, max: 0 }); + expect(getSpeakerLimits({ use_speakers: false, are_speakers_mandatory: false, min_speakers: 0, max_speakers: 0 })).toEqual({ min: 0, max: 0 }); }); it('honors an explicit min_speakers of 0 even when speakers are mandatory', () => { - // min_speakers=0 is a real, distinct configuration from "unset" - the - // nullish check must not treat 0 as missing and fall back to defaultMin=1. - expect(getSpeakerLimits({ are_speakers_mandatory: true, min_speakers: 0 })).toEqual({ min: 0, max: Infinity }); + // min_speakers=0 is a real, distinct configuration from a positive minimum - + // the API always sends a concrete min_speakers, so 0 must pass through as-is. + expect(getSpeakerLimits({ are_speakers_mandatory: true, min_speakers: 0, max_speakers: 5 })).toEqual({ min: 0, max: 5 }); }); it('honors an explicit finite range', () => { @@ -61,10 +53,6 @@ describe('getSpeakerCountErrorField', () => { expect(getSpeakerCountErrorField(5, 3, 3)).toBe('remove_speakers'); }); - it('reports add_min_number_speakers when there is no upper bound', () => { - expect(getSpeakerCountErrorField(0, 2, Infinity)).toBe('add_min_number_speakers'); - }); - it('reports add_only_one_speaker when exactly one speaker is required', () => { expect(getSpeakerCountErrorField(0, 1, 1)).toBe('add_only_one_speaker'); }); @@ -77,3 +65,35 @@ describe('getSpeakerCountErrorField', () => { expect(getSpeakerCountErrorField(0, 2, 5)).toBe('add_speakers'); }); }); + +describe('validateSpeakerCount', () => { + it('is valid when the event type does not use speakers at all, regardless of count', () => { + expect(validateSpeakerCount({ type: { use_speakers: false }, speakers: [] })).toEqual({ valid: true }); + }); + + it('is invalid when exactly one speaker is required and none were added', () => { + const result = validateSpeakerCount({ type: { use_speakers: true, min_speakers: 1, max_speakers: 1 }, speakers: [] }); + expect(result.valid).toBe(false); + expect(result.errorField).toBe('add_only_one_speaker'); + }); + + it('is valid once enough speakers were added to satisfy the minimum', () => { + const speakers = [{ id: 1 }, { id: 2 }]; + expect(validateSpeakerCount({ type: { use_speakers: true, min_speakers: 2, max_speakers: 5 }, speakers })).toEqual({ valid: true }); + }); + + it('is invalid, with the correct excess, when the count exceeds the max', () => { + // regression test for the Complete-action bypass: a speaker removed on the Speakers step + // can leave a presentation over/under limits without ever re-running this check there, so + // this same function must also gate the Review step's Complete action before it submits. + const speakers = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const result = validateSpeakerCount({ type: { use_speakers: true, min_speakers: 1, max_speakers: 2 }, speakers }); + expect(result).toEqual({ valid: false, errorField: 'remove_speakers', min: 1, max: 2, excess: 1 }); + }); + + it('is invalid when the entity has no speakers array at all', () => { + const result = validateSpeakerCount({ type: { use_speakers: true, min_speakers: 1, max_speakers: 1 } }); + expect(result.valid).toBe(false); + expect(result.errorField).toBe('add_only_one_speaker'); + }); +}); diff --git a/src/components/speaker-limits/index.js b/src/components/speaker-limits/index.js index 455f4af..48e6b4d 100644 --- a/src/components/speaker-limits/index.js +++ b/src/components/speaker-limits/index.js @@ -13,22 +13,17 @@ export const getSpeakerLimits = (type) => { if (!type) return { min: 0, max: 0 }; - const defaultMin = type.are_speakers_mandatory ? 1 : 0; - const defaultMax = (type.are_speakers_mandatory || type.use_speakers) ? Infinity : 0; - const min = type.min_speakers ?? defaultMin; - const possibleMax = type.max_speakers ?? defaultMax; + // min_speakers/max_speakers are non-nullable ints on the API's PresentationType + // (see PresentationTypeSerializer) - always present, never Infinity/unbounded. + const { min_speakers: min, max_speakers: max } = type; // Protection against invalid configuration of max_speakers < min_speakers - const max = possibleMax >= min ? possibleMax : min; - return { min, max }; + return { min, max: max >= min ? max : min }; }; export const getSpeakerCountErrorField = (speakersCount, minSpeakers, maxSpeakers) => { if (speakersCount > maxSpeakers) return "remove_speakers"; switch (true) { - // There is no upper limit of speakers but there is a minimum - case (Infinity === maxSpeakers): - return "add_min_number_speakers"; // There should be only one speaker case (minSpeakers === maxSpeakers && maxSpeakers === 1): return "add_only_one_speaker"; @@ -40,3 +35,23 @@ export const getSpeakerCountErrorField = (speakersCount, minSpeakers, maxSpeaker return "add_speakers"; } }; + +// Single source of truth for "is this presentation's speaker count valid" - used both +// by the Speakers step (on save) and the Review step (on final Complete), so the +// Complete action can't finalize a presentation the Speakers step would have rejected. +export const validateSpeakerCount = (entity) => { + if (!entity?.type?.use_speakers) return { valid: true }; + + const speakersCount = Array.isArray(entity.speakers) ? entity.speakers.length : 0; + const { min, max } = getSpeakerLimits(entity.type); + + if (speakersCount <= max && speakersCount >= min) return { valid: true }; + + return { + valid: false, + errorField: getSpeakerCountErrorField(speakersCount, min, max), + min, + max, + excess: speakersCount - max + }; +}; diff --git a/src/i18n/en.json b/src/i18n/en.json index 51cbe17..83438e1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -208,7 +208,6 @@ "add_speakers": "You need to add between {min} and {max} {speakers} to the {presentation}.", "add_only_one_speaker": "You need to add one {speaker} to the {presentation}.", "add_exact_number_of_speakers": "You need to add {min} {speakers} to the {presentation}.", - "add_min_number_speakers": "You need to add at least {min} {speakers} to the {presentation}.", "missing_speaker": "You need to select a {speaker}.", "remove_speakers": "You can have at most {max} {speakers} in the {presentation}. Please remove {excess}." }