diff --git a/.github/workflows/phpcsfixer.yml b/.github/workflows/phpcsfixer.yml index e21e695..a81b095 100644 --- a/.github/workflows/phpcsfixer.yml +++ b/.github/workflows/phpcsfixer.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.1', '8.2', '8.4'] + php-versions: ['8.2', '8.5'] steps: - name: Checkout diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 79b546b..15ecd82 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.1', '8.4'] + php-versions: ['8.2', '8.4'] steps: - name: Checkout diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 559b01c..5c26f98 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -25,7 +25,7 @@ jobs: if: "!contains(github.event.head_commit.message, '[ci skip]')" strategy: matrix: - php-versions: ['8.1', '8.2', '8.3', '8.4', '8.5'] + php-versions: ['8.2', '8.3', '8.4', '8.5'] steps: - name: Free Disk Space (Ubuntu) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55975e1..d951f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,39 @@ # Changelog All notable changes to this project will be documented in this file. -## [Unreleased] +## [4.0.0](https://github.com/michalsn/codeigniter-htmx/compare/v2.3.0...v4.0.0) - 2026-09-02 + +### Breaking changes + +- Requires PHP 8.2 or later. +- Requires CodeIgniter 4.7 or later. +- Drops htmx 2 support and targets the final htmx 4 request/response model and event lifecycle. +- Replaces `IncomingRequest::getTrigger()` with `getSource()` and removes `getTriggerName()` and `getTriggeringEvent()`. The `getSource()` and `getTarget()` methods now return htmx 4 element identifiers in `tag#id` format. +- Removes the third `$after` argument from `Response::triggerClientEvent()` because htmx 4 removed the `HX-Trigger-After-Swap` and `HX-Trigger-After-Settle` response headers. +- Keeps the `RedirectResponse::hxLocation()` argument order introduced in version 2.3. Its legacy `handler` position remains reserved, but passing a value now throws because final htmx 4 no longer supports that callback option. + +### Enhancements + +- Added `IncomingRequest::getRequestType()`, `isPartial()`, and `isFull()` for `HX-Request-Type`. +- Added the htmx 4 swap styles, including `outerSync`, morph swaps, `textContent`, and shorthand insertion styles. +- Extended `RedirectResponse::hxLocation()` with `selectOOB`, `transition`, array event data, `false` values for `push` and `replace`, and URL normalization for history options. + +### Changed + +- Updated the Debug Toolbar integration for the fetch-based htmx 4 request lifecycle. +- Reworked the error modal for htmx 4 error responses, including a sandboxed HTML preview and raw response source. + +## [2.3.0](https://github.com/michalsn/codeigniter-htmx/compare/v2.2.0...v2.3.0) - 2026-07-24 + +### Fixes + +- Added the missing `push`, `replace`, `select`, and `handler` parameters to `RedirectResponse::hxLocation()`. + +## [2.2.0](https://github.com/michalsn/codeigniter-htmx/compare/v2.1.0...v2.2.0) - 2026-02-04 + +### Fixes + +- Updated the Debug Toolbar integration for newer CodeIgniter releases, including the method signature required by CodeIgniter 4.7 and support for required after filters. ## [2.1.0](https://github.com/michalsn/codeigniter-htmx/compare/v2.0.0...v2.1.0) - 2024-11-06 diff --git a/README.md b/README.md index 501193e..c4951fe 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,25 @@ # CodeIgniter HTMX -A set of methods for `IncomingRequest`, `Response` and `RedirectResponse` classes to help you work with [htmx](https://htmx.org) fluently in CodeIgniter 4 framework. +A set of methods for `IncomingRequest`, `Response` and `RedirectResponse` classes to help you work with [htmx](https://four.htmx.org/) fluently in CodeIgniter 4 framework. It also provides some additional help with **handling errors** and **Debug Toolbar** in development mode as well as support for **view fragments**. +This version targets the htmx 4 request/response model and event lifecycle. Applications that still use htmx 2 should use the `v2` branch. + [![PHPUnit](https://github.com/michalsn/codeigniter-htmx/actions/workflows/phpunit.yml/badge.svg)](https://github.com/michalsn/codeigniter-htmx/actions/workflows/phpunit.yml) [![PHPStan](https://github.com/michalsn/codeigniter-htmx/actions/workflows/phpstan.yml/badge.svg)](https://github.com/michalsn/codeigniter-htmx/actions/workflows/phpstan.yml) [![Deptrac](https://github.com/michalsn/codeigniter-htmx/actions/workflows/deptrac.yml/badge.svg)](https://github.com/michalsn/codeigniter-htmx/actions/workflows/deptrac.yml) [![Coverage Status](https://coveralls.io/repos/github/michalsn/codeigniter-htmx/badge.svg?branch=develop)](https://coveralls.io/github/michalsn/codeigniter-htmx?branch=develop) -![PHP](https://img.shields.io/badge/PHP-%5E8.0-blue) -![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-blue) +![PHP](https://img.shields.io/badge/PHP-%5E8.2-blue) +![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.7-blue) ## Installation composer require michalsn/codeigniter-htmx > [!NOTE] -> Remember - you still need to include the `htmx` javascript library inside the `head` tag. +> This package does not install the browser-side htmx library. Install htmx 4 explicitly - for example, `npm install htmx.org@4` - and include it in your page. An unversioned npm install may still resolve to htmx 2 during the htmx 4 release transition. ## Docs diff --git a/composer.json b/composer.json index eec9c34..6e46469 100644 --- a/composer.json +++ b/composer.json @@ -13,11 +13,11 @@ ], "homepage": "https://github.com/michalsn/codeigniter-htmx", "require": { - "php": "^8.0" + "php": "^8.2", + "codeigniter4/framework": "^4.7" }, "require-dev": { - "codeigniter4/devkit": "^1.0", - "codeigniter4/framework": "^4.3" + "codeigniter4/devkit": "^1.3" }, "minimum-stability": "dev", "prefer-stable": true, @@ -48,16 +48,12 @@ "ci": [ "Composer\\Config::disableProcessTimeout", "@cs", - "@deduplicate", - "@inspect", "@analyze", "@test" ], "cs": "php-cs-fixer fix --ansi --verbose --dry-run --diff", "cs-fix": "php-cs-fixer fix --ansi --verbose --diff", "style": "@cs-fix", - "deduplicate": "phpcpd app/ src/", - "inspect": "deptrac analyze --cache-file=build/deptrac.cache", "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", "test": "phpunit" } diff --git a/docs/configuration.md b/docs/configuration.md index 7327837..d80039a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,9 +12,10 @@ We will get our copy ready for modifications. Available options: -- [$toolbarDecorator](#toolbarDecorator) -- [$errorModalDecorator](#errorModalDecorator) -- [$skipViewDecoratorsString](#skipViewDecoratorsString) +- [$toolbarDecorator](#toolbardecorator) +- [$errorModalDecorator](#errormodaldecorator) +- [$skipViewDecoratorsString](#skipviewdecoratorsstring) +- [$storePreviousURL](#storepreviousurl) ### $toolbarDecorator @@ -42,7 +43,6 @@ In the `production` environment these decorators are ignored by design. So this Specifies whether the HTMX request URL should be stored in the session, for use with the `previous_url()` helper function. For more information, see the [user guide](https://codeigniter.com/user_guide/helpers/url_helper.html#previous_url). -Basically, if you use HTMX extensively, including for navigating your site, you will probably want to leave it as `true`, -and in cases where storing the request is not desirable, even if it uses HTMX, you can use custom header, to indicate the -AJAX call or [ajax-header](https://github.com/bigskysoftware/htmx-extensions/blob/main/src/ajax-header/README.md) extension, -which will add the necessary headers automatically. URLs from AJAX requests are always excluded from session storage. +Basically, if you use HTMX extensively, including for navigating your site, you will probably want to leave it as `true`. +If storing the request is not desirable, mark it as a traditional AJAX request or use a custom header in your application flow. +URLs from AJAX requests are always excluded from session storage. diff --git a/docs/debug_toolbar.md b/docs/debug_toolbar.md index 3574c46..e20c03a 100644 --- a/docs/debug_toolbar.md +++ b/docs/debug_toolbar.md @@ -1,11 +1,11 @@ # Debug Toolbar -As long as you **don't use** the [head-support](https://htmx.org/extensions/head-support/) extension, +As long as you **don't use** the [hx-head](https://four.htmx.org/extensions/hx-head) extension, the Debug Toolbar should work out of the box. It will be updated after every request, so please remember it will only display the latest information. If you want to see what happened in earlier request, use the `History` tab in the Toolbar. -If you're using the `head-support` extension then the Debug Toolbar rendering will not work for `htmx` requests. +If you're using the `hx-head` extension then the Debug Toolbar rendering will not work for `htmx` requests. You can still access the toolbar for a given request by checking the URL in the `debugbar-link` response header. This feature can be disabled in the [Config](configuration.md) file. diff --git a/docs/error_handling.md b/docs/error_handling.md index 8e36a0b..4226f19 100644 --- a/docs/error_handling.md +++ b/docs/error_handling.md @@ -1,5 +1,25 @@ # Error handling -By default, when an HTTP error response occurs, htmx is not displaying the error. This library changes it so that in the development mode, errors are displayed in a modal window. +HTMX 4 handles error responses through its response handling rules, using `hx-status:*` attributes and `htmx.config.noSwap`. + +In development mode, when `errorModalDecorator` is enabled, this library overrides that default browser-side behavior for failed HTMX requests. + +Instead of allowing HTMX to continue with its normal response handling, the raw response is displayed in a modal window and the normal HTMX swap is skipped. + +This makes it easier to inspect exception pages, validation output, and malformed HTML returned during development, without changing the actual HTTP status code. + +HTML error pages are shown in a sandboxed preview iframe, with a source view available for inspecting the raw response. JSON and plain-text responses are shown directly as source. + +If you want to use HTMX's native error handling rules in development instead, disable `errorModalDecorator` in the config. + +When the decorator is disabled, you can configure HTMX directly, for example: + +```html + +``` + +You can also use `hx-status:*` attributes to define per-status error targets in your application. This feature can be disabled in the [Config](configuration.md) file. diff --git a/docs/html_formatter.md b/docs/html_formatter.md index a1dbf12..1a5b07b 100644 --- a/docs/html_formatter.md +++ b/docs/html_formatter.md @@ -10,9 +10,11 @@ We should edit the `app/Config/Format.php` file to include the necessary changes php spark htmx:publish -Since content negotiation will be triggered for any format other than `json` or `xml`, we have two options: +With HTMX 4, requests already send `Accept: text/html`, so in the most common HTMX case no extra client-side configuration is needed. -1. Set the custom headers for every request via HTML tag +If you want to use the formatter outside HTMX requests, you still have two options: + +1. Set the custom headers for a request explicitly ```html hx-headers='{"Accept":"text/html"}' ``` @@ -28,7 +30,7 @@ Since content negotiation will be triggered for any format other than `json` or ### Example -This is an sample of using HTML formatter: +This is a sample of using HTML formatter: ```php response->triggerClientEvent('showMessage', [ + 'level' => 'info', + 'message' => 'Saved', +]); +``` + +Events are now sent through `HX-Trigger`. Listen at the appropriate point in the htmx 4 event lifecycle when later client-side handling is required. + +`setReswap()` accepts the final htmx 4 swap styles, including `innerMorph`, `outerMorph`, `outerSync`, `textContent`, `before`, `after`, `prepend`, and `append`, in addition to the existing styles. + +`RedirectResponse::hxLocation()` supports the serializable htmx 4 request-context options exposed by this package: `source`, `event`, `target`, `swap`, `values`, `headers`, `select`, `selectOOB`, `push`, `replace`, and `transition`. + +The `push`, `replace`, and `select` parameter positions introduced in package version 2.3 are retained for positional calls. The legacy `handler` position is reserved as a migration guard, but passing a value throws an `InvalidArgumentException`: final htmx 4 no longer supports a response callback in `htmx.ajax()` options. + +## Server behavior to review + +htmx 4 swaps error responses by default; only `204` and `304` are excluded. If your application expects the htmx 2 behavior, either configure `htmx.config.noSwap` or use `hx-status:*` attributes. The package's development error modal continues to show error responses without changing their HTTP status. + +If a cache can serve both full and partial responses for the same URL, make the variants explicit. A typical starting point is: + +```http +Vary: HX-Request-Type +``` + +Extend `Vary` when the representation also depends on another request header. + +## Client-side checklist + +The following application-level changes are intentionally not duplicated here; review them in the official migration guide: + +- explicit attribute inheritance and the temporary htmx 2 compatibility extension; +- renamed or removed attributes and configuration options; +- the `fetch()`-based request and event lifecycle; +- history restoration, timeouts, and out-of-band swap ordering; +- the htmx 4 extension registration model. + +Use the official upgrade checker as a first pass, then test behavior that depends on inheritance, error responses, history, and custom events. + +## References + +- [Migrating from htmx 2.x to 4.x](https://four.htmx.org/docs#migrating-from-htmx-2x-to-4x) +- [What's new in htmx 4](https://four.htmx.org/docs/whats-new-in-htmx-4) +- [htmx 4.0.0 release notes](https://four.htmx.org/announcements/2026-08-28-htmx-4.0.0-is-released) +- [htmx 4 reference](https://four.htmx.org/reference/) diff --git a/docs/incoming_request.md b/docs/incoming_request.md index 85a8ae6..fc4fc9c 100644 --- a/docs/incoming_request.md +++ b/docs/incoming_request.md @@ -5,18 +5,18 @@ Available methods: - [isHtmx()](#ishtmx) - [isBoosted()](#isboosted) - [isHistoryRestoreRequest()](#ishistoryrestorerequest) +- [getRequestType()](#getrequesttype) +- [isPartial()](#ispartial) +- [isFull()](#isfull) - [getCurrentUrl()](#getcurrenturl) - [getPrompt()](#getprompt) +- [getSource()](#getsource) - [getTarget()](#gettarget) -- [getTrigger()](#gettrigger) -- [getTriggerName()](#gettriggername) -- [getTriggeringEvent()](#gettriggeringevent) - [is()](#is) ### isHtmx() -Checks if there is a `HX-Request` header in place. -Indicates that the request was fired with htmx. +Checks if the request carries the `HX-Request` header with the value `true`. ```php $this->request->isHtmx(); @@ -25,7 +25,7 @@ $this->request->isHtmx(); ### isBoosted() Checks if there is a `HX-Boosted` header in place. -Indicates that the request is via an element using [hx-boost](https://htmx.org/attributes/hx-boost) +Indicates that the request is via an element using [hx-boost](https://four.htmx.org/reference/attributes/hx-boost) ```php $this->request->isBoosted(); @@ -34,65 +34,83 @@ $this->request->isBoosted(); ### isHistoryRestoreRequest() Checks if there is a `HX-History-Restore-Request` header in place. -True if the request is for history restoration after a miss in the local history cache. +True if the request is for history restoration. ```php $this->request->isHistoryRestoreRequest(); ``` -### getCurrentUrl() +### getRequestType() -Checks the `HX-Current-URL` header and return current URL of the browser. +Checks the `HX-Request-Type` header. +Returns `partial` for targeted swaps and `full` for body-level requests, including `hx-boost` requests, or requests using `hx-select`. ```php -$this->request->getCurrentUrl(); +$this->request->getRequestType(); ``` -### getPrompt() +### isPartial() -Checks the `HX-Prompt` header - the user response to an [hx-prompt](https://htmx.org/attributes/hx-prompt/). +Convenience method for checking if `HX-Request-Type` equals `partial`. ```php -$this->request->getPrompt(); +$this->request->isPartial(); ``` -### getTarget() +### isFull() -Checks the `HX-Target` header. Returns the `id` of the target element if it exists. +Convenience method for checking if `HX-Request-Type` equals `full`. ```php -$this->request->getTarget(); +$this->request->isFull(); +``` + +### getCurrentUrl() + +Checks the `HX-Current-URL` header and returns the current URL of the browser. + +```php +$this->request->getCurrentUrl(); ``` -### getTrigger() +### getPrompt() -Checks the `HX-Trigger` header. Returns the `id` of the triggered element if it exists. +Checks the `HX-Prompt` header sent by the optional htmx 4 [hx-prompt extension](https://four.htmx.org/extensions/hx-prompt). ```php -$this->request->getTrigger(); +$this->request->getPrompt(); ``` -### getTriggerName() +### getSource() -Checks the `HX-Trigger-Name` header. Returns the `name` of the triggered element if it exists. +Checks the `HX-Source` header. +In htmx 4 this identifies the triggering element using the element identifier format, for example `button#save`. ```php -$this->request->getTriggerName(); +$this->request->getSource(); ``` -### getTriggeringEvent() +### getTarget() -Checks the `Triggering-Event` header. The value of the header is a JSON serialized version of the event that triggered the request. -Check the [event-header](https://htmx.org/extensions/event-header/) plugin for more information. +Checks the `HX-Target` header. +In htmx 4 it identifies the target element using the element identifier format, for example `div#results`. + +```php +$this->request->getTarget(); +``` ### is() -This new method is available in CodeIgniter since v4.3. It's a handful shortcut and alternative to another CodeIgniter method: `getMethod()`. But it also provides different types of checks - you can read more about it in the [user guide](https://codeigniter.com/user_guide/incoming/incomingrequest.html#is). +This method is available in every CodeIgniter version supported by this package. It is a convenient alternative to `getMethod()` and also supports other request-type checks. See the [CodeIgniter user guide](https://codeigniter.com/user_guide/incoming/incomingrequest.html#is) for details. -Along with this library, we added two new parameters that can be used: `htmx` and `boosted` which are equivalent of using `isHtmx()` and `isBoosted()` methods. +Along with this library, we added extra parameters that can be used: `htmx`, `boosted`, `partial`, and `full`. ```php $this->request->is('htmx'); // or $this->request->is('boosted'); +// or +$this->request->is('partial'); +// or +$this->request->is('full'); ``` diff --git a/docs/index.md b/docs/index.md index 1c348e2..9fc0831 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,15 @@ # CodeIgniter HTMX Documentation -This library is set of methods for `IncomingRequest`, `Response` and `RedirectResponse` classes to help you work with htmx fluently in CodeIgniter 4 framework. +This library provides methods for the `IncomingRequest`, `Response`, and `RedirectResponse` classes to help you work fluently with htmx in CodeIgniter 4. It also provides some additional help with **handling errors** and **Debug Toolbar** in development mode as well as support for **view fragments**. +This version targets the htmx 4 request/response model and event lifecycle. For htmx 2 projects, use the `v2` branch. + ### Requirements -![PHP](https://img.shields.io/badge/PHP-%5E8.0-blue) -![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-blue) +![PHP](https://img.shields.io/badge/PHP-%5E8.2-blue) +![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.7-blue) ### Table of Contents @@ -15,12 +17,13 @@ It also provides some additional help with **handling errors** and **Debug Toolb * [Configuration](configuration.md) * [Error handling](error_handling.md) * [View fragments](view_fragments.md) -* [IncomingReqeuest](incoming_request.md) +* [IncomingRequest](incoming_request.md) * [Response](response.md) -* [RediretResponse](redirect_response.md) +* [RedirectResponse](redirect_response.md) * [HTML Formatter](html_formatter.md) * [Debug Toolbar](debug_toolbar.md) * [Troubleshooting](troubleshooting.md) +* [HTMX 4 migration](htmx_4_migration.md) ### Demos diff --git a/docs/installation.md b/docs/installation.md index b3b8a2e..3929132 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,11 +3,19 @@ - [Composer Installation](#composer-installation) - [Manual Installation](#manual-installation) -Remember - you still need to include the `htmx` javascript library inside the `head` tag. +This version requires PHP 8.2 or later and CodeIgniter 4.7 or later. + +This package does not install the browser-side htmx library. Install the htmx 4 major version explicitly: + +```console +npm install htmx.org@4 +``` + +An unversioned `npm install htmx.org` may still install htmx 2 during the htmx 4 release transition. See the [htmx 4.0.0 release notes](https://four.htmx.org/announcements/2026-08-28-htmx-4.0.0-is-released) for details. Include the resulting JavaScript bundle in your page's `head` element. ## Composer Installation -The only thing you have to do is to run this command, and you're ready to go. +Install the PHP integration with Composer: ```console composer require michalsn/codeigniter-htmx @@ -36,5 +44,3 @@ public $files = [ APPPATH . 'ThirdParty/htmx/src/Common.php', ]; ``` - - diff --git a/docs/redirect_response.md b/docs/redirect_response.md index 9557fb1..bfe083f 100644 --- a/docs/redirect_response.md +++ b/docs/redirect_response.md @@ -13,9 +13,43 @@ Sets the `HX-Location` header to redirect without reloading the whole page. ```php return redirect()->hxLocation('/path'); ``` -For convenience, the set path with `http(s)://` will be converted to relative. Like this: `http://example.com/articles/` it will become `/articles/`. -For more information, please see [hx-location](https://htmx.org/headers/hx-location/). +For convenience, absolute `http(s)://` paths are converted to relative paths. For example, `http://example.com/articles/` becomes `/articles/`. + +Supported fields mirror the serializable htmx 4 request-context options accepted by `HX-Location`: + +- `path` - required +- `source` +- `event` +- `target` +- `swap` +- `values` +- `headers` +- `select` +- `selectOOB` +- `push` +- `replace` +- `transition` + +`push` and `replace` accept a path, the strings `true` or `false`, or the boolean `false`. Absolute URLs used for `path`, `push`, or `replace` are normalized to same-origin paths. + +For positional calls, `push`, `replace`, and `select` retain their positions from version 2.3. The legacy `handler` position is also reserved to prevent subsequent arguments from being misinterpreted. Because final htmx 4 no longer supports this callback option, passing a non-null `handler` throws an `InvalidArgumentException`. Use client-side htmx lifecycle events instead. + +Example: + +```php +return redirect()->hxLocation( + path: '/photos', + target: '#content', + swap: 'innerHTML', + select: '#photos-list', + selectOOB: '#flash:beforeend', + push: '/photos', + transition: true, +); +``` + +For more information, see the htmx 4 [HX-Location response header documentation](https://four.htmx.org/reference/headers/hx-location/). ### hxRedirect() @@ -27,7 +61,7 @@ return redirect()->hxRedirect('/path'); ### hxRefresh() -If called the client side will do a full refresh of the page. +If called, the client performs a full refresh of the page. ```php return redirect()->hxRefresh(); diff --git a/docs/response.md b/docs/response.md index ffbb95a..ab92131 100644 --- a/docs/response.md +++ b/docs/response.md @@ -27,10 +27,10 @@ $this->response->setReplaceUrl('/replaced-url'); ### setReswap() -Sets the value in `HX-Reswap` header. Allows you to specify how the response will be swapped. See [hx-swap](https://htmx.org/attributes/hx-swap) for possible values. +Sets the value in `HX-Reswap` header. Allows you to specify how the response will be swapped. See [hx-swap](https://four.htmx.org/reference/attributes/hx-swap/) for possible values. ```php -$this->response->setReswap('innerHTML show:#another-div:top'); +$this->response->setReswap('innerHTML show:top showTarget:#another-div'); ``` ### setRetarget() @@ -43,7 +43,7 @@ $this->response->setRetarget('#another-div'); ### setReselect() -Sets the value in `HX-Reselect` header. A CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing [hx-select](https://htmx.org/attributes/hx-select/) on the triggering element. +Sets the value in `HX-Reselect` header. A CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing [`hx-select`](https://four.htmx.org/reference/) on the triggering element. ```php $this->response->setReselect('#another-div'); @@ -51,15 +51,13 @@ $this->response->setReselect('#another-div'); ### triggerClientEvent() -Allows you to set the headers: `HX-Trigger`, `HX-Trigger-After-Settle` or `HX-Trigger-After-Swap`. +Allows you to set the `HX-Trigger` header. -This method has 3 parameters: -* `name` -* `params` -* `method` - which can be one of: `receive` (default), `settle`, `swap`. +This method has two parameters: + +- `name` +- `params` ```php $this->response->triggerClientEvent('showMessage', ['level' => 'info', 'message' => 'Here Is A Message']); ``` - -For more information, please see [hx-trigger](https://htmx.org/headers/hx-trigger/). diff --git a/mkdocs.yml b/mkdocs.yml index b9fb6bf..b8d4ef5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,9 +59,10 @@ nav: - Configuration: configuration.md - Error handling: error_handling.md - View fragments: view_fragments.md - - IncomingReqeuest: incoming_request.md + - IncomingRequest: incoming_request.md - Response: response.md - RedirectResponse: redirect_response.md - HTML Formatter: html_formatter.md - Debug Toolbar: debug_toolbar.md - Troubleshooting: troubleshooting.md + - HTMX 4 migration: htmx_4_migration.md diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 00b7e7c..759763c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -21,12 +21,6 @@ parameters: count: 1 path: src/View/View.php - - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertArrayHasKey\(\) with ''_ci_previous_url'' and non\-empty\-array will always evaluate to true\.$#' - identifier: method.alreadyNarrowedType - count: 2 - path: tests/CodeIgniterTest.php - - message: ''' #^Call to deprecated method __construct\(\) of class CodeIgniter\\HTTP\\Response\: diff --git a/rector.php b/rector.php index b357855..146d2f4 100644 --- a/rector.php +++ b/rector.php @@ -7,26 +7,18 @@ use Rector\CodeQuality\Rector\Class_\CompleteDynamicPropertiesRector; use Rector\CodeQuality\Rector\Empty_\SimplifyEmptyCheckOnEmptyArrayRector; use Rector\CodeQuality\Rector\Expression\InlineIfToExplicitIfRector; -use Rector\CodeQuality\Rector\Foreach_\UnusedForeachValueToArrayKeysRector; use Rector\CodeQuality\Rector\FuncCall\ChangeArrayPushToArrayAssignRector; -use Rector\CodeQuality\Rector\FuncCall\SimplifyRegexPatternRector; use Rector\CodeQuality\Rector\FuncCall\SimplifyStrposLowerRector; use Rector\CodeQuality\Rector\FuncCall\SingleInArrayToCompareRector; use Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector; -use Rector\CodeQuality\Rector\If_\CombineIfRector; -use Rector\CodeQuality\Rector\If_\ExplicitBoolCompareRector; -use Rector\CodeQuality\Rector\If_\ShortenElseIfRector; -use Rector\CodeQuality\Rector\If_\SimplifyIfElseToTernaryRector; use Rector\CodeQuality\Rector\If_\SimplifyIfReturnBoolRector; use Rector\CodeQuality\Rector\Ternary\TernaryEmptyArrayArrayDimFetchToCoalesceRector; use Rector\CodeQuality\Rector\Ternary\UnnecessaryTernaryExpressionRector; use Rector\CodingStyle\Rector\ClassMethod\FuncGetArgsToVariadicParamRector; use Rector\CodingStyle\Rector\ClassMethod\MakeInheritedMethodVisibilitySameAsParentRector; -use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\CodingStyle\Rector\FuncCall\VersionCompareFuncCallToConstantRector; use Rector\Config\RectorConfig; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPromotedPropertyRector; -use Rector\EarlyReturn\Rector\Foreach_\ChangeNestedForeachIfsToEarlyContinueRector; use Rector\EarlyReturn\Rector\If_\ChangeIfElseValueAssignToEarlyReturnRector; use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector; use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector; @@ -38,7 +30,6 @@ use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector; use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\SetList; -use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; use Rector\TypeDeclaration\Rector\Empty_\EmptyOnNullableObjectToInstanceOfRector; use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector; use Rector\ValueObject\PhpVersion; @@ -46,9 +37,9 @@ return static function (RectorConfig $rectorConfig): void { $rectorConfig->sets([ SetList::DEAD_CODE, - LevelSetList::UP_TO_PHP_80, + LevelSetList::UP_TO_PHP_82, PHPUnitSetList::PHPUNIT_CODE_QUALITY, - PHPUnitSetList::PHPUNIT_100, + PHPUnitSetList::COMPOSER_BASED, ]); $rectorConfig->parallel(); @@ -80,7 +71,7 @@ } // Set the target version for refactoring - $rectorConfig->phpVersion(PhpVersion::PHP_74); + $rectorConfig->phpVersion(PhpVersion::PHP_82); // Auto-import fully qualified class names $rectorConfig->importNames(); @@ -105,27 +96,19 @@ $rectorConfig->rule(SimplifyUselessVariableRector::class); $rectorConfig->rule(RemoveAlwaysElseRector::class); - $rectorConfig->rule(CountArrayToEmptyArrayComparisonRector::class); - $rectorConfig->rule(ChangeNestedForeachIfsToEarlyContinueRector::class); $rectorConfig->rule(ChangeIfElseValueAssignToEarlyReturnRector::class); $rectorConfig->rule(SimplifyStrposLowerRector::class); - $rectorConfig->rule(CombineIfRector::class); $rectorConfig->rule(SimplifyIfReturnBoolRector::class); $rectorConfig->rule(InlineIfToExplicitIfRector::class); $rectorConfig->rule(PreparedValueToEarlyReturnRector::class); - $rectorConfig->rule(ShortenElseIfRector::class); - $rectorConfig->rule(SimplifyIfElseToTernaryRector::class); - $rectorConfig->rule(UnusedForeachValueToArrayKeysRector::class); $rectorConfig->rule(ChangeArrayPushToArrayAssignRector::class); $rectorConfig->rule(UnnecessaryTernaryExpressionRector::class); - $rectorConfig->rule(SimplifyRegexPatternRector::class); $rectorConfig->rule(FuncGetArgsToVariadicParamRector::class); $rectorConfig->rule(MakeInheritedMethodVisibilitySameAsParentRector::class); $rectorConfig->rule(SimplifyEmptyArrayCheckRector::class); $rectorConfig->rule(SimplifyEmptyCheckOnEmptyArrayRector::class); $rectorConfig->rule(TernaryEmptyArrayArrayDimFetchToCoalesceRector::class); $rectorConfig->rule(EmptyOnNullableObjectToInstanceOfRector::class); - $rectorConfig->rule(DisallowedEmptyRuleFixerRector::class); $rectorConfig ->ruleWithConfiguration(TypedPropertyFromAssignsRector::class, [ /** @@ -141,5 +124,4 @@ $rectorConfig->rule(CompleteDynamicPropertiesRector::class); $rectorConfig->rule(SingleInArrayToCompareRector::class); $rectorConfig->rule(VersionCompareFuncCallToConstantRector::class); - $rectorConfig->rule(ExplicitBoolCompareRector::class); }; diff --git a/src/Common.php b/src/Common.php index e6c48a5..9b674c0 100644 --- a/src/Common.php +++ b/src/Common.php @@ -29,7 +29,7 @@ function view_fragment(string $name, array|string $fragments, array $data = [], } $options['fragments'] = is_string($fragments) - ? array_map('trim', explode(',', $fragments)) + ? array_map(trim(...), explode(',', $fragments)) : $fragments; return $renderer->setData($data, 'raw')->renderFragments($name, $options, $saveData); diff --git a/src/Format/HTMLFormatter.php b/src/Format/HTMLFormatter.php index 82531b4..9362d0c 100644 --- a/src/Format/HTMLFormatter.php +++ b/src/Format/HTMLFormatter.php @@ -60,7 +60,7 @@ protected function formatTable($data): string foreach ($data as $row) { // Suppressing the "array to string conversion" notice // Keep the "evil" @ here - $row = @array_map('strval', $row); + $row = @array_map(strval(...), $row); $table->addRow($row); } diff --git a/src/HTTP/HtmxTrait.php b/src/HTTP/HtmxTrait.php index 15ab51a..93c9d57 100644 --- a/src/HTTP/HtmxTrait.php +++ b/src/HTTP/HtmxTrait.php @@ -2,16 +2,24 @@ namespace Michalsn\CodeIgniterHtmx\HTTP; -use InvalidArgumentException; +use CodeIgniter\Exceptions\InvalidArgumentException; trait HtmxTrait { private array $swapOptions = [ 'innerHTML', 'outerHTML', + 'outerSync', + 'innerMorph', + 'outerMorph', + 'textContent', + 'before', 'beforebegin', + 'prepend', 'afterbegin', + 'append', 'beforeend', + 'after', 'afterend', 'delete', 'none', diff --git a/src/HTTP/IncomingRequest.php b/src/HTTP/IncomingRequest.php index 5601b2e..8e638b5 100644 --- a/src/HTTP/IncomingRequest.php +++ b/src/HTTP/IncomingRequest.php @@ -8,6 +8,9 @@ class IncomingRequest extends BaseIncomingRequest { /** * Indicates that the request is triggered by Htmx. + * + * Checks whether the request carries the HX-Request header + * with the value "true". */ public function isHtmx(): bool { @@ -23,8 +26,7 @@ public function isBoosted(): bool } /** - * True if the request is for history restoration - * after a miss in the local history cache. + * True if the request is for history restoration. */ public function isHistoryRestoreRequest(): bool { @@ -32,58 +34,69 @@ public function isHistoryRestoreRequest(): bool } /** - * The current URL of the browser. + * The request type for HTMX 4 requests. + * + * Returns "partial" for targeted swaps and "full" + * for body-level swaps, including hx-boost requests, + * or requests using hx-select. */ - public function getCurrentUrl(): ?string + public function getRequestType(): ?string { - return $this->getHtmxHeader('HX-Current-Url'); + return $this->getHtmxHeader('HX-Request-Type'); } /** - * The user response to an hx-prompt. + * Indicates a partial HTMX request. */ - public function getPrompt(): ?string + public function isPartial(): bool { - return $this->getHtmxHeader('HX-Prompt'); + return $this->getRequestType() === 'partial'; } /** - * The id of the target element if it exists. + * Indicates a full HTMX request. */ - public function getTarget(): ?string + public function isFull(): bool { - return $this->getHtmxHeader('HX-Target'); + return $this->getRequestType() === 'full'; } /** - * The id of the triggered element if it exists. + * The current URL of the browser. */ - public function getTrigger(): ?string + public function getCurrentUrl(): ?string { - return $this->getHtmxHeader('HX-Trigger'); + return $this->getHtmxHeader('HX-Current-Url'); } /** - * The name of the triggered element if it exists. + * The prompt response sent by the optional hx-prompt extension. */ - public function getTriggerName(): ?string + public function getPrompt(): ?string { - return $this->getHtmxHeader('HX-Trigger-Name'); + return $this->getHtmxHeader('HX-Prompt'); } /** - * The value of the header is a JSON serialized - * version of the event that triggered the request. + * The identifier of the triggered element if it exists. * - * @see https://htmx.org/extensions/event-header/ + * In HTMX 4 this is sent in HX-Source and uses the + * element identifier format, such as "button#submit". */ - public function getTriggeringEvent(bool $toArray = true): array|object|null + public function getSource(): ?string { - if (! $this->hasHeader('Triggering-Event')) { - return null; - } + return $this->getHtmxHeader('HX-Source'); + } - return json_decode($this->header('Triggering-Event')->getValueLine(), $toArray); + /** + * The identifier of the target element if it exists. + * + * HTMX 4 uses the same element identifier format as HX-Source, + * for example "div#results". + */ + public function getTarget(): ?string + { + return $this->getHtmxHeader('HX-Target'); } /** @@ -110,8 +123,8 @@ private function getHtmxHeaderToBool(string $header): bool /** * Checks this request type. * - * @param string $type HTTP verb or 'json' or 'ajax' or 'htmx' or 'boosted' - * @phpstan-param string|'get'|'post'|'put'|'delete'|'head'|'patch'|'options'|'json'|'ajax'|'htmx'|'boosted' $type + * @param string $type HTTP verb or 'json' or 'ajax' or 'htmx' or 'boosted' or 'partial' or 'full' + * @phpstan-param string|'get'|'post'|'put'|'delete'|'head'|'patch'|'options'|'json'|'ajax'|'htmx'|'boosted'|'partial'|'full' $type */ public function is(string $type): bool { @@ -125,6 +138,14 @@ public function is(string $type): bool return $this->isBoosted(); } + if ($valueUpper === 'PARTIAL') { + return $this->isPartial(); + } + + if ($valueUpper === 'FULL') { + return $this->isFull(); + } + return parent::is($type); } } diff --git a/src/HTTP/RedirectResponse.php b/src/HTTP/RedirectResponse.php index 8e5531f..63b9632 100644 --- a/src/HTTP/RedirectResponse.php +++ b/src/HTTP/RedirectResponse.php @@ -2,6 +2,7 @@ namespace Michalsn\CodeIgniterHtmx\HTTP; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\RedirectResponse as BaseRedirectResponse; class RedirectResponse extends BaseRedirectResponse @@ -15,17 +16,25 @@ class RedirectResponse extends BaseRedirectResponse public function hxLocation( string $path, ?string $source = null, - ?string $event = null, + array|string|null $event = null, ?string $target = null, ?string $swap = null, ?array $values = null, ?array $headers = null, + false|string|null $push = null, + false|string|null $replace = null, + ?string $select = null, + ?string $handler = null, + ?string $selectOOB = null, + ?bool $transition = null, ): RedirectResponse { - if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { - $path = (string) service('uri', $path, false)->withScheme('')->setHost(''); + if ($handler !== null) { + throw new InvalidArgumentException( + 'The "handler" option is not supported by htmx 4. Use client-side htmx lifecycle events instead.', + ); } - $data = ['path' => '/' . ltrim($path, '/')]; + $data = ['path' => $this->normalizeLocationPath($path)]; if ($source !== null) { $data['source'] = $source; @@ -52,6 +61,26 @@ public function hxLocation( $data['headers'] = $headers; } + if ($push !== null) { + $data['push'] = $this->normalizeHistoryOption($push); + } + + if ($replace !== null) { + $data['replace'] = $this->normalizeHistoryOption($replace); + } + + if ($select !== null) { + $data['select'] = $select; + } + + if ($selectOOB !== null) { + $data['selectOOB'] = $selectOOB; + } + + if ($transition !== null) { + $data['transition'] = $transition; + } + return $this->setStatusCode(200)->setHeader('HX-Location', json_encode($data)); } @@ -76,4 +105,22 @@ public function hxRefresh(): RedirectResponse { return $this->setStatusCode(200)->setHeader('HX-Refresh', 'true'); } + + private function normalizeLocationPath(string $path): string + { + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + $path = (string) service('uri', $path, false)->withScheme('')->setHost(''); + } + + return '/' . ltrim($path, '/'); + } + + private function normalizeHistoryOption(false|string $option): false|string + { + if ($option === false || $option === 'true' || $option === 'false') { + return $option; + } + + return $this->normalizeLocationPath($option); + } } diff --git a/src/HTTP/Response.php b/src/HTTP/Response.php index 147d361..e73e652 100644 --- a/src/HTTP/Response.php +++ b/src/HTTP/Response.php @@ -2,8 +2,8 @@ namespace Michalsn\CodeIgniterHtmx\HTTP; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\Response as BaseResponse; -use InvalidArgumentException; class Response extends BaseResponse { @@ -66,14 +66,9 @@ public function setReselect(string $selector): Response /** * Allows you to trigger client side events. */ - public function triggerClientEvent(string $name, array|string $params = '', string $after = 'receive'): Response + public function triggerClientEvent(string $name, array|string $params = ''): Response { - $header = match ($after) { - 'receive' => 'HX-Trigger', - 'settle' => 'HX-Trigger-After-Settle', - 'swap' => 'HX-Trigger-After-Swap', - default => throw new InvalidArgumentException('A value for "after" argument must be one of: "receive", "settle", or "swap".'), - }; + $header = 'HX-Trigger'; if ($this->hasHeader($header)) { $data = json_decode($this->header($header)->getValue(), true); diff --git a/src/View/error_modal_decorator.js b/src/View/error_modal_decorator.js index bb2cec8..388cad4 100644 --- a/src/View/error_modal_decorator.js +++ b/src/View/error_modal_decorator.js @@ -1,44 +1,200 @@ if (typeof window.htmx !== 'undefined') { - htmx.on('htmx:responseError', function (event) { - const xhr = event.detail.xhr; - - event.stopPropagation(); - - // Create modal - const htmxErrorModal = document.createElement('div'); - htmxErrorModal.id = 'htmxErrorModal' - // Set title - const htmxErrorModalTitle = document.createElement('h2'); - const htmxErrorModalTitleContent = document.createTextNode('Error: ' + xhr.status); - htmxErrorModalTitle.appendChild(htmxErrorModalTitleContent); - - // Set close buton - const htmxErrorModalCloseButton = document.createElement('button'); - const htmxErrorModalCloseButtonContent = document.createTextNode('X'); - htmxErrorModalCloseButton.appendChild(htmxErrorModalCloseButtonContent); - htmxErrorModalCloseButton.id = 'htmxErrorModalCloseButton'; - - // Set error content - const htmxErrorModalContent = document.createElement('textarea'); - htmxErrorModalContent.innerHTML = xhr.response; - - // Set styles - htmxErrorModal.setAttribute('style', 'position: absolute; max-width: 90%; left: 50%; transform: translateX(-50%); z-index: 99999; background: #fbe0e0; padding: 20px; border-radius: 5px; font-family: sans-serif; top: 50px;'); - htmxErrorModalTitle.setAttribute('style', 'display: inline-block;') - htmxErrorModalCloseButton.setAttribute('style', 'border: 1px solid; padding: 5px 8px 3px 8px; display: inline-block; float: right;'); - htmxErrorModalContent.setAttribute('style', 'border: 1px solid #ccc; width: 80vw; height: 80vh'); - - // Append content to modal - htmxErrorModal.appendChild(htmxErrorModalTitle); - htmxErrorModal.appendChild(htmxErrorModalCloseButton); - htmxErrorModal.appendChild(htmxErrorModalContent); - - // Add modal to DOM - document.body.appendChild(htmxErrorModal); - - // Handle close button - htmxErrorModalCloseButton.onclick = function remove() { - htmxErrorModal.parentElement.removeChild(htmxErrorModal); + const handledResponses = new WeakSet(); + + const isHtmlResponse = function (body, contentType) { + if (typeof contentType === 'string') { + const normalizedContentType = contentType.toLowerCase(); + + if ( + normalizedContentType.includes('text/html') + || normalizedContentType.includes('application/xhtml+xml') + ) { + return true; + } } + + const trimmedBody = body.trim().toLowerCase(); + + return trimmedBody.startsWith('= 400) { + showFetchResponseError(ctx.response); + event.preventDefault(); + } + }); + + htmx.on('htmx:error', function (event) { + const detail = event.detail || {}; + const ctx = detail.ctx || {}; + + if (ctx.response) { + if (ctx.response.raw && handledResponses.has(ctx.response.raw)) { + return; + } + + showFetchResponseError(ctx.response); + return; + } + + showModal(ctx.status || 'request error', detail.error ? String(detail.error) : '', ''); }); + } diff --git a/src/View/toolbar_decorator.js b/src/View/toolbar_decorator.js index ffbc9b2..2b9b862 100644 --- a/src/View/toolbar_decorator.js +++ b/src/View/toolbar_decorator.js @@ -1,10 +1,27 @@ if (typeof window.htmx !== 'undefined' && typeof window.loadDoc !== 'undefined' && document.getElementById('debugbar_dynamic_script')) { - htmx.on('htmx:afterSettle', function (event) { - let debugBarTime = event.detail.xhr.getResponseHeader('debugbar-time'); - if (debugBarTime !== null) { + let lastDebugBarTime = null; + + const getDebugBarTime = function (event) { + const detail = event.detail || {}; + const response = detail.ctx && detail.ctx.response ? detail.ctx.response : null; + + if (response !== null && response.headers && typeof response.headers.get === 'function') { + return response.headers.get('debugbar-time'); + } + + return null; + }; + + const refreshDebugBar = function (event) { + const debugBarTime = getDebugBarTime(event); + + if (debugBarTime !== null && debugBarTime !== lastDebugBarTime) { + lastDebugBarTime = debugBarTime; loadDoc(debugBarTime); } - }); + }; + + htmx.on('htmx:finally:request', refreshDebugBar); } diff --git a/tests/CodeIgniterTest.php b/tests/CodeIgniterTest.php index 676795f..04d99de 100644 --- a/tests/CodeIgniterTest.php +++ b/tests/CodeIgniterTest.php @@ -39,7 +39,6 @@ public function testStorePreviousURLIsHTMX(): void ob_get_clean(); $this->assertTrue(service('request')->isHTMX()); - $this->assertArrayHasKey('_ci_previous_url', $_SESSION); $this->assertSame('https://example.com/index.php/?previous=saved_from_htmx', $_SESSION['_ci_previous_url']); } @@ -59,7 +58,6 @@ public function testDontStorePreviousURLIsHTMX(): void ob_get_clean(); $this->assertTrue(service('request')->isHTMX()); - $this->assertArrayHasKey('_ci_previous_url', $_SESSION); $this->assertSame('https://example.com/index.php/?previous=original', $_SESSION['_ci_previous_url']); } } diff --git a/tests/HTTP/IncomingRequestTest.php b/tests/HTTP/IncomingRequestTest.php index 4780442..acdcb8d 100644 --- a/tests/HTTP/IncomingRequestTest.php +++ b/tests/HTTP/IncomingRequestTest.php @@ -4,11 +4,11 @@ namespace Tests\HTTP; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Test\CIUnitTestCase; use Config\App; -use InvalidArgumentException; use Michalsn\CodeIgniterHtmx\HTTP\IncomingRequest; /** @@ -67,6 +67,32 @@ public function testIsHistoryRestoreRequestIsFalse(): void $this->assertFalse($this->request->isHistoryRestoreRequest()); } + public function testGetRequestType(): void + { + $header = 'partial'; + $this->request->appendHeader('HX-Request-Type', $header); + $this->assertSame($header, $this->request->getRequestType()); + } + + public function testGetRequestTypeIsNull(): void + { + $this->assertNull($this->request->getRequestType()); + } + + public function testIsPartial(): void + { + $this->request->appendHeader('HX-Request-Type', 'partial'); + $this->assertTrue($this->request->isPartial()); + $this->assertFalse($this->request->isFull()); + } + + public function testIsFull(): void + { + $this->request->appendHeader('HX-Request-Type', 'full'); + $this->assertTrue($this->request->isFull()); + $this->assertFalse($this->request->isPartial()); + } + public function testGetCurrentUrl(): void { $header = 'https://codeigniter-htmx-demo.test/'; @@ -81,7 +107,7 @@ public function testGetCurrentUrlIsNull(): void public function testGetPrompt(): void { - $header = 'prompt test'; + $header = 'Delete this item?'; $this->request->appendHeader('HX-Prompt', $header); $this->assertSame($header, $this->request->getPrompt()); } @@ -93,7 +119,7 @@ public function testGetPromptIsNull(): void public function testGetTarget(): void { - $header = '#response-div'; + $header = 'div#response-div'; $this->request->appendHeader('HX-Target', $header); $this->assertSame($header, $this->request->getTarget()); } @@ -103,40 +129,16 @@ public function testGetTargetIsNull(): void $this->assertNull($this->request->getTarget()); } - public function testGetTrigger(): void - { - $header = 'test-id'; - $this->request->appendHeader('HX-Trigger', $header); - $this->assertSame($header, $this->request->getTrigger()); - } - - public function testGetTriggerIsNull(): void + public function testGetSource(): void { - $this->assertNull($this->request->getTrigger()); + $header = 'button#test-id'; + $this->request->appendHeader('HX-Source', $header); + $this->assertSame($header, $this->request->getSource()); } - public function testGetTriggerName(): void + public function testGetSourceIsNull(): void { - $header = 'test-name'; - $this->request->appendHeader('HX-Trigger-Name', $header); - $this->assertSame($header, $this->request->getTriggerName()); - } - - public function testGetTriggerNameIsNull(): void - { - $this->assertNull($this->request->getTriggerName()); - } - - public function testGetTriggeringEvent(): void - { - $header = '{"isTrusted":true,"htmx-internal-data":{"triggerSpec":{"trigger":"click"},"handledFor":["button.btn.btn-sm btn-primary"]},"screenX":1347,"screenY":238,"pageX":106,"pageY":128,"clientX":106,"clientY":128,"x":106,"y":128,"offsetX":93,"offsetY":11,"ctrlKey":false,"shiftKey":false,"altKey":false,"metaKey":false,"button":0,"buttons":0,"relatedTarget":null,"movementX":0,"movementY":0,"mozPressure":0,"mozInputSource":1,"MOZ_SOURCE_UNKNOWN":0,"MOZ_SOURCE_MOUSE":1,"MOZ_SOURCE_PEN":2,"MOZ_SOURCE_ERASER":3,"MOZ_SOURCE…ck","target":"button.btn.btn-sm btn-primary","srcElement":"button.btn.btn-sm btn-primary","currentTarget":"button.btn.btn-sm btn-primary","eventPhase":2,"bubbles":true,"cancelable":true,"returnValue":true,"defaultPrevented":false,"composed":true,"timeStamp":1599,"cancelBubble":false,"originalTarget":"button.btn.btn-sm btn-primary","explicitOriginalTarget":"button.btn.btn-sm btn-primary","NONE":0,"CAPTURING_PHASE":1,"AT_TARGET":2,"BUBBLING_PHASE":3,"ALT_MASK":1,"CONTROL_MASK":2,"SHIFT_MASK":4,"META_MASK":8}'; - $this->request->appendHeader('Triggering-Event', $header); - $this->assertSame(json_decode($header), $this->request->getTriggeringEvent()); - } - - public function testGetTriggeringEventIsNull(): void - { - $this->assertNull($this->request->getTriggeringEvent()); + $this->assertNull($this->request->getSource()); } public function testIsMethodWithHtmxParam(): void @@ -151,6 +153,18 @@ public function testIsMethodWithBoostedParam(): void $this->assertTrue($request->is('boosted')); } + public function testIsMethodWithPartialParam(): void + { + $request = $this->request->setHeader('HX-Request-Type', 'partial'); + $this->assertTrue($request->is('partial')); + } + + public function testIsMethodWithFullParam(): void + { + $request = $this->request->setHeader('HX-Request-Type', 'full'); + $this->assertTrue($request->is('full')); + } + public function testIsMethodWithInvalidParam(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/HTTP/RedirectResponseTest.php b/tests/HTTP/RedirectResponseTest.php index 8af2e12..5e7ac5a 100644 --- a/tests/HTTP/RedirectResponseTest.php +++ b/tests/HTTP/RedirectResponseTest.php @@ -4,9 +4,9 @@ namespace Tests\HTTP; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use Config\App; -use InvalidArgumentException; use Michalsn\CodeIgniterHtmx\HTTP\RedirectResponse; /** @@ -50,10 +50,10 @@ public function testHxLocationWithFullPath(): void public function testHxLocationWithSourceAndEvent(): void { - $this->response = $this->response->hxLocation(path: '/foo', source: '#myElem', event: 'doubleclick'); + $this->response = $this->response->hxLocation(path: '/foo', source: '#myElem', event: ['type' => 'doubleclick']); $this->assertTrue($this->response->hasHeader('HX-Location')); - $expected = json_encode(['path' => '/foo', 'source' => '#myElem', 'event' => 'doubleclick']); + $expected = json_encode(['path' => '/foo', 'source' => '#myElem', 'event' => ['type' => 'doubleclick']]); $this->assertSame($expected, $this->response->getHeaderLine('HX-Location')); $this->assertSame(200, $this->response->getStatusCode()); } @@ -78,6 +78,110 @@ public function testHxLocationWithValuesAndHeaders(): void $this->assertSame(200, $this->response->getStatusCode()); } + public function testHxLocationWithSelectPushAndReplace(): void + { + $this->response = $this->response->hxLocation( + path: '/foo', + select: '#fragment', + push: '/pushed', + replace: '/replaced', + ); + + $this->assertTrue($this->response->hasHeader('HX-Location')); + $expected = json_encode([ + 'path' => '/foo', + 'push' => '/pushed', + 'replace' => '/replaced', + 'select' => '#fragment', + ]); + $this->assertSame($expected, $this->response->getHeaderLine('HX-Location')); + $this->assertSame(200, $this->response->getStatusCode()); + } + + public function testHxLocationKeepsVersionTwoPointThreePositionalArgumentOrder(): void + { + $this->response = $this->response->hxLocation( + '/foo', + null, + null, + null, + null, + null, + null, + 'true', + 'false', + '#fragment', + ); + + $expected = json_encode([ + 'path' => '/foo', + 'push' => 'true', + 'replace' => 'false', + 'select' => '#fragment', + ]); + $this->assertSame($expected, $this->response->getHeaderLine('HX-Location')); + } + + public function testHxLocationRejectsVersionTwoHandler(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "handler" option is not supported by htmx 4.'); + + $this->response->hxLocation(path: '/foo', handler: 'myHandler'); + } + + public function testHxLocationWithSelectOobAndTransition(): void + { + $this->response = $this->response->hxLocation( + path: '/foo', + selectOOB: '#flash:beforeend', + transition: true, + ); + + $expected = json_encode([ + 'path' => '/foo', + 'selectOOB' => '#flash:beforeend', + 'transition' => true, + ]); + $this->assertSame($expected, $this->response->getHeaderLine('HX-Location')); + } + + public function testHxLocationWithPushFalse(): void + { + $this->response = $this->response->hxLocation(path: '/foo', push: false); + + $this->assertSame( + json_encode(['path' => '/foo', 'push' => false]), + $this->response->getHeaderLine('HX-Location'), + ); + } + + public function testHxLocationPreservesTrueAndFalseHistoryOptions(): void + { + $this->response = $this->response->hxLocation(path: '/foo', push: 'true', replace: false); + + $this->assertSame( + json_encode(['path' => '/foo', 'push' => 'true', 'replace' => false]), + $this->response->getHeaderLine('HX-Location'), + ); + } + + public function testHxLocationNormalizesPushAndReplaceFullUrl(): void + { + $this->response = $this->response->hxLocation( + path: '/foo', + push: 'https://example.com/pushed?page=1#top', + replace: 'https://example.com/replaced?sort=asc', + ); + + $expected = json_encode([ + 'path' => '/foo', + 'push' => '/pushed?page=1#top', + 'replace' => '/replaced?sort=asc', + ]); + $this->assertSame($expected, $this->response->getHeaderLine('HX-Location')); + } + public function testHxLocationThrowInvalidArgumentException(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/HTTP/ResponseTest.php b/tests/HTTP/ResponseTest.php index 3757917..b7d84c9 100644 --- a/tests/HTTP/ResponseTest.php +++ b/tests/HTTP/ResponseTest.php @@ -4,9 +4,9 @@ namespace Tests\HTTP; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use Config\App; -use InvalidArgumentException; use Michalsn\CodeIgniterHtmx\HTTP\Response; /** @@ -65,6 +65,34 @@ public function testSetReswapWithModifier(): void $this->assertSame('innerHTML swap:1s', $this->response->getHeaderLine('HX-Reswap')); } + public function testSetReswapWithTextContent(): void + { + $this->response->setReswap('textContent'); + + $this->assertSame('textContent', $this->response->getHeaderLine('HX-Reswap')); + } + + public function testSetReswapWithAlias(): void + { + $this->response->setReswap('append'); + + $this->assertSame('append', $this->response->getHeaderLine('HX-Reswap')); + } + + public function testSetReswapWithMorph(): void + { + $this->response->setReswap('innerMorph'); + + $this->assertSame('innerMorph', $this->response->getHeaderLine('HX-Reswap')); + } + + public function testSetReswapWithOuterSync(): void + { + $this->response->setReswap('outerSync'); + + $this->assertSame('outerSync', $this->response->getHeaderLine('HX-Reswap')); + } + public function testSetReswapThrowInvalidArgumentException(): void { $this->expectException(InvalidArgumentException::class); @@ -117,33 +145,6 @@ public function testTriggerClientEventAndPassDetailsMultipleCalls(): void ); } - public function testTriggerClientEventWithSettle(): void - { - $this->response->triggerClientEvent('showMessage', '', 'settle'); - - $this->assertSame( - '{"showMessage":""}', - $this->response->getHeaderLine('HX-Trigger-After-Settle'), - ); - } - - public function testTriggerClientEventWithSwap(): void - { - $this->response->triggerClientEvent('showMessage', '', 'swap'); - - $this->assertSame( - '{"showMessage":""}', - $this->response->getHeaderLine('HX-Trigger-After-Swap'), - ); - } - - public function testTriggerClientEventThrowInvalidArgumentException(): void - { - $this->expectException(InvalidArgumentException::class); - - $this->response->triggerClientEvent('event1', 'A message', 'foo'); - } - public function testTriggerClientEventThrowInvalidArgumentExceptionForHeaderContent(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/View/ToolbarDecoratorTest.php b/tests/View/ToolbarDecoratorTest.php index 187df1a..4644071 100644 --- a/tests/View/ToolbarDecoratorTest.php +++ b/tests/View/ToolbarDecoratorTest.php @@ -58,9 +58,11 @@ public function testDecoratorApply(): void $view->setVar('testString', 'Hello World'); $expected1 = 'Hello World'; $expected2 = 'id="htmxToolbarScript"'; + $expected3 = "htmx.on('htmx:finally:request', refreshDebugBar)"; $this->assertStringContainsString($expected1, $view->render('with_decorator')); $this->assertStringContainsString($expected2, $view->render('with_decorator')); + $this->assertStringContainsString($expected3, $view->render('with_decorator')); } public function testDecoratorDisabled(): void