Skip to content

FE: CFP reopen notification, recipient selection and Notify button - #1050

Open
caseylocker wants to merge 17 commits into
masterfrom
feature/cfp-reopen-notify
Open

FE: CFP reopen notification, recipient selection and Notify button#1050
caseylocker wants to merge 17 commits into
masterfrom
feature/cfp-reopen-notify

Conversation

@caseylocker

@caseylocker caseylocker commented Aug 24, 2026

Copy link
Copy Markdown

ref: https://app.clickup.com/t/86bbkbrct

Front-end half of the CFP reopen notification. Spec: SDS §7 + §10.

Stacked on #1047

Base is refactor/reopen-into-materials-panel, not master. The control lives inside the Materials panel that #1047 introduces. #1047 has since merged, so this can be unstacked with git rebase --onto origin/master 7692c57 whenever it is convenient.

Backend

PUT .../submission-period/reopen/notify (86bbkbrue) is now deployed to dev, so the control works end to end there. Note the response field is a queue count, not a delivery count: the API returns the number of recipients whose email job was enqueued, and the success message says "queued" for that reason.

What it does

An admin picks which of the submitter, the speakers and the moderator to email about a live reopen window. Rows come from the event already in state, so there is no extra request.

The part that is easy to get wrong

The UI shows rows; the endpoint takes two channels (speaker_ids plus include_submitter). Rows are keyed by identity, never by email, and two identities merge into one row when their emails match case-insensitively, so a shared mailbox gets one message while the payload still names both people.

include_submitter is a bare boolean carrying no identity, which the server resolves from the persisted getCreatedBy(). So rows derive from props.entity (persisted), not state.entity (editable) — otherwise an unsaved submitter swap would relabel the row while the send still reached the saved creator.

Note for reviewers: the wiring is five touchpoints, not four

SDS §7 and the ClickUp ticket both say four. They omit the page-level props destructure in edit-summit-event-page.js. Routing an action to EventForm takes: the named import, the const { … } = props destructure in the component body, the JSX prop, the mapDispatchToProps entry, and the this.props destructure in the form's handler.

Missing the page-level one does not produce undefined. The module-scope import shadows through, so the JSX resolves to an un-dispatched action creator: no request, no error, suite green.

Layout

event-form.js is now event-form/index.js, with ReopenNotifyPanel.jsx, utils.js and the tests beside it, matching promocode-form/. Every external importer resolves unchanged. Nothing was added to src/models/.

Testing

19 tests: the row model, the notify thunk, and the panel's behaviour through the real form. Full suite green at 172 suites / 1529 tests. Verified end to end on dev against a real reopened activity.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ef7bf2c-b4b4-4248-86be-4797a37a3b30

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the front-end UI and client-side wiring for sending CFP “submission reopened” notification emails to selected recipients (submitter/speakers/moderator) from the event edit form, including recipient-row modeling and the thunk that calls the (future) API endpoint.

Changes:

  • Wires a new notifySubmissionReopened thunk from the edit page down into EventForm.
  • Implements recipient row modeling (dedupe by identity, merge by normalized email) and payload building for the notify endpoint.
  • Adds UI (checkbox recipient selection + confirm dialog + “Notify selected” button), i18n strings, and comprehensive unit tests for model/UI/action behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/pages/events/edit-summit-event-page.js Wires the new notify thunk through page props, JSX props, and connect.
