diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5edbad1..5bc7d78 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
push:
- branches: [ main ]
+ branches: [ main, 5.x-dev ]
pull_request:
- branches: [ main ]
+ branches: [ main, 5.x-dev ]
env:
PHP_EXTENSIONS: intl, pdo_sqlite
@@ -30,5 +30,53 @@ jobs:
- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress --error-format=github
+ - name: Show PHPCS installed standards
+ run: vendor/bin/phpcs -i || true
+
+ - name: Ensure PHPCS sees Cake & Slevomat standards
+ run: |
+ vendor/bin/phpcs --config-set installed_paths \
+ vendor/cakephp/cakephp-codesniffer,vendor/slevomat/coding-standard
+ vendor/bin/phpcs -i
+
- name: Run PHPCS
- run: vendor/bin/phpcs
\ No newline at end of file
+ run: vendor/bin/phpcs
+
+ tests:
+ name: PHP ${{ matrix.php }} • PHPUnit
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - php: '8.2'
+ experimental: false
+ - php: '8.3'
+ experimental: false
+ - php: '8.4'
+ experimental: true # allow failures if ecosystem lags
+ continue-on-error: ${{ matrix.experimental }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: ${{ env.PHP_EXTENSIONS }}
+ ini-values: |
+ memory_limit=512M
+ coverage: none
+
+ - name: Validate composer.json
+ run: composer validate --strict
+
+ - name: Install dependencies (cached)
+ uses: ramsey/composer-install@v3
+ with:
+ composer-options: --no-interaction --no-progress
+
+ - name: Run PHPUnit
+ run: vendor/bin/phpunit
diff --git a/README.md b/README.md
index ad52220..70b15ec 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,8 @@ document.body.addEventListener('htmx:configRequest', (event) => {
## Rendering blocks and OOB Swap
The `setBlock()` function allows you to render a specific block while removing other blocks that might be rendered. This is particularly useful when you need to update only a portion of your view.
+Calling `setBlock(null)` clears any selection.
+
```php
$this->Htmx->setBlock('userTable');
```
@@ -130,9 +132,15 @@ The `addBlocks()` function allows you to add multiple blocks to the list of bloc
$this->Htmx->addBlocks(['userTable', 'pagination']);
$this->Htmx->addBlocks(['userTable', 'pagination'], true); // Appends the blocks to the existing array.
```
+> **Note:** `addBlocks()` appends by default. Pass `false` as the second argument to replace:
+>
+> ```php
+> $this->Htmx->addBlocks(['usersTable', 'pagination']); // append (default)
+> $this->Htmx->addBlocks(['onlyThis'], false); // replace
+> ```
### OOB Swap
-Htmx supports updating multiple targets by returning multiple partial responses with [`hx-swap-oop`](https://htmx.org/docs/#oob_swaps).
+Htmx supports updating multiple targets by returning multiple partial responses with [`hx-swap-oob`](https://htmx.org/docs/#oob_swaps).
See the example `Users index search functionality with pagination update`
Note if you are working with tables like in the example. You might need to add
```javascript
@@ -142,6 +150,17 @@ Note if you are working with tables like in the example. You might need to add
```
In your template or layout.
+
+### Clearing Blocks
+You can clear the current block selection in two equivalent ways:
+```php
+// Explicitly clear any selection
+$this->Htmx->clearBlocks();
+
+// Or, using setBlock(null)
+$this->Htmx->setBlock(null);
+```
+
## Examples
### Users index search functionality
diff --git a/composer.json b/composer.json
index cdf0301..695bd1e 100644
--- a/composer.json
+++ b/composer.json
@@ -10,7 +10,9 @@
"require-dev": {
"phpunit/phpunit": "^10.1",
"phpstan/phpstan": "^2.1",
- "cakephp/cakephp-codesniffer": "^5.2"
+ "cakephp/cakephp-codesniffer": "^5.2",
+ "slevomat/coding-standard": "^8.15",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0"
},
"autoload": {
"psr-4": {
@@ -19,8 +21,7 @@
},
"autoload-dev": {
"psr-4": {
- "CakeHtmx\\Test\\": "tests/",
- "Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
+ "CakeHtmx\\Test\\": "tests/"
}
},
"config": {
@@ -29,6 +30,7 @@
}
},
"scripts": {
+ "test": "phpunit",
"stan": "phpstan analyse",
"cs-check": "phpcs --colors -p -s",
"cs-fix": "phpcbf --colors -p -s"
diff --git a/src/Controller/Component/HtmxComponent.php b/src/Controller/Component/HtmxComponent.php
index 95019df..a586b83 100644
--- a/src/Controller/Component/HtmxComponent.php
+++ b/src/Controller/Component/HtmxComponent.php
@@ -91,31 +91,33 @@ public function beforeRender(Event $event): void
*/
public function afterRender(Event $event): void
{
- if (!empty($this->blocks)) {
- /** @var \Cake\View\View $view */
- $view = $event->getSubject();
- // empty the content and replace with the ones we want
- $view->assign('content', '');
-
- $first = true;
- foreach ($this->blocks as $block) {
- if ($view->exists($block)) {
- $fetchBlock = $view->fetch($block);
-
- if (!$first) {
- $fetchBlock = preg_replace(
- '/(<[^\/][^>]*)(>)/',
- '$1 hx-swap-oob="innerHTML"$2',
- $fetchBlock,
- 1,
- );
- }
-
- $view->append('content', $fetchBlock);
-
- if ($first) {
- $first = false;
- }
+ if (empty($this->blocks)) {
+ return;
+ }
+
+ /** @var \Cake\View\View $view */
+ $view = $event->getSubject();
+ // empty the content and replace with the ones we want
+ $view->assign('content', '');
+
+ $first = true;
+ foreach ($this->blocks as $block) {
+ if ($view->exists($block)) {
+ $fetchBlock = $view->fetch($block);
+
+ if (!$first) {
+ $fetchBlock = preg_replace(
+ '/(<[^\/][^>]*)(>)/',
+ '$1 hx-swap-oob="innerHTML"$2',
+ $fetchBlock,
+ 1,
+ );
+ }
+
+ $view->append('content', $fetchBlock);
+
+ if ($first) {
+ $first = false;
}
}
}
@@ -392,12 +394,13 @@ private function encodeTriggers(array $triggers): string
/**
* Set a specific block to render
* Removes other blocks that might be rendered
+ * Passing `null` will clear all blocks.
*
* @param string|null $block Name of the block
*/
public function setBlock(?string $block): static
{
- $this->blocks = [$block];
+ $this->blocks = !empty($block) ? [$block] : [];
return $this;
}
@@ -420,8 +423,14 @@ public function addBlock(string $block): static
* @param array $blocks List of block names to render
* @param bool $append Whether to append the blocks or replace existing ones
*/
- public function addBlocks(array $blocks, bool $append = false): static
+ public function addBlocks(array $blocks, bool $append = true): static
{
+ // Make sure no empty blocks
+ $blocks = array_values(array_filter(
+ $blocks,
+ static fn($b) => is_string($b) && $b !== '',
+ ));
+
if ($append) {
$this->blocks = array_merge($this->blocks, $blocks);
} else {
@@ -440,4 +449,16 @@ public function getBlocks(): ?array
{
return $this->blocks;
}
+
+ /**
+ * Clear all blocks so none will be rendered.
+ *
+ * @return static
+ */
+ public function clearBlocks(): static
+ {
+ $this->blocks = [];
+
+ return $this;
+ }
}
diff --git a/tests/TestCase/Controller/Component/HtmxComponentTest.php b/tests/TestCase/Controller/Component/HtmxComponentTest.php
new file mode 100644
index 0000000..ec6ac3e
--- /dev/null
+++ b/tests/TestCase/Controller/Component/HtmxComponentTest.php
@@ -0,0 +1,494 @@
+Controller = new Controller($request);
+ $this->Controller = $this->Controller->setResponse(new Response());
+
+ $registry = new ComponentRegistry($this->Controller);
+ $this->Htmx = new HtmxComponent($registry);
+ }
+
+ /**
+ * tearDown method.
+ *
+ * @return void
+ */
+ protected function tearDown(): void
+ {
+ unset($this->Htmx, $this->Controller);
+ parent::tearDown();
+ }
+
+ /**
+ * beforeRender(): on a non-htmx request, no headers are written
+ * even if triggers have been added.
+ *
+ * @return void
+ */
+ public function testBeforeRenderNonHtmxDoesNotWriteHeaders(): void
+ {
+ // Attach detector to current (non-htmx) request
+ $req = $this->Controller->getRequest();
+ $req->addDetector('htmx', function ($r) {
+ return filter_var($r->getHeaderLine('HX-Request'), FILTER_VALIDATE_BOOLEAN);
+ });
+ $this->Controller = $this->Controller->setRequest($req);
+
+ // Sanity check: not htmx
+ $this->assertFalse($this->Controller->getRequest()->is('htmx'));
+
+ // Add a trigger; since not htmx, prepare() should not run
+ $this->Htmx->addTrigger('alpha');
+
+ $this->Htmx->beforeRender(new Event('Controller.beforeRender', $this->Controller));
+ $this->assertSame('', $this->Controller->getResponse()->getHeaderLine('HX-Trigger'));
+ }
+
+ /**
+ * beforeRender(): when request is htmx, accumulated triggers are applied.
+ *
+ * @return void
+ */
+ public function testBeforeRenderAppliesTriggersWhenHtmx(): void
+ {
+ // Use stubbed request that reports is('htmx') === true
+ $this->Controller = $this->Controller->setRequest($this->makeHtmxTrueRequest());
+
+ // Add triggers (component retains them)
+ $this->Htmx->addTrigger('alpha')->addTrigger('beta');
+
+ // Run the hook
+ $this->Htmx->beforeRender(new Event('Controller.beforeRender', $this->Controller));
+
+ // Assert headers were written by prepare()
+ $this->assertSame('alpha,beta', $this->Controller->getResponse()->getHeaderLine('HX-Trigger'));
+ }
+
+ /**
+ * Test afterRender(): renders chosen blocks and adds hx-swap-oob to subsequent ones.
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::afterRender()
+ */
+ public function testAfterRenderBlocksAndOobSwap(): void
+ {
+ // Use a partial mock to force exists() true for our block names.
+ $view = $this->getMockBuilder(View::class)
+ ->setConstructorArgs([$this->Controller->getRequest()])
+ ->onlyMethods(['exists'])
+ ->getMock();
+
+ $view->method('exists')->willReturnCallback(function (string $name): bool {
+ return in_array($name, ['first', 'second'], true);
+ });
+
+ // Define two blocks
+ $view->start('first');
+ echo '
A
';
+ $view->end();
+
+ $view->start('second');
+ echo '';
+ $view->end();
+
+ $this->Htmx->addBlocks(['first', 'second']);
+
+ // Prime content then ensure it's cleared/replaced
+ $view->assign('content', 'ORIGINAL');
+
+ $this->Htmx->afterRender(new Event('View.afterRender', $view));
+ $content = $view->fetch('content');
+
+ $this->assertStringContainsString('A
', $content);
+ $this->assertStringContainsString(
+ '',
+ $content,
+ );
+ $this->assertStringNotContainsString('ORIGINAL', $content);
+ }
+
+ /**
+ * Test header getter helpers.
+ *
+ * @return void
+ */
+ public function testHeaderGetters(): void
+ {
+ $req = $this->Controller->getRequest()
+ ->withHeader('HX-Current-Url', 'https://example.test/page')
+ ->withHeader('HX-Prompt', 'yes')
+ ->withHeader('HX-Target', 'list')
+ ->withHeader('HX-Trigger-Name', 'delete')
+ ->withHeader('HX-Trigger', 'btn-42');
+ $this->Controller = $this->Controller->setRequest($req);
+
+ $registry = new ComponentRegistry($this->Controller);
+ $this->Htmx = new HtmxComponent($registry);
+
+ $this->assertSame('https://example.test/page', $this->Htmx->getCurrentUrl());
+ $this->assertSame('yes', $this->Htmx->getPromptResponse());
+ $this->assertSame('list', $this->Htmx->getTarget());
+ $this->assertSame('delete', $this->Htmx->getTriggerName());
+ $this->assertSame('btn-42', $this->Htmx->getTriggerId());
+ }
+
+ /**
+ * Test simple response header mutators.
+ *
+ * @return void
+ */
+ public function testSimpleHeaderMutators(): void
+ {
+ $this->Htmx->location('/go');
+ $this->assertSame('/go', $this->Controller->getResponse()->getHeaderLine('HX-Location'));
+
+ $this->Htmx->pushUrl('/new');
+ $this->assertSame('/new', $this->Controller->getResponse()->getHeaderLine('HX-Push-Url'));
+
+ $this->Htmx->replaceUrl('/replace');
+ $this->assertSame('/replace', $this->Controller->getResponse()->getHeaderLine('HX-Replace-Url'));
+
+ $this->Htmx->reswap('none');
+ $this->assertSame('none', $this->Controller->getResponse()->getHeaderLine('HX-Reswap'));
+
+ $this->Htmx->retarget('#modal');
+ $this->assertSame('#modal', $this->Controller->getResponse()->getHeaderLine('HX-Retarget'));
+ }
+
+ /**
+ * prepare(): no headers when no triggers exist.
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::prepare()
+ */
+ public function testPrepareNoTriggersNoHeaders(): void
+ {
+ $this->Htmx->prepare();
+ $res = $this->Controller->getResponse();
+
+ $this->assertSame('', $res->getHeaderLine('HX-Trigger'));
+ $this->assertSame('', $res->getHeaderLine('HX-Trigger-After-Settle'));
+ $this->assertSame('', $res->getHeaderLine('HX-Trigger-After-Swap'));
+ }
+
+ /**
+ * prepare(): CSV encoding when all bodies are null.
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::prepare()
+ */
+ public function testPrepareWithCsvEncodedTriggers(): void
+ {
+ $this->Htmx
+ ->addTrigger('alpha')
+ ->addTrigger('beta')
+ ->addTriggerAfterSettle('gamma')
+ ->addTriggerAfterSwap('delta');
+
+ $this->Htmx->prepare();
+ $res = $this->Controller->getResponse();
+
+ $this->assertSame('alpha,beta', $res->getHeaderLine('HX-Trigger'));
+ $this->assertSame('gamma', $res->getHeaderLine('HX-Trigger-After-Settle'));
+ $this->assertSame('delta', $res->getHeaderLine('HX-Trigger-After-Swap'));
+ }
+
+ /**
+ * prepare(): JSON encoding when any body is non-null.
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::prepare()
+ */
+ public function testPrepareWithJsonEncodedTriggers(): void
+ {
+ // Reset response to avoid header accumulation
+ $this->Controller = $this->Controller->setResponse(new Response());
+ $registry = new ComponentRegistry($this->Controller);
+ $this->Htmx = new HtmxComponent($registry);
+
+ $this->Htmx
+ ->addTrigger('saved', ['id' => 7])
+ ->addTrigger('notify', 'ok') // multiple keys in JSON
+ ->addTriggerAfterSettle('notice', 'ok')
+ ->addTriggerAfterSwap('ping', ['a' => 1, 'b' => 2]);
+
+ $this->Htmx->prepare();
+ $res = $this->Controller->getResponse();
+
+ $this->assertSame('{"saved":{"id":7},"notify":"ok"}', $res->getHeaderLine('HX-Trigger'));
+ $this->assertSame('{"notice":"ok"}', $res->getHeaderLine('HX-Trigger-After-Settle'));
+ $this->assertSame('{"ping":{"a":1,"b":2}}', $res->getHeaderLine('HX-Trigger-After-Swap'));
+ }
+
+ /**
+ * Test redirect() sets HX-Redirect and 200 status.
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::redirect()
+ */
+ public function testRedirect(): void
+ {
+ $this->Htmx->redirect('/somewhere');
+ $resp = $this->Controller->getResponse();
+
+ $this->assertSame('/somewhere', $resp->getHeaderLine('HX-Redirect'));
+ $this->assertSame(200, $resp->getStatusCode());
+ }
+
+ /**
+ * Test clientRefresh() sets HX-Refresh and 200 status.
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::clientRefresh()
+ */
+ public function testClientRefresh(): void
+ {
+ $this->Htmx->clientRefresh();
+ $resp = $this->Controller->getResponse();
+
+ $this->assertSame('true', $resp->getHeaderLine('HX-Refresh'));
+ $this->assertSame(200, $resp->getStatusCode());
+ }
+
+ /**
+ * Test stopPolling() sets 286 status, optional headers, and body.
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::stopPolling()
+ */
+ public function testStopPolling(): void
+ {
+ $resp = $this->Htmx->stopPolling('done', ['X-Test' => '1']);
+
+ $this->assertInstanceOf(Response::class, $resp);
+ /** @var \Cake\Http\Response $resp */
+ $this->assertSame(286, $resp->getStatusCode());
+ $this->assertSame('1', $resp->getHeaderLine('X-Test'));
+ $this->assertSame('done', (string)$resp->getBody());
+ }
+
+ /**
+ * Test block list helpers: setBlock(), addBlock(), addBlocks(), getBlocks().
+ *
+ * @return void
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::setBlock()
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::addBlock()
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::addBlocks()
+ * @link \CakeHtmx\Controller\Component\HtmxComponent::getBlocks()
+ */
+ public function testBlockHelpers(): void
+ {
+ $this->Htmx->setBlock('usersTable');
+ $this->assertSame(['usersTable'], $this->Htmx->getBlocks());
+
+ $this->Htmx->addBlock('pagination');
+ $this->assertSame(['usersTable', 'pagination'], $this->Htmx->getBlocks());
+
+ // Replace with new list
+ $this->Htmx->addBlocks(['a', 'b'], false);
+ $this->assertSame(['a', 'b'], $this->Htmx->getBlocks());
+
+ // Append to existing
+ $this->Htmx->addBlocks(['c'], true);
+ $this->assertSame(['a', 'b', 'c'], $this->Htmx->getBlocks());
+ }
+
+ /**
+ * addBlocks(): default behavior appends to existing blocks (append = true).
+ *
+ * @return void
+ */
+ public function testAddBlocksDefaultAppends(): void
+ {
+ // Seed with one block
+ $this->Htmx->setBlock('initial');
+ $this->assertSame(['initial'], $this->Htmx->getBlocks());
+
+ // Call addBlocks() WITHOUT the $append argument -> should append
+ $this->Htmx->addBlocks(['a', 'b']); // default append=true
+ $this->assertSame(['initial', 'a', 'b'], $this->Htmx->getBlocks());
+
+ // Another default call should keep appending
+ $this->Htmx->addBlocks(['c']);
+ $this->assertSame(['initial', 'a', 'b', 'c'], $this->Htmx->getBlocks());
+ }
+
+ /**
+ * afterRender(): skips non-existent blocks and still clears original content.
+ *
+ * @return void
+ */
+ public function testAfterRenderSkipsMissingBlocksAndClearsContent(): void
+ {
+ // Mock a view where only 'exists' returns false for all names
+ $view = $this->getMockBuilder(View::class)
+ ->setConstructorArgs([$this->Controller->getRequest()])
+ ->onlyMethods(['exists'])
+ ->getMock();
+
+ $view->method('exists')->willReturn(false);
+
+ // Seed content and request non-existent blocks
+ $view->assign('content', 'ORIGINAL');
+ $this->Htmx->addBlocks(['nope', 'missing', 'ghost']);
+
+ $this->Htmx->afterRender(new Event('View.afterRender', $view));
+
+ // Content should be cleared, and no appended markup
+ $this->assertSame('', $view->fetch('content'));
+ }
+
+ /**
+ * addTrigger(): later calls with the same key overwrite; mixed null/non-null bodies
+ * force JSON encoding in prepare().
+ *
+ * @return void
+ */
+ public function testTriggersOverwriteAndMixedBodiesYieldJson(): void
+ {
+ // Overwrite same key
+ $this->Htmx->addTrigger('event', null);
+ $this->Htmx->addTrigger('event', ['id' => 123]); // last write wins
+
+ // Mix of null and non-null across families => JSON everywhere
+ $this->Htmx->addTrigger('another', null);
+ $this->Htmx->addTriggerAfterSettle('settleOne', null);
+ $this->Htmx->addTriggerAfterSettle('settleTwo', 'ok');
+ $this->Htmx->addTriggerAfterSwap('swapOne', null);
+ $this->Htmx->addTriggerAfterSwap('swapTwo', ['a' => 1]);
+
+ $this->Htmx->prepare();
+ $res = $this->Controller->getResponse();
+
+ $this->assertSame('{"event":{"id":123},"another":null}', $res->getHeaderLine('HX-Trigger'));
+ $this->assertSame('{"settleOne":null,"settleTwo":"ok"}', $res->getHeaderLine('HX-Trigger-After-Settle'));
+ $this->assertSame('{"swapOne":null,"swapTwo":{"a":1}}', $res->getHeaderLine('HX-Trigger-After-Swap'));
+ }
+
+ /**
+ * setBlock(null) should be safe and not change content or explode.
+ *
+ * @return void
+ */
+ public function testSetBlockNullIsSafe(): void
+ {
+ $view = $this->getMockBuilder(View::class)
+ ->setConstructorArgs([$this->Controller->getRequest()])
+ ->onlyMethods(['exists'])
+ ->getMock();
+
+ $view->method('exists')->willReturn(false);
+
+ $view->assign('content', 'ORIGINAL');
+
+ $this->Htmx->setBlock(null);
+ $this->Htmx->afterRender(new Event('View.afterRender', $view));
+
+ // Nothing was selected -> content stays as-is
+ $this->assertSame('ORIGINAL', $view->fetch('content'));
+ }
+
+ /**
+ * prepare(): must not drop existing response headers that the app has set.
+ *
+ * @return void
+ */
+ public function testPreparePreservesExistingHeaders(): void
+ {
+ // Seed an application header
+ $this->Controller = $this->Controller->setResponse(
+ $this->Controller->getResponse()->withHeader('X-App', 'keep'),
+ );
+
+ // Add a trigger so prepare() will write HX-Trigger
+ $this->Htmx->addTrigger('alpha');
+ $this->Htmx->prepare();
+
+ $res = $this->Controller->getResponse();
+ $this->assertSame('keep', $res->getHeaderLine('X-App'));
+ $this->assertSame('alpha', $res->getHeaderLine('HX-Trigger'));
+ }
+
+ /**
+ * Create a stubbed request where is('htmx') always returns true.
+ *
+ * Useful for simulating htmx requests in tests without adding
+ * extra files or named classes.
+ *
+ * @return \Cake\Http\ServerRequest
+ */
+ private function makeHtmxTrueRequest(): ServerRequest
+ {
+ return new class extends ServerRequest {
+ public function is(array|string $type, mixed ...$args): bool
+ {
+ if ($type === 'htmx' || (is_array($type) && in_array('htmx', $type, true))) {
+ return true;
+ }
+
+ return parent::is($type, ...$args);
+ }
+ };
+ }
+
+ /**
+ * clearBlocks(): removes all blocks; afterRender() should not change content.
+ *
+ * @return void
+ */
+ public function testClearBlocks(): void
+ {
+ $view = new View($this->Controller->getRequest());
+ $view->assign('content', 'ORIGINAL');
+
+ $this->Htmx->setBlock('usersTable');
+ $this->Htmx->clearBlocks();
+
+ $this->assertSame([], $this->Htmx->getBlocks());
+
+ $this->Htmx->afterRender(new Event('View.afterRender', $view));
+ $this->assertSame('ORIGINAL', $view->fetch('content'));
+ }
+}
diff --git a/tests/TestCase/Middleware/HtmxRequestMiddlewareTest.php b/tests/TestCase/Middleware/HtmxRequestMiddlewareTest.php
new file mode 100644
index 0000000..7f91cd4
--- /dev/null
+++ b/tests/TestCase/Middleware/HtmxRequestMiddlewareTest.php
@@ -0,0 +1,163 @@
+middleware = new HtmxRequestMiddleware();
+ }
+
+ /**
+ * Test that HX-Request header with "true" sets the `htmx` detector.
+ *
+ * @return void
+ */
+ public function testProcessSetsHtmxTrueWhenHeaderTrue(): void
+ {
+ $request = (new ServerRequest())->withHeader('HX-Request', 'true');
+
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->willReturnCallback(function ($req) {
+ $this->assertTrue($req->is('htmx'));
+ $this->assertFalse($req->is('boosted'));
+ $this->assertFalse($req->is('historyRestoreRequest'));
+ $this->assertTrue($req->is('htmx-noboost'));
+
+ return new Response();
+ });
+
+ $res = $this->middleware->process($request, $handler);
+ $this->assertInstanceOf(Response::class, $res);
+ }
+
+ /**
+ * Test that HX-Boosted header with "true" sets the `boosted` detector,
+ * and disables `htmx-noboost`.
+ *
+ * @return void
+ */
+ public function testProcessSetsBoostedTrueWhenHeaderTrue(): void
+ {
+ $request = (new ServerRequest())
+ ->withHeader('HX-Request', 'true')
+ ->withHeader('HX-Boosted', 'true');
+
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->willReturnCallback(function ($req) {
+ $this->assertTrue($req->is('htmx'));
+ $this->assertTrue($req->is('boosted'));
+ $this->assertFalse($req->is('htmx-noboost'));
+
+ return new Response();
+ });
+
+ $this->middleware->process($request, $handler);
+ }
+
+ /**
+ * Test that HX-History-Restore-Request header with "true" sets
+ * the `historyRestoreRequest` detector.
+ *
+ * @return void
+ */
+ public function testProcessSetsHistoryRestoreRequestTrueWhenHeaderTrue(): void
+ {
+ $request = (new ServerRequest())
+ ->withHeader('HX-Request', 'true')
+ ->withHeader('HX-History-Restore-Request', 'true');
+
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->willReturnCallback(function ($req) {
+ $this->assertTrue($req->is('htmx'));
+ $this->assertTrue($req->is('historyRestoreRequest'));
+ $this->assertFalse($req->is('boosted'));
+ $this->assertTrue($req->is('htmx-noboost'));
+
+ return new Response();
+ });
+
+ $this->middleware->process($request, $handler);
+ }
+
+ /**
+ * Test that "false" values in HX headers evaluate correctly as false.
+ *
+ * @return void
+ */
+ public function testProcessFalseValuesAreHandled(): void
+ {
+ $request = (new ServerRequest())
+ ->withHeader('HX-Request', 'false')
+ ->withHeader('HX-Boosted', 'false')
+ ->withHeader('HX-History-Restore-Request', 'false');
+
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->willReturnCallback(function ($req) {
+ $this->assertFalse($req->is('htmx'));
+ $this->assertFalse($req->is('boosted'));
+ $this->assertFalse($req->is('historyRestoreRequest'));
+ $this->assertFalse($req->is('htmx-noboost'));
+
+ return new Response();
+ });
+
+ $this->middleware->process($request, $handler);
+ }
+
+ /**
+ * Test that when no HX headers are present, all detectors evaluate to false.
+ *
+ * @return void
+ */
+ public function testProcessMissingHeadersDefaultToFalse(): void
+ {
+ $request = new ServerRequest();
+
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->willReturnCallback(function ($req) {
+ $this->assertFalse($req->is('htmx'));
+ $this->assertFalse($req->is('boosted'));
+ $this->assertFalse($req->is('historyRestoreRequest'));
+ $this->assertFalse($req->is('htmx-noboost'));
+
+ return new Response();
+ });
+
+ $this->middleware->process($request, $handler);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index ddf23a7..49c1a56 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -38,18 +38,3 @@
return;
}
-
-/**
- * Load schema from a SQL dump file.
- *
- * If your plugin does not use database fixtures you can
- * safely delete this.
- *
- * If you want to support multiple databases, consider
- * using migrations to provide schema for your plugin,
- * and using \Migrations\TestSuite\Migrator to load schema.
- */
-use Cake\TestSuite\Fixture\SchemaLoader;
-
-// Load a schema dump file.
-(new SchemaLoader())->loadSqlFiles('tests/schema.sql', 'test');
diff --git a/tests/schema.sql b/tests/schema.sql
deleted file mode 100644
index cb878dd..0000000
--- a/tests/schema.sql
+++ /dev/null
@@ -1 +0,0 @@
--- Test database schema for CakeHtmx