Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/presentations-table.js

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

src/components/presentations-table.js:93 — outside this PR's diff hunk, so GitHub won't let me anchor to it; file-level comment instead.

@caseylocker Not a change request on this PR — recording the Delete-button defect here so the analysis lives next to the code rather than only in the Copilot thread that got resolved.

The guard on the Delete button is dead, and has been since it was written:

{!presentation.submissionIsClosed && presentation.canDelete() && <button  >}

Presentation has never defined a submissionIsClosed member. On master the class only declared the underscore-prefixed private field (presentation.js:42), read internally by canEdit() (:150); there is no getter and no assignment to the un-prefixed name anywhere in the file. So presentation.submissionIsClosed is undefined, !undefined is true, and the first term is a constant.

That leaves the condition as canDelete() alone (presentation.js:198-203), which checks !is_published and selection_plan_id === selectionPlan.id and nothing time-related. The Delete button therefore renders on any unpublished presentation of the plan, including long after the submission window closed.

No data is at risk — the server does enforce it. PresentationService::deletePresentation (summit-api :590-592):

$can_delete_closed_submissions = is_null($current_user) || $current_user->isAdmin();
if ($presentation->isSubmissionClosed() && !$can_delete_closed_submissions)
    throw new ValidationException(...)

So the user-visible defect is a destructive action offered in the UI that always fails once the window is closed.

Confirming your read in the resolved Copilot thread: this PR did not cause it. Removing _submissionIsClosed changed nothing, because nothing outside the class ever read it. Your call to leave it alone here is right — the fix belongs in canDelete(), and it would change delete behaviour for every submission rather than for reopened ones:

canDelete(nowUtc) {
    if (!this._selectionPlan) return false;
    if (this._presentation.is_published) return false;
    if (this._presentation.selection_plan_id !== this._selectionPlan.id) return false;
    return !this.isSubmissionClosed(nowUtc);   // the check canEdit() already computes
}

presentations-table.js already has nowUtc in scope (mapStateToProps:121), so the call site becomes {presentation.canDelete(nowUtc) && …}. Worth its own ticket so it gets tested on its own terms.

Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ const PresentationsTable = ({

const handleEditPresentation = (ev, presentation) => {
ev.preventDefault();
history.push(presentation.getProgressLink());
// getProgressLink asks canEdit, so before the first Clock tick it would resolve every
// reopened presentation to /preview, and the layout's redirect guard makes that one-way.
// Do nothing until the clock is real rather than navigate somewhere we cannot come back from.
if (nowUtc == null) return;

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 The nowUtc == null guard trades a wrong navigation for a silent dead click, and in the degraded mode the PR's Known-limitation paragraph describes, that dead click is indefinite.

If the time-service request hangs — accepted, never answered, never errored — uicore's Clock never assigns state.timestamp: tick() is null !== timestamp && (...), the error fallback (processServerTimeResponseError, which seeds moment().unix()) only fires on a rejected fetch, and the fetch has no timeout of its own. Until the browser's fetch timeout finally rejects it — minutes, not seconds — nowUtc stays null, so every title click here no-ops with no feedback and getStatus(nowUtc) renders the status column blank. The Known-limitation section covers the frozen-window symptom ("a window does not lapse client side") but not this navigation dead-end, which is the part a speaker would actually notice and report.

The guard itself is right — navigating through getProgressLink on a wrong clock is the one-way /preview strand this PR exists to prevent. Suggested fix, either level is fine:

// minimal: make the disabled state visible instead of silent
<a onClick={ev => handleEditPresentation(ev, presentation)}
   className={nowUtc == null ? "disabled" : ""}
   title={nowUtc == null ? T.translate("presentations.clock_syncing") : ""}>{p.title}</a>

or simply extend the PR description's Known-limitation paragraph to state that while unsynced the table is non-navigable and statuses are blank, so the tradeoff is on record. Recovery paths already exist (visibilitychange re-sync, browser fetch timeout → error fallback), so I'd not block on either.

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.

Took the documentation option, and you were more right than the comment claimed: the Known limitation paragraph was not just incomplete, it was wrong about the state. It said nowUtc "stays frozen at its seed". There is no seed. clock-reducer.js:25 seeds null, which is the entire reason the guard you are looking at exists. Rewritten.

Your mechanism checks out end to end in the installed bundle: initial state = {timestamp: null}, tick is null !== e && (...) so the interval no-ops forever while null, getServerTime is a bare fetch with no AbortController, and processServerTimeResponseError is only reachable from .catch. Nothing here gates the table's render on the clock either, so the dead click is exactly as long-lived as you describe.

The paragraph now records all three symptoms rather than only the frozen window: non-navigable table, with the reason the no-op is deliberate rather than an oversight, and the blank status column.

One thing worth adding, because it is also the workaround: the block is only on this navigation path. presentation-layout.js:79 gates its redirect on nowUtc != null, so a speaker arriving at the edit URL directly, from the admin's deep link, a bookmark or browser back, is unaffected and edits normally. So the table is non-navigable while unsynced, but the app is not stuck. That is in the paragraph too, alongside the recovery paths you named.

Left the visible-disabled affordance out. It needs a new string and a disabled style for a state that self-heals on tab re-focus, and you said you would not block on either, so I would rather not add UI for it inside this PR. Happy to take it if you would rather see it.

history.push(presentation.getProgressLink(nowUtc));
};

const handleReviewPresentation = (ev, presentation) => {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@
"review_subtitle": "Your {presentation} is submitted and awaiting review by Track Chairs",
"permission_denied": "Permission Denied",
"no_edit": "You are not allowed to edit this presentation",
"submission_reopened": "Submission reopened until {end_date} {when}. Finish your changes before it closes.",
"chair_comments": "Chair Comments",
"presentation_material": "{presentation} Material",
"role": "Role",
Expand Down
25 changes: 21 additions & 4 deletions src/layouts/presentation-layout.js

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

src/layouts/presentation-layout.js:84 — outside this PR's diff hunk, so GitHub won't let me anchor to it; file-level comment instead.

@caseylocker Not a change request on this PR — recording a latent crash sitting three lines below the redirect this PR modified, so it doesn't stay undocumented. It is pre-existing: the line is byte-identical on master.

The !speaker branch references a variable that doesn't exist in scope:

if (!speaker) {
    history.push(`/app/${summit.slug}/all-plans/profile`);
}

render() destructures match, entity, speaker, history, loading, location, selectionPlan, selectionPlansSettings, nowUtc — no summit. There is no module-scope or imported summit in this file either, so if the branch ever fires, summit.slug throws a ReferenceError and the render crashes, instead of redirecting the speaker-less user to the profile page as intended. The value is available the whole time: mapStateToProps already maps summit: baseState.summit; it just never gets pulled out of props.

Reachability is the open question: speaker is baseState.speaker, and a logged-in member without a speaker profile navigating to a presentation URL would land here — I have not traced whether an upstream guard makes that state unreachable in practice. Either way the branch as written can only ever crash or no-op; it cannot do what it says.

Fix is one word — add summit to the destructuring on line 71:

let { match, entity, speaker, history, loading, location, selectionPlan, selectionPlansSettings, nowUtc, summit } = this.props;

Since it predates the branch and is not on a line this PR changes, keeping it out of this PR is the right call — same reasoning as the canDelete() item. Worth folding both into the same cleanup ticket. (Side note for that ticket: history.push during render is itself a side effect worth converting to a <Redirect/> like the line above it.)

@caseylocker caseylocker Aug 12, 2026

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.

Noted, and agreed it stays out of this PR for the reason you give: it predates the branch and is not on a line this PR touches.

Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,32 @@ class PresentationLayout extends React.Component {
this.props.getPresentation(newId);
}

this.presentation.updatePresentation(newProps.entity, newProps.track);
// Gated on the props each call actually reads. This component now subscribes to the
// clock, so props change every second; updatePresentation is not cheap or side-effect
// free (it recomputes allowed media uploads and grouped tags, rewrites step visibility,
// and writes progressNum onto the redux entity), and none of that depends on the tick.
// The per-tick re-render still happens, which is what locks the form on time.
// Identity comparison is sound here: presentation-reducer builds a new entity object on
// RECEIVE_PRESENTATION and PRESENTATION_UPDATED.
if (newProps.selectionPlan !== this.props.selectionPlan) {
this.presentation.updateSelectionPlan(newProps.selectionPlan);
}

if (newProps.entity !== this.props.entity || newProps.track !== this.props.track) {
this.presentation.updatePresentation(newProps.entity, newProps.track);
}
}

render(){
let { match, entity, speaker, history, loading, location, selectionPlan, selectionPlansSettings } = this.props;
let { match, entity, speaker, history, loading, location, selectionPlan, selectionPlansSettings, nowUtc } = this.props;
let isNew = !match.params.presentation_id;

if (loading || (!isNew && !entity.id)) return null;

if (!isNew && match.params.presentation_id == entity.id && !this.presentation.canEdit() && !location.pathname.endsWith('preview') ) {
// nowUtc is null until the first Clock tick. Evaluating the gate against a seed would
// let a fast device clock read a live grant as expired, and this redirect is one-way:
// the guard below skips it once already on /preview, so a corrected tick never undoes it.
if (!isNew && nowUtc != null && match.params.presentation_id == entity.id && !this.presentation.canEdit(nowUtc) && !location.pathname.endsWith('preview') ) {
return(<Redirect to={`${match.url}/preview`} />);
}

Expand All @@ -87,13 +103,14 @@ class PresentationLayout extends React.Component {

}

const mapStateToProps = ({ baseState, presentationState }) => ({
const mapStateToProps = ({ baseState, presentationState, clockState }) => ({
speaker: baseState.speaker,
summit: baseState.summit,
loading: baseState.loading,
tagGroups: baseState.tagGroups,
selectionPlansSettings: baseState.selectionPlansSettings,
loggedSpeaker: baseState.speaker,
nowUtc: clockState.nowUtc,
...presentationState
})

Expand Down
68 changes: 63 additions & 5 deletions src/model/presentation.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ class Presentation {
this._presentation.selectionPlan = summit.selection_plans.find(sp => sp.id === presentation.selection_plan_id);
this._tagGroups = tagGroups;
this._track = null;
this._submissionIsClosed = selectionPlan ? !nowBetween(selectionPlan.submission_begin_date, selectionPlan.submission_end_date) : true;

this._steps = [
{name: 'NEW', lcName: 'new', step: 0},
Expand Down Expand Up @@ -77,11 +76,21 @@ class Presentation {
this._presentation.progressNum = currentStep.step;
}

// the plan is captured at construction, but navigating refetches it by id and swaps the copy
// held in redux, so a long-lived instance has to be told or canEdit keeps gating on the old one
updateSelectionPlan(selectionPlan) {
this._selectionPlan = selectionPlan;
}
Comment thread
smarcet marked this conversation as resolved.

/**
* @param nowUtc
* @returns {React.ReactNode}
*/
getStatus(nowUtc) {
// every branch below classifies the submission and selection windows against nowUtc, so
// before the first Clock tick there is no answer to give. null coerces to 0 in these
// comparisons, which would render a confidently wrong status; render nothing instead.
if (nowUtc == null) return null;

const {is_published, status, selection_status, selectionPlan} = this._presentation;
const {
Expand Down Expand Up @@ -146,8 +155,57 @@ class Presentation {
return (this._presentation.is_published || this._presentation.status === 'Received');
}

canEdit() {
if (!this._selectionPlan || this._submissionIsClosed) return false;
/**
* The operative reopen deadline, or null when a grant is not what is letting this
* presentation be edited. Four things must hold, mirroring the API's
* isSubmissionReopened(): the plan is enabled, it has a submission end date, that window
* has actually ENDED, and the grant is still live. "Not open" is not the same as "ended" —
* the window is also not open before it starts, and honoring a grant there would admit
* edits the API refuses.
*
* Single definition on purpose: canEdit() gates on it and the banner displays it, and if
* the two drifted the banner would announce a deadline that does not constrain anything —
* e.g. after an admin extends submission_end_date past an existing grant.
*
* @param nowUtc epoch seconds from the Clock, or null before the first tick
* @returns {number|null}
*/
getReopenedUntil(nowUtc) {
if (nowUtc == null) return null;
if (!this._selectionPlan || this._selectionPlan.is_enabled === false) return null;
// ungranted arrives as null on the list feeds and '' on the detail feed, which coerces
// every null to empty string; a falsy check covers both
const until = this._presentation.submission_reopened_until;
if (!until) return null;
// no end date means no window to have ended, so there is nothing to reopen. Without this
// the comparison below is nowUtc <= 0 (null and '' coerce, undefined gives NaN), which is
// false, so the grant would be honored and the form would render against a plan every
// write fails on. Falsy check, matching the coercion note above.
if (!this._selectionPlan.submission_end_date) return null;
if (nowUtc <= this._selectionPlan.submission_end_date) return null;

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 getReopenedUntil mirrors three of the API's four invariants — it omits the null-end-date guard, so the stated one-for-one mirror has a gap.

Presentation::isSubmissionReopened() returns false when the plan has no submission end date (summit-api Presentation.php:2609-2610: if (is_null($submission_end_date)) return false;). Here, a null/undefined submission_end_date makes nowUtc <= this._selectionPlan.submission_end_date evaluate false, so the check falls through and the grant is honored: the form renders and the banner announces a deadline, while the server refuses every save.

Reachability is admittedly thin: the reopen endpoint refuses to stamp a grant on a plan without an end date (PresentationSubmissionReopenService.php:70-72), so it takes the end date being cleared after a grant exists — I have not verified admin tooling even allows that. But the doc comment on this method claims a full mirror of isSubmissionReopened(), and this is the one branch where it isn't, at the cost of one line:

if (!this._selectionPlan.submission_end_date) return null;

placed before the nowUtc <= comparison. Falsy check on purpose, matching the file's own convention for API-coerced empties (null on list feeds, '' after the detail-feed reducer coercion).

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.

Confirmed and fixed in c1cb9b1. Your citations are exact: Presentation.php:2610 on merged main is if (is_null($submission_end_date)) return false;, and PresentationSubmissionReopenService.php:71-72 refuses to stamp the grant in the first place. The doc comment claimed a full mirror and enforced three of four, so I took your line verbatim, falsy rather than null for the reason you gave, and corrected the comment to say four.

Two things I found verifying it that make this worse than the write-refusal you described.

It gates edit access, not just the banner. nowBetween is local and unguarded (src/utils/methods.js:54-57): now < (null * 1000) is now < 0, false. So a missing end date reads as a closed window, submissionIsClosed is true, and with the grant wrongly honored canEdit() rests entirely on it. The form renders and is editable. With the guard, canEdit() returns false at the submissionIsClosed && !reopened line. So this was not only a bogus banner.

The refusal is a 500, not a validation error. PresentationService.php:547 evaluates !$current_selection_plan->isSubmissionOpen() first, which reaches SelectionPlan::getStageStatus(), which dereferences $start_date->format(...) and $end_date->format(...) inside a Log::debug(sprintf(...)) at lines 668-678, before the if (empty($start_date) || empty($end_date)) return null; at line 680. sprintf arguments evaluate eagerly, so a null end date throws before its own guard is reached. Pre-existing on main, unrelated to either PR, and I am not touching it here. Worth noting because it means the speaker would have filled in a form and then taken a 500 rather than a clear message, which raises what this one line is worth.

On reachability, which you flagged as unverified: SummitSelectionPlanValidationRulesFactory.php:35,65 has submission_end_date as nullable, and required_with:submission_begin_date only binds when begin is sent, so the API's own validation permits the state. I did not trace whether the update service treats an explicit null as clear or as unchanged, so I am not calling it confirmed reachable, only not structurally blocked.

No test: this repo has no test harness at all, no jest and no test script, so there is nothing to add without introducing one. Verified by reasoning through the three falsy shapes plus ended-and-live, not-yet-ended and expired, and yarn build-dev compiles.

return nowUtc < until ? until : null;
}

/**
* @param nowUtc epoch seconds, server-synced via the Clock
* @returns {boolean}
*/
canEdit(nowUtc) {
if (!this._selectionPlan) return false;
// the API refuses writes on a disabled plan, and a disabled one does reach client state:
// selection-plan-layout refetches the plan by id on navigation, that endpoint applies no
// enabled filter unlike the /me feed, and base-reducer swaps the filtered copy for it.
// Only an explicit false blocks, so a payload without the field still edits normally.
if (this._selectionPlan.is_enabled === false) return false;

// computed per call, not snapshotted in the constructor, so a window that ends while the
// page is open locks the form without a reload. Still on nowBetween's local clock, as it
// was before this feature; the reopen check below is the part that moved to the synced
// one, because a 24h grant makes skew a far larger fraction than a multi-week plan window.
const submissionIsClosed = !nowBetween(this._selectionPlan.submission_begin_date, this._selectionPlan.submission_end_date);
const reopened = !!this.getReopenedUntil(nowUtc);

if (submissionIsClosed && !reopened) return false;

let speakers = this._presentation.speakers.map(s => {
if (typeof s == 'object') return s.id;
Expand All @@ -172,9 +230,9 @@ class Presentation {
return (!this._presentation.is_published && belongsToSP);
}

getProgressLink() {
getProgressLink(nowUtc) {

if (this.canEdit()) {
if (this.canEdit(nowUtc)) {

let step = 'summary';

Expand Down
19 changes: 17 additions & 2 deletions src/pages/edit-presentation-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import React, {useContext, useEffect, useState} from 'react';
import {connect} from 'react-redux';
import T from 'i18n-react/dist/i18n-react';
import Swal from "sweetalert2";
import moment from "moment-timezone";
import {formatEpoch} from "openstack-uicore-foundation/lib/utils/methods";
import {
savePresentation,
completePresentation,
Expand Down Expand Up @@ -42,7 +44,7 @@ import {getMarketingValue} from "../components/marketing-setting";
import '../styles/edit-presentation-page.less';
import {SelectionPlanContext} from "../components/SelectionPlanContext";

const EditPresentationPage = ({entity, track, presentation, selectionPlan, summit, match, selectionPlansSettings, showInfoPopup, setShowInfoPopup, ...props}) => {
const EditPresentationPage = ({entity, track, presentation, selectionPlan, summit, match, selectionPlansSettings, showInfoPopup, setShowInfoPopup, nowUtc, ...props}) => {
const {setSelectionPlanCtx} = useContext(SelectionPlanContext);

const [selectionPlanSettings, setSelectionPlanSettings] = useState(null);
Expand Down Expand Up @@ -102,11 +104,23 @@ const EditPresentationPage = ({entity, track, presentation, selectionPlan, summi
});
}

// asks the model rather than re-deriving the condition, so the banner cannot announce a
// deadline that canEdit() does not actually gate on
const reopenedUntil = presentation.getReopenedUntil(nowUtc);

return (
<div className="page-wrap" id="edit-presentation-page">
<div className="presentation-header-wrapper">
<h2>{title} {`${selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation")}`}</h2>
</div>
{reopenedUntil &&
<div className="alert alert-warning">
{T.translate("edit_presentation.submission_reopened", {
end_date: formatEpoch(reopenedUntil, "MMMM DD, YYYY h:mm a"),
when: moment.tz.guess(),
})}
</div>
}
<PresentationNav activeStep={step} progress={presentation.getPresentationProgress()} steps={navSteps} selectionPlanSettings={selectionPlanSettings} />

{step === 'summary' &&
Expand Down Expand Up @@ -192,12 +206,13 @@ const EditPresentationPage = ({entity, track, presentation, selectionPlan, summi
);
}

const mapStateToProps = ({baseState, presentationState}) => ({
const mapStateToProps = ({baseState, presentationState, clockState}) => ({
summit: baseState.summit,
tagGroups: baseState.tagGroups,
loading: baseState.loading,
loggedSpeaker: baseState.speaker,
selectionPlansSettings: baseState.selectionPlansSettings,
nowUtc: clockState.nowUtc,
...presentationState
})

Expand Down
16 changes: 14 additions & 2 deletions src/reducers/clock-reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
import { REHYDRATE } from 'redux-persist';
import { LOGOUT_USER } from 'openstack-uicore-foundation/lib/security/actions';
import {
UPDATE_CLOCK,
} from '../actions/clock-actions';
const localNowUtc = Date.now();

// null, not the browser clock: uicore's Clock leaves state.timestamp null until the time
// service answers, and tick() no-ops until then, so any seed here survives for a whole round
// trip. A device clock running fast past submission_reopened_until would make an active grant
// read as expired, and the resulting redirect to /preview is one-way. Consumers treat null as
// "not known yet" rather than as a time. Ticks arrive in epoch SECONDS, as does the API.
const DEFAULT_STATE = {
nowUtc: localNowUtc,
nowUtc: null,
};

const clockReducer = (state = DEFAULT_STATE, action) => {
Expand All @@ -26,6 +31,13 @@ const clockReducer = (state = DEFAULT_STATE, action) => {
switch (type) {
case LOGOUT_USER:
return DEFAULT_STATE;
case REHYDRATE:
// A persisted clock is always stale, and builds before this seed was corrected stored
// milliseconds. The config blacklist cannot discard it: it only filters outbound
// writes, while autoMergeLevel2 merges every stored key back in on rehydrate. Returning
// a NEW object is what suppresses that merge, since the reconciler skips any key whose
// substate the reducer already modified.
return { nowUtc: null };
case UPDATE_CLOCK: {
const { timestamp } = payload;
return { ...state, nowUtc: timestamp };
Expand Down
3 changes: 3 additions & 0 deletions src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import clockReducer from "./reducers/clock-reducer";
const config = {
key: 'root',
storage,
// the clock is live state — persisting it rehydrates a stale nowUtc over the seed, which is how
// a millisecond value written before the seed was fixed survives into the next session
blacklist: ['clockState'],
}

const reducers = persistCombineReducers(config, {
Expand Down