src/models/reopen-notification-recipients.js Adds recipient-row construction and conversion to the notify payload shape.
src/i18n/en.json Adds new strings for notify UI labels, confirm copy, roles, and success message.
src/components/forms/event-form.js Adds notify selection UI, confirm flow, selection intersection logic, and invokes the notify thunk.
src/components/forms/tests/event-form-notify.test.js Adds tests for recipient model behavior and notify UI behavior in EventForm.
src/actions/event-actions.js Adds notifySubmissionReopened thunk and supporting action type constant.
src/actions/tests/event-actions-notify.test.js Adds action tests verifying request shape, loading flags, success count handling, and stop-loading on failure.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/components/forms/event-form/utils.js
@caseylocker
caseylocker requested a review from smarcet August 25, 2026 13:23
@smarcet
smarcet requested a review from santipalenque August 25, 2026 14:07

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@santipalenque santipalenque left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker there are many review comments that I keep asking over and over again, like the unnecessary comments, or the meaningless tests. Please make sure to review those yourself before sending the PR to review.
Also please clean up the models directory, its purpose is not to hold util methods for specific pages

expect(snackbar.payload.type).toBe("success");
});

it("stops loading even when the request rejects", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please lets only have meaningful tests

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut from 37 to 19. Gone: the ordering assertion on startLoading, tests for inputs the API cannot produce (speaker ids are serialised as ints), render tests that re-asserted row-model behaviour already covered at the unit level, and four that only reached their state by calling rerender directly, which bypasses the page's if (loading) return null. That gate also meant the post-confirm intersection could never narrow anything in the running app, so that code is gone too, along with its tests.

Kept the one you flagged here. The thunk's .finally is the only thing that clears loading on rejection, and the page renders null while loading is set, so a stuck flag blanks the page rather than leaving a spinner.

Comment thread src/actions/event-actions.js Outdated
export const CHANGE_SEARCH_TERM = "CHANGE_SEARCH_TERM";
export const SUBMISSION_PERIOD_REOPENED = "SUBMISSION_PERIOD_REOPENED";
export const SUBMISSION_PERIOD_CLOSED = "SUBMISSION_PERIOD_CLOSED";
// Dispatched so uicore's putRequest has a receive action to fire (it dispatches the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please only add meaningful comments

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. Went through the whole PR and trimmed comments.

Comment thread src/components/forms/event-form.js Outdated
[ROLE.SPEAKER]: "edit_event.notify_role_speaker"
};

const NOTIFY_ROW_GAP = 7;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good reason. I assumed no-magic-numbers would flag an inline gap, but the rule resolves with detectObjects: false, so a numeric object value is never flagged. Constants removed and the values inlined as 10, matching the other inline gaps in this file.

Comment thread src/components/forms/event-form.js Outdated
reopenCustomHours: "",
// Transient and per-form by design: there is no server-side record of who was
// notified last time, so remembering a selection would assert more than the
// backend can back up. Empty on mount, emptied again after a successful send.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for this comment. When you write a comment think if it really needs a comment, if we have to explain in a comment why a variable has an empty array as default every time, then the code will be 90% comments

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Comment thread src/components/forms/event-form.js Outdated
if (confirmed) onCloseSubmission(entity.id)?.catch(() => {});
}

// Persisted entity, not the editable one: `include_submitter` carries no identity,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be one line: "state entity doesn't include id"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it rather than shortened it, since the suggested wording would not have been accurate: state.entity is initialised as { ...props.entity }, so it does carry the id. The actual reason for reading props.entity is that state.entity is the editable copy, and include_submitter is a bare boolean the server resolves against the saved creator, so an unsaved submitter swap would relabel the row while the send still reached the persisted one. That is in the PR description now. Happy to put a one-line version back here if you would rather it live in the code.

Comment thread src/components/forms/event-form.js Outdated
const notifySelection = toNotifyPayload(recipientRows, notifyChecked);
// The button asks the same function that builds the payload whether there is
// anything to send, so a checked key whose row has since disappeared or gone
// disabled cannot leave an enabled button that does nothing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make it shorter, comments should be hints, not a thorough explanation

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Comment thread src/components/forms/event-form.js Outdated
{speakerDeepLink}
</span>
)}
<div style={{ flexBasis: "100%" }}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try not to keep growing an already extremely large file, this whole section could be an auxiliary component and only add one line to this render.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The block is now ReopenNotifyPanel, and render gets one line. While moving it I also turned event-form.js into event-form/index.js with the panel, its utils and the tests beside it, matching promocode-form/. Every external importer resolves unchanged.

