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, {