From 152514b78a8004d991d10bdbebb6ab83102a937a Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 6 Aug 2026 10:09:13 -0500 Subject: [PATCH 1/5] add compatibility with new single task lock restriction and endpoints The backend now enforces one active edit lock per user and locks a task bundle as a single row on its primary task instead of one row per member, which had silently broken bundle creation here (it called the now-removed bulk lock endpoint) and left 409 lock conflicts with no dedicated UI. Replaces per-task bundle locking/refreshing with a single lockTaskBundle call representing the bundle's full desired membership, and adds a release-conflicting-lock-and-retry flow to both the single-task and bundle-lock dialogs. --- .../HOCs/WithLockedTask/WithLockedTask.jsx | 37 ++- .../HOCs/WithTaskBundle/WithTaskBundle.jsx | 280 +++++++++--------- src/components/TaskPane/Messages.js | 22 ++ src/components/TaskPane/TaskPane.jsx | 44 ++- .../Widgets/TaskBundleWidget/Messages.js | 22 ++ .../TaskBundleWidget/TaskBundleWidget.jsx | 49 ++- src/services/Server/APIRoutes.js | 2 +- src/services/Server/Server.js | 14 +- src/services/Task/LockConflict.js | 23 ++ src/services/Task/LockConflict.test.js | 54 ++++ src/services/Task/Task.js | 67 +---- 11 files changed, 403 insertions(+), 211 deletions(-) create mode 100644 src/services/Task/LockConflict.js create mode 100644 src/services/Task/LockConflict.test.js diff --git a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx index 6fd9b6c67..2231878c9 100644 --- a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx +++ b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx @@ -2,6 +2,7 @@ import _omit from "lodash/omit"; import { Component } from "react"; import { connect } from "react-redux"; import { bindActionCreators } from "redux"; +import { getLockConflict } from "../../../services/Task/LockConflict"; import { refreshTaskLock, releaseTask, @@ -45,6 +46,8 @@ const WithLockedTask = function (WrappedComponent) { tryingLock: false, failureDetails: null, lockedAt: null, + lockConflict: null, + releasingConflict: false, }; lockTask = (task) => { @@ -52,7 +55,7 @@ const WithLockedTask = function (WrappedComponent) { return Promise.reject("Invalid task"); } - this.setState({ tryingLock: true, failureDetails: null }); + this.setState({ tryingLock: true, failureDetails: null, lockConflict: null }); return this.props .startTask(task.id) .then(() => { @@ -67,11 +70,38 @@ const WithLockedTask = function (WrappedComponent) { return true; }) .catch((err) => { - this.setState({ readOnly: true, tryingLock: false, failureDetails: err.details }); + this.setState({ + readOnly: true, + tryingLock: false, + failureDetails: err.details, + lockConflict: getLockConflict(err), + }); return false; }); }; + /** + * Releases the task the user already holds a lock on elsewhere (per a + * one-lock-per-user 409 conflict), then retries locking the given task. + */ + releaseConflictingLockAndRetry = async (task) => { + const conflictTaskId = this.state.lockConflict?.lockedTaskId; + if (!conflictTaskId) { + return false; + } + + this.setState({ releasingConflict: true }); + try { + await this.props.releaseTask(conflictTaskId); + } catch (error) { + console.warn("Error releasing conflicting lock:", error); + } finally { + this.setState({ releasingConflict: false }); + } + + return this.lockTask(task); + }; + unlockTask = (task) => { if (!task) { return Promise.reject("Invalid task"); @@ -176,6 +206,9 @@ const WithLockedTask = function (WrappedComponent) { unlockTask={this.unlockTask} refreshTaskLock={this.refreshTaskLock} requestUnlock={this.requestUnlock} + lockConflict={this.state.lockConflict} + releasingConflict={this.state.releasingConflict} + releaseConflictingLockAndRetry={this.releaseConflictingLockAndRetry} /> ); } diff --git a/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx b/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx index ff328334a..3c4c5dda3 100644 --- a/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx +++ b/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx @@ -4,17 +4,17 @@ import { connect } from "react-redux"; import { bindActionCreators } from "redux"; import AsCooperativeWork from "../../../interactions/Task/AsCooperativeWork"; import { addError } from "../../../services/Error/Error"; +import { getLockConflict } from "../../../services/Task/LockConflict"; import { bundleTasks, deleteTaskBundle, fetchTaskBundle, - lockMultipleTasks, + lockTaskBundle, releaseMultipleTasks, + releaseTask, updateTaskBundle, } from "../../../services/Task/Task"; -const LOCK_REFRESH_INTERVAL = 600000; // 10 minutes - /** * WithTaskBundle passes down methods for creating new task bundles and * updating existing ones, as well as tracking a current bundle @@ -33,10 +33,10 @@ export function WithTaskBundle(WrappedComponent) { loading: false, updateTaskBundleError: false, isDeletingBundle: false, + lockConflict: null, + pendingMemberIds: null, }; - refreshLockInterval = null; - async componentDidMount() { const { task } = this.props; if (Number.isFinite(task?.bundleId)) { @@ -61,6 +61,8 @@ export function WithTaskBundle(WrappedComponent) { initialBundle: null, loading: false, error: null, + lockConflict: null, + pendingMemberIds: null, }); if (Number.isFinite(task?.bundleId)) { await this.fetchBundle(task.bundleId); @@ -70,7 +72,6 @@ export function WithTaskBundle(WrappedComponent) { } componentWillUnmount() { - this.stopLockRefresh(); if (!this.state.isDeletingBundle) { this.unlockBundleTasks(); } @@ -78,38 +79,20 @@ export function WithTaskBundle(WrappedComponent) { } handleBeforeUnload = () => { - this.stopLockRefresh(); if (!this.state.isDeletingBundle) { this.unlockBundleTasks(); } }; - startLockRefresh = (taskIds, skipImmediateRefresh = false) => { - this.stopLockRefresh(); - - // Filter out the primary task ID before setting up refresh - // since the primary task is managed by WithLockedTask - const tasksToRefresh = taskIds.filter((taskId) => taskId !== this.props.task?.id); - - if (tasksToRefresh.length === 0) { - return; - } - - // Only do immediate refresh if not skipped (e.g., when tasks were just locked) - if (!skipImmediateRefresh) { - this.props.lockMultipleTasks(tasksToRefresh).catch((error) => { - console.log("Error refreshing task locks:", error); - }); - } - - this.refreshLockInterval = setInterval(() => { - this.props.lockMultipleTasks(tasksToRefresh); - }, LOCK_REFRESH_INTERVAL); - }; - - stopLockRefresh = () => { - clearInterval(this.refreshLockInterval); - this.refreshLockInterval = null; + /** + * Looks up full task data for the given ids from the redux tasks entity + * store (already populated by whatever loaded the map/cluster data the + * user selected these tasks from). The lockTaskBundle response only + * confirms lock/membership, not task data, so this is how taskBundle.tasks + * gets hydrated for tasks that aren't part of an already-fetched bundle. + */ + hydrateTasks = (taskIds) => { + return taskIds.map((id) => this.props.taskEntities?.[id]).filter(Boolean); }; fetchBundle = async (bundleId) => { @@ -135,8 +118,14 @@ export function WithTaskBundle(WrappedComponent) { } this.updateBundlingConditions(); + + // Fetching a bundle no longer locks it server-side (bundles are locked + // as a single covering row on the primary task, established only via + // lockTaskBundle) - explicitly (re)establish that membership so bundle + // members are actually protected while this user is viewing/editing it. if (!this.props.taskReadOnly && taskBundle) { - this.startLockRefresh(taskBundle.taskIds); + const memberTaskIds = taskBundle.taskIds.filter((id) => id !== task?.id); + await this.syncBundleLock(memberTaskIds); } } catch (error) { console.error("Error fetching bundle:", error); @@ -245,24 +234,32 @@ export function WithTaskBundle(WrappedComponent) { } }; - lockTasks = async (taskIds) => { + /** + * Locks the bundle's primary task with the given member task ids as its + * full desired membership (replacing whatever it covered before) - the + * single source of truth for "what's in this bundle right now" is always + * the covering lock's membership, not a per-task lock/unlock call. + * + * On a one-lock-per-user conflict (409), records it in lockConflict/ + * pendingMemberIds (for a later releaseConflictingLockAndRetry) instead of + * the generic "lockError". + */ + syncBundleLock = async (memberTaskIds) => { const { task } = this.props; - const tasksToLock = taskIds.filter((taskId) => taskId !== task.id); - - if (tasksToLock.length === 0) { - return []; - } - try { - const tasks = await this.props.lockMultipleTasks(tasksToLock); - return Array.isArray(tasks) ? tasks : []; + await this.props.lockTaskBundle(task.id, memberTaskIds); + this.setState({ lockConflict: null, pendingMemberIds: null }); + return true; } catch (error) { - console.error("Error locking tasks:", error); - this.setState({ - error: "lockError", - }); - return []; + const conflict = getLockConflict(error); + if (conflict) { + this.setState({ lockConflict: conflict, pendingMemberIds: memberTaskIds }); + } else { + console.error("Error locking task bundle:", error); + this.setState({ error: "lockError" }); + } + return false; } }; @@ -279,115 +276,81 @@ export function WithTaskBundle(WrappedComponent) { } }; - refreshTaskLock = async (taskIds) => { - const { task } = this.props; - - // Filter out the primary task ID before refreshing locks - const tasksToRefresh = taskIds.filter((taskId) => taskId !== task.id); - - if (tasksToRefresh.length === 0) { - return; // No tasks to refresh - } - - await this.props.lockMultipleTasks(tasksToRefresh); - }; - createTaskBundle = async (taskIds) => { if (taskIds.length > 50) { this.setState({ bundleLimitError: true }); return false; } - this.setState({ loading: true }); + this.setState({ loading: true, error: null }); - const tasksToLock = taskIds.filter((taskId) => taskId !== this.props.task?.id); + const memberTaskIds = taskIds.filter((taskId) => taskId !== this.props.task?.id); - if (tasksToLock.length === 0) { + if (memberTaskIds.length === 0) { this.setState({ loading: false }); return false; } - try { - const tasks = await this.lockTasks(tasksToLock); - - // Check if we successfully locked the tasks - if (!tasks || tasks.length === 0) { - this.setState({ - error: "lockError", - loading: false, - }); - return false; - } - - this.setState(() => ({ - loading: false, - taskBundle: { - tasks: [this.props.task, ...tasks], - taskIds: taskIds, - }, - })); - - this.startLockRefresh(taskIds, true); // Skip immediate refresh since tasks were just locked - return true; - } catch (error) { - console.error("Error creating task bundle:", error); - this.setState({ - error: "lockError", - loading: false, - }); + const locked = await this.syncBundleLock(memberTaskIds); + if (!locked) { + this.setState({ loading: false }); return false; } + + this.setState({ + loading: false, + taskBundle: { + tasks: [this.props.task, ...this.hydrateTasks(memberTaskIds)], + taskIds, + }, + }); + return true; }; addTaskToBundle = async (taskId) => { - this.setState({ loading: true }); + this.setState({ loading: true, error: null }); - try { - const tasks = await this.lockTasks([taskId]); - - if (!tasks || tasks.length === 0) { - this.setState({ - error: "lockError", - loading: false, - }); - return false; - } + const currentMemberIds = this.state.taskBundle.taskIds.filter( + (id) => id !== this.props.task?.id, + ); + const updatedMemberIds = [...currentMemberIds, taskId]; - this.setState((prevState) => ({ - loading: false, - taskBundle: { - ...prevState.taskBundle, - tasks: [...prevState.taskBundle.tasks, ...tasks], - taskIds: [...prevState.taskBundle.taskIds, taskId], - }, - })); - - this.startLockRefresh([...this.state.taskBundle.taskIds, taskId], true); // Skip immediate refresh since task was just locked - return true; - } catch (error) { - console.error("Error adding task to bundle:", error); - this.setState({ - error: "lockError", - loading: false, - }); + const locked = await this.syncBundleLock(updatedMemberIds); + if (!locked) { + this.setState({ loading: false }); return false; } + + this.setState((prevState) => ({ + loading: false, + taskBundle: { + ...prevState.taskBundle, + tasks: [...prevState.taskBundle.tasks, ...this.hydrateTasks([taskId])], + taskIds: [...prevState.taskBundle.taskIds, taskId], + }, + })); + return true; }; removeTaskFromBundle = async (taskId) => { const { taskBundle, initialBundle } = this.state; - if ((this.props.task && !initialBundle) || !initialBundle?.taskIds.includes(taskId)) { - try { - await this.unlockTasks([taskId]); - } catch (error) { - console.error("Error unlocking task:", error); + const updatedTaskIds = taskBundle.taskIds.filter((id) => id !== taskId); + const updatedMemberIds = updatedTaskIds.filter((id) => id !== this.props.task?.id); + + // Only tasks added during this live editing session (not yet part of the + // persisted bundle) need their lock released immediately - a task removed + // from an already-persisted bundle is handled server-side when the bundle + // update is actually submitted. + const wasPersisted = initialBundle?.taskIds?.includes(taskId) ?? false; + if (!wasPersisted) { + const locked = await this.syncBundleLock(updatedMemberIds); + if (!locked) { return false; } } - if (taskBundle?.taskIds.length <= 2) { - this.stopLockRefresh(); + if (taskBundle.taskIds.length <= 2) { this.setState({ taskBundle: null, selectedTasks: [], @@ -395,20 +358,14 @@ export function WithTaskBundle(WrappedComponent) { return true; } - const updatedTaskIds = taskBundle.taskIds.filter((id) => id !== taskId); const updatedTasks = taskBundle.tasks.filter((task) => task.id !== taskId); - const updatedTaskBundle = { - ...taskBundle, - taskIds: updatedTaskIds, - tasks: updatedTasks, - }; - - this.stopLockRefresh(); - this.startLockRefresh(updatedTaskIds); - this.setState({ - taskBundle: updatedTaskBundle, + taskBundle: { + ...taskBundle, + taskIds: updatedTaskIds, + tasks: updatedTasks, + }, selectedTasks: updatedTaskIds, }); @@ -416,11 +373,13 @@ export function WithTaskBundle(WrappedComponent) { }; clearActiveTaskBundle = async () => { - const { taskBundle, initialBundle } = this.state; - const taskIds = taskBundle.taskIds.filter( - (taskId) => !initialBundle?.taskIds.includes(taskId) && taskId !== this.props.task.id, + const { initialBundle } = this.state; + const memberTaskIdsToKeep = (initialBundle?.taskIds || []).filter( + (taskId) => taskId !== this.props.task?.id, ); - await this.unlockTasks(taskIds); + + await this.syncBundleLock(memberTaskIdsToKeep); + this.setState({ selectedTasks: [], taskBundle: null, @@ -442,7 +401,6 @@ export function WithTaskBundle(WrappedComponent) { this.setState({ updateTaskBundleError: false }); if (!taskBundle && initialBundle) { - this.stopLockRefresh(); this.setState({ isDeletingBundle: true }); await this.props.deleteTaskBundle(initialBundle?.bundleId); return null; @@ -470,13 +428,30 @@ export function WithTaskBundle(WrappedComponent) { ); if (tasksToUnlock.length > 0) { - // Log unlock attempt for debugging - console.log(`Unlocking ${tasksToUnlock.length} bundle tasks`); this.unlockTasks(tasksToUnlock); } } }; + releaseConflictingLockAndRetry = async () => { + const { lockConflict, pendingMemberIds } = this.state; + if (!lockConflict) { + return false; + } + + try { + await this.props.releaseTask(lockConflict.lockedTaskId); + } catch (error) { + console.warn("Error releasing conflicting lock:", error); + } + + return this.syncBundleLock(pendingMemberIds || []); + }; + + clearLockConflict = () => { + this.setState({ lockConflict: null, pendingMemberIds: null }); + }; + render() { return ( ); } }; } +export const mapStateToProps = (state) => ({ + taskEntities: state.entities?.tasks, +}); + export const mapDispatchToProps = (dispatch) => bindActionCreators( { @@ -515,12 +500,13 @@ export const mapDispatchToProps = (dispatch) => bundleTasks, deleteTaskBundle, updateTaskBundle, - lockMultipleTasks, + lockTaskBundle, releaseMultipleTasks, + releaseTask, addError, }, dispatch, ); export default (WrappedComponent) => - connect(null, mapDispatchToProps)(WithTaskBundle(WrappedComponent)); + connect(mapStateToProps, mapDispatchToProps)(WithTaskBundle(WrappedComponent)); diff --git a/src/components/TaskPane/Messages.js b/src/components/TaskPane/Messages.js index fcb3b4373..9c39b8412 100644 --- a/src/components/TaskPane/Messages.js +++ b/src/components/TaskPane/Messages.js @@ -122,6 +122,28 @@ export default defineMessages({ defaultMessage: "Request Unlock", }, + lockConflictTitle: { + id: "Task.pane.lockConflictDialog.title", + defaultMessage: "You already have a task locked", + }, + + lockConflictDescription: { + id: "Task.pane.lockConflictDialog.description", + defaultMessage: + "You still hold the lock on task #{taskId}. Release it to lock this task instead.", + }, + + lockConflictDescriptionWithParent: { + id: "Task.pane.lockConflictDialog.descriptionWithParent", + defaultMessage: + 'You still hold the lock on task #{taskId} in "{parentName}". Release it to lock this task instead.', + }, + + releaseLockAndContinueLabel: { + id: "Task.pane.lockConflictDialog.releaseLockAndContinueLabel", + defaultMessage: "Release Lock & Continue", + }, + saveChangesLabel: { id: "Task.pane.controls.saveChanges.label", defaultMessage: "Save Changes", diff --git a/src/components/TaskPane/TaskPane.jsx b/src/components/TaskPane/TaskPane.jsx index 1c075273f..86403b226 100644 --- a/src/components/TaskPane/TaskPane.jsx +++ b/src/components/TaskPane/TaskPane.jsx @@ -524,7 +524,49 @@ export class TaskPane extends Component { - {this.state.showLockFailureDialog && ( + {this.state.showLockFailureDialog && this.props.lockConflict && ( + } + prompt={ + + } + icon="unlocked-icon" + onClose={() => this.clearLockFailure()} + controls={ + + + {this.props.releasingConflict || this.props.tryingLock ? ( + + ) : ( + + )} + + } + /> + )} + {this.state.showLockFailureDialog && !this.props.lockConflict && ( } prompt={ diff --git a/src/components/Widgets/TaskBundleWidget/Messages.js b/src/components/Widgets/TaskBundleWidget/Messages.js index 4d3a15ce9..823b172f2 100644 --- a/src/components/Widgets/TaskBundleWidget/Messages.js +++ b/src/components/Widgets/TaskBundleWidget/Messages.js @@ -232,4 +232,26 @@ export default defineMessages({ id: "TaskBundleWidget.cannotEditLockedTask", defaultMessage: "Task is locked by another user", }, + lockConflictTitle: { + id: "Widgets.TaskBundleWidget.lockConflict.title", + defaultMessage: "You already have a task locked", + }, + lockConflictDescription: { + id: "Widgets.TaskBundleWidget.lockConflict.description", + defaultMessage: + "You still hold the lock on task #{taskId}. Release it to continue building this bundle.", + }, + lockConflictDescriptionWithParent: { + id: "Widgets.TaskBundleWidget.lockConflict.descriptionWithParent", + defaultMessage: + 'You still hold the lock on task #{taskId} in "{parentName}". Release it to continue building this bundle.', + }, + cancelLabel: { + id: "Widgets.TaskBundleWidget.lockConflict.cancel.label", + defaultMessage: "Cancel", + }, + releaseLockAndContinueLabel: { + id: "Widgets.TaskBundleWidget.lockConflict.releaseLockAndContinue.label", + defaultMessage: "Release Lock & Continue", + }, }); diff --git a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.jsx b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.jsx index 2acda3ece..c76ab770a 100644 --- a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.jsx +++ b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.jsx @@ -5,7 +5,7 @@ import _map from "lodash/map"; import _pick from "lodash/pick"; import _sum from "lodash/sum"; import _values from "lodash/values"; -import { Component } from "react"; +import { Component, useState } from "react"; import { FormattedMessage } from "react-intl"; import { Popup } from "react-leaflet"; import AsCooperativeWork from "../../../interactions/Task/AsCooperativeWork"; @@ -14,6 +14,7 @@ import { toLatLngBounds } from "../../../services/MapBounds/MapBounds"; import { buildSearchURL } from "../../../services/SearchCriteria/SearchCriteria"; import { TaskAction } from "../../../services/Task/TaskAction/TaskAction"; import { WidgetDataTarget, registerWidgetType } from "../../../services/Widget/Widget"; +import BasicDialog from "../../BasicDialog/BasicDialog"; import BusySpinner from "../../BusySpinner/BusySpinner"; import Dropdown from "../../Dropdown/Dropdown"; import MapPane from "../../EnhancedMap/MapPane/MapPane"; @@ -553,6 +554,13 @@ const BundleInterface = (props) => { const challenge = props.browsedChallenge; return (
+ {props.lockConflict && ( + + )} {bundleEditsDisabled && ( { ); }; +const LockConflictDialog = ({ lockConflict, onRelease, onCancel }) => { + const [releasing, setReleasing] = useState(false); + + const handleRelease = () => { + setReleasing(true); + onRelease().finally(() => setReleasing(false)); + }; + + return ( + } + prompt={ + + } + icon="unlocked-icon" + onClose={onCancel} + controls={ +
+ + +
+ } + /> + ); +}; + const ClearFiltersControl = ({ clearFilters }) => ( - {this.props.releasingConflict || this.props.tryingLock ? ( - - ) : ( + {this.state.showLockFailureDialog && + this.props.taskReadOnly && + this.props.taskLockConflict && ( + } + prompt={ + + + {this.props.taskLockConflict.parentName && ( +
+ +
+ )} + {this.props.taskLockConflict.startedAt && ( +
+ +
+ )} + {this.props.taskLockConflict.bundledTasks?.length > 0 && ( +
+ +
+ )} +
+ } + icon="unlocked-icon" + onClose={() => this.clearLockFailure()} + controls={ + - )} - - } - /> - )} - {this.state.showLockFailureDialog && !this.props.lockConflict && ( - } - prompt={ - - - {this.props.lockFailureDetails?.message ?? - this.props.intl.formatMessage(messages.genericLockFailure)} - - - - } - icon="unlocked-icon" - onClose={() => this.clearLockFailure()} - controls={ - - - {this.props.tryingLock ? ( -
+ + {this.props.releasingTaskLockConflict || this.props.tryingLock ? ( -
- ) : ( + ) : ( + + )} +
+ } + /> + )} + {this.state.showLockFailureDialog && + this.props.taskReadOnly && + !this.props.taskLockConflict && ( + } + prompt={ + + + {this.props.lockFailureDetails?.message ?? + this.props.intl.formatMessage(messages.genericLockFailure)} + + + + } + icon="unlocked-icon" + onClose={() => this.clearLockFailure()} + controls={ + - )} - - {!this.state.unlockRequested ? ( + {this.props.tryingLock ? ( +
+ +
+ ) : ( + + )} - ) : ( -
Request Sent!
- )} + {!this.state.unlockRequested ? ( + + ) : ( +
Request Sent!
+ )} -
- - } - /> - )} +
+ + } + /> + )} {this.state.showLockOptionsDialog && ( } @@ -656,20 +721,18 @@ export class TaskPane extends Component { )} - + {this.state.releasingLock ? ( +
+ +
+ ) : ( + + )} } /> From b8af145b50c836528e7295d496bd0de2f316df31 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Tue, 25 Aug 2026 15:43:09 -0500 Subject: [PATCH 3/5] fix locking behaviors on completed tasks --- .../HOCs/WithCurrentTask/WithCurrentTask.jsx | 18 +++++++++-- .../HOCs/WithLockedTask/WithLockedTask.jsx | 31 +++++++++++++++++-- .../HOCs/WithTaskBundle/WithTaskBundle.jsx | 3 +- src/components/TaskPane/TaskPane.jsx | 10 ++++-- src/services/Task/TaskLock.js | 18 +++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 src/services/Task/TaskLock.js diff --git a/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx b/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx index 4ed05dc3b..8ed3ebb55 100644 --- a/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx +++ b/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx @@ -36,6 +36,7 @@ import { updateTaskTags, } from "../../../services/Task/Task"; import { TaskLoadMethod } from "../../../services/Task/TaskLoadMethod/TaskLoadMethod"; +import { isLockableTask } from "../../../services/Task/TaskLock"; import { fetchTaskForReview } from "../../../services/Task/TaskReview/TaskReview"; import { fetchUser } from "../../../services/User/User"; import { renewVirtualChallenge } from "../../../services/VirtualChallenge/VirtualChallenge"; @@ -398,9 +399,20 @@ export const nextRandomTask = async (dispatch, props, currentTaskId, taskLoadBy) * Load and lock a requested next task */ export const nextRequestedTask = function (dispatch, props, requestedTaskId) { - return dispatch(fetchTask(requestedTaskId)) - .then(() => dispatch(startTask(requestedTaskId))) - .then((normalizedResults) => normalizedResults?.entities?.tasks?.[normalizedResults.result]); + const taskFrom = (normalizedResults) => + normalizedResults?.entities?.tasks?.[normalizedResults.result]; + + return dispatch(fetchTask(requestedTaskId)).then((fetched) => { + // Skip the lock for tasks that can't be worked on - the user gets only one + // at a time, and WithLockedTask won't take one for these either + if (!isLockableTask(taskFrom(fetched))) { + return taskFrom(fetched); + } + + return dispatch(startTask(requestedTaskId)).then( + (started) => taskFrom(started) ?? taskFrom(fetched), + ); + }); }; /** diff --git a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx index 87feefe33..74bda8dd0 100644 --- a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx +++ b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx @@ -9,6 +9,7 @@ import { requestUnlock, startTask, } from "../../../services/Task/Task"; +import { isLockableTask } from "../../../services/Task/TaskLock"; // Used for lock storage events. Users will be locked from other task tabs // if logging out, signing back in, or have multiple tabs on one task @@ -53,6 +54,7 @@ const WithLockedTask = function (WrappedComponent) { lockedAt: null, lockConflict: null, releasingConflict: false, + lockNotApplicable: false, }; lockTask = (task) => { @@ -60,7 +62,27 @@ const WithLockedTask = function (WrappedComponent) { return Promise.reject("Invalid task"); } - this.setState({ tryingLock: true, failureDetails: null, lockConflict: null }); + if (!isLockableTask(task)) { + // Read-only, but not a failure: there's no lock to offer to retry or + // request, so flag it separately from a genuine lock failure + this.setState({ + readOnly: true, + tryingLock: false, + failureDetails: null, + lockConflict: null, + lockedAt: null, + lockNotApplicable: true, + }); + lockStorage.removeLock(task.id); + return Promise.resolve(false); + } + + this.setState({ + tryingLock: true, + failureDetails: null, + lockConflict: null, + lockNotApplicable: false, + }); return this.props .startTask(task.id) .then(() => { @@ -148,6 +170,10 @@ const WithLockedTask = function (WrappedComponent) { return Promise.reject("Invalid task"); } + if (!isLockableTask(task)) { + return Promise.resolve(false); + } + return this.props .refreshTaskLock(task.id) .then(() => { @@ -177,7 +203,7 @@ const WithLockedTask = function (WrappedComponent) { syncLocks = () => { const { task } = this.props; - if (task) { + if (task && isLockableTask(task)) { if (!lockStorage.isLocked(task.id)) { this.refreshTaskLock(task); } @@ -229,6 +255,7 @@ const WithLockedTask = function (WrappedComponent) { unlockTask={this.unlockTask} refreshTaskLock={this.refreshTaskLock} requestUnlock={this.requestUnlock} + taskLockNotApplicable={this.state.lockNotApplicable} taskLockConflict={this.state.lockConflict} releasingTaskLockConflict={this.state.releasingConflict} releaseConflictingTaskLockAndRetry={this.releaseConflictingLockAndRetry} diff --git a/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx b/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx index c8f0788d6..0b8339cca 100644 --- a/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx +++ b/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx @@ -13,6 +13,7 @@ import { releaseTask, updateTaskBundle, } from "../../../services/Task/Task"; +import { isFinalStatus } from "../../../services/Task/TaskStatus/TaskStatus"; /** * WithTaskBundle passes down methods for creating new task bundles and @@ -135,7 +136,7 @@ export function WithTaskBundle(WrappedComponent) { bundleEditsDisabled = true; break; - case taskReadOnly === true: + case taskReadOnly === true && !isFinalStatus(task?.status): reason = "readOnly"; bundleEditsDisabled = true; break; diff --git a/src/components/TaskPane/TaskPane.jsx b/src/components/TaskPane/TaskPane.jsx index 53ee0a455..a0da3f055 100644 --- a/src/components/TaskPane/TaskPane.jsx +++ b/src/components/TaskPane/TaskPane.jsx @@ -312,6 +312,10 @@ export class TaskPane extends Component { // doesn't expire while the mapper is actively working on the task this.clearLockRefreshInterval(); this.lockRefreshInterval = setInterval(() => { + if (this.props.taskLockNotApplicable) { + return; + } + this.props.refreshTaskLock(this.props.task).then((success) => { if (!success) { this.setState({ showLockFailureDialog: true }); @@ -337,7 +341,9 @@ export class TaskPane extends Component { } if (this.props.taskReadOnly && !prevProps.taskReadOnly) { - this.setState({ showLockFailureDialog: true }); + // Read-only because the task is already complete isn't a lock failure - + // there's nothing to retry or request an unlock for + this.setState({ showLockFailureDialog: !this.props.taskLockNotApplicable }); } else if (!this.props.taskReadOnly && prevProps.taskReadOnly) { // The lock came through after all (a retry, a refresh, or a release of // the conflicting lock succeeded), so the failure dialog is stale @@ -417,7 +423,7 @@ export class TaskPane extends Component { - {this.props.tryingLock ? ( + {this.props.taskLockNotApplicable ? null : this.props.tryingLock ? ( ) : this.props.taskReadOnly ? ( + !isFinalStatus(task?.status) || task?.reviewStatus === TaskReviewStatus.rejected; From a84c7434bc1136da62432f947dda160b0116d760 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Tue, 25 Aug 2026 16:08:31 -0500 Subject: [PATCH 4/5] prevent locking on paused challenges --- .../HOCs/WithLockedTask/WithLockedTask.jsx | 6 ++--- src/services/Task/TaskLock.js | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx index 74bda8dd0..deb84bdbb 100644 --- a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx +++ b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx @@ -62,7 +62,7 @@ const WithLockedTask = function (WrappedComponent) { return Promise.reject("Invalid task"); } - if (!isLockableTask(task)) { + if (!isLockableTask(task, this.props.challenge)) { // Read-only, but not a failure: there's no lock to offer to retry or // request, so flag it separately from a genuine lock failure this.setState({ @@ -170,7 +170,7 @@ const WithLockedTask = function (WrappedComponent) { return Promise.reject("Invalid task"); } - if (!isLockableTask(task)) { + if (!isLockableTask(task, this.props.challenge)) { return Promise.resolve(false); } @@ -203,7 +203,7 @@ const WithLockedTask = function (WrappedComponent) { syncLocks = () => { const { task } = this.props; - if (task && isLockableTask(task)) { + if (task && isLockableTask(task, this.props.challenge)) { if (!lockStorage.isLocked(task.id)) { this.refreshTaskLock(task); } diff --git a/src/services/Task/TaskLock.js b/src/services/Task/TaskLock.js index c5c4df949..82ec7677e 100644 --- a/src/services/Task/TaskLock.js +++ b/src/services/Task/TaskLock.js @@ -7,12 +7,25 @@ import { isFinalStatus } from "./TaskStatus/TaskStatus"; * A task in a final status (Fixed/AlreadyFixed/FalsePositive) can't be worked * on any further, so there's nothing for a lock to protect. The one exception * is a task a reviewer rejected, which the mapper still has to revise and - * resubmit. Locking the rest would just burn the user's single allowed lock - - * and pop a conflict dialog against whatever they're actually working on - for - * a task they can only look at. + * resubmit. Nothing can be worked on in a paused challenge either. Locking any + * of these would just burn the user's single allowed lock - and pop a conflict + * dialog against whatever they're actually working on - for a task they can + * only look at. + * + * The parent challenge is read from `challenge` when given, otherwise from + * `task.parent` if it has been denormalized into the challenge object (it's a + * bare id when it hasn't). The server enforces this too, so a caller that + * can't supply the challenge just gets the rejection from there instead. * * Note this concerns edit locks only. Reviewers claim tasks through a separate * review-claim mechanism (see TaskReview's startReview) that is unaffected. */ -export const isLockableTask = (task) => - !isFinalStatus(task?.status) || task?.reviewStatus === TaskReviewStatus.rejected; +export const isLockableTask = (task, challenge = null) => { + const parentChallenge = challenge ?? (typeof task?.parent === "object" ? task.parent : null); + + if (parentChallenge?.paused) { + return false; + } + + return !isFinalStatus(task?.status) || task?.reviewStatus === TaskReviewStatus.rejected; +}; From bff938410fd958548000bdcc90cfeb9a9bb74913 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Tue, 25 Aug 2026 16:17:56 -0500 Subject: [PATCH 5/5] fix complete challenge animation --- src/components/CongratulateModal/CongratulateModal.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/CongratulateModal/CongratulateModal.jsx b/src/components/CongratulateModal/CongratulateModal.jsx index 18b2cbd09..611fca11b 100644 --- a/src/components/CongratulateModal/CongratulateModal.jsx +++ b/src/components/CongratulateModal/CongratulateModal.jsx @@ -1,11 +1,18 @@ import { Component } from "react"; -import Confetti from "react-dom-confetti"; +import ConfettiModule from "react-dom-confetti"; import { FormattedMessage } from "react-intl"; import Modal from "../Modal/Modal"; import SvgSymbol from "../SvgSymbol/SvgSymbol"; import messages from "./Messages"; import "./CongratulateModal.scss"; +// react-dom-confetti is Babel-era CJS: `exports.default = Confetti` alongside an +// `__esModule` marker. Vite's dep optimizer doesn't pick up on that marker and +// emits `export default require_confetti()`, so a default import receives the +// whole module object rather than the component. Unwrap it here; the fallback +// covers bundlers that do honor the marker and hand back the class directly. +const Confetti = ConfettiModule?.default ?? ConfettiModule; + /** * CongratulateModal presents a celebratory modal that displays a * congratulatory message along with a confetti cannon visual effect