├── .github
└── workflows
│ └── ci.yml
├── LICENSE
├── README.md
├── composer.json
├── config
└── bootstrap.php
├── docs
├── Ajax.md
├── Component
│ └── Ajax.md
├── Contributing.md
├── Install.md
├── README.md
└── View
│ └── Ajax.md
├── phpcs.xml
├── phpstan.neon
├── src
├── AjaxPlugin.php
├── Controller
│ └── Component
│ │ └── AjaxComponent.php
└── View
│ ├── AjaxView.php
│ └── JsonEncoder.php
└── tests
├── TestCase
├── Controller
│ └── Component
│ │ └── AjaxComponentTest.php
└── View
│ └── AjaxViewTest.php
└── config
└── routes.php
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | pull_request:
6 | workflow_dispatch:
7 |
8 | jobs:
9 | testsuite:
10 | runs-on: ubuntu-22.04
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | php-version: ['8.1', '8.4']
15 | db-type: [sqlite, mysql, pgsql]
16 | prefer-lowest: ['']
17 | include:
18 | - php-version: '8.1'
19 | db-type: 'sqlite'
20 | prefer-lowest: 'prefer-lowest'
21 |
22 | services:
23 | postgres:
24 | image: postgres
25 | ports:
26 | - 5432:5432
27 | env:
28 | POSTGRES_PASSWORD: postgres
29 |
30 | steps:
31 | - uses: actions/checkout@v4
32 |
33 | - name: Setup Service
34 | if: matrix.db-type == 'mysql'
35 | run: |
36 | sudo service mysql start
37 | mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp;'
38 | - name: Setup PHP
39 | uses: shivammathur/setup-php@v2
40 | with:
41 | php-version: ${{ matrix.php-version }}
42 | extensions: mbstring, intl, pdo_${{ matrix.db-type }}
43 | coverage: pcov
44 |
45 | - name: Get composer cache directory
46 | id: composercache
47 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
48 |
49 | - name: Cache dependencies
50 | uses: actions/cache@v4
51 | with:
52 | path: ${{ steps.composercache.outputs.dir }}
53 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }}
54 |
55 | - name: Composer install
56 | run: |
57 | composer --version
58 | if ${{ matrix.prefer-lowest == 'prefer-lowest' }}
59 | then
60 | composer update --prefer-lowest --prefer-stable
61 | composer require --dev dereuromark/composer-prefer-lowest:dev-master
62 | else
63 | composer install --no-progress --prefer-dist --optimize-autoloader
64 | fi
65 | - name: Run PHPUnit
66 | run: |
67 | if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi
68 | if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp'; fi
69 | if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi
70 | if [[ ${{ matrix.php-version }} == '8.1' && ${{ matrix.db-type }} == 'sqlite' ]]
71 | then
72 | vendor/bin/phpunit --coverage-clover=coverage.xml
73 | else
74 | vendor/bin/phpunit
75 | fi
76 | - name: Validate prefer-lowest
77 | if: matrix.prefer-lowest == 'prefer-lowest'
78 | run: vendor/bin/validate-prefer-lowest -m
79 |
80 | - name: Upload coverage reports to Codecov
81 | if: success() && matrix.php-version == '8.1'
82 | uses: codecov/codecov-action@v4
83 | with:
84 | token: ${{ secrets.CODECOV_TOKEN }}
85 |
86 | validation:
87 | name: Coding Standard & Static Analysis
88 | runs-on: ubuntu-22.04
89 |
90 | steps:
91 | - uses: actions/checkout@v4
92 |
93 | - name: Setup PHP
94 | uses: shivammathur/setup-php@v2
95 | with:
96 | php-version: '8.1'
97 | extensions: mbstring, intl
98 | coverage: none
99 |
100 | - name: Get composer cache directory
101 | id: composercache
102 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
103 |
104 | - name: Cache dependencies
105 | uses: actions/cache@v4
106 | with:
107 | path: ${{ steps.composercache.outputs.dir }}
108 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }}
109 |
110 | - name: Composer Install
111 | run: composer stan-setup
112 |
113 | - name: Run phpstan
114 | run: composer stan
115 |
116 | - name: Run phpcs
117 | run: composer cs-check
118 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Mark Scherer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CakePHP Ajax Plugin
2 | [](https://github.com/dereuromark/cakephp-ajax/actions/workflows/ci.yml?query=branch%3Amaster)
3 | [](https://codecov.io/gh/dereuromark/cakephp-ajax)
4 | [](https://packagist.org/packages/dereuromark/cakephp-ajax)
5 | [](https://php.net/)
6 | [](LICENSE)
7 | [](https://packagist.org/packages/dereuromark/cakephp-ajax)
8 | [](https://github.com/php-fig-rectified/fig-rectified-standards)
9 |
10 | A CakePHP plugin that makes working with AJAX a "piece of cake".
11 |
12 | This branch is for **CakePHP 5.0+**. For details see [version map](https://github.com/dereuromark/cakephp-ajax/wiki#cakephp-version-map).
13 |
14 | ## What is this plugin for?
15 | Basically DRY (Don't repeat yourself) and easy AJAX handling.
16 |
17 | ### Demo
18 | See the [Sandbox app](https://sandbox.dereuromark.de/sandbox/ajax-examples) for live demos.
19 |
20 | ### Key features
21 | - Auto-handling via View class mapping and making controller actions available both AJAX and non-AJAX by design.
22 | - Flash message and redirect (prevention) support.
23 |
24 | See [my article](https://www.dereuromark.de/2014/01/09/ajax-and-cakephp/) for details on the history of this view class and plugin code.
25 |
26 | ## Installation & Docs
27 |
28 | - [Documentation](docs/README.md)
29 |
30 | ### Possible TODOs
31 |
32 | * Maybe add helpers and additional goodies around auto-complete, edit-in-place, ...
33 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dereuromark/cakephp-ajax",
3 | "description": "A CakePHP plugin that makes working with AJAX a piece of cake.",
4 | "license": "MIT",
5 | "type": "cakephp-plugin",
6 | "keywords": [
7 | "cakephp",
8 | "plugin",
9 | "AJAX",
10 | "asynchronous",
11 | "view"
12 | ],
13 | "authors": [
14 | {
15 | "name": "Mark Scherer",
16 | "homepage": "https://www.dereuromark.de",
17 | "role": "Author"
18 | }
19 | ],
20 | "homepage": "https://github.com/dereuromark/cakephp-ajax",
21 | "support": {
22 | "issues": "https://github.com/dereuromark/cakephp-ajax/issues",
23 | "source": "https://github.com/dereuromark/cakephp-ajax"
24 | },
25 | "require": {
26 | "php": ">=8.1",
27 | "cakephp/cakephp": "^5.0.10"
28 | },
29 | "require-dev": {
30 | "ext-mbstring": "*",
31 | "dereuromark/cakephp-tools": "^3.0.0",
32 | "fig-r/psr2r-sniffer": "dev-master",
33 | "phpunit/phpunit": "^10.5 || ^11.5 || ^12.1"
34 | },
35 | "minimum-stability": "stable",
36 | "prefer-stable": true,
37 | "autoload": {
38 | "psr-4": {
39 | "Ajax\\": "src/"
40 | }
41 | },
42 | "autoload-dev": {
43 | "psr-4": {
44 | "Ajax\\Test\\": "tests/",
45 | "Cake\\Test\\": "vendor/cakephp/cakephp/tests/",
46 | "TestApp\\": "tests/TestApp/src/"
47 | }
48 | },
49 | "config": {
50 | "allow-plugins": {
51 | "dealerdirect/phpcodesniffer-composer-installer": true
52 | }
53 | },
54 | "scripts": {
55 | "cs-check": "phpcs --extensions=php",
56 | "cs-fix": "phpcbf --extensions=php",
57 | "lowest": "validate-prefer-lowest",
58 | "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json",
59 | "stan": "phpstan analyse",
60 | "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^2.0.0 && mv composer.backup composer.json",
61 | "test": "vendor/bin/phpunit",
62 | "test-coverage": "vendor/bin/phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/config/bootstrap.php:
--------------------------------------------------------------------------------
1 | Just use JsonView instead.
16 |
17 | Tip: Using RequestHandler component and `.json` extension for your URL will automatically do that for you.
18 |
19 | ### Main use cases
20 |
21 | #### Serving HTML and JSON simultaneously
22 | You have an action that is rendered as HTML usually, but in one instance you need the same data via AJAX.
23 | Instead of duplicating the action, you can leverage the Ajax Component + View class to use the same action for both output types.
24 | Especially the rendered HTML snippet can be the same, for easier use inside the JS frontend then.
25 | It can also on top ship the flash messages that are generated in the process.
26 |
27 | #### Providing a consistent return object for your frontend
28 |
29 | With JsonView your actions might sometimes return a bit inconsistent responses, missing some keys or alike.
30 | The AjaxView is designed to make the response more consistent.
31 |
32 | You either get a 200 status code and your defined response structure:
33 | ```json
34 | {
35 | "content": "[Result of our rendered template.ctp]",
36 | "_redirect": null
37 | }
38 | ```
39 |
40 | Or you get a non-200 code with the typical error structure:
41 | ```json
42 | {
43 | "code": 404,
44 | "message": "Not Found",
45 | "url": "..."
46 | }
47 | ```
48 |
49 | A typical JS (e.g. jQuery) code can then easily use that to distinguish those two cases:
50 | ```js
51 | $(function() {
52 | $('#countries').change(function() {
53 | var selectedValue = $(this).val();
54 | var targetUrl = $(this).attr('rel') + '?id=' + selectedValue;
55 | $.ajax({
56 | type: 'get',
57 | url: targetUrl,
58 | beforeSend: function(xhr) {
59 | xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
60 | },
61 | success: function(response) {
62 | if (response.content) {
63 | $('#provinces').html(response.content);
64 | }
65 | },
66 | error: function(e) {
67 | alert("An error occurred: " + e.responseText.message);
68 | }
69 | });
70 | });
71 | });
72 | ```
73 |
--------------------------------------------------------------------------------
/docs/Component/Ajax.md:
--------------------------------------------------------------------------------
1 | # Ajax Component
2 | This component works together with the AjaxView to easily switch output type from HTML to JSON
3 | format and adds some additional sugar on top.
4 | Please see the View class docs for the main documentation.
5 |
6 | ## Features
7 | By default the CakePHP RequestHandler, when included, will prevent redirects in AJAX, **but** it will
8 | follow those redirects and return the content via requestAction(). This might not always be desired.
9 |
10 | This plugin prevents this internal request, and instead returns the URL and status code inside the JSON response.
11 |
12 | ## Setup
13 | Load the Ajax component inside `Controller::initialize()`:
14 | ```php
15 | $this->loadComponent('Ajax.Ajax');
16 | ```
17 |
18 | You can pass the settings either directly inline here, or use Configure to set them globally.
19 |
20 | If you want to enable it only for certain actions, use the `actions` config key to whitelist certain actions.
21 | You could also do a blacklist programmatically:
22 | ```php
23 | /**
24 | * @return void
25 | */
26 | public function initialize() {
27 | parent::initialize();
28 | ...
29 |
30 | // Option A
31 | if (!in_array($this->request->getParam('action'), ['customAction'], true)) {
32 | $this->loadComponent('Ajax.Ajax');
33 | }
34 |
35 | // Option B (preferred)
36 | $this->loadComponent('Ajax.Ajax', [
37 | 'actions' => ['customAction'],
38 | ]);
39 | }
40 | ```
41 | In general, a whitelist setup is usually recommended.
42 |
43 | ## Usage
44 | This component will avoid those redirects completely and pass those down as part of the content of the JSON response object:
45 |
46 | "_redirect":{"url":"http://controller/action","status":200}, ...
47 |
48 | Flash messages are also caught and passed down as part of the response:
49 |
50 | "_message":{"success":["Yeah, that was a normal POST and redirect (PRG)."]}, ...
51 |
52 | Don't forget `Configure::write('Ajax.flashKey', 'FlashMessage');`
53 | if you want to use it with Tools.Flash component (multi/stackable messages).
54 |
55 | You can pass content along with it, as well, those JSON response keys will not be prefixed with a `_` underscore then, as they
56 | are not reserved:
57 | ```php
58 | $content = ['id' => 1, 'title' => 'title'];
59 | $this->set(compact('content'));
60 | $this->set('serialize', ['content']);
61 | ```
62 | results in
63 |
64 | "content":{...}, ...
65 |
66 | ### AJAX Delete
67 |
68 | For usability reasons you might want to delete a row in a paginated table, without the need to refresh the whole page.
69 | All you need to do here is
70 | - Add a specific class to the "post link"
71 | - Add some custom JS to catch the "post link JS"
72 | - Make sure the AjaxComponent is loaded for this action
73 |
74 | The default bake action usually already works perfectly:
75 |
76 | ```php
77 | public function delete($id = null) {
78 | $this->request->allowMethod(['post', 'delete']);
79 | $group = $this->Groups->get($id);
80 |
81 | $this->Groups->deleteOrFail($group);
82 | $this->Flash->success(__('The group has been deleted.'));
83 |
84 | return $this->redirect(['action' => 'index']);
85 | }
86 | ```
87 | The JSON response even contains the flash message and redirect URL in case you want to use that in your JS response handling:
88 | ```
89 | {
90 | "error":null,
91 | "content":null,
92 | "_message":[{"message":"The group has been deleted.","key":"flash","element":"Flash\/success","params":[]}],
93 | "_redirect":{"url":"http:\/\/app.local\/groups","status":302}
94 | }
95 | ```
96 |
97 | If you have some custom "fail" logic, though, you need to do a small adjustment.
98 | Then just modify your delete action to pass down the error to the view for cases where this is needed:
99 | ```php
100 | public function delete($id = null) {
101 | $this->request->allowMethod(['post', 'delete']);
102 | $group = $this->Groups->get($id);
103 |
104 | if ($group->status === $group::STATUS_PUBLIC) {
105 | $error = 'Already public, deleting not possible in that state.';
106 | $this->Flash->error($error);
107 | $this->set(compact('error'));
108 |
109 | // Since we are not deleting, referer redirect is safe to use here
110 | return $this->redirect($this->referer(['action' => 'index'], true));
111 | }
112 |
113 | $this->Groups->deleteOrFail($group);
114 | $this->Flash->success(__('The group has been deleted.'));
115 |
116 | return $this->redirect(['action' => 'index']);
117 | }
118 | ```
119 |
120 | If you don't pass the error to the view, you would need to read/parse the passed flash messages (key=error), which could be a bit more difficult to do.
121 | But the adjustment above is still minimal (1-2 lines difference from the baked default action for delete case).
122 |
123 | The nice bonus is the auto-fallback: The controller and all deleting works normally for those that have JS disabled.
124 |
125 | A live example can be found in the [Sandbox](https://sandbox.dereuromark.de/sandbox/ajax-examples/table).
126 |
127 | ### Simple boolean response
128 |
129 | In cases like "edit in place" you often just need a basic AJAX response as boolean YES/NO, maybe with an error message on top.
130 | Since we ideally always return a 200 OK response, we need a different way of signaling the frontend if the operation was successful.
131 |
132 | Here you can simplify it using the special "error"/"success" keys that auto-format the reponse as JSON:
133 | ```php
134 | $this->request->allowMethod('post');
135 |
136 | $value = $this->request->getData('value');
137 | if (!$this->process($value)) {
138 | $error = 'Didnt work out!';
139 | $this->set(compact('error'));
140 | } else {
141 | $success = true; // Or a text like 'You did it!'
142 | $this->set(compact('success'));
143 | }
144 | ```
145 |
146 | In the case of x-editable as "edit in place" JS all you need is to check for the error message:
147 | ```js
148 | success: function(response, newValue) {
149 | if (response.error) {
150 | return response.error; //msg will be shown in editable form
151 | }
152 | }
153 | ```
154 |
155 | ## Configs
156 |
157 | - 'autoDetect' => true // Detect AJAX automatically, regardless of the extension
158 | - 'resolveRedirect' => true // Send redirects to the view, without actually redirecting
159 | - 'flashKey' => 'Message.flash' // Set to false to disable
160 | - 'actions' => [] // Set to an array of actions if you want to only whitelist these specific actions
161 |
--------------------------------------------------------------------------------
/docs/Contributing.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Getting Started
4 |
5 | * Make sure you have a [GitHub account](https://github.com/signup/free)
6 | * Fork the repository on GitHub.
7 |
8 | ## Making Changes
9 |
10 | I am looking forward to your contributions. There are several ways to help out:
11 | * Write missing testcases
12 | * Write patches for bugs/features, preferably with testcases included
13 |
14 | There are a few guidelines that I need contributors to follow:
15 | * Coding standards (`composer cs-check` to check and `composer cs-fix` to fix)
16 | * Passing tests (you can enable travis to assert your changes pass) for Windows and Unix (`php phpunit.phar`)
17 |
18 | Tip: You can use the composer commands to set up everything:
19 | * `composer install`
20 |
21 | Now you can run the tests via `composer test` and get coverage via `composer test-coverage` commands.
22 |
23 | # Additional Resources
24 |
25 | * [Coding standards guide (extending/overwriting the CakePHP ones)](https://github.com/php-fig-rectified/fig-rectified-standards/)
26 | * [CakePHP coding standards](https://book.cakephp.org/3.0/en/contributing/cakephp-coding-conventions.html)
27 | * [General GitHub documentation](https://help.github.com/)
28 | * [GitHub pull request documentation](https://help.github.com/send-pull-requests/)
29 |
--------------------------------------------------------------------------------
/docs/Install.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | ## How to include
4 | Installing the Plugin is pretty much as with every other CakePHP Plugin.
5 |
6 | ```
7 | composer require dereuromark/cakephp-ajax
8 | ```
9 |
10 | Details @ https://packagist.org/packages/dereuromark/cakephp-ajax
11 |
12 | Load the plugin:
13 | ```
14 | bin/cake plugin load Ajax
15 | ```
16 |
17 | Note that you do not have to load the plugin if you do not use the plugin's bootstrap or require other plugins (like IdeHelper) to know about it. It also doesn't hurt to load it, though.
18 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # CakePHP Ajax Plugin Documentation
2 |
3 | ## Installation
4 | * [Installation](Install.md)
5 |
6 | ## Documentation
7 | * [Ajax plugin](Ajax.md)
8 | * [View/Ajax](View/Ajax.md)
9 | * [Component/Ajax](Component/Ajax.md)
10 |
11 | ## Contributing
12 | Your help is greatly appreciated.
13 |
14 | * See [Contributing](Contributing.md) for details.
15 |
--------------------------------------------------------------------------------
/docs/View/Ajax.md:
--------------------------------------------------------------------------------
1 | # Ajax View
2 |
3 | A CakePHP view class to make working with AJAX a bit easier.
4 |
5 | ## Configs
6 | First enable JSON extensions if you want to use `json` extension.
7 | You can either use the included bootstrap file, or add the snippet manually to your own one:
8 | ```
9 | Router::extensions(['json']);
10 | ```
11 |
12 | ## Usage
13 | Using the `json` extension you can then access your action through the following URL:
14 | ```
15 | /controller/action.json
16 | ```
17 |
18 | You can enable the AjaxView class it in your actions like so:
19 | ```php
20 | // new
21 | $this->viewBuilder()->setClassName('Ajax.Ajax');
22 |
23 | // old
24 | $this->viewClass = 'Ajax.Ajax';
25 | ```
26 | Using the AjaxComponent you can save yourself that call, as it can auto-detect AJAX request.
27 |
28 |
29 | ### Basic view rendering
30 | Instead of GET we request it via AJAX:
31 | ```php
32 | public function favorites() {
33 | $this->request->allowMethod('ajax');
34 | $this->viewClass = 'Ajax.Ajax'; // Only necessary without the Ajax component
35 | }
36 | ```
37 |
38 | The result can be this, for example:
39 | ```
40 | {
41 | "content": [Result of our rendered favorites.ctp as HTML string],
42 | "error": ''
43 | }
44 | ```
45 | You can add more data to the response object via `serialize`.
46 |
47 |
48 | ### Drop down selections
49 | ```php
50 | public function statesAjax() {
51 | $this->request->allowMethod('ajax');
52 | $id = $this->request->getQuery('id');
53 | if (!$id) {
54 | throw new NotFoundException();
55 | }
56 |
57 | $this->viewClass = 'Ajax.Ajax'; // Only necessary without the Ajax component
58 |
59 | $states = $this->States->getListByCountry($id);
60 | $this->set(compact('states'));
61 | }
62 | ```
63 |
64 | ## Custom Plugin helpers
65 | If your view classes needs additional plugin helpers, and you are not using the controller way anymore to load/define helpers, then you might need to extend the view class to project level and add them there:
66 | ```php
67 | namespace App\View;
68 |
69 | use Ajax\View\AjaxView as PluginAjaxView;
70 |
71 | class AjaxView extends PluginAjaxView {
72 |
73 | /**
74 | * @return void
75 | */
76 | public function initialize() {
77 | parent::initialize();
78 | $this->loadHelper('...);
79 | ...
80 | }
81 |
82 | }
83 | ```
84 | Then make sure you load the app `Ajax` view class instead of the `Ajax.Ajax` one.
85 | If you are using the component, you can set Configure key `'Ajax.viewClass'` to your `'Ajax'` here.
86 |
87 | ## Tips
88 | I found the following quite useful for your jQuery AJAX code as some browsers might not properly work without it (at least for me it used to).
89 | ```
90 | beforeSend: function(xhr) {
91 | xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
92 | },
93 | ```
94 |
--------------------------------------------------------------------------------
/phpcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | config
7 | src
8 | tests
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 8
3 | paths:
4 | - src/
5 | bootstrapFiles:
6 | - %rootDir%/../../../tests/bootstrap.php
7 | ignoreErrors:
8 | - identifier: missingType.generics
9 |
--------------------------------------------------------------------------------
/src/AjaxPlugin.php:
--------------------------------------------------------------------------------
1 |
33 | */
34 | protected array $_defaultConfig = [
35 | 'viewClass' => 'Ajax.Ajax',
36 | 'autoDetect' => true,
37 | 'resolveRedirect' => true,
38 | 'flashKey' => 'Flash.flash',
39 | 'actions' => [],
40 | ];
41 |
42 | /**
43 | * @param \Cake\Controller\ComponentRegistry $collection
44 | * @param array $config
45 | */
46 | public function __construct(ComponentRegistry $collection, $config = []) {
47 | $defaults = (array)Configure::read('Ajax') + $this->_defaultConfig;
48 | $config += $defaults;
49 | parent::__construct($collection, $config);
50 | }
51 |
52 | /**
53 | * @param array $config
54 | * @return void
55 | */
56 | public function initialize(array $config): void {
57 | if (!$this->_config['autoDetect'] || !$this->_isActionEnabled()) {
58 | return;
59 | }
60 | $this->respondAsAjax = $this->getController()->getRequest()->is('ajax');
61 | }
62 |
63 | /**
64 | * Called before the Controller::beforeRender(), and before
65 | * the view class is loaded, and before Controller::render()
66 | *
67 | * @param \Cake\Event\EventInterface $event
68 | * @return void
69 | */
70 | public function beforeRender(EventInterface $event): void {
71 | if (!$this->respondAsAjax) {
72 | return;
73 | }
74 |
75 | $this->_respondAsAjax();
76 | }
77 |
78 | /**
79 | * @return void
80 | */
81 | protected function _respondAsAjax(): void {
82 | $this->getController()->viewBuilder()->setClassName($this->_config['viewClass']);
83 |
84 | // Set flash messages to the view
85 | if ($this->_config['flashKey']) {
86 | $message = $this->getController()->getRequest()->getSession()->consume($this->_config['flashKey']);
87 | $this->getController()->set('_message', $message);
88 | }
89 |
90 | // If `serialize` is true, *all* viewVars will be serialized; no need to add _message.
91 | if ($this->_isControllerSerializeTrue()) {
92 | return;
93 | }
94 |
95 | $serializeKeys = ['_message'];
96 | if (!empty($this->getController()->viewBuilder()->getVar('serialize'))) {
97 | $serializeKeys = array_merge($serializeKeys, (array)$this->getController()->viewBuilder()->getVar('serialize'));
98 | }
99 | $this->getController()->set('serialize', $serializeKeys);
100 | }
101 |
102 | /**
103 | * Called before Controller::redirect(). Allows you to replace the URL that will
104 | * be redirected to with a new URL.
105 | *
106 | * @param \Cake\Event\EventInterface $event Event
107 | * @param array|string $url Either the string or URL array that is being redirected to.
108 | * @param \Cake\Http\Response $response
109 | * @return void
110 | */
111 | public function beforeRedirect(EventInterface $event, $url, Response $response): void {
112 | if (!$this->respondAsAjax || !$this->_config['resolveRedirect']) {
113 | return;
114 | }
115 |
116 | $url = Router::url($url, true);
117 |
118 | $status = $response->getStatusCode();
119 | $response = $response->withStatus(200)->withoutHeader('Location');
120 | $this->getController()->setResponse($response);
121 |
122 | $this->getController()->enableAutoRender();
123 | $this->getController()->set('_redirect', compact('url', 'status'));
124 |
125 | $event->stopPropagation();
126 |
127 | if ($this->_isControllerSerializeTrue()) {
128 | return;
129 | }
130 |
131 | $serializeKeys = ['_redirect'];
132 | if ($this->getController()->viewBuilder()->getVar('serialize')) {
133 | $serializeKeys = array_merge($serializeKeys, (array)$this->getController()->viewBuilder()->getVar('serialize'));
134 | }
135 | $this->getController()->set('serialize', $serializeKeys);
136 | // Further changes will be required here when the change to immutable response objects is completed
137 | $response = $this->getController()->render();
138 | $event->setResult($response);
139 | }
140 |
141 | /**
142 | * Checks to see if the Controller->viewVar labeled `serialize` is set to boolean true.
143 | *
144 | * @return bool
145 | */
146 | protected function _isControllerSerializeTrue(): bool {
147 | if ($this->getController()->viewBuilder()->getVar('serialize') === true) {
148 | return true;
149 | }
150 |
151 | return false;
152 | }
153 |
154 | /**
155 | * Checks if we are using action whitelisting and if so checks if this action is whitelisted.
156 | *
157 | * @return bool
158 | */
159 | protected function _isActionEnabled(): bool {
160 | $actions = $this->getConfig('actions');
161 | if (!$actions) {
162 | return true;
163 | }
164 |
165 | return in_array($this->getController()->getRequest()->getParam('action'), $actions, true);
166 | }
167 |
168 | }
169 |
--------------------------------------------------------------------------------
/src/View/AjaxView.php:
--------------------------------------------------------------------------------
1 |
32 | */
33 | protected array $_passedVars = [
34 | 'viewVars', 'autoLayout', 'ext', 'helpers', 'view', 'layout', 'name', 'theme',
35 | 'layoutPath', 'plugin', 'passedArgs', 'subDir', 'template', 'templatePath',
36 | ];
37 |
38 | /**
39 | * The subdirectory. AJAX views are always in ajax.
40 | *
41 | * @var string
42 | */
43 | protected string $subDir = '';
44 |
45 | /**
46 | * List of special view vars.
47 | *
48 | * @var array
49 | */
50 | protected array $_specialVars = ['serialize', '_jsonOptions', '_jsonp'];
51 |
52 | /**
53 | * Constructor
54 | *
55 | * @param \Cake\Http\ServerRequest|null $request Request instance.
56 | * @param \Cake\Http\Response|null $response Response instance.
57 | * @param \Cake\Event\EventManager|null $eventManager Event manager instance.
58 | * @param array $viewOptions View options. See View::$_passedVars for list of
59 | * options which get set as class properties.
60 | */
61 | public function __construct(
62 | ?ServerRequest $request = null,
63 | ?Response $response = null,
64 | ?EventManager $eventManager = null,
65 | array $viewOptions = [],
66 | ) {
67 | parent::__construct($request, $response, $eventManager, $viewOptions);
68 |
69 | $this->disableAutoLayout();
70 |
71 | if ($this->subDir === '') {
72 | $this->subDir = 'ajax';
73 | $this->templatePath = str_replace(DS . 'json', '', $this->templatePath);
74 | $this->templatePath = str_replace(DS . 'ajax', '', $this->templatePath);
75 | }
76 |
77 | if (isset($response)) {
78 | $response = $response->withType('json');
79 | $this->response = $response;
80 | }
81 | }
82 |
83 | /**
84 | * Renders an AJAX view.
85 | * The rendered content will be part of the JSON response object and
86 | * can be accessed via `response.content` in JavaScript.
87 | *
88 | * If an error or success has been set, the rendering will be skipped.
89 | *
90 | * @param string|null $view The view being rendered.
91 | * @param string|null $layout The layout being rendered.
92 | * @return string The rendered view.
93 | */
94 | public function render(?string $view = null, $layout = null): string {
95 | $dataToSerialize = [
96 | 'error' => null,
97 | 'success' => null,
98 | 'content' => null,
99 | ];
100 |
101 | if ($view === null) {
102 | $view = $this->template;
103 | }
104 | if ($view === '') {
105 | $view = null;
106 | }
107 |
108 | if (isset($this->viewVars['error'])) {
109 | $dataToSerialize['error'] = $this->viewVars['error'];
110 | $view = null;
111 | }
112 | if (isset($this->viewVars['success'])) {
113 | $dataToSerialize['success'] = $this->viewVars['success'];
114 | $view = null;
115 | }
116 |
117 | if ($view !== null && !isset($this->viewVars['_redirect']) && $this->_getTemplateFileName($view)) {
118 | $dataToSerialize['content'] = parent::render($view, $layout);
119 | }
120 |
121 | $this->viewVars = Hash::merge($dataToSerialize, $this->viewVars);
122 | if (isset($this->viewVars['serialize'])) {
123 | $dataToSerialize = $this->_dataToSerialize($this->viewVars['serialize'], $dataToSerialize);
124 | }
125 |
126 | return $this->_serialize($dataToSerialize);
127 | }
128 |
129 | /**
130 | * Serialize(json_encode) accumulated data from both our custom render method
131 | * and viewVars set by the user.
132 | *
133 | * @param array $dataToSerialize Array of data that is to be serialzed.
134 | * @return string The serialized data.
135 | */
136 | protected function _serialize(array $dataToSerialize = []): string {
137 | return JsonEncoder::encode($dataToSerialize);
138 | }
139 |
140 | /**
141 | * Returns data to be serialized based on the value of viewVars.
142 | *
143 | * @param array|string|bool $serialize The name(s) of the view variable(s) that
144 | * need(s) to be serialized. If true all available view variables will be used.
145 | * @param array $additionalData Data items that were defined internally in our own
146 | * render method.
147 | * @return array The data to serialize.
148 | */
149 | protected function _dataToSerialize(array|bool|string $serialize, array $additionalData = []): array {
150 | if ($serialize === true) {
151 | $data = array_diff_key(
152 | $this->viewVars,
153 | array_flip($this->_specialVars),
154 | );
155 |
156 | return $data;
157 | }
158 |
159 | foreach ((array)$serialize as $alias => $key) {
160 | if (is_numeric($alias)) {
161 | $alias = $key;
162 | }
163 | if (array_key_exists($key, $this->viewVars)) {
164 | $additionalData[$alias] = $this->viewVars[$key];
165 | }
166 | }
167 |
168 | return $additionalData;
169 | }
170 |
171 | }
172 |
--------------------------------------------------------------------------------
/src/View/JsonEncoder.php:
--------------------------------------------------------------------------------
1 | $dataToSerialize
11 | * @param int $options
12 | *
13 | * @throws \RuntimeException
14 | *
15 | * @return string
16 | */
17 | public static function encode(array $dataToSerialize, int $options = 0): string {
18 | $result = json_encode($dataToSerialize, $options);
19 |
20 | $error = null;
21 | if (json_last_error() !== JSON_ERROR_NONE) {
22 | $error = 'JSON encoding failed: ' . json_last_error_msg();
23 | }
24 |
25 | if ($result === false || $error) {
26 | throw new RuntimeException($error ?: 'JSON encoding failed');
27 | }
28 |
29 | return $result;
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/tests/TestCase/Controller/Component/AjaxComponentTest.php:
--------------------------------------------------------------------------------
1 | Controller = new AjaxTestController(new ServerRequest(), new Response());
41 | }
42 |
43 | /**
44 | * @return void
45 | */
46 | public function testNonAjax() {
47 | $this->Controller->startupProcess();
48 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax);
49 | }
50 |
51 | /**
52 | * @throws \Exception
53 | * @return void
54 | */
55 | public function testDefaults() {
56 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
57 |
58 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response());
59 | $this->Controller->components()->load('Flash');
60 |
61 | $this->assertTrue($this->Controller->components()->Ajax->respondAsAjax);
62 |
63 | $this->Controller->components()->Flash->custom('A message');
64 | $session = $this->Controller->getRequest()->getSession()->read('Flash.flash');
65 | $expected = [
66 | [
67 | 'message' => 'A message',
68 | 'key' => 'flash',
69 | 'element' => 'flash/custom',
70 | 'params' => [],
71 | ],
72 | ];
73 | $this->assertEquals($expected, $session);
74 |
75 | $event = new Event('Controller.beforeRender');
76 | $this->Controller->components()->Ajax->beforeRender($event);
77 |
78 | $this->assertEquals('Ajax.Ajax', $this->Controller->viewBuilder()->getClassName());
79 | $this->assertEquals($expected, $this->Controller->viewBuilder()->getVar('_message'));
80 |
81 | $session = $this->Controller->getRequest()->getSession()->read('Flash.flash');
82 | $this->assertNull($session);
83 |
84 | $this->Controller->redirect('/');
85 | $expected = [
86 | 'Content-Type' => [
87 | 'application/json',
88 | ],
89 | ];
90 | $this->assertSame($expected, $this->Controller->getResponse()->getHeaders());
91 |
92 | $expected = [
93 | 'url' => Router::url('/', true),
94 | 'status' => 302,
95 | ];
96 | $this->assertEquals($expected, $this->Controller->viewBuilder()->getVar('_redirect'));
97 | }
98 |
99 | /**
100 | * @throws \Exception
101 | * @return void
102 | */
103 | public function testAutoDetectOnFalse() {
104 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
105 |
106 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response());
107 |
108 | $this->Controller->components()->unload('Ajax');
109 | $this->Controller->components()->load('Ajax.Ajax', ['autoDetect' => false]);
110 |
111 | $this->Controller->startupProcess();
112 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax);
113 | }
114 |
115 | /**
116 | * @throws \Exception
117 | * @return void
118 | */
119 | public function testActionsInvalid() {
120 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
121 | Configure::write('Ajax.actions', ['foo']);
122 |
123 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response());
124 |
125 | $this->Controller->components()->unload('Ajax');
126 | $this->Controller->components()->load('Ajax.Ajax');
127 |
128 | $this->Controller->startupProcess();
129 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax);
130 | }
131 |
132 | /**
133 | * @throws \Exception
134 | * @return void
135 | */
136 | public function testActions() {
137 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
138 | Configure::write('Ajax.actions', ['foo']);
139 |
140 | $this->Controller = new AjaxTestController(new ServerRequest(['params' => ['action' => 'foo']]), new Response());
141 |
142 | $this->Controller->components()->unload('Ajax');
143 | $this->Controller->components()->load('Ajax.Ajax');
144 |
145 | $this->Controller->startupProcess();
146 | $this->assertTrue($this->Controller->components()->Ajax->respondAsAjax);
147 | }
148 |
149 | /**
150 | * @throws \Exception
151 | * @return void
152 | */
153 | public function testAutoDetectOnFalseViaConfig() {
154 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
155 | Configure::write('Ajax.autoDetect', false);
156 |
157 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response());
158 |
159 | $this->Controller->components()->unload('Ajax');
160 | $this->Controller->components()->load('Ajax.Ajax');
161 |
162 | $this->Controller->startupProcess();
163 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax);
164 | }
165 |
166 | /**
167 | * @throws \Exception
168 | * @return void
169 | */
170 | public function testSetVars() {
171 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
172 |
173 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response());
174 |
175 | $this->Controller->components()->unload('Ajax');
176 |
177 | $content = ['id' => 1, 'title' => 'title'];
178 | $this->Controller->set(compact('content'));
179 | $this->Controller->set('serialize', ['content']);
180 |
181 | $this->Controller->components()->load('Ajax.Ajax');
182 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVars());
183 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVar('serialize'));
184 | $this->assertEquals('content', $this->Controller->viewBuilder()->getVar('serialize')[0]);
185 | }
186 |
187 | /**
188 | * @return void
189 | */
190 | public function testSetVarsWithRedirect() {
191 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
192 |
193 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response());
194 | $this->Controller->startupProcess();
195 |
196 | $content = ['id' => 1, 'title' => 'title'];
197 | $this->Controller->set(compact('content'));
198 | $this->Controller->set('serialize', ['content']);
199 |
200 | // Let's try a permanent redirect
201 | $this->Controller->redirect('/', 301);
202 | $expected = [
203 | 'Content-Type' => [
204 | 'application/json',
205 | ],
206 | ];
207 | $this->assertSame($expected, $this->Controller->getResponse()->getHeaders());
208 |
209 | $expected = [
210 | 'url' => Router::url('/', true),
211 | 'status' => 301,
212 | ];
213 | $this->assertEquals($expected, $this->Controller->viewBuilder()->getVar('_redirect'));
214 |
215 | $this->Controller->set(['_message' => 'test']);
216 | $this->Controller->redirect('/');
217 | $this->assertArrayHasKey('_message', $this->Controller->viewBuilder()->getVars());
218 |
219 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVars());
220 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVar('serialize'));
221 | $this->assertTrue(in_array('content', $this->Controller->viewBuilder()->getVar('serialize')));
222 | }
223 |
224 | }
225 |
--------------------------------------------------------------------------------
/tests/TestCase/View/AjaxViewTest.php:
--------------------------------------------------------------------------------
1 | Ajax = new AjaxView();
38 | }
39 |
40 | /**
41 | * @return void
42 | */
43 | public function testSerialize() {
44 | $Request = new ServerRequest();
45 | $Response = new Response();
46 | $items = [
47 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'],
48 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'],
49 | ];
50 | $View = new AjaxView($Request, $Response);
51 | $View->set(['items' => $items, 'serialize' => ['items']]);
52 | $result = $View->render('');
53 |
54 | $response = $View->getResponse();
55 | $this->assertSame('application/json', $response->getType());
56 | $expected = ['error' => null, 'success' => null, 'content' => null, 'items' => $items];
57 | $expected = json_encode($expected);
58 | $this->assertTextEquals($expected, $result);
59 | }
60 |
61 | /**
62 | * @return void
63 | */
64 | public function testRenderWithSerialize() {
65 | $Request = new ServerRequest();
66 | $Response = new Response();
67 | $items = [
68 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'],
69 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'],
70 | ];
71 | $View = new AjaxView($Request, $Response);
72 | $View->set(['items' => $items, 'serialize' => 'items']);
73 | $View->setTemplatePath('Items');
74 | $result = $View->render('index');
75 |
76 | $response = $View->getResponse();
77 | $this->assertSame('application/json', $response->getType());
78 | $expected = ['error' => null, 'success' => null, 'content' => 'My Ajax Index Test ctp' . PHP_EOL, 'items' => $items];
79 | $expected = json_encode($expected);
80 | $this->assertTextEquals($expected, $result);
81 | }
82 |
83 | /**
84 | * Test the case where the `serialize` viewVar is set to true signaling that all viewVars
85 | * should be serialized.
86 | *
87 | * @return void
88 | */
89 | public function testSerializeSetTrue() {
90 | $Request = new ServerRequest();
91 | $Response = new Response();
92 | $items = [
93 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'],
94 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'],
95 | ];
96 | $multiple = 'items';
97 | $View = new AjaxView($Request, $Response);
98 | $View->set(['items' => $items, 'multiple' => $multiple, 'serialize' => true]);
99 | $result = $View->render('');
100 |
101 | $response = $View->getResponse();
102 | $this->assertSame('application/json', $response->getType());
103 | $expected = ['error' => null, 'success' => null, 'content' => null, 'items' => $items, 'multiple' => $multiple];
104 | $expected = json_encode($expected);
105 | $this->assertTextEquals($expected, $result);
106 | }
107 |
108 | /**
109 | * @return void
110 | */
111 | public function testError() {
112 | $Request = new ServerRequest();
113 | $Response = new Response();
114 | $items = [
115 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'],
116 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'],
117 | ];
118 | $View = new AjaxView($Request, $Response);
119 | $View->set(['error' => 'Some message', 'items' => $items, 'serialize' => ['error', 'items']]);
120 | $View->setTemplatePath('Items');
121 | $result = $View->render('index');
122 |
123 | $response = $View->getResponse();
124 | $this->assertSame('application/json', $response->getType());
125 | $expected = ['error' => 'Some message', 'success' => null, 'content' => null, 'items' => $items];
126 | $expected = json_encode($expected);
127 | $this->assertTextEquals($expected, $result);
128 | }
129 |
130 | /**
131 | * @return void
132 | */
133 | public function testWithoutSubdir() {
134 | $Request = new ServerRequest();
135 | $Response = new Response();
136 | $View = new AjaxView($Request, $Response);
137 | $View->setTemplatePath('Items');
138 | $View->setSubDir('');
139 | $result = $View->render('index');
140 |
141 | $response = $View->getResponse();
142 | $this->assertSame('application/json', $response->getType());
143 | $expected = ['error' => null, 'success' => null, 'content' => 'My Index Test ctp' . PHP_EOL];
144 | $expected = json_encode($expected);
145 | $this->assertTextEquals($expected, $result);
146 | }
147 |
148 | }
149 |
--------------------------------------------------------------------------------
/tests/config/routes.php:
--------------------------------------------------------------------------------
1 | scope('/', function (RouteBuilder $routes) {
11 | $routes->connect('/:controller', ['action' => 'index'], ['routeClass' => 'DashedRoute']);
12 | $routes->connect('/:controller/:action/*', [], ['routeClass' => 'DashedRoute']);
13 | });
14 |
--------------------------------------------------------------------------------