From cf8d904931477cd0dd10e5ba2f39d560a1889fad Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 22 Feb 2026 00:14:58 +0600 Subject: [PATCH 1/3] Reload element page when static file path changes (#13235) - Backend: Element/Update sets static_file_changed in response when static_file is dirty - Frontend: add MODx.reloadIfStaticFileChanged() helper; use it in template, chunk, snippet, plugin, tv panels to reload page on path change instead of duplicating logic --- core/src/Revolution/Processors/Element/Update.php | 11 ++++++++++- manager/assets/modext/core/modx.layout.js | 13 +++++++++++++ .../modext/widgets/element/modx.panel.chunk.js | 6 ++++-- .../modext/widgets/element/modx.panel.plugin.js | 6 ++++-- .../modext/widgets/element/modx.panel.snippet.js | 6 ++++-- .../modext/widgets/element/modx.panel.template.js | 7 +++---- .../assets/modext/widgets/element/modx.panel.tv.js | 6 ++++-- 7 files changed, 42 insertions(+), 13 deletions(-) diff --git a/core/src/Revolution/Processors/Element/Update.php b/core/src/Revolution/Processors/Element/Update.php index 81b424c5589..a2110ceb8b7 100644 --- a/core/src/Revolution/Processors/Element/Update.php +++ b/core/src/Revolution/Processors/Element/Update.php @@ -28,6 +28,8 @@ abstract class Update extends UpdateProcessor public $previousCategory; /** @var modElement $object */ public $object; + /** @var bool Whether static_file path was changed (used to trigger frontend refresh) */ + private bool $staticFileChanged = false; public function beforeSet() { @@ -79,6 +81,10 @@ public function beforeSave() } } + if ($this->object->isStatic()) { + $this->staticFileChanged = $this->object->isDirty('static_file'); + } + return !$this->hasErrors(); } @@ -105,7 +111,10 @@ public function cleanup() array_push($fields, ($this->classKey == modTemplate::class ? 'templatename' : 'name')); return $this->success( '', - array_merge($this->object->get($fields), ['previous_category' => $this->previousCategory]) + array_merge($this->object->get($fields), [ + 'previous_category' => $this->previousCategory, + 'static_file_changed' => $this->staticFileChanged, + ]) ); } } diff --git a/manager/assets/modext/core/modx.layout.js b/manager/assets/modext/core/modx.layout.js index f74246198e6..7722ff6f0ba 100644 --- a/manager/assets/modext/core/modx.layout.js +++ b/manager/assets/modext/core/modx.layout.js @@ -905,3 +905,16 @@ MODx.loadPage = MODx.LayoutMgr.loadPage; MODx.showDashboard = MODx.LayoutMgr.showDashboard; MODx.hideDashboard = MODx.LayoutMgr.hideDashboard; MODx.changeMenu = MODx.LayoutMgr.changeMenu; + +/** + * If the element update response indicates static_file path changed, reload the page and return true. + * @param {Object} resultObject - response.result.object from element update processor + * @returns {boolean} true if page reload was triggered + */ +MODx.reloadIfStaticFileChanged = function(resultObject) { + if (resultObject && resultObject.static_file_changed) { + MODx.loadPage(MODx.request.a, 'id=' + resultObject.id); + return true; + } + return false; +}; diff --git a/manager/assets/modext/widgets/element/modx.panel.chunk.js b/manager/assets/modext/widgets/element/modx.panel.chunk.js index b354fc5b416..744993e6de6 100644 --- a/manager/assets/modext/widgets/element/modx.panel.chunk.js +++ b/manager/assets/modext/widgets/element/modx.panel.chunk.js @@ -440,8 +440,10 @@ Ext.extend(MODx.panel.Chunk, MODx.FormPanel, { }, success: function(r) { + const data = r.result.object; + if (MODx.reloadIfStaticFileChanged(data)) { return; } if (MODx.request.id) { Ext.getCmp('modx-grid-element-properties').save(); } - this.getForm().setValues(r.result.object); + this.getForm().setValues(data); const c = Ext.getCmp('modx-chunk-category').getValue(), @@ -449,7 +451,7 @@ Ext.extend(MODx.panel.Chunk, MODx.FormPanel, { t = Ext.getCmp('modx-tree-element') ; if (t) { - const node = t.getNodeById(`n_chunk_element_${Ext.getCmp('modx-chunk-id').getValue()}_${r.result.object.previous_category}`); + const node = t.getNodeById(`n_chunk_element_${Ext.getCmp('modx-chunk-id').getValue()}_${data.previous_category}`); if (node) { node.destroy(); } t.refreshNode(n, true); } diff --git a/manager/assets/modext/widgets/element/modx.panel.plugin.js b/manager/assets/modext/widgets/element/modx.panel.plugin.js index cd0ffa6432a..ea9dc4551be 100644 --- a/manager/assets/modext/widgets/element/modx.panel.plugin.js +++ b/manager/assets/modext/widgets/element/modx.panel.plugin.js @@ -461,16 +461,18 @@ Ext.extend(MODx.panel.Plugin, MODx.FormPanel, { }, success: function(o) { + const data = o.result.object; + if (MODx.reloadIfStaticFileChanged(data)) { return; } if (MODx.request.id) { Ext.getCmp('modx-grid-element-properties').save(); } Ext.getCmp('modx-grid-plugin-event').getStore().commitChanges(); - this.getForm().setValues(o.result.object); + this.getForm().setValues(data); const t = Ext.getCmp('modx-tree-element'); if (t) { const c = Ext.getCmp('modx-plugin-category').getValue(), u = c !== '' && c != null && c !== 0 ? `n_plugin_category_${c}` : 'n_type_plugin', - node = t.getNodeById(`n_plugin_element_${Ext.getCmp('modx-plugin-id').getValue()}_${o.result.object.previous_category}`) + node = t.getNodeById(`n_plugin_element_${Ext.getCmp('modx-plugin-id').getValue()}_${data.previous_category}`) ; if (node) { node.destroy(); } t.refreshNode(u, true); diff --git a/manager/assets/modext/widgets/element/modx.panel.snippet.js b/manager/assets/modext/widgets/element/modx.panel.snippet.js index f5fe3f257dc..601ce07a1fe 100644 --- a/manager/assets/modext/widgets/element/modx.panel.snippet.js +++ b/manager/assets/modext/widgets/element/modx.panel.snippet.js @@ -444,17 +444,19 @@ Ext.extend(MODx.panel.Snippet, MODx.FormPanel, { }, success: function(r) { + const data = r.result.object; + if (MODx.reloadIfStaticFileChanged(data)) { return; } if (MODx.request.id) { Ext.getCmp('modx-grid-element-properties').save(); } - this.getForm().setValues(r.result.object); + this.getForm().setValues(data); const t = Ext.getCmp('modx-tree-element'); if (t) { const c = Ext.getCmp('modx-snippet-category').getValue(), u = c !== '' && c != null && c !== 0 ? `n_snippet_category_${c}` : 'n_type_snippet', - node = t.getNodeById(`n_snippet_element_${Ext.getCmp('modx-snippet-id').getValue()}_${r.result.object.previous_category}`) + node = t.getNodeById(`n_snippet_element_${Ext.getCmp('modx-snippet-id').getValue()}_${data.previous_category}`) ; if (node) { node.destroy(); diff --git a/manager/assets/modext/widgets/element/modx.panel.template.js b/manager/assets/modext/widgets/element/modx.panel.template.js index 44d9766f4bb..9ff6471496c 100644 --- a/manager/assets/modext/widgets/element/modx.panel.template.js +++ b/manager/assets/modext/widgets/element/modx.panel.template.js @@ -568,10 +568,9 @@ Ext.extend(MODx.panel.Template, MODx.FormPanel, { }); }, success: function(response) { - const - data = response.result.object, - tree = Ext.getCmp('modx-tree-element') - ; + const data = response.result.object; + if (MODx.reloadIfStaticFileChanged(data)) { return; } + const tree = Ext.getCmp('modx-tree-element'); if (MODx.request.id) { Ext.getCmp('modx-grid-element-properties').save(); } diff --git a/manager/assets/modext/widgets/element/modx.panel.tv.js b/manager/assets/modext/widgets/element/modx.panel.tv.js index b02049f199d..57da317509c 100644 --- a/manager/assets/modext/widgets/element/modx.panel.tv.js +++ b/manager/assets/modext/widgets/element/modx.panel.tv.js @@ -752,18 +752,20 @@ Ext.extend(MODx.panel.TV, MODx.FormPanel, { }, success: function(r) { + const data = r.result.object; + if (MODx.reloadIfStaticFileChanged(data)) { return; } Ext.getCmp('modx-grid-tv-template').getStore().commitChanges(); Ext.getCmp('modx-grid-tv-security').getStore().commitChanges(); Ext.getCmp('modx-grid-element-sources').getStore().commitChanges(); if (MODx.request.id) { Ext.getCmp('modx-grid-element-properties').save(); } - this.getForm().setValues(r.result.object); + this.getForm().setValues(data); const t = Ext.getCmp('modx-tree-element'); if (t) { const c = Ext.getCmp('modx-tv-category').getValue(), u = c !== '' && c != null && c !== 0 ? `n_tv_category_${c}` : 'n_type_tv', - node = t.getNodeById(`n_tv_element_${Ext.getCmp('modx-tv-id').getValue()}_${r.result.object.previous_category}`); + node = t.getNodeById(`n_tv_element_${Ext.getCmp('modx-tv-id').getValue()}_${data.previous_category}`); if (node) { node.destroy(); } t.refreshNode(u, true); } From 3877015afb423476eb09fb09b6200b0e671eb018 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 27 Jul 2026 17:28:03 +0600 Subject: [PATCH 2/3] fix(elements): keep static_file_changed in all Update cleanups Subclass cleanup() overrides dropped the reload flag for Chunk, Snippet, Plugin, and TV. Share getCleanupData(), detect via staticSourceChanged(), and cover all five processors with regression tests (#13235). --- .../Element/StaticFileChangedUpdateTest.php | 146 ++++++++++++++++++ .../Processors/Element/Chunk/Update.php | 4 +- .../Processors/Element/Plugin/Update.php | 7 +- .../Processors/Element/Snippet/Update.php | 4 +- .../Processors/Element/TemplateVar/Update.php | 6 +- .../Revolution/Processors/Element/Update.php | 37 +++-- manager/assets/modext/core/modx.layout.js | 2 +- 7 files changed, 178 insertions(+), 28 deletions(-) create mode 100644 _build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php diff --git a/_build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php b/_build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php new file mode 100644 index 00000000000..584f2a5ef53 --- /dev/null +++ b/_build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php @@ -0,0 +1,146 @@ +tmpDir = sys_get_temp_dir() . '/modx-static-file-changed-' . uniqid('', true); + mkdir($this->tmpDir, 0777, true); + } + + /** + * @after + */ + public function tearDownFixtures() + { + parent::tearDownFixtures(); + foreach ([ + modChunk::class, + modSnippet::class, + modPlugin::class, + modTemplate::class, + modTemplateVar::class, + ] as $class) { + $objects = $this->modx->getCollection($class, [ + 'name:LIKE' => 'UnitTestStaticFile%', + ]); + if ($class === modTemplate::class) { + $objects = $this->modx->getCollection($class, [ + 'templatename:LIKE' => 'UnitTestStaticFile%', + ]); + } + foreach ($objects as $object) { + $object->remove(); + } + } + if ($this->tmpDir && is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*') ?: [] as $file) { + @unlink($file); + } + @rmdir($this->tmpDir); + } + $this->modx->error->reset(); + } + + /** + * @dataProvider providerElementTypes + */ + public function testStaticFileChangedFlagOnPathChange( + string $classKey, + string $processor, + string $nameField, + string $contentField, + string $contentValue + ) { + $fileA = $this->tmpDir . '/a.' . substr(md5($classKey), 0, 6) . '.txt'; + $fileB = $this->tmpDir . '/b.' . substr(md5($classKey), 0, 6) . '.txt'; + file_put_contents($fileA, $contentValue); + file_put_contents($fileB, $contentValue); + + /** @var \MODX\Revolution\modElement $element */ + $element = $this->modx->newObject($classKey); + $element->fromArray([ + $nameField => 'UnitTestStaticFile' . substr(md5($classKey), 0, 6), + 'description' => 'static file changed test', + 'static' => true, + 'static_file' => $fileA, + $contentField => $contentValue, + ]); + $this->assertTrue((bool)$element->save(), 'Failed to create static ' . $classKey); + + $payload = [ + 'id' => $element->get('id'), + $nameField => $element->get($nameField), + 'description' => $element->get('description'), + 'category' => 0, + 'locked' => false, + 'static' => 1, + 'static_file' => $fileB, + $contentField => $contentValue, + 'clearCache' => false, + ]; + if ($classKey === modPlugin::class) { + $payload['disabled'] = false; + } + + $result = $this->modx->runProcessor($processor, $payload); + $this->assertTrue($this->checkForSuccess($result), $result->getMessage()); + $object = $result->getObject(); + $this->assertArrayHasKey('static_file_changed', $object); + $this->assertTrue((bool)$object['static_file_changed'], 'Expected static_file_changed=true after path change for ' . $classKey); + + $payload['static_file'] = $fileB; + $result = $this->modx->runProcessor($processor, $payload); + $this->assertTrue($this->checkForSuccess($result), $result->getMessage()); + $object = $result->getObject(); + $this->assertArrayHasKey('static_file_changed', $object); + $this->assertFalse((bool)$object['static_file_changed'], 'Expected static_file_changed=false when path unchanged for ' . $classKey); + } + + public function providerElementTypes(): array + { + return [ + 'chunk' => [modChunk::class, ChunkUpdate::class, 'name', 'snippet', '

static

'], + 'snippet' => [modSnippet::class, SnippetUpdate::class, 'name', 'snippet', 'return "static";'], + 'plugin' => [modPlugin::class, PluginUpdate::class, 'name', 'plugincode', 'return true;'], + 'template' => [modTemplate::class, TemplateUpdate::class, 'templatename', 'content', 'static'], + 'tv' => [modTemplateVar::class, TemplateVarUpdate::class, 'name', 'default_text', 'static'], + ]; + } +} diff --git a/core/src/Revolution/Processors/Element/Chunk/Update.php b/core/src/Revolution/Processors/Element/Chunk/Update.php index bcec19933b4..f9df90f3a42 100644 --- a/core/src/Revolution/Processors/Element/Chunk/Update.php +++ b/core/src/Revolution/Processors/Element/Chunk/Update.php @@ -53,8 +53,6 @@ public function beforeSave() public function cleanup() { - return $this->success('', - array_merge($this->object->get(['id', 'name', 'description', 'locked', 'category', 'snippet']), - ['previous_category' => $this->previousCategory])); + return $this->success('', $this->getCleanupData(['id', 'name', 'description', 'locked', 'category', 'snippet'])); } } diff --git a/core/src/Revolution/Processors/Element/Plugin/Update.php b/core/src/Revolution/Processors/Element/Plugin/Update.php index c1fa021ed6e..d4ca37c5260 100644 --- a/core/src/Revolution/Processors/Element/Plugin/Update.php +++ b/core/src/Revolution/Processors/Element/Plugin/Update.php @@ -91,9 +91,8 @@ public function setSystemEvents() public function cleanup() { - return $this->success('', array_merge( - $this->object->get(['id', 'name', 'description', 'locked', 'category', 'disabled', 'plugincode']), - ['previous_category' => $this->previousCategory] - )); + return $this->success('', $this->getCleanupData([ + 'id', 'name', 'description', 'locked', 'category', 'disabled', 'plugincode', + ])); } } diff --git a/core/src/Revolution/Processors/Element/Snippet/Update.php b/core/src/Revolution/Processors/Element/Snippet/Update.php index 74741026c91..42b1a92901e 100644 --- a/core/src/Revolution/Processors/Element/Snippet/Update.php +++ b/core/src/Revolution/Processors/Element/Snippet/Update.php @@ -54,8 +54,6 @@ public function beforeSave() public function cleanup() { - return $this->success('', - array_merge($this->object->get(['id', 'name', 'description', 'locked', 'category', 'snippet']), - ['previous_category' => $this->previousCategory])); + return $this->success('', $this->getCleanupData(['id', 'name', 'description', 'locked', 'category', 'snippet'])); } } diff --git a/core/src/Revolution/Processors/Element/TemplateVar/Update.php b/core/src/Revolution/Processors/Element/TemplateVar/Update.php index deea3f12674..5dda1da58bb 100644 --- a/core/src/Revolution/Processors/Element/TemplateVar/Update.php +++ b/core/src/Revolution/Processors/Element/TemplateVar/Update.php @@ -247,9 +247,9 @@ public function setMediaSources() public function cleanup() { - return $this->success('', - array_merge($this->object->get(['id', 'name', 'description', 'locked', 'category', 'default_text']), - ['previous_category' => $this->previousCategory])); + return $this->success('', $this->getCleanupData([ + 'id', 'name', 'description', 'locked', 'category', 'default_text', + ])); } } diff --git a/core/src/Revolution/Processors/Element/Update.php b/core/src/Revolution/Processors/Element/Update.php index a2110ceb8b7..806a6823359 100644 --- a/core/src/Revolution/Processors/Element/Update.php +++ b/core/src/Revolution/Processors/Element/Update.php @@ -28,8 +28,8 @@ abstract class Update extends UpdateProcessor public $previousCategory; /** @var modElement $object */ public $object; - /** @var bool Whether static_file path was changed (used to trigger frontend refresh) */ - private bool $staticFileChanged = false; + /** @var bool Whether static source identity changed (path/source/static); triggers manager reload */ + protected bool $staticFileChanged = false; public function beforeSet() { @@ -81,9 +81,8 @@ public function beforeSave() } } - if ($this->object->isStatic()) { - $this->staticFileChanged = $this->object->isDirty('static_file'); - } + /* Reload manager form when static source identity changes (path, media source, or static flag). */ + $this->staticFileChanged = $this->object->staticSourceChanged(); return !$this->hasErrors(); } @@ -105,16 +104,26 @@ public function afterSave() } } + /** + * Build success payload shared by all element Update processors. + * Subclasses must use this so static_file_changed is never dropped. + * + * @param array $fields Object fields to include + * @return array + */ + protected function getCleanupData(array $fields): array + { + return array_merge($this->object->get($fields), [ + 'previous_category' => $this->previousCategory, + 'static_file_changed' => $this->staticFileChanged, + ]); + } + public function cleanup() { - $fields = array('id', 'description', 'locked', 'category', 'content'); - array_push($fields, ($this->classKey == modTemplate::class ? 'templatename' : 'name')); - return $this->success( - '', - array_merge($this->object->get($fields), [ - 'previous_category' => $this->previousCategory, - 'static_file_changed' => $this->staticFileChanged, - ]) - ); + $fields = ['id', 'description', 'locked', 'category', 'content']; + $fields[] = ($this->classKey == modTemplate::class ? 'templatename' : 'name'); + + return $this->success('', $this->getCleanupData($fields)); } } diff --git a/manager/assets/modext/core/modx.layout.js b/manager/assets/modext/core/modx.layout.js index 7722ff6f0ba..20345e8425d 100644 --- a/manager/assets/modext/core/modx.layout.js +++ b/manager/assets/modext/core/modx.layout.js @@ -913,7 +913,7 @@ MODx.changeMenu = MODx.LayoutMgr.changeMenu; */ MODx.reloadIfStaticFileChanged = function(resultObject) { if (resultObject && resultObject.static_file_changed) { - MODx.loadPage(MODx.request.a, 'id=' + resultObject.id); + MODx.loadPage(MODx.request.a, `id=${resultObject.id}`); return true; } return false; From 12d0f87bfb4dd1afc203e2bf89d04ee9594a1104 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 27 Jul 2026 17:30:45 +0600 Subject: [PATCH 3/3] style(test): fix PHPCS on StaticFileChangedUpdateTest --- .../Element/StaticFileChangedUpdateTest.php | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/_build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php b/_build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php index 584f2a5ef53..e9b3353bbb5 100644 --- a/_build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php +++ b/_build/test/Tests/Processors/Element/StaticFileChangedUpdateTest.php @@ -1,4 +1,5 @@ modx->getCollection($class, [ 'name:LIKE' => 'UnitTestStaticFile%', ]); @@ -123,14 +127,20 @@ public function testStaticFileChangedFlagOnPathChange( $this->assertTrue($this->checkForSuccess($result), $result->getMessage()); $object = $result->getObject(); $this->assertArrayHasKey('static_file_changed', $object); - $this->assertTrue((bool)$object['static_file_changed'], 'Expected static_file_changed=true after path change for ' . $classKey); + $this->assertTrue( + (bool)$object['static_file_changed'], + 'Expected static_file_changed=true after path change for ' . $classKey + ); $payload['static_file'] = $fileB; $result = $this->modx->runProcessor($processor, $payload); $this->assertTrue($this->checkForSuccess($result), $result->getMessage()); $object = $result->getObject(); $this->assertArrayHasKey('static_file_changed', $object); - $this->assertFalse((bool)$object['static_file_changed'], 'Expected static_file_changed=false when path unchanged for ' . $classKey); + $this->assertFalse( + (bool)$object['static_file_changed'], + 'Expected static_file_changed=false when path unchanged for ' . $classKey + ); } public function providerElementTypes(): array