diff --git a/core/src/Controllers/MoveDocument.php b/core/src/Controllers/MoveDocument.php index 4d2d1094b6..7cd97535c3 100644 --- a/core/src/Controllers/MoveDocument.php +++ b/core/src/Controllers/MoveDocument.php @@ -1,9 +1,10 @@ - 0) { - $parentDocument = $this->getDocument($newParentID); - if ($parentDocument->deleted) { - $this->managerTheme->alertAndQuit('error_parent_deleted'); - }; - $children = allChildren($document->getKey()); + } + if ($newParentID > 0) { + $parentDocument = $this->getDocument($newParentID); + if (MoveDocumentTargetGuard::blocksParent($parentDocument)) { + $this->managerTheme->alertAndQuit('error_parent_deleted'); + }; + $children = allChildren($document->getKey()); if (\in_array($parentDocument->getKey(), $children, true)) { $this->managerTheme->alertAndQuit('You cannot move a document to a child document!', false); } diff --git a/core/src/Support/MoveDocumentTargetGuard.php b/core/src/Support/MoveDocumentTargetGuard.php new file mode 100644 index 0000000000..c3fc792eb3 --- /dev/null +++ b/core/src/Support/MoveDocumentTargetGuard.php @@ -0,0 +1,13 @@ +deleted === 1; + } +} diff --git a/core/tests/Unit/MoveDocumentTargetGuardTest.php b/core/tests/Unit/MoveDocumentTargetGuardTest.php new file mode 100644 index 0000000000..1b18ed2e7e --- /dev/null +++ b/core/tests/Unit/MoveDocumentTargetGuardTest.php @@ -0,0 +1,16 @@ +deleted = 1; + + $activeParent = new SiteContent(); + $activeParent->deleted = 0; + + expect(MoveDocumentTargetGuard::blocksParent(null))->toBeTrue() + ->and(MoveDocumentTargetGuard::blocksParent($deletedParent))->toBeTrue() + ->and(MoveDocumentTargetGuard::blocksParent($activeParent))->toBeFalse(); +}); diff --git a/manager/media/script/tests/tree-drop-guard-helper.test.js b/manager/media/script/tests/tree-drop-guard-helper.test.js new file mode 100644 index 0000000000..cc19ffc7d1 --- /dev/null +++ b/manager/media/script/tests/tree-drop-guard-helper.test.js @@ -0,0 +1,24 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const helper = require('../tree-drop-guard-helper'); + +test('canDropIntoTarget blocks deleted tree nodes', () => { + assert.equal(helper.canDropIntoTarget({ + dataset: { + deleted: '1' + } + }), false); +}); + +test('canDropIntoTarget allows active tree nodes', () => { + assert.equal(helper.canDropIntoTarget({ + dataset: { + deleted: '0' + } + }), true); +}); + +test('canDropIntoTarget allows targets without dataset metadata', () => { + assert.equal(helper.canDropIntoTarget(null), true); + assert.equal(helper.canDropIntoTarget({}), true); +}); diff --git a/manager/media/script/tree-drop-guard-helper.js b/manager/media/script/tree-drop-guard-helper.js new file mode 100644 index 0000000000..813459280f --- /dev/null +++ b/manager/media/script/tree-drop-guard-helper.js @@ -0,0 +1,28 @@ +(function (root, factory) { + var exported = factory(); + + if (typeof module === 'object' && module.exports) { + module.exports = exported; + } + + root.modxTreeDropGuardHelper = exported; +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function isDeletedTarget(targetAnchor) { + if (!targetAnchor || !targetAnchor.dataset) { + return false; + } + + return parseInt(targetAnchor.dataset.deleted || '0', 10) === 1; + } + + function canDropIntoTarget(targetAnchor) { + return !isDeletedTarget(targetAnchor); + } + + return { + canDropIntoTarget: canDropIntoTarget, + isDeletedTarget: isDeletedTarget + }; +})); diff --git a/manager/media/style/default/ajax.php b/manager/media/style/default/ajax.php index c2eadba2a1..d53c9d3ef0 100755 --- a/manager/media/style/default/ajax.php +++ b/manager/media/style/default/ajax.php @@ -1,6 +1,7 @@ 0 && empty(SiteContent::find($parent)); + $parentDocument = $parent > 0 ? SiteContent::withTrashed()->find($parent) : null; + $parentDeleted = $parent > 0 && MoveDocumentTargetGuard::blocksParent($parentDocument); if ($parentDeleted) { $json['errors'] = $_lang['error_parent_deleted']; } elseif (empty($json['errors'])) { diff --git a/manager/media/style/default/js/modx.js b/manager/media/style/default/js/modx.js index c2186e3250..1dd5dae806 100755 --- a/manager/media/style/default/js/modx.js +++ b/manager/media/style/default/js/modx.js @@ -906,8 +906,14 @@ e.dataTransfer.dropEffect = 'all'; e.dataTransfer.setData('text', this.id.substr(4)); }, + isBlockedDropTarget: function (target) { + return !!(w.modxTreeDropGuardHelper && !w.modxTreeDropGuardHelper.canDropIntoTarget(target)); + }, ondragenter: function (e) { - if (d.getElementById('node' + modx.tree.itemToChange) === (this.parentNode.closest('#node' + modx.tree.itemToChange) || this.parentNode)) { + if ( + d.getElementById('node' + modx.tree.itemToChange) === (this.parentNode.closest('#node' + modx.tree.itemToChange) || this.parentNode) + || modx.tree.isBlockedDropTarget(this) + ) { this.parentNode.className = ''; e.dataTransfer.effectAllowed = 'none'; e.dataTransfer.dropEffect = 'none'; @@ -921,7 +927,12 @@ e.preventDefault(); }, ondragover: function (e) { - if (modx.tree.drag) { + if (modx.tree.isBlockedDropTarget(this)) { + this.parentNode.className = ''; + e.dataTransfer.effectAllowed = 'none'; + e.dataTransfer.dropEffect = 'none'; + modx.tree.drag = false; + } else if (modx.tree.drag) { var a = e.clientY; var b = parseInt(this.getBoundingClientRect().top); var c = a - b; @@ -960,6 +971,15 @@ e.preventDefault(); }, ondrop: function (e) { + if (modx.tree.isBlockedDropTarget(this)) { + this.parentNode.removeAttribute('class'); + this.parentNode.removeAttribute('draggable'); + modx.alert(modx.lang.error_parent_deleted); + modx.tree.restoreTree(); + e.preventDefault(); + return; + } + let el = d.getElementById('node' + modx.tree.itemToChange); let els = null; let id = modx.tree.itemToChange; diff --git a/manager/media/style/liquid/ajax.php b/manager/media/style/liquid/ajax.php index 3fa2ad41ed..479db7f9a6 100755 --- a/manager/media/style/liquid/ajax.php +++ b/manager/media/style/liquid/ajax.php @@ -1,6 +1,7 @@ 0 && empty(SiteContent::find($parent)); + $parentDocument = $parent > 0 ? SiteContent::withTrashed()->find($parent) : null; + $parentDeleted = $parent > 0 && MoveDocumentTargetGuard::blocksParent($parentDocument); if ($parentDeleted) { $json['errors'] = $_lang['error_parent_deleted']; } elseif (empty($json['errors'])) { diff --git a/manager/media/style/liquid/js/modx.js b/manager/media/style/liquid/js/modx.js index 43cb9f2412..8e63036608 100755 --- a/manager/media/style/liquid/js/modx.js +++ b/manager/media/style/liquid/js/modx.js @@ -923,8 +923,14 @@ e.dataTransfer.dropEffect = 'all'; e.dataTransfer.setData('text', this.id.substr(4)); }, + isBlockedDropTarget: function (target) { + return !!(w.modxTreeDropGuardHelper && !w.modxTreeDropGuardHelper.canDropIntoTarget(target)); + }, ondragenter: function (e) { - if (d.getElementById('node' + modx.tree.itemToChange) === (this.parentNode.closest('#node' + modx.tree.itemToChange) || this.parentNode)) { + if ( + d.getElementById('node' + modx.tree.itemToChange) === (this.parentNode.closest('#node' + modx.tree.itemToChange) || this.parentNode) + || modx.tree.isBlockedDropTarget(this) + ) { this.parentNode.className = ''; e.dataTransfer.effectAllowed = 'none'; e.dataTransfer.dropEffect = 'none'; @@ -938,7 +944,12 @@ e.preventDefault(); }, ondragover: function (e) { - if (modx.tree.drag) { + if (modx.tree.isBlockedDropTarget(this)) { + this.parentNode.className = ''; + e.dataTransfer.effectAllowed = 'none'; + e.dataTransfer.dropEffect = 'none'; + modx.tree.drag = false; + } else if (modx.tree.drag) { var a = e.clientY; var b = parseInt(this.getBoundingClientRect().top); var c = a - b; @@ -977,6 +988,15 @@ e.preventDefault(); }, ondrop: function (e) { + if (modx.tree.isBlockedDropTarget(this)) { + this.parentNode.removeAttribute('class'); + this.parentNode.removeAttribute('draggable'); + modx.alert(modx.lang.error_parent_deleted); + modx.tree.restoreTree(); + e.preventDefault(); + return; + } + let el = d.getElementById('node' + modx.tree.itemToChange); let els = null; let id = modx.tree.itemToChange; diff --git a/manager/views/frame/1.blade.php b/manager/views/frame/1.blade.php index 72cc0ae228..812fb62af6 100644 --- a/manager/views/frame/1.blade.php +++ b/manager/views/frame/1.blade.php @@ -136,6 +136,7 @@ function iconHtml($icon, $attrs = '') { empty_recycle_bin: "{{ManagerTheme::getLexicon('empty_recycle_bin')}}", empty_recycle_bin_empty: "{{ManagerTheme::getLexicon('empty_recycle_bin_empty')}}", error_no_privileges: "{{ManagerTheme::getLexicon('error_no_privileges')}}", + error_parent_deleted: "{{ManagerTheme::getLexicon('error_parent_deleted')}}", expand_tree: "{{ManagerTheme::getLexicon('expand_tree')}}", loading_doc_tree: "{{ManagerTheme::getLexicon('loading_doc_tree')}}", loading_menu: "{{ManagerTheme::getLexicon('loading_menu')}}", @@ -218,6 +219,7 @@ function iconHtml($icon, $attrs = '') { echo (empty($opened) ? '' : 'modx.openedArray[' . implode("] = 1;\n modx.openedArray[", $opened) . '] = 1;') . "\n"; ?> + @if ($modx->getConfig('show_picker'))