diff --git a/src/components/presentations-table.js b/src/components/presentations-table.js
index cd8dc21..09968b9 100644
--- a/src/components/presentations-table.js
+++ b/src/components/presentations-table.js
@@ -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;
+ history.push(presentation.getProgressLink(nowUtc));
};
const handleReviewPresentation = (ev, presentation) => {
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 516404b..bc37772 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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",
diff --git a/src/layouts/presentation-layout.js b/src/layouts/presentation-layout.js
index 5973b40..9b65cb8 100644
--- a/src/layouts/presentation-layout.js
+++ b/src/layouts/presentation-layout.js
@@ -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();
}
@@ -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
})
diff --git a/src/model/presentation.js b/src/model/presentation.js
index eae416d..e6265fb 100644
--- a/src/model/presentation.js
+++ b/src/model/presentation.js
@@ -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},
@@ -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;
+ }
+
/**
* @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 {
@@ -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;
+ 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;
@@ -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';
diff --git a/src/pages/edit-presentation-page.js b/src/pages/edit-presentation-page.js
index cfaf1b0..7225658 100644
--- a/src/pages/edit-presentation-page.js
+++ b/src/pages/edit-presentation-page.js
@@ -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,
@@ -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);
@@ -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 (
{title} {`${selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation")}`}
+ {reopenedUntil &&
+
+ {T.translate("edit_presentation.submission_reopened", {
+ end_date: formatEpoch(reopenedUntil, "MMMM DD, YYYY h:mm a"),
+ when: moment.tz.guess(),
+ })}
+
+ }
{step === 'summary' &&
@@ -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
})
diff --git a/src/reducers/clock-reducer.js b/src/reducers/clock-reducer.js
index 30dd27b..ed259e7 100644
--- a/src/reducers/clock-reducer.js
+++ b/src/reducers/clock-reducer.js
@@ -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) => {
@@ -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 };
diff --git a/src/store.js b/src/store.js
index df2cb42..b8198ba 100644
--- a/src/store.js
+++ b/src/store.js
@@ -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, {