From abb724b7829f078a4f3eae2dc26a16eab0bd2aff Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 21 Aug 2026 08:53:34 -0500 Subject: [PATCH 1/3] refactor(event-form): move the CFP reopen section into the Materials panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reopen control sat at the top of the activity form, above Submitter, where it was visible but not grouped with the thing it governs. Moved it inside the Materials panel, below the materials table. The panels are a single open accordion (showSection starts at "main"), so the panel is collapsed on load and the move would otherwise hide whether a talk has a live window. The panel title carries that state instead: Materials Materials — submission reopened until August 22, 2026 6:51 am Title and section body share one gate, isReopenSectionVisible(). Keying the title on isSubmissionReopened() alone would announce a deadline in exactly the case the comment on isReopenApplicable warns about: a live grant whose plan window was since extended, which the server no longer honours. There is already a test for that case and it now covers the title too. Also: * Panel gets an explicit id="materials". uicore's Panel derives its DOM id from the title when none is passed, so a dynamic title would otherwise move the id every time a grant opens or closes. * Drops the "CFP submission window" label inside the section. The panel title already names the group, and two headings stacked read as noise. * renderEventForm in the test file expands the panel before asserting, since uicore's Panel mounts children only while expanded. queryByText, not getByText, so the tests that assert the control is absent still exercise a real absence rather than passing on a collapsed panel. * Test fixture gains materials: [], which the Table now actually maps over. Verified in the browser against api.dev.fnopen.com on summit 13 / activity 3535: both title states, the collapsed header, and the explicit panel id. Co-Authored-By: Claude --- .../forms/__tests__/event-form.test.js | 42 ++- src/components/forms/event-form.js | 259 ++++++++++-------- src/i18n/en.json | 2 +- 3 files changed, 178 insertions(+), 125 deletions(-) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index 81b730f59..601faf683 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -56,7 +56,10 @@ describe("EventForm", () => { moderator: null, sponsors: [], tags: [], - extra_questions: [] + extra_questions: [], + // The Materials panel is expanded by renderEventForm, so uicore's Table now + // actually mounts and maps over this. + materials: [] }, errors: {}, onSubmit: jest.fn(), @@ -82,8 +85,18 @@ describe("EventForm", () => { onClone: jest.fn() }; - const renderEventForm = (overrides = {}) => - render(); + // The reopen section lives inside the Materials panel, and uicore's Panel mounts + // its children only while expanded, so every assertion here needs it open first. + // queryByText, not getByText: the panel itself is gated on a saved presentation, + // and the tests that assert the control is absent pass entities that suppress it. + const renderEventForm = (overrides = {}) => { + const result = render(); + const materialsHeading = screen.queryByText(/^edit_event\.materials/, { + selector: ".panel-title" + }); + if (materialsHeading) fireEvent.click(materialsHeading); + return result; + }; // Built on baseProps.entity (not the bare object from the task brief) because // several unrelated, pre-existing render paths (TagInput, isEventType, @@ -117,6 +130,14 @@ describe("EventForm", () => { ).toBeInTheDocument(); }); + it("leaves the Materials panel title plain when there is no grant", () => { + renderEventForm({ entity: baseEntity }); + + expect( + screen.getByText("edit_event.materials", { selector: ".panel-title" }) + ).toBeInTheDocument(); + }); + it("does not offer the reopen control on a new presentation", () => { renderEventForm({ entity: { ...baseEntity, id: 0 } }); @@ -183,6 +204,11 @@ describe("EventForm", () => { expect( screen.queryByText(/edit_event.reopened_until/) ).not.toBeInTheDocument(); + // The panel title shares the section's gate for exactly this case: keyed on the + // grant alone it would announce a deadline the server no longer honours. + expect( + screen.getByText("edit_event.materials", { selector: ".panel-title" }) + ).toBeInTheDocument(); }); it("disables the reopen button when no valid hours value is selected", async () => { @@ -385,6 +411,16 @@ describe("EventForm", () => { ).not.toBeInTheDocument(); }); + it("announces the deadline on the collapsed Materials panel title", () => { + renderEventForm({ entity: grantedEntity }); + + expect( + screen.getByText("edit_event.materials_reopened", { + selector: ".panel-title" + }) + ).toBeInTheDocument(); + }); + it("still shows the reopened state when the payload was not expanded", () => { renderEventForm({ entity: grantedUnexpandedEntity }); diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index 12c503091..9ac5a77ea 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -766,6 +766,20 @@ class EventForm extends React.Component { return moment().unix() > plan.submission_end_date; } + // The panel title and the section body MUST share one gate. Keying the title on + // isSubmissionReopened() alone would announce a deadline in exactly the case the + // comment on isReopenApplicable describes: a live grant whose plan window was + // since extended, which the server no longer treats as operative. + isReopenSectionVisible() { + const { entity } = this.state; + return ( + this.isPresentation() && + !this.isNew() && + entity.selection_plan_id > 0 && + this.isReopenApplicable() + ); + } + isSubmissionReopened() { const deadline = this.getReopenDeadline(); return !!deadline?.isAfter(moment()); @@ -1259,126 +1273,6 @@ class EventForm extends React.Component {

)} - {this.isPresentation() && - !this.isNew() && - entity.selection_plan_id > 0 && - this.isReopenApplicable() && ( -
-
- - {!this.isSubmissionReopened() && ( -
- - - {reopenHours === "custom" && ( - <> - - - this.setState({ - reopenCustomHours: ev.target.value - }) - } - /> - - )} - -
- )} - {this.isSubmissionReopened() && ( -
- - {T.translate("edit_event.reopened_until", { - deadline: this.getReopenDeadline().format( - REOPEN_DEADLINE_FORMAT - ) - })} - - {entity.submission_reopened_by && ( - - {T.translate("edit_event.reopened_by", { - admin: `${entity.submission_reopened_by.first_name} ${entity.submission_reopened_by.last_name}` - })} - - )} - - {window.CFP_APP_BASE_URL && ( - - -   - -   - {speakerDeepLink} - - )} -
- )} -
-
- )}
  @@ -2073,8 +1967,17 @@ class EventForm extends React.Component { {entity.id != 0 && this.isEventType(EVENT_TYPE_PRESENTATION) && ( +
+ )} + {this.isSubmissionReopened() && ( +
+ + {T.translate("edit_event.reopened_until", { + deadline: this.getReopenDeadline().format( + REOPEN_DEADLINE_FORMAT + ) + })} + + {entity.submission_reopened_by && ( + + {T.translate("edit_event.reopened_by", { + admin: `${entity.submission_reopened_by.first_name} ${entity.submission_reopened_by.last_name}` + })} + + )} + + {window.CFP_APP_BASE_URL && ( + + +   + +   + {speakerDeepLink} + + )} +
+ )} +
+ + )} )} diff --git a/src/i18n/en.json b/src/i18n/en.json index d8ff57b56..49357a913 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -640,6 +640,7 @@ "event_cloned": "Activity cloned successfully.", "proximity_alert": "Speaker/s have sessions 90min before or after:", "materials": "Materials", + "materials_reopened": "Materials — submission reopened until {deadline}", "add_material": "Add Material", "display_on_site": "Display on site", "type": "Type", @@ -714,7 +715,6 @@ "save_and_mark_complete": "Save & Mark Complete", "event_saved_as_draft": "Activity saved as draft successfully.", "reopen_submission": "Reopen submission", - "reopen_submission_section": "CFP submission window", "reopen_duration": "Reopen for", "reopen_duration_24": "24 hours", "reopen_duration_48": "48 hours", From e149743a7a003994ae5ef5d9d8566d0541e96527 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 21 Aug 2026 09:18:52 -0500 Subject: [PATCH 2/3] test(event-form): assert the reopened title while the panel is collapsed Copilot review on #1047. The test named "announces the deadline on the collapsed Materials panel title" never collapsed the panel, because renderEventForm expands Materials for every test in the file. That is the one behaviour the move exists to preserve, and it had no coverage. Gating the title on showSection, which would blank the header exactly when an operator needs it, passed all 29 tests. Collapse again after render, and assert the panel body is unmounted so the test cannot drift back to asserting the expanded state. Verified: it passes against current code and fails against that mutation. Co-Authored-By: Claude --- src/components/forms/__tests__/event-form.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index 601faf683..e19b865ab 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -414,6 +414,15 @@ describe("EventForm", () => { it("announces the deadline on the collapsed Materials panel title", () => { renderEventForm({ entity: grantedEntity }); + // renderEventForm leaves the panel open. Collapse it again: the header + // reading without expanding is the whole point of it, so asserting while + // expanded passes even when the title is gated on showSection. Asserting + // the body is unmounted keeps it that way. + fireEvent.click( + screen.getByText(/^edit_event\.materials/, { selector: ".panel-title" }) + ); + + expect(document.querySelector("#materials .panel-body")).toBeNull(); expect( screen.getByText("edit_event.materials_reopened", { selector: ".panel-title" From 7692c571485cc998a5e71edba67f0a71c47f3c04 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 21 Aug 2026 10:22:14 -0500 Subject: [PATCH 3/3] refactor(event-form): extract the panel title, trim the comments Reviewer notes on #1047. Extract the Materials panel title into getMaterialsPanelTitle(), alongside the other reopen getters. Pulls a nested ternary out of a 2500-line render and matches the class's existing getReopenDeadline/getMaxReopenHours shape. The gate it shares with the section body is now held by the collapsed-title test rather than by proximity: mutation-checked by gating the method on showSection, which fails that test and nothing else. Trim four comment blocks. event-form.js on master carries no multi-line comment blocks in 2460 lines, and all four were new here. Kept the two non-obvious claims: entity-from-state in isReopenApplicable, and queryByText-not-getByText in the test helper. 169 suites / 1510 tests pass. eslint: 0 errors, same 327 pre-existing warnings as master. Co-Authored-By: Claude --- .../forms/__tests__/event-form.test.js | 13 ++----- src/components/forms/event-form.js | 36 ++++++++----------- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index e19b865ab..076ed9ba4 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -57,8 +57,6 @@ describe("EventForm", () => { sponsors: [], tags: [], extra_questions: [], - // The Materials panel is expanded by renderEventForm, so uicore's Table now - // actually mounts and maps over this. materials: [] }, errors: {}, @@ -85,10 +83,8 @@ describe("EventForm", () => { onClone: jest.fn() }; - // The reopen section lives inside the Materials panel, and uicore's Panel mounts - // its children only while expanded, so every assertion here needs it open first. - // queryByText, not getByText: the panel itself is gated on a saved presentation, - // and the tests that assert the control is absent pass entities that suppress it. + // Panel mounts children only while expanded. queryByText, not getByText: the + // absence tests pass entities that suppress the panel itself. const renderEventForm = (overrides = {}) => { const result = render(); const materialsHeading = screen.queryByText(/^edit_event\.materials/, { @@ -414,10 +410,7 @@ describe("EventForm", () => { it("announces the deadline on the collapsed Materials panel title", () => { renderEventForm({ entity: grantedEntity }); - // renderEventForm leaves the panel open. Collapse it again: the header - // reading without expanding is the whole point of it, so asserting while - // expanded passes even when the title is gated on showSection. Asserting - // the body is unmounted keeps it that way. + // Collapsed is the point: expanded passes even if gated on showSection. fireEvent.click( screen.getByText(/^edit_event\.materials/, { selector: ".panel-title" }) ); diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index 9ac5a77ea..08ff6f8d1 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -745,15 +745,8 @@ class EventForm extends React.Component { return entity.class_name === "Presentation"; } - // The API's isSubmissionReopened() requires three things: the plan enabled, its - // submission window actually ended, and a live grant. Keying the UI on the grant - // alone lets it announce a deadline the server no longer treats as operative -- - // e.g. an admin grants a reopen, then extends the plan's submission_end_date past - // it, and the speaker is editing under normal open-window rules again. - // entity comes from state, not props, because that is what the render gate reads. - // handleChangeSelectionPlan writes selection_plan_id into state without saving, and - // componentDidUpdate only syncs the other way, so reading props here would judge - // eligibility against the persisted plan while the form displays a different one. + // entity from state, not props: handleChangeSelectionPlan writes the plan into + // state without saving, so props would judge a plan the form is not showing. isReopenApplicable() { const { selectionPlansOpts } = this.props; const { entity } = this.state; @@ -766,10 +759,8 @@ class EventForm extends React.Component { return moment().unix() > plan.submission_end_date; } - // The panel title and the section body MUST share one gate. Keying the title on - // isSubmissionReopened() alone would announce a deadline in exactly the case the - // comment on isReopenApplicable describes: a live grant whose plan window was - // since extended, which the server no longer treats as operative. + // Title and section body must share this gate: a grant whose plan window was + // since extended is no longer the operative deadline. isReopenSectionVisible() { const { entity } = this.state; return ( @@ -796,6 +787,15 @@ class EventForm extends React.Component { ); } + getMaterialsPanelTitle() { + if (!this.isReopenSectionVisible() || !this.isSubmissionReopened()) { + return T.translate("edit_event.materials"); + } + return T.translate("edit_event.materials_reopened", { + deadline: this.getReopenDeadline().format(REOPEN_DEADLINE_FORMAT) + }); + } + // Mirrors the server's CFP_MAX_REOPEN_HOURS so an over-ceiling value is caught // before the confirm dialog rather than by the 412 after it. dotenv values are // strings, hence the coercion. Unset means uncapped: the server's 412 stays the @@ -1969,15 +1969,7 @@ class EventForm extends React.Component {