@@ -0,0 +1,136 @@
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker this directory models used to hold only classes with behavior that were reused accross pages and components. Now you started adding util files with methods that duplicate code and are only used on a single page or component. Please refactor this directory, remove all files that are used by a single owner, move the specific methods used on a single source to a local utils file under the same directory, define which methods are global and are reused across clients and put them in utils

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. My file is out of src/models/ and now lives next to its only consumer as event-form/utils.js, so this PR no longer adds to the problem.

The wider cleanup I have left alone here, since it is other features' code: app-config.js and speakers-report.js each have a single importer, and materializer-allowlist.js is used only by the allowlist panel. Only member.js and lead-report-settings.js are genuinely shared.

@smarcet

smarcet commented Aug 25, 2026

Copy link
Copy Markdown

@caseylocker please review @santipalenque comments

caseylocker added a commit that referenced this pull request Aug 25, 2026
…ests

Responds to the review on #1050.

- Drop the narrative comments added by this PR. The Apache header stays.
- Extract the recipient block into ReopenNotifyPanel so render gains one line
  instead of ~57. toggleNotifyRecipient now needs a constructor bind: it was
  only ever called inside an arrow, and is passed as a bare prop.
- Move event-form.js to event-form/index.js and colocate ReopenNotifyPanel,
  utils and the tests beside it, matching promocode-form. External importers
  resolve unchanged. src/models/reopen-notification-recipients.js is gone;
  src/models/ is back to its previous contents.
- Inline the two hoisted spacing constants. no-magic-numbers resolves with
  detectObjects:false, so a numeric object value was never flagged.
- Drop the post-confirm intersection and the submitter pin. The page returns
  null while loading is set, and every thunk that replaces the entity starts
  loading before its request, so EventForm is unmounted whenever props.entity
  changes. The intersection could never narrow anything in the running app.
- Cut tests to 19. Removed the four that reached their state by calling
  rerender directly, bypassing that loading gate, plus duplicates of the row
  model already covered in utils.test.js.

Co-Authored-By: Claude <noreply@anthropic.com>
@caseylocker

Copy link
Copy Markdown
Author

All addressed. Comments: cleaned. Tests: 37 down to 19, detail in the thread on the action test. models/: my file is out and colocated with its consumer.

On the repeats, that is a fair hit. The comment density and the test volume were both things I should have caught before sending this out, not after.

The PR description is updated.

caseylocker and others added 17 commits August 25, 2026 13:13
Co-Authored-By: Claude <noreply@anthropic.com>
…est can fail

Co-Authored-By: Claude <noreply@anthropic.com>
…tion

Derives the button's disabled state from toNotifyPayload instead of the raw
checked-key count, so a checked recipient whose row disappears or goes
disabled (e.g. removed from the talk, email cleared) can no longer leave an
enabled button that silently does nothing on click.
A row merged across a shared mailbox kept only the first identity's name,
hiding the second person from the checkbox label and the confirm dialog
even though the payload still carried their id. buildRecipientRows now
carries a names array and joins it into the display name on merge.

handleNotifySpeakers also built the send payload from the rows captured
before the confirm-dialog await. If the entity refreshes while the dialog
is open and a merged row splits, the stale rows could still ship an id
whose checkbox now reads unticked. The payload is now re-derived from
fresh rows after the await; the dialog copy still describes what the
admin saw when they pressed the button.
Round 2's re-derivation fixed the shrink case (a merged row splitting away
a checked person) but opened the grow case: a refresh while the dialog is
open could merge an unchecked identity onto an already-checked row, and
the plain re-derivation would then ship that identity's id even though
they were never ticked and never named in the dialog.

The send now uses the intersection of the pre-await selection (what the
admin saw and ticked) and the post-await selection (what is still valid).
Intersecting can only ever shrink the set, never add anyone.
Un-export normalizeEmail: nothing in src imported it, only the test.
Cover its trimming through buildRecipientRows instead and drop the
trivial-helper describe block.

Drop a comment that claimed the suite would stay green without the
props destructure; the render tests fail without it.

Co-Authored-By: Claude <noreply@anthropic.com>
getRecipientRows built rows from state.entity, which is editable, unsaved
form state. include_submitter is a bare boolean with no identity in it;
the server resolves it from the persisted creator. An unsaved submitter
swap could show and let the admin tick the wrong person while the payload
still mailed whoever is actually saved, and a mid-dialog change to the
persisted creator could make the boolean silently denote someone the
dialog never named even after the round-3 intersection.

getRecipientRows now reads props.entity (last server truth) instead of
state.entity. The submitter identity is also pinned before the confirm
dialog and compared after it, since a boolean channel cannot be
intersected on identity the way speakerIds can.

Also drops the post-intersection empty-payload guard: with the submitter
now pinned, a mid-dialog identity change can legitimately intersect the
payload down to nothing after the admin already confirmed a send. Letting
the request go through and surface the server's own empty-selection 412
is consistent with treating a silent local skip as a lie about what was
sent.
Round 4 removed the post-intersection empty-payload guard because it
conflicted with a required test that asserted the send thunk was called
with a fully-empty payload. That test was mis-specified: relying on the
not-yet-built endpoint's 412 to explain an empty send couples this change
to an untested contract, and if the eventual implementation instead
returns 200 with a zero count, the admin sees a false success snackbar
for a send that never happened. The client already knows locally when
there is nothing left to send.

Restores the guard and fixes the test to assert the thunk is NOT called
once the submitter pin (round 4) intersects the payload down to empty,
rather than asserting a call with an empty body.
- destructure props in handleNotifySpeakers (react/destructuring-assignment)
- drop the duplicated spacing rationale; the reopen row above is a
  row-direction flex with gap 10 and abc-checkbox rows are 17px, not 22px
- drop a test comment justifying getAllByLabelText by a MemberInput
  singleValue that the file's own member-input mock removed
- drop an unused onNotifySubmissionReopened mock from a test that never sends
…ests

Responds to the review on #1050.

- Drop the narrative comments added by this PR. The Apache header stays.
- Extract the recipient block into ReopenNotifyPanel so render gains one line
  instead of ~57. toggleNotifyRecipient now needs a constructor bind: it was
  only ever called inside an arrow, and is passed as a bare prop.
- Move event-form.js to event-form/index.js and colocate ReopenNotifyPanel,
  utils and the tests beside it, matching promocode-form. External importers
  resolve unchanged. src/models/reopen-notification-recipients.js is gone;
  src/models/ is back to its previous contents.
- Inline the two hoisted spacing constants. no-magic-numbers resolves with
  detectObjects:false, so a numeric object value was never flagged.
- Drop the post-confirm intersection and the submitter pin. The page returns
  null while loading is set, and every thunk that replaces the entity starts
  loading before its request, so EventForm is unmounted whenever props.entity
  changes. The intersection could never narrow anything in the running app.
- Cut tests to 19. Removed the four that reached their state by calling
  rerender directly, bypassing that loading gate, plus duplicates of the row
  model already covered in utils.test.js.

Co-Authored-By: Claude <noreply@anthropic.com>
@caseylocker
caseylocker force-pushed the feature/cfp-reopen-notify branch from 6847ef1 to 1b7ace9 Compare August 25, 2026 18:15
@caseylocker
caseylocker changed the base branch from refactor/reopen-into-materials-panel to master August 25, 2026 18:15
@caseylocker

Copy link
Copy Markdown
Author

@santipalenque your comments have been addressed. I also repointed to master in prep for the merge.

@santipalenque santipalenque left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @caseylocker

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants