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 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 6fd9b6c67..deb84bdbb 100644 --- a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx +++ b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx @@ -2,12 +2,14 @@ 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, 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 @@ -36,6 +38,11 @@ const lockStorage = { * about to work on. If a lock cannot be acquired, the WrappedCompont will be * passed a `taskReadOnly` flag set to true * + * Locks are only ever released intentionally: via the lock dialog, the locked + * tasks widget, a lock-conflict release, or a server-side release on task + * completion/skip. Merely navigating away from (or closing) a task keeps the + * lock, leaving the user's work claimed until they release it or it expires. + * * @author [Kelli Rotstan](https://github.com/krotstan) */ const WithLockedTask = function (WrappedComponent) { @@ -45,6 +52,9 @@ const WithLockedTask = function (WrappedComponent) { tryingLock: false, failureDetails: null, lockedAt: null, + lockConflict: null, + releasingConflict: false, + lockNotApplicable: false, }; lockTask = (task) => { @@ -52,7 +62,27 @@ const WithLockedTask = function (WrappedComponent) { return Promise.reject("Invalid task"); } - this.setState({ tryingLock: true, failureDetails: null }); + 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({ + 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(() => { @@ -67,26 +97,65 @@ 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. + * + * Exposed as `releaseConflictingTaskLockAndRetry` (and the conflict itself + * as `taskLockConflict`) rather than the plainer names: WithTaskBundle + * tracks its own, separate bundle-lock conflict under `lockConflict` and + * sits inside this HOC, so unnamespaced props would be clobbered by it. + */ + 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); + }; + + /** + * Explicitly release the lock on the given task. Only ever called in + * response to a deliberate user action - see the class doc above. + */ unlockTask = (task) => { if (!task) { return Promise.reject("Invalid task"); } - this.props + const release = this.props .releaseTask(task.id) .then(() => { //wait for lock to be cleared in db and provide some leeway //time with setTimeout before triggering storage event setTimeout(() => lockStorage.removeLock(task.id), 1500); + return true; }) - .catch(() => null); + .catch(() => false); this.setState({ lockedAt: null }); + + return release; }; requestUnlock = (taskId) => { @@ -101,11 +170,15 @@ const WithLockedTask = function (WrappedComponent) { return Promise.reject("Invalid task"); } + if (!isLockableTask(task, this.props.challenge)) { + return Promise.resolve(false); + } + return this.props .refreshTaskLock(task.id) .then(() => { if (this.state.readOnly) { - this.setState({ readOnly: false, failureDetails: null }); + this.setState({ readOnly: false, failureDetails: null, lockConflict: null }); } this.setState({ lockedAt: Date.now() }); @@ -115,7 +188,14 @@ const WithLockedTask = function (WrappedComponent) { return true; }) .catch((err) => { - this.setState({ readOnly: true, failureDetails: err.details }); + // A refresh can hit the same one-lock-per-user conflict an initial + // lock can (e.g. the user grabbed a lock elsewhere in the meantime), + // so surface it the same way and let the conflict dialog handle it + this.setState({ + readOnly: true, + failureDetails: err.details, + lockConflict: getLockConflict(err), + }); return false; }); }; @@ -123,7 +203,7 @@ const WithLockedTask = function (WrappedComponent) { syncLocks = () => { const { task } = this.props; - if (task) { + if (task && isLockableTask(task, this.props.challenge)) { if (!lockStorage.isLocked(task.id)) { this.refreshTaskLock(task); } @@ -142,10 +222,10 @@ const WithLockedTask = function (WrappedComponent) { componentDidUpdate(prevProps) { if (prevProps?.task?.id !== this.props.task?.id) { - if (prevProps.task) { - this.unlockTask(prevProps.task); - } - + // The lock on the previous task is deliberately left in place - releasing + // a lock is always an explicit user action. If the previous lock is still + // held, locking this task reports a lock conflict and the wrapped + // component can offer to release it (releaseConflictingLockAndRetry). if (this.props.task) { this.lockTask(this.props.task); } @@ -156,10 +236,9 @@ const WithLockedTask = function (WrappedComponent) { const { task } = this.props; window.removeEventListener("storage", this.syncLocks); + // Only the local tab bookkeeping is cleared here; the lock itself stays + // until the user releases it (or it expires server-side). if (task) { - if (localStorage.getItem("isLoggedIn")) { - this.unlockTask(task); - } lockStorage.removeLock(task.id); } } @@ -176,6 +255,10 @@ 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 ff328334a..0b8339cca 100644 --- a/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx +++ b/src/components/HOCs/WithTaskBundle/WithTaskBundle.jsx @@ -4,16 +4,16 @@ 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, - releaseMultipleTasks, + lockTaskBundle, + releaseTask, updateTaskBundle, } from "../../../services/Task/Task"; - -const LOCK_REFRESH_INTERVAL = 600000; // 10 minutes +import { isFinalStatus } from "../../../services/Task/TaskStatus/TaskStatus"; /** * WithTaskBundle passes down methods for creating new task bundles and @@ -32,11 +32,10 @@ export function WithTaskBundle(WrappedComponent) { resetSelectedTasks: null, loading: false, updateTaskBundleError: false, - isDeletingBundle: false, + lockConflict: null, + pendingMemberIds: null, }; - refreshLockInterval = null; - async componentDidMount() { const { task } = this.props; if (Number.isFinite(task?.bundleId)) { @@ -44,72 +43,43 @@ export function WithTaskBundle(WrappedComponent) { } this.updateBundlingConditions(); - window.addEventListener("beforeunload", this.handleBeforeUnload); } async componentDidUpdate(prevProps) { const { task } = this.props; if (task && task?.id !== prevProps?.task?.id) { - if (this.state.taskBundle) { - this.unlockTasks(this.state.taskBundle.taskIds); - } - + // The previous bundle's covering lock is deliberately left in place - + // releasing it is always an explicit user action (see WithLockedTask). this.setState({ selectedTasks: [], taskBundle: null, initialBundle: null, loading: false, error: null, + lockConflict: null, + pendingMemberIds: null, }); if (Number.isFinite(task?.bundleId)) { await this.fetchBundle(task.bundleId); } this.updateBundlingConditions(); + } else if (this.props.taskReadOnly !== prevProps.taskReadOnly) { + // Read-only status is one of the bundling conditions, so it has to be + // reevaluated whenever the lock is acquired or lost + this.updateBundlingConditions(); } } - componentWillUnmount() { - this.stopLockRefresh(); - if (!this.state.isDeletingBundle) { - this.unlockBundleTasks(); - } - window.removeEventListener("beforeunload", this.handleBeforeUnload); - } - - 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 +105,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); @@ -160,7 +136,7 @@ export function WithTaskBundle(WrappedComponent) { bundleEditsDisabled = true; break; - case taskReadOnly === true: + case taskReadOnly === true && !isFinalStatus(task?.status): reason = "readOnly"; bundleEditsDisabled = true; break; @@ -245,51 +221,33 @@ 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 : []; - } catch (error) { - console.error("Error locking tasks:", error); - this.setState({ - error: "lockError", - }); - return []; - } - }; - - unlockTasks = async (taskIds) => { - if (!taskIds || taskIds.length === 0) { - return; - } - try { - await this.props.releaseMultipleTasks(taskIds); + await this.props.lockTaskBundle(task.id, memberTaskIds); + this.setState({ lockConflict: null, pendingMemberIds: null }); + return true; } catch (error) { - console.warn("Error unlocking tasks:", error); - this.setState({ error: "unlockError" }); - } - }; - - 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 + 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; } - - await this.props.lockMultipleTasks(tasksToRefresh); }; createTaskBundle = async (taskIds) => { @@ -298,96 +256,75 @@ export function WithTaskBundle(WrappedComponent) { 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 +332,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 +347,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,8 +375,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; } @@ -461,20 +392,23 @@ export function WithTaskBundle(WrappedComponent) { return null; }; - unlockBundleTasks = () => { - if (this.state.taskBundle) { - // Only unlock tasks that aren't the primary task - // since the primary task is managed by WithLockedTask - const tasksToUnlock = this.state.taskBundle.taskIds.filter( - (taskId) => taskId !== this.props.task?.id, - ); - - 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() { @@ -485,6 +419,9 @@ export function WithTaskBundle(WrappedComponent) { "deleteTaskBundle", "updateTaskBundle", "removeTaskFromBundle", + "lockTaskBundle", + "releaseTask", + "taskEntities", ])} taskBundle={this.state.taskBundle} initialBundle={this.state.initialBundle} @@ -502,12 +439,19 @@ export function WithTaskBundle(WrappedComponent) { resetSelectedTasks={this.resetSelectedTasks} error={this.state.error} bundlingDisabledReason={this.state.bundlingDisabledReason} + lockConflict={this.state.lockConflict} + releaseConflictingLockAndRetry={this.releaseConflictingLockAndRetry} + clearLockConflict={this.clearLockConflict} /> ); } }; } +export const mapStateToProps = (state) => ({ + taskEntities: state.entities?.tasks, +}); + export const mapDispatchToProps = (dispatch) => bindActionCreators( { @@ -515,12 +459,12 @@ export const mapDispatchToProps = (dispatch) => bundleTasks, deleteTaskBundle, updateTaskBundle, - lockMultipleTasks, - releaseMultipleTasks, + lockTaskBundle, + 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..7efaeecf1 100644 --- a/src/components/TaskPane/Messages.js +++ b/src/components/TaskPane/Messages.js @@ -122,6 +122,43 @@ export default defineMessages({ defaultMessage: "Request Unlock", }, + lockConflictTitle: { + id: "Task.pane.lockConflictDialog.title", + defaultMessage: "You're Already Working on a Task", + }, + + lockConflictDescription: { + id: "Task.pane.lockConflictDialog.description", + defaultMessage: + "You can only have one task locked at a time, and you still hold the lock on task #{taskId}.", + }, + + lockConflictChallenge: { + id: "Task.pane.lockConflictDialog.challenge", + defaultMessage: "Challenge: {parentName}", + }, + + lockConflictStarted: { + id: "Task.pane.lockConflictDialog.started", + defaultMessage: "Started {startedAgo}", + }, + + lockConflictBundle: { + id: "Task.pane.lockConflictDialog.bundle", + defaultMessage: + "Bundled with {count, plural, one {# other task} other {# other tasks}}, which will be released too.", + }, + + goToLockedTaskLabel: { + id: "Task.pane.lockConflictDialog.goToLockedTaskLabel", + defaultMessage: "Go to Locked Task", + }, + + 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 6d1a153f9..a0da3f055 100644 --- a/src/components/TaskPane/TaskPane.jsx +++ b/src/components/TaskPane/TaskPane.jsx @@ -1,4 +1,5 @@ import classNames from "classnames"; +import { formatDistanceToNow, parseISO } from "date-fns"; import _findIndex from "lodash/findIndex"; import PropTypes from "prop-types"; import { Component, Fragment, useEffect, useState } from "react"; @@ -190,6 +191,7 @@ export class TaskPane extends Component { unlockRequested: false, showLockOptionsDialog: false, extendingLock: false, + releasingLock: false, }; tryLockingTask = () => { @@ -217,6 +219,27 @@ export class TaskPane extends Component { }); }; + /** + * Explicitly release the lock on the current task and head back to the + * challenge (or virtual challenge) browse view. Navigating away on its own no + * longer releases anything, so the release has to happen here. + */ + releaseTaskLock = async () => { + this.setState({ releasingLock: true }); + + try { + await this.props.unlockTask(this.props.task); + } finally { + this.setState({ releasingLock: false, showLockOptionsDialog: false }); + } + + this.props.history.push( + Number.isFinite(this.props.virtualChallengeId) + ? `/browse/virtual/${this.props.virtualChallengeId}` + : `/browse/challenges/${this.props.task?.parent?.id ?? this.props.task.parent}`, + ); + }; + /** * Clear the lock-refresh timer if one is set */ @@ -289,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 }); @@ -314,7 +341,13 @@ 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 + this.setState({ showLockFailureDialog: false, unlockRequested: false }); } } @@ -390,7 +423,7 @@ export class TaskPane extends Component { - {this.props.tryingLock ? ( + {this.props.taskLockNotApplicable ? null : this.props.tryingLock ? ( ) : this.props.taskReadOnly ? ( - {this.state.showLockFailureDialog && ( - } - prompt={ - - - {this.props.lockFailureDetails?.message ?? - this.props.intl.formatMessage(messages.genericLockFailure)} - - - - } - icon="unlocked-icon" - onClose={() => this.clearLockFailure()} - controls={ - - - {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.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 && ( } @@ -614,20 +727,18 @@ export class TaskPane extends Component { )} - + {this.state.releasingLock ? ( +
+ +
+ ) : ( + + )} } /> 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 }) => (