diff --git a/app/Config/View.php b/app/Config/View.php index cf8dd06f1065..582ef73276b1 100644 --- a/app/Config/View.php +++ b/app/Config/View.php @@ -59,4 +59,21 @@ class View extends BaseView * @var list> */ public array $decorators = []; + + /** + * Subdirectory within app/Views for namespaced view overrides. + * + * Namespaced views will be searched in: + * + * app/Views/{$appOverridesFolder}/{Namespace}/{view_path}.{php|html...} + * + * This allows application-level overrides for package or module views + * without modifying vendor source files. + * + * Examples: + * 'overrides' -> app/Views/overrides/Example/Blog/post/card.php + * 'vendor' -> app/Views/vendor/Example/Blog/post/card.php + * '' -> app/Views/Example/Blog/post/card.php (direct mapping) + */ + public string $appOverridesFolder = 'overrides'; } diff --git a/system/View/View.php b/system/View/View.php index 184f2528c32c..5e860853f44c 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -201,6 +201,18 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n $this->renderVars['file'] = $this->viewPath . $this->renderVars['view']; + if (str_contains($this->renderVars['view'], '\\')) { + $overrideFolder = $this->config->appOverridesFolder !== '' + ? trim($this->config->appOverridesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR + : ''; + + $this->renderVars['file'] = $this->viewPath + . $overrideFolder + . ltrim(str_replace('\\', DIRECTORY_SEPARATOR, $this->renderVars['view']), DIRECTORY_SEPARATOR); + } else { + $this->renderVars['file'] = $this->viewPath . $this->renderVars['view']; + } + if (! is_file($this->renderVars['file'])) { $this->renderVars['file'] = $this->loader->locateFile( $this->renderVars['view'], diff --git a/tests/system/View/ViewTest.php b/tests/system/View/ViewTest.php index b15e603408d8..d2dc5697ef54 100644 --- a/tests/system/View/ViewTest.php +++ b/tests/system/View/ViewTest.php @@ -17,7 +17,7 @@ use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\View\Exceptions\ViewException; -use Config; +use Config\View as ViewConfig; use PHPUnit\Framework\Attributes\Group; /** @@ -28,15 +28,16 @@ final class ViewTest extends CIUnitTestCase { private FileLocatorInterface $loader; private string $viewsDir; - private Config\View $config; + private ViewConfig $config; protected function setUp(): void { parent::setUp(); - $this->loader = service('locator'); - $this->viewsDir = __DIR__ . '/Views'; - $this->config = new Config\View(); + $this->loader = service('locator'); + $this->viewsDir = __DIR__ . '/Views'; + $this->config = new ViewConfig(); + $this->config->appOverridesFolder = ''; } public function testSetVarStoresData(): void @@ -413,4 +414,76 @@ public function testViewExcerpt(): void $this->assertSame('CodeIgniter is a PHP full-stack web framework...', $view->excerpt('CodeIgniter is a PHP full-stack web framework that is light, fast, flexible and secure.', 48)); $this->assertSame('CodeIgniter - это полнофункциональный веб-фреймворк...', $view->excerpt('CodeIgniter - это полнофункциональный веб-фреймворк на PHP, который является легким, быстрым, гибким и безопасным.', 54)); } + + public function testRenderNamespacedViewPriorityToAppViews(): void + { + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->never())->method('locateFile'); + + $view = new View($this->config, $this->viewsDir, $loader); + + $view->setVar('testString', 'Hello World'); + $expected = '

Hello World

'; + + $output = $view->render('Nested\simple'); + + $this->assertStringContainsString($expected, $output); + } + + public function testRenderNamespacedViewFallsBackToLoader(): void + { + $namespacedView = 'Some\Library\View'; + + $realFile = $this->viewsDir . '/simple.php'; + + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->once()) + ->method('locateFile') + ->with($namespacedView . '.php', 'Views', 'php') + ->willReturn($realFile); + + $view = new View($this->config, $this->viewsDir, $loader); + + $view->setVar('testString', 'Hello World'); + $output = $view->render($namespacedView); + + $this->assertStringContainsString('

Hello World

', $output); + } + + public function testRenderNamespacedViewWithExplicitExtension(): void + { + $namespacedView = 'Some\Library\View.html'; + + $realFile = $this->viewsDir . '/simple.php'; + + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->once()) + ->method('locateFile') + ->with($namespacedView, 'Views', 'html') + ->willReturn($realFile); + + $view = new View($this->config, $this->viewsDir, $loader); + $view->setVar('testString', 'Hello World'); + + $view->render($namespacedView); + } + + public function testOverrideWithCustomFolderChecksSubdirectory(): void + { + $this->config->appOverridesFolder = 'overrides'; + + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->once()) + ->method('locateFile') + ->with('Nested\simple.php', 'Views', 'php') + ->willReturn($this->viewsDir . '/simple.php'); + + $view = new View($this->config, $this->viewsDir, $loader); + + $view->setVar('testString', 'Fallback Content'); + + $output = $view->render('Nested\simple'); + + $this->assertStringContainsString('

Fallback Content

', $output); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 19013de5cff8..0fabe898719c 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -191,6 +191,8 @@ Libraries - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` - **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. +- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. + Commands ======== diff --git a/user_guide_src/source/outgoing/views.rst b/user_guide_src/source/outgoing/views.rst index 2b73def3e485..2c6b3cbe1dc6 100644 --- a/user_guide_src/source/outgoing/views.rst +++ b/user_guide_src/source/outgoing/views.rst @@ -104,6 +104,53 @@ example, you could load the **blog_view.php** file from **example/blog/Views** b .. literalinclude:: views/005.php +.. _views-overriding-namespaced-views: + +Overriding Namespaced Views +=========================== + +.. versionadded:: 4.7.0 + +You can override a namespaced view by creating a matching directory structure within your application's **app/Views** directory. +This allows you to customize the output of modules or packages without modifying their core source code. + +Configuration +------------- + +By default, overrides are looked for in the **app/Views/overrides** directory. You can configure this location via the ``$appOverridesFolder`` property in **app/Config/View.php**: + +.. code-block:: php + + public string $appOverridesFolder = 'overrides'; + +If you prefer to map namespaces directly to the root of **app/Views** (without a subdirectory), you can set this value to an empty string (``''``). + +Example +------- + +Assume you have a module named **Blog** with the namespace ``Example\Blog``. The original view file is located at: + +.. code-block:: text + + /modules + └── Example + └── Blog + └── Views + └── blog_view.php + +To override this view (using the default configuration), create a file at the matching path within **app/Views/overrides**: + +.. code-block:: text + + /app + └── Views + └── overrides <-- Configured $appOverridesFolder + └── Example <-- Matches the first part of namespace + └── Blog <-- Matches the second part of namespace + └── blog_view.php <-- Your custom view + +Now, when you call ``view('Example\Blog\blog_view')``, CodeIgniter will automatically load your custom view from **app/Views/overrides/Example/Blog/blog_view.php** instead of the original module view file. + .. _caching-views: Caching Views