├── .gitbook.yaml ├── .github └── workflows │ └── main.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── composer.json ├── docs ├── clonning.md ├── configuration.md ├── getting-started.md ├── icons.md ├── index.md ├── installation.md ├── javascript-events.md ├── renderers.md ├── tips-and-tricks.md └── usage.md ├── mkdocs.yml └── src ├── MultipleInput.php ├── MultipleInputColumn.php ├── TabularColumn.php ├── TabularInput.php ├── assets ├── FontAwesomeAsset.php ├── MultipleInputAsset.php ├── MultipleInputSortableAsset.php └── src │ ├── css │ ├── multiple-input.css │ ├── multiple-input.min.css │ ├── sorting.css │ └── sorting.min.css │ └── js │ ├── jquery.multipleInput.js │ ├── jquery.multipleInput.min.js │ ├── sortable.js │ └── sortable.min.js ├── components ├── BaseColumn.php └── ValuePreparer.php └── renderers ├── BaseRenderer.php ├── DivRenderer.php ├── ListRenderer.php ├── RendererInterface.php └── TableRenderer.php /.gitbook.yaml: -------------------------------------------------------------------------------- 1 | root: ./docs/ 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build: 9 | name: Deploy docs 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout master 13 | uses: actions/checkout@v1 14 | 15 | - name: Deploy docs 16 | uses: mhausenblas/mkdocs-deploy-gh-pages@master 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Yii2 multiple input change log 2 | ============================== 3 | 4 | 2.30.0 (in development) 5 | ======================= 6 | - #363 fix rendering header and footer in case of using POS_ROWS_BEGIN in TableRenderer (unclead) 7 | 8 | 2.30.0 9 | ======================= 10 | - #369 fix rendering action buttons in case the dataset contains non-numeric indices (unclead) 11 | 12 | 2.29.0 13 | ======================= 14 | - fix addind active form fields doesn't work properly in case of 10 rows and more (unclead) 15 | - revert changes in normalize method because it affected ajax validation 16 | - fix addind active form fields in nested columns (unclead) 17 | 18 | 2.28.0 19 | ======================= 20 | - replace outdated jquery-sortable.js with a modern alternative (sortable.js) (sankam-nikolya) 21 | 22 | 2.27.0 23 | ====== 24 | - #367 (fix) ajax validation doesn't work for newly added/cloned inputs 25 | 26 | 2.26.2 27 | ====== 28 | - prevent error loop in case of undefined $wrapper.data('multipleInput') (cebe) 29 | 30 | 2.26.1 31 | ====== 32 | - remove version from composer.json 33 | 34 | 2.26.0 35 | ====== 36 | - fix calculation of the current row index 37 | - fix incrementing the current row index after adding a new row 38 | 39 | 2.25.0 40 | ====== 41 | - rework cloning: fix #277, #351, #348 (unclead) 42 | 43 | 2.24.0 44 | ====== 45 | - #339 don't set tabindex explicitly 46 | 47 | 2.23.0 48 | ====== 49 | - always use `id` from the settings if it is specified 50 | - Ability to add custom tabindex via options array 51 | - #335 fix input name in case of one column and enabled sorting 52 | 53 | 2.22.0 54 | ====== 55 | - Ignore dev files in zip distribution (sup-ham) 56 | - #292 Fixed tests for last PHPUnit 57 | - Added support prepare values of attributes with same name as the relation 58 | 59 | 2.21.4 60 | ====== 61 | - Fix replaceAll unfinished modify 62 | - More completely type detect to replaceAll 63 | 64 | 2.21.3 65 | ====== 66 | - fix retrieving AR relation data 67 | 68 | 2.21.2 69 | ====== 70 | - FIX wrapper options for bootstrap theme 71 | 72 | 2.21.1 73 | ====== 74 | - #286 avoid removal of all rows on the page when there are several widgets on the page and method `clear` was called 75 | 76 | 2.21.0 77 | ====== 78 | - #279 ability to prepend new row instead of append 79 | 80 | 2.20.6 81 | ====== 82 | 83 | - don't cast JsExpression to string after replace widget placeholder 84 | 85 | 2.20.0 86 | ====== 87 | 88 | - #278 allow zero name 89 | - #261 replace the widget placeholder in all nested options 90 | 91 | 2.19.0 92 | ====== 93 | - add template for input (bscheshirwork) 94 | - pass more params to a prepareValue closure (bscheshirwork) 95 | - add DivRenderer (bscheshirwork) 96 | 97 | 2.18.0 98 | ====== 99 | - #246 accept `\Traversable` in model attribute for `yield` compatibility (bscheshirwork) 100 | - #250 accept `\Traversable` in TableRenderer and ListRenderer for `yield` compatibility (bscheshirwork) 101 | - #253 allow to omit a name for static column 102 | - #257 added `jsPositions` property for the `BaseRenderer` to set right order js-code in `jsInit` and `jsTemplates` (Spell6inder) 103 | - #259 added `columnOptions` property in the `BaseColumn` for TableRenderer and ListRenderer to support HTML options of individual column (InsaneSkull) 104 | 105 | 2.17.0 106 | ====== 107 | - #215 collect all js script that has to be evaluate when add new row (not only from " on ready" section) 108 | - #198 introduce the option `theme` to disable all bootstrap css classes 109 | - #197 explicitly set tabindex for all inputs 110 | - #175 option `showGeneralError` to enable displaying of general error message 111 | 112 | 2.16.0 113 | ====== 114 | - #220 fixed error message for clientValidation and ajaxValidation (antkaz) 115 | - #228 added `iconMap` and `iconSource`property for MultipleInput and TabularInput 116 | - #228 changed the following methods to support icon class: 117 | BaseColumn->renderDragColumn(), TableRenderer->renderCellContent(), BaseRenderer->prepareButtons() 118 | - #194 added support of yii\base\DynamicModel 119 | - #186 added event `afterDropRow` 120 | 121 | 2.15.0 122 | ======================= 123 | - #217 added `layoutConfig` property for the ListRenderer (antkaz) 124 | 125 | 2.14.0 126 | ====== 127 | - #202 added extra buttons (dimmitri) 128 | - PR#201 added optional clone button (alex-nesterov) 129 | 130 | 2.13.0 131 | ====== 132 | - #152 added ability to allow an empty list (or set `min` property to 0) for TabularInput 133 | 134 | 2.12.0 135 | ====== 136 | - Rename yii\base\Object to yii\base\BaseObject 137 | 138 | 139 | 2.11.0 140 | ====== 141 | - Added the possibility to substitute buttons before rows 142 | 143 | 2.10.0 144 | ====== 145 | - #170: Added global options `enableError` 146 | - #154: Added missing js event: beforeAddRow 147 | 148 | 2.9.0 149 | ===== 150 | 151 | - Pass the added row to `afterAddRow` event 152 | 153 | 2.8.2 154 | ===== 155 | 156 | - Fixed conflict with jQuery UI sortable 157 | 158 | 2.8.1 159 | ===== 160 | 161 | - Fixed client validation 162 | 163 | 2.8.0 164 | ===== 165 | 166 | - #137: added option `nameSuffix` to avoid errors related to duplication of id in case when you use several copies of the widget on a page 167 | 168 | 2.7.1 169 | ===== 170 | 171 | - Fixed assets 172 | 173 | 2.7.0 174 | ===== 175 | 176 | - Fixed an incorrect behavior of widget in case of ajax loading (e.g. in modal window) 177 | 178 | 2.6.1 179 | ===== 180 | 181 | - Fixed assets 182 | 183 | 2.6.0 184 | ===== 185 | 186 | - PR#132: Implemented `Sortting` (sankam-nikolya) 187 | - PR#132: fixed if attribute is set and hasProperty return false (sankam-nikolya) 188 | 189 | 2.5.0 190 | ===== 191 | 192 | - #127: fixed js actions 193 | 194 | 2.4.0 195 | ===== 196 | 197 | - Implemented `ListRenderer` 198 | 199 | 2.3.1 200 | ===== 201 | 202 | - Fixed ajax validation for embedded fields 203 | 204 | 2.3.0 205 | ===== 206 | 207 | - #107: render a hidden input when `MultipleInput` is used for active field 208 | - #109: respect ID when using a widget's placeholder 209 | 210 | 2.2.0 211 | ===== 212 | 213 | - #104: Fixed preparation of js attributes (Choate, unclead) 214 | - Fixed removal of row with index 0 via js api method (pvlg) 215 | 216 | 217 | 2.1.1 218 | ===== 219 | 220 | - Enh: Passing a deleted row to the event 221 | 222 | 2.1.0 223 | ===== 224 | 225 | - Enh #37: Support of client validation 226 | 227 | 2.0.1 228 | ===== 229 | 230 | - Bug #105: Change vendor name in namespace from yii to unclead to respect Yii recommendations 231 | 232 | 2.0.0 233 | ===== 234 | 235 | - Renamed `limit` option to `max` 236 | - Changed namespace from `unclead\widgets` to `yii\multipleinput` 237 | - #92: Adjustments for correct work with AR relations 238 | - Enh #104: Added method to set value of an particular option 239 | 240 | 1.4.1 241 | ===== 242 | 243 | - #99: Respect "defaultValue" if it is set and current value is empty (unclead) 244 | 245 | 1.4.0 246 | ----- 247 | 248 | - #94: added ability to set custom renderer (unclead, bokodi-dev) 249 | - #97: Respect `addButtonPosition` when rendering the button (unclead) 250 | 251 | 1.3.1 252 | ----- 253 | 254 | - Bug: Use method `::className` instead of `::class` 255 | 256 | 1.3.0 257 | ----- 258 | 259 | - #79 Added support for embedded MultipleInput widget (unclead, execut) 260 | - Enh: Added ability to render `add` button in the footer (unclead) 261 | - Enh: Improving for better work without ActiveForm (unclead) 262 | - Enh: Added ability to render `add` button at several positions (unclead) 263 | 264 | 1.2.19 265 | ------ 266 | 267 | - #85: fixed `$enableError` not render element in template (thiagotalma) 268 | 269 | 1.2.18 270 | ------ 271 | 272 | - #81 fixed output of errors in case of non-ajax validation 273 | 274 | 1.2.17 275 | ------ 276 | 277 | - Enh: increased default value for the property `limit` (ivansal) 278 | - Enh: Added support of associative array in data (ivansal) 279 | - Bug: fixed double execution events for included MultipleInput (fiamma06) 280 | 281 | 1.2.16 282 | ------ 283 | 284 | - Bug #70: replacing of the placeholder after preparing the content of row 285 | 286 | 1.2.15 287 | ------ 288 | 289 | - Added note about usage widget with ajax 290 | 291 | 1.2.14 292 | ------ 293 | 294 | - Bug #71: trigger the event after actual removal of row 295 | 296 | 1.2.13 297 | ------ 298 | 299 | - Added new js events (add/remove/clear inputs) and integrated the gulp for minification of assets (veksa) 300 | - Added support of closure for parameter `options` (veksa) 301 | 302 | 1.2.12 303 | ------ 304 | 305 | - Hotfix: Fixed error when array_key_exits (kongoon) 306 | 307 | 1.2.11 308 | ------ 309 | 310 | - Bug #61: Fixed a rendering of remove button 311 | - Bug #62: Incorrect behavior is case when `min` is equal to `limit` 312 | - Bug #64: Radio/checkbox lists doesn't work correctly 313 | 314 | 1.2.10 315 | ------ 316 | 317 | - Enh #59 Added columnClass property (unclead) 318 | 319 | 1.2.9 320 | ----- 321 | 322 | - Enh #56: add `rowOptions` property 323 | 324 | 1.2.8 325 | ----- 326 | 327 | - Enh: Don't show action column when limit is `equal` to `min` 328 | 329 | 1.2.7 330 | ----- 331 | 332 | - Bug #55: Attach click events to the widget wrapper instead of `$(document)` 333 | 334 | 1.2.6 335 | ----- 336 | 337 | - Bug #49: urlencoded field token replacement in js template (rolmonk) 338 | - Enh #48: Added option `min` for setting minimum number of rows 339 | - Enh: added option `addButtonPosition` 340 | 341 | 1.2.5 342 | ----- 343 | 344 | - Bug #46: Renamed placeholder to avoid conflict with other plugins 345 | - Bug #47: Use Html helper for rendering buttons instead of Button widget 346 | - Enh: Deleted yii2-bootstrap dependency 347 | 348 | 1.2.4 349 | ----- 350 | 351 | - Bug #39: TabularInput: now new row does't copy values from the most recent row 352 | - Enh #40: Pass the current row for removal when calling `beforeDeleteRow` event 353 | 354 | 355 | 1.2.3 356 | ----- 357 | 358 | - Enh #34: Added option `allowEmptyList` (unclead) 359 | - Enh #35: Added option `enableGuessTitle` for MultipleInput (unclead) 360 | - Bug #36: Use PCRE_MULTILINE modifier in regex 361 | 362 | 1.2.2 363 | ----- 364 | 365 | - Enh #31: Added support of anonymous function for `items` attribute (unclead, stepancher) 366 | - Enh: added hidden field for radio and checkbox inputs (unclead, kotchuprik) 367 | - Enh: improved css (fiamma06) 368 | 369 | 1.2.1 370 | ----- 371 | 372 | - Bug #25 fixed rendering when data is empty 373 | - Bug #27 fixed element's prefix generation 374 | 375 | 1.2.0 376 | ----- 377 | 378 | - Bug #19 Refactoring rendering of inputs (unclead) 379 | - Bug #20 Added hasAttribute checking for AR models (unclead) 380 | - Enh #22 Added `TabularInput` widget (unclead), rendering logic has been moved to separate class (renderer) 381 | 382 | 1.1.0 383 | ----- 384 | 385 | - Bug #17: display inline errors (unclead, mikbox74) 386 | - Enh #11: Improve js events (unclead) 387 | - Bug #16: correct use of defaultValue property (unclead) 388 | - code improvements (unclead) 389 | 390 | 1.0.4 391 | -------------------- 392 | 393 | - Bug #15: Fix setting current values of dropDownList (unclead) 394 | - Bug #16: fix render of dropDown and similar inputs (unclead) 395 | - Enh: Add attributeOptions property 396 | 397 | 1.0.3 398 | ----- 399 | - Bug: Hidden fields no longer break markup (unclead, kotchuprik) 400 | 401 | 1.0.2 402 | ----- 403 | 404 | - Enh: added minified version of js script (unclead) 405 | - Enh #8: renamed placeholders for avoid conflicts with other widgets (unclead) 406 | - Enh #7: customization of header cell 407 | 408 | 1.0.1 409 | ----- 410 | 411 | - Enh #1: Implemented ability to use widget as column type (unclead) 412 | - Enh: add js events (ZAYEC77) 413 | 414 | 1.0.0 415 | ----- 416 | 417 | first stable release 418 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Eugene Tupikov 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | * Neither the names of Eugene Tupikov or unclead nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yii2 Multiple input widget. 2 | Yii2 widget for handle multiple inputs for an attribute of model and tabular input for batch of models. 3 | 4 | [![Latest Stable Version](https://poser.pugx.org/unclead/yii2-multiple-input/v/stable)](https://packagist.org/packages/unclead/yii2-multiple-input) 5 | [![Total Downloads](https://poser.pugx.org/unclead/yii2-multiple-input/downloads)](https://packagist.org/packages/unclead/yii2-multiple-input) 6 | [![Daily Downloads](https://poser.pugx.org/unclead/yii2-multiple-input/d/daily)](https://packagist.org/packages/unclead/yii2-multiple-input) 7 | [![Latest Unstable Version](https://poser.pugx.org/unclead/yii2-multiple-input/v/unstable)](https://packagist.org/packages/unclead/yii2-multiple-input) 8 | [![License](https://poser.pugx.org/unclead/yii2-multiple-input/license)](https://packagist.org/packages/unclead/yii2-multiple-input) 9 | 10 | ## Latest release 11 | The latest stable version of the extension is v2.27.0 Follow the [instruction](./UPGRADE.md) for upgrading from previous versions 12 | 13 | ## Installation 14 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 15 | 16 | Either run 17 | 18 | ``` 19 | php composer.phar require unclead/yii2-multiple-input "~2.0" 20 | ``` 21 | 22 | or add 23 | 24 | ``` 25 | "unclead/yii2-multiple-input": "~2.0" 26 | ``` 27 | 28 | to the require section of your `composer.json` file. 29 | 30 | ## Basic usage 31 | 32 | ![Single column example](./resources/images/single-column.gif?raw=true) 33 | 34 | For example you want to have an ability of entering several emails of user on profile page. 35 | In this case you can use yii2-multiple-input widget like in the following code 36 | 37 | ```php 38 | use unclead\multipleinput\MultipleInput; 39 | 40 | ... 41 | 42 | field($model, 'emails')->widget(MultipleInput::className(), [ 44 | 'max' => 6, 45 | 'min' => 2, // should be at least 2 rows 46 | 'allowEmptyList' => false, 47 | 'enableGuessTitle' => true, 48 | 'addButtonPosition' => MultipleInput::POS_HEADER, // show add button in the header 49 | ]) 50 | ->label(false); 51 | ?> 52 | ``` 53 | 54 | ## Documentation 55 | 56 | You can find a full version of documentation [here](https://unclead.github.io/yii2-multiple-input/) 57 | 58 | ## License 59 | 60 | **yii2-multiple-input** is released under the BSD 3-Clause License. See the bundled [LICENSE.md](./LICENSE.md) for details. 61 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | Upgrading Instructions for yii2-multiple-widget 2 | =============================================== 3 | 4 | !!!IMPORTANT!!! 5 | 6 | The following upgrading instructions are cumulative. That is, 7 | if you want to upgrade from version A to version C and there is 8 | version B between A and C, you need to following the instructions 9 | for both A and B. 10 | 11 | Upgrade 2.12.0 12 | -------------- 13 | - Ensure you use yii2 2.0.13 and higher, otherwise you have to use previous version of the widget 14 | 15 | Upgrade from 2.2.0 tp 2.3.0 16 | --------------------------- 17 | 18 | - Ensure that you set `id` option in case you are using js actions, otherwise your old code won't work. 19 | 20 | Upgrade from 2.0.0 tp 2.0.1 21 | --------------------------- 22 | 23 | - Change namespace prefix `yii\multipleinput\` to `unclead\multipleinput\`. 24 | 25 | Upgrade from 1.4 to 2.0 26 | ----------------------- 27 | 28 | - Rename option `limit` to `max` 29 | - Change namespace prefix `unclead\widgets\` to `yii\multipleinput\`. 30 | 31 | Upgrade from 1.3 to 1.4 32 | ----------------------- 33 | - In scope of #97 was changed a behavior of rendering add button. The button renders now depends on option `addButtonPosition` and now this 34 | option is not set by default. 35 | 36 | 37 | Upgrade from 1.2 to 1.3 38 | ----------------------- 39 | 40 | - The mechanism of customization configuration by using index placeholder was changed in scope of implementing support of nested `MultipleInput` 41 | If you customize configuration by using index placeholder you have to add ID of widget to the placeholder. 42 | For example, `multiple_index` became `multiple_index_question_list` 43 | 44 | 45 | Upgrade from version less then 1.1.0 46 | ------------------------------------ 47 | 48 | After installing version 1.1.0 you have to rename js events following the next schema: 49 | 50 | - Event `init` rename to `afterInit` 51 | - Event `addNewRow` rename to `afterAddRow` 52 | - Event `removeRow` rename to `afterDeleteRow` 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unclead/yii2-multiple-input", 3 | "description": "Widget for handle multiple inputs for an attribute of Yii2 framework model", 4 | "keywords": [ 5 | "yii2", 6 | "yii2 multiple input", 7 | "yii2 array input", 8 | "yii2 multiple field", 9 | "yii2 tabular input" 10 | ], 11 | "type": "yii2-extension", 12 | "license": "BSD-3-Clause", 13 | "support": { 14 | "issues": "https://github.com/unclead/yii2-multiple-input/issues?state=open", 15 | "source": "https://github.com/unclead/yii2-multiple-input" 16 | }, 17 | "authors": [ 18 | { 19 | "name": "Eugene Tupikov", 20 | "email": "unclead.nsk@gmail.com" 21 | } 22 | ], 23 | "require": { 24 | "php": ">=5.4.0", 25 | "yiisoft/yii2": ">=2.0.38" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "5.7.*" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "unclead\\multipleinput\\examples\\": "examples/", 33 | "unclead\\multipleinput\\": "src/", 34 | "unclead\\multipleinput\\tests\\": "tests/" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/clonning.md: -------------------------------------------------------------------------------- 1 | # Clonning 2 | 3 | ![Clone button example](https://raw.githubusercontent.com/unclead/yii2-multiple-input/master/resources/images/clone-button.gif) 4 | 5 | ```text 6 | use unclead\multipleinput\MultipleInput; 7 | 8 | ... 9 | 10 | $form->field($model, 'products')->widget(MultipleInput::className(), [ 11 | 'max' => 10, 12 | 'cloneButton' => true, 13 | 'columns' => [ 14 | [ 15 | 'name' => 'product_id', 16 | 'type' => 'dropDownList', 17 | 'title' => 'Special Products', 18 | 'defaultValue' => 1, 19 | 'items' => [ 20 | 1 => 'id: 1, price: $19.99, title: product1', 21 | 2 => 'id: 2, price: $29.99, title: product2', 22 | 3 => 'id: 3, price: $39.99, title: product3', 23 | 4 => 'id: 4, price: $49.99, title: product4', 24 | 5 => 'id: 5, price: $59.99, title: product5', 25 | ], 26 | ], 27 | [ 28 | 'name' => 'time', 29 | 'type' => DateTimePicker::className(), 30 | 'title' => 'due date', 31 | 'defaultValue' => date('d-m-Y h:i') 32 | ], 33 | [ 34 | 'name' => 'count', 35 | 'title' => 'Count', 36 | 'defaultValue' => 1, 37 | 'enableError' => true, 38 | 'options' => [ 39 | 'type' => 'number', 40 | 'class' => 'input-priority', 41 | ] 42 | ] 43 | ] 44 | ])->label(false); 45 | ``` 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Widget support the following options that are additionally recognized over and above the configuration options in the InputWidget. 4 | 5 | ## Base options 6 | 7 | **theme** _string_: specify the theme of the widget. Available 2 themes: 8 | 9 | * `default` with only widget css classes 10 | * `bs` \(twitter bootstrap\) theme with additional BS ccs classes\). 11 | 12 | Default value is `bs` 13 | 14 | **max** _integer_: maximum number of rows. If not set will default to unlimited 15 | 16 | **min** _integer_: minimum number of rows. Set to `0` if you need the empty list in case you don't have any data 17 | 18 | **prepend** _boolean_: add a new row to the beginning of the list, not to the end 19 | 20 | **attributeOptions** _array_: client-side attribute options, e.g. enableAjaxValidation. You may use this property in case when you use widget without a model, since in this case widget is not able to detect client-side options automatically 21 | 22 | **addButtonPosition** _integer\|array_: the position\(s\) of `add` button. This can be `MultipleInput::POS_HEADER`, `MultipleInput::POS_ROW`, `MultipleInput::POS_ROW_BEGIN` or `MultipleInput::POS_FOOTER`. 23 | 24 | **addButtonOptions** _array_: the HTML options for `add` button. Can contains `class` and `label` keys 25 | 26 | **removeButtonOptions** _array_: the HTML options for `remove` button. Can contains `class` and `label` keys 27 | 28 | **cloneButton** _bool_: whether need to enable clone buttons or not 29 | 30 | **cloneButtonOptions** _array_: the HTML options for `remove` button. Can contains `class` and `label` keys 31 | 32 | **data** _array_: array of values in case you use widget without model 33 | 34 | **models** _array_: the list of models. Required in case you use `TabularInput` widget 35 | 36 | **allowEmptyList** _boolean_: whether to allow the empty list. **Deprecateed** use the `min` option instead 37 | 38 | **columnClass** _string_: the name of column class. You can specify your own class to extend base functionality. Defaults to `unclead\multipleinput\MultipleInputColumn` for `MultipleInput` and `unclead\multipleinput\TabularColumn` for `TabularInput`. 39 | 40 | **rendererClass** _string_: the name of renderer class. You can specify your own class to extend base functionality. Defaults to `unclead\multipleinput\renderers\TableRenderer`. 41 | 42 | **columns** _array_: the row columns configuration where you can set the properties which is described below 43 | 44 | **rowOptions** _array\|\Closure_: the HTML attributes for the table body rows. This can be either an array specifying the common HTML attributes for all body rows, or an anonymous function that returns an array of the HTML attributes. It should have the following signature: 45 | 46 | ```php 47 | function ($model, $index, $context) 48 | ``` 49 | 50 | * `$model`: the current data model being rendered 51 | * `$index`: the zero-based index of the data model in the model array 52 | * `$context`: the widget object 53 | 54 | **sortable** _bool_: whether need to enable sorting or not 55 | 56 | **modelClass** _string_: a class of model which is used to render `TabularInput`. You must specify this property when a list of `models` is empty. If this property is not specified the widget will detect it based on a class of `models` 57 | 58 | **extraButtons** _string\|\Closure_: the HTML content that will be rendered after the buttons. It can be either string or an anonymous function that returns a string which will be treated as HTML content. It should have the following signature: 59 | 60 | ```php 61 | function ($model, $index, $context) 62 | ``` 63 | 64 | * `$model`: the current data model being rendered 65 | * `$index`: the zero-based index of the data model in the model array 66 | * `$context`: the MultipleInput widget object 67 | 68 | **layoutConfig** _array_: CSS grid classes for horizontal layout \(only supported for `ListRenderer` class\). This must be an array with these keys: 69 | 70 | * `'offsetClass'`: the offset grid class to append to the wrapper if no label is rendered 71 | * `'labelClass'`: the label grid class 72 | * `'wrapperClass'`: the wrapper grid class 73 | * `'errorClass'`: the error grid class 74 | 75 | **showGeneralError** _bool_: whether need to show error message for main attribute, when you don't want to validate particular input and want to validate a filed in general. 76 | 77 | ## Column options 78 | 79 | **name** _string_: input name. _Required options_ 80 | 81 | **type** _string_: type of the input. If not set will default to `textInput`. Read more about the types described below 82 | 83 | **title** _string_: the column title 84 | 85 | **value** _Closure_: you can set it to an anonymous function with the following signature: 86 | 87 | ```php 88 | function($data) {} 89 | ``` 90 | 91 | **defaultValue** _string_: default value of input 92 | 93 | **items** _array_\|_Closure_: the items for input with type dropDownList, listBox, checkboxList, radioList or anonymous function which return array of items and has the following signature: 94 | 95 | ```php 96 | function($data) {} 97 | ``` 98 | 99 | **options** _array_\|_Closure_: the HTML attributes for the input, you can set it as array or an anonymous function with the following signature: 100 | 101 | ```php 102 | function($data) {} 103 | ``` 104 | 105 | **headerOptions** _array_: the HTML attributes for the header cell 106 | 107 | **enableError** _boolean_: whether to render inline error for the input. Default to `false` 108 | 109 | **errorOptions** _array_: the HTMl attributes for the error tag 110 | 111 | **nameSuffix** _string_: the unique prefix for attribute's name to avoid id duplication e.g. in case of using several copies of the widget on a page and one column is a Select2 widget 112 | 113 | **tabindex** _integer_: use it to customize a form element `tabindex` 114 | 115 | **attributeOptions** _array_: client-side options of the attribute, e.g. enableAjaxValidation. You can use this property for custom configuration of the column (attribute). By default, the column will use options which are defined on widget level. 116 | 117 | _Supported versions >= 2.1.0 118 | 119 | **columnOptions** _array|\Closure_: the HTML attributes for the indivdual table body column. This can be either an array specifying the common HTML attributes for indivdual body column, or an anonymous function that returns an array of the HTML attributes. 120 | 121 | It should have the following signature: 122 | ```php 123 | function ($model, $index, $context) 124 | ``` 125 | * `$model`: the current data model being rendered 126 | * `$index`: the zero-based index of the data model in the model array 127 | * `$context`: the widget object 128 | 129 | _Supported versions >= 2.18.0_ 130 | 131 | **inputTemplate** _string_: the template of input for customize view. Default is `{input}`. 132 | 133 | **Example** 134 | 135 | `
{input}
` 136 | 137 | ## Input types 138 | 139 | Each column in a row can has their own type. Widget supports: 140 | 141 | * all yii2 html input types: 142 | * `textInput` 143 | * `dropDownList` 144 | * `radioList` 145 | * `textarea` 146 | * For more detail look at [Html helper class](http://www.yiiframework.com/doc-2.0/yii-helpers-html.html) 147 | * input widget \(widget that extends from `InputWidget` class\). For example, `yii\widgets\MaskedInput` 148 | * `static` to output a static HTML content 149 | 150 | For using widget as column input you may use the following code: 151 | 152 | ```php 153 | echo $form->field($model, 'phones')->widget(MultipleInput::className(), [ 154 | ... 155 | 'columns' => [ 156 | ... 157 | [ 158 | 'name' => 'phones', 159 | 'title' => $model->getAttributeLabel('phones'), 160 | 'type' => \yii\widgets\MaskedInput::className(), 161 | 'options' => [ 162 | 'class' => 'input-phone', 163 | 'mask' => '999-999-99-99', 164 | ], 165 | ], 166 | ], 167 | ])->label(false); 168 | ``` 169 | 170 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | I found this small guide here [https://stackoverflow.com/a/51849747](https://stackoverflow.com/a/51849747) and I think it is a good example of basic usage of the widget 4 | 5 | ## Question 6 | 7 | I want to generate a different number of rows with values from my database. How can I do this? 8 | 9 | I can design my columns in view and edit data manually after a page was generated. But miss how to program the number of rows and their values in the view. 10 | 11 | My code is as follows: 12 | 13 | ```text 14 | field($User, 'User')->widget(MultipleInput::className(), [ 15 | 'min' => 0, 16 | 'max' => 4, 17 | 'columns' => [ 18 | [ 19 | 'name' => 'name', 20 | 'title' => 'Name', 21 | 'type' => 'textInput', 22 | 'options' => [ 23 | 'onchange' => $onchange, 24 | ], 25 | ], 26 | [ 27 | 'name' => 'birth', 28 | 'type' => \kartik\date\DatePicker::className(), 29 | 'title' => 'Birth', 30 | 'value' => function($data) { 31 | return $data['day']; 32 | }, 33 | 34 | 'options' => [ 35 | 'pluginOptions' => [ 36 | 'format' => 'dd.mm.yyyy', 37 | 'todayHighlight' => true 38 | ] 39 | ] 40 | ], 41 | 42 | ] 43 | ])->label(false); 44 | ``` 45 | 46 | How can I make \(for example\) 8 rows with different values, and also have the ability to edit/remove/update some of them? 47 | 48 | ## Answer 49 | 50 | You need to look into the documentation as it says that you need to assign a separate field into the model which will store all the schedule in form of JSON and then provide it back to the field when editing/updating the model. 51 | 52 | You have not added the appropriate model to verify how are you creating the field User in your given case above. so, I will try to create a simple example that will help you implement it in your scenario. 53 | 54 | For Example. 55 | 56 | You have to store a user in the database along with his favorite books. 57 | 58 | ```text 59 | User 60 | id, name, email 61 | 62 | Books 63 | id, name 64 | ``` 65 | 66 | Create a field/column in your User table with the name schedule of type text, you can write a migration or add manually. Add it to the rules in the User model as safe. 67 | 68 | like below 69 | 70 | ```text 71 | public function rules() { 72 | return [ 73 | ....//other rules 74 | [ [ 'schedule'] , 'safe' ] 75 | ]; 76 | } 77 | ``` 78 | 79 | Add the widget to the newly created column in ActiveForm 80 | 81 | ```text 82 | echo $form->field($model,'schedule')->widget(MultipleInput::class,[ 83 | 'max' => 4, 84 | 'columns' => [ 85 | [ 86 | 'name' => 'book_id', 87 | 'type' => 'dropDownList', 88 | 'title' => 'Book', 89 | 'items' => ArrayHelper::map( Books::find()->asArray()->all (),'id','name'), 90 | ], 91 | ] 92 | 93 | ]); 94 | ``` 95 | 96 | When saving the User model convert the array to JSON string 97 | 98 | ```text 99 | if( Yii::$app->request->isPost && $model->load(Yii::$app->request->post()) ){ 100 | $model->schedule = \yii\helpers\Json::encode($model->schedule); 101 | $model->save(); 102 | } 103 | ``` 104 | 105 | Override the afterFind\(\) of the User model to covert the JSON back to the array before loading the form 106 | 107 | ```text 108 | public function afterFind() { 109 | parent::afterFind(); 110 | $this->schedule = \yii\helpers\Json::decode($this->schedule); 111 | } 112 | ``` 113 | 114 | Now when saved the schedule field against the current user will have the JSON for the selected rows for the books, as many selected, for example, if I saved three books having ids\(1,2,3\) then it will have JSON 115 | 116 | ```text 117 | { 118 | "0": { 119 | "book_id": "1" 120 | }, 121 | "2": { 122 | "book_id": "2" 123 | }, 124 | "3": { 125 | "book_id": "3" 126 | } 127 | } 128 | ``` 129 | 130 | The above JSON will be converted to an array in the afterFind\(\) so that the widget loads the saved schedule when you EDIT the record. 131 | 132 | Now go to your update page or edit the newly saved model you will see the books loaded automatically. 133 | 134 | -------------------------------------------------------------------------------- /docs/icons.md: -------------------------------------------------------------------------------- 1 | # Using other icon libraries 2 | 3 | Multiple input and Tabular input widgets now support FontAwesome and indeed any other icon library you chose to integrate into your project. 4 | 5 | To take advantage of this, please proceed as follows: 6 | 7 | 1. Include the preferred icon library in your project. If you wish to use **font awesome**, you can use the included FontAwesomeAsset which will integrate the free fa from their CDN; 8 | 2. Add a mapping for your preferred icon library if it is not in the `iconMap` array of the widget, like the following; 9 | 10 | ```text 11 | public $iconMap = [ 12 | 'glyphicons' => [ 13 | 'drag-handle' => 'glyphicon glyphicon-menu-hamburger', 14 | 'remove' => 'glyphicon glyphicon-remove', 15 | 'add' => 'glyphicon glyphicon-plus', 16 | 'clone' => 'glyphicon glyphicon-duplicate', 17 | ], 18 | 'fa' => [ 19 | 'drag-handle' => 'fa fa-bars', 20 | 'remove' => 'fa fa-times', 21 | 'add' => 'fa fa-plus', 22 | 'clone' => 'fa fa-files-o', 23 | ], 24 | 'my-amazing-icons' => [ 25 | 'drag-handle' => 'my my-bars', 26 | 'remove' => 'my my-times', 27 | 'add' => 'my my-plus', 28 | 'clone' => 'my my-files', 29 | ] 30 | ]; 31 | ``` 32 | 33 | 3. Set the preferred icon source 34 | 35 | ```text 36 | public $iconSource = 'my-amazing-icons'; 37 | ``` 38 | 39 | If you do none of the above, the default behavior which assumes you are using `glyphicons` is retained. 40 | 41 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Yii2 Multiple input widget. 2 | Yii2 widget for handle multiple inputs for an attribute of model and tabular input for batch of models. 3 | 4 | [![Latest Stable Version](https://poser.pugx.org/unclead/yii2-multiple-input/v/stable)](https://packagist.org/packages/unclead/yii2-multiple-input) 5 | [![Total Downloads](https://poser.pugx.org/unclead/yii2-multiple-input/downloads)](https://packagist.org/packages/unclead/yii2-multiple-input) 6 | [![Daily Downloads](https://poser.pugx.org/unclead/yii2-multiple-input/d/daily)](https://packagist.org/packages/unclead/yii2-multiple-input) 7 | [![Latest Unstable Version](https://poser.pugx.org/unclead/yii2-multiple-input/v/unstable)](https://packagist.org/packages/unclead/yii2-multiple-input) 8 | [![License](https://poser.pugx.org/unclead/yii2-multiple-input/license)](https://packagist.org/packages/unclead/yii2-multiple-input) 9 | 10 | ## Latest release 11 | The latest stable version of the extension is v2.25.0 Follow the [instruction](./UPGRADE.md) for upgrading from previous versions 12 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 4 | 5 | Either run 6 | 7 | ```text 8 | php composer.phar require unclead/yii2-multiple-input "~2.0" 9 | ``` 10 | 11 | or add 12 | 13 | ```text 14 | "unclead/yii2-multiple-input": "~2.0" 15 | ``` 16 | 17 | to the `require`section of your `composer.json` file. 18 | 19 | -------------------------------------------------------------------------------- /docs/javascript-events.md: -------------------------------------------------------------------------------- 1 | # Javascript events 2 | 3 | This widget has following events: 4 | 5 | * `afterInit`: triggered after initialization 6 | * `afterAddRow`: triggered after new row insertion 7 | * `beforeDeleteRow`: triggered before the row removal 8 | * `afterDeleteRow`: triggered after the row removal 9 | * `afterDropRow`: triggered after drop the row when sortable mode is on 10 | 11 | **Example** 12 | 13 | ```javascript 14 | jQuery('#multiple-input').on('afterInit', function(){ 15 | console.log('calls on after initialization event'); 16 | }).on('beforeAddRow', function(e, row, currentIndex) { 17 | console.log('calls on before add row event'); 18 | }).on('afterAddRow', function(e, row, currentIndex) { 19 | console.log('calls on after add row event'); 20 | }).on('beforeDeleteRow', function(e, row, currentIndex){ 21 | // row - HTML container of the current row for removal. 22 | // For TableRenderer it is tr.multiple-input-list__item 23 | console.log('calls on before remove row event.'); 24 | return confirm('Are you sure you want to delete row?') 25 | }).on('afterDeleteRow', function(e, row, currentIndex){ 26 | console.log('calls on after remove row event'); 27 | console.log(row); 28 | }).on('afterDropRow', function(e, item){ 29 | console.log('calls on after drop row', item); 30 | }); 31 | ``` 32 | 33 | ## JavaScript operations 34 | 35 | ### add 36 | 37 | Adding new row with specified settings. 38 | 39 | Input arguments: 40 | 41 | * _object_ - values for inputs, can be filled with tags for dynamically added options for select \(for ajax select\). 42 | 43 | Example: 44 | 45 | ```javascript 46 | $('#multiple-input').multipleInput('add', {first: 10, second: ''}); 47 | ``` 48 | 49 | ### remove 50 | 51 | Remove row with specified index. 52 | 53 | Input arguments: 54 | 55 | * _integer_ - row number for removing, if not specified then removes last row. 56 | 57 | Example: 58 | 59 | ```javascript 60 | $('#multiple-input').multipleInput('remove', 2); 61 | ``` 62 | 63 | ### clear 64 | 65 | Remove all rows 66 | 67 | ```javascript 68 | $('#multiple-input').multipleInput('clear'); 69 | ``` 70 | 71 | ### option 72 | 73 | Get or set a particular option 74 | 75 | Input arguments: 76 | 77 | * _string_ - a name of an option 78 | * _mixed_ - a value of an option \(optional\). If specified will be used as a new value of an option; 79 | 80 | Example: 81 | 82 | ```javascript 83 | $('#multiple-input').multipleInput('option', 'max'); 84 | $('#multiple-input').multipleInput('option', 'max', 10); 85 | ``` 86 | 87 | -------------------------------------------------------------------------------- /docs/renderers.md: -------------------------------------------------------------------------------- 1 | # Renderers 2 | 3 | Currently widget supports three type of renderers 4 | 5 | ## TableRenderer 6 | 7 | ![Table renderer](https://raw.githubusercontent.com/unclead/yii2-multiple-input/master/resources/images/table-renderer.jpg?raw=true) 8 | 9 | This renderer is enabled by default. 10 | 11 | ## ListRenderer 12 | 13 | ![List renderer](https://raw.githubusercontent.com/unclead/yii2-multiple-input/master/resources/images/list-renderer.jpg?raw=true) 14 | 15 | To enable this renderer you have to use an option `rendererClass` 16 | 17 | ```php 18 | field($model, 'schedule')->widget(MultipleInput::className(), [ 20 | 'rendererClass' => \unclead\multipleinput\renderers\ListRenderer::className(), 21 | 'max' => 4, 22 | 'allowEmptyList' => true, 23 | 'rowOptions' => function($model) { 24 | $options = []; 25 | 26 | if ($model['priority'] > 1) { 27 | $options['class'] = 'danger'; 28 | } 29 | return $options; 30 | }, 31 | ``` 32 | 33 | ## DivRenderer 34 | 35 | ![List renderer](https://raw.githubusercontent.com/unclead/yii2-multiple-input/master/resources/images/list-renderer.jpg?raw=true) 36 | 37 | To enable this renderer you have to use an option `rendererClass` 38 | 39 | ```php 40 | field($model, 'schedule')->widget(MultipleInput::className(), [ 42 | 'rendererClass' => \unclead\multipleinput\renderers\ListRenderer::class, 43 | 'addButtonPosition' => MultipleInput::POS_ROW, // show add button inside of the row 44 | 'extraButtons' => function ($model, $index, $context) { 45 | if ($index === 0) { 46 | return Html::tag('div', Yii::t('object', 'Add object'), ['class' => 'mi-after-add']); 47 | } 48 | 49 | return Html::tag('div', Yii::t('object', 'Remove object'), ['class' => 'mi-after-remove']); 50 | }, 51 | 'layoutConfig' => [ 52 | 'offsetClass' => 'col-md-offset-2', 53 | 'labelClass' => 'col-md-2', 54 | 'wrapperClass' => 'col-md-6', 55 | 'errorClass' => 'col-md-offset-2 col-md-6', 56 | 'buttonActionClass' => 'col-md-offset-1 col-md-2', 57 | ], 58 | ... 59 | ``` 60 | 61 | -------------------------------------------------------------------------------- /docs/tips-and-tricks.md: -------------------------------------------------------------------------------- 1 | # Tips and tricks 2 | 3 | ## How to customize buttons 4 | 5 | You can customize `add` and `remove` buttons via `addButtonOptions` and `removeButtonOptions`. Here is a simple example of how you can use those options: 6 | 7 | ```php 8 | echo $form->field($model, 'emails')->widget(MultipleInput::className(), [ 9 | 'max' => 5, 10 | 'addButtonOptions' => [ 11 | 'class' => 'btn btn-success', 12 | 'label' => 'add' // also you can use html code 13 | ], 14 | 'removeButtonOptions' => [ 15 | 'label' => 'remove' 16 | ] 17 | ]) 18 | ->label(false); 19 | ``` 20 | 21 | ## How to add content after the buttons 22 | 23 | You can add html content after `add` and `remove` buttons via `extraButtons`. 24 | 25 | ```php 26 | echo $form->field($model, 'field')->widget(MultipleInput::className(), [ 27 | 'rendererClass' => \unclead\multipleinput\renderers\ListRenderer::class, 28 | 'extraButtons' => function ($model, $index, $context) { 29 | return Html::tag('span', '', ['class' => "btn-show-hide-{$index} glyphicon glyphicon-eye-open btn btn-info"]); 30 | }, 31 | ]) 32 | ->label(false); 33 | ``` 34 | 35 | ## Work with an empty list 36 | 37 | In some cases, you need to have the ability to delete all rows in the list. For this purpose, you can use option `allowEmptyList` like in the example below: 38 | 39 | ```php 40 | echo $form->field($model, 'emails')->widget(MultipleInput::className(), [ 41 | 'max' => 5, 42 | 'allowEmptyList' => true 43 | ]) 44 | ->label(false); 45 | ``` 46 | 47 | Also, you can set `0` in `min` option if you don't need the first blank row when data is empty. 48 | 49 | ## Guess column title 50 | 51 | Sometimes you can use the widget without defining columns but you want to have the column header of the table. In this case, you can use the option `enableGuessTitle` like in the example below: 52 | 53 | ```php 54 | echo $form->field($model, 'emails')->widget(MultipleInput::className(), [ 55 | 'max' => 5, 56 | 'allowEmptyList' => true, 57 | 'enableGuessTitle' => true 58 | ]) 59 | ->label(false); 60 | ``` 61 | 62 | ## Ajax loading of a widget 63 | 64 | Assume you want to load a widget via ajax and then show it inside the modal window. In this case, you MUST: 65 | 66 | * Ensure that you specified the ID of the widget otherwise the widget will get a random ID and it can be the same as the ID of others elements on the page. 67 | * Ensure that you use the widget inside ActiveForm because it works incorrectly in this case. 68 | 69 | ## Use of a widget's placeholder 70 | 71 | You can use a placeholder `{multiple_index}` in a widget configuration, e.g. for implementation of dependent drop-down lists. 72 | 73 | ```php 74 | field($model, 'field')->widget(MultipleInput::className(), [ 75 | 'id' => 'my_id', 76 | 'allowEmptyList' => false, 77 | 'rowOptions' => [ 78 | 'id' => 'row{multiple_index_my_id}', 79 | ], 80 | 'columns' => [ 81 | [ 82 | 'name' => 'category', 83 | 'type' => 'dropDownList', 84 | 'title' => 'Category', 85 | 'defaultValue' => '1', 86 | 'items' => [ 87 | '1' => 'Test 1', 88 | '2' => 'Test 2', 89 | '3' => 'Test 3', 90 | '4' => 'Test 4', 91 | ], 92 | 'options' => [ 93 | 'onchange' => <<< JS 94 | $.post("list?id=" + $(this).val(), function(data){ 95 | console.log(data); 96 | $("select#subcat-{multiple_index_my_id}").html(data); 97 | }); 98 | JS 99 | ], 100 | ], 101 | [ 102 | 'name' => 'subcategory', 103 | 'type' => 'dropDownList', 104 | 'title' => 'Subcategory', 105 | 'items' => [], 106 | 'options'=> [ 107 | 'id' => 'subcat-{multiple_index_my_id}' 108 | ], 109 | ], 110 | ] 111 | ]); 112 | ?> 113 | ``` 114 | 115 | **Important** Ensure that you added ID of widget to a base placeholder `multiple_index` 116 | 117 | ## Custom index of the row 118 | 119 | Assume that you want to set a specific index for each row. In this case, you can pass the `data` attribute as an associative array as in the example below: 120 | 121 | ```php 122 | field($model, 'field')->widget(MultipleInput::className(), [ 123 | 'allowEmptyList' => false, 124 | 'data' => [ 125 | 3 => [ 126 | 'day' => '27.02.2015', 127 | 'user_id' => 31, 128 | 'priority' => 1, 129 | 'enable' => 1 130 | ], 131 | 132 | 'some-key' => [ 133 | 'day' => '27.02.2015', 134 | 'user_id' => 33, 135 | 'priority' => 2, 136 | 'enable' => 0 137 | ], 138 | ] 139 | 140 | ... 141 | ``` 142 | 143 | ## Embedded MultipleInput widget 144 | 145 | You can use nested `MultipleInput` as in the example below: 146 | 147 | ```php 148 | echo MultipleInput::widget([ 149 | 'model' => $model, 150 | 'attribute' => 'questions', 151 | 'attributeOptions' => $commonAttributeOptions, 152 | 'columns' => [ 153 | [ 154 | 'name' => 'question', 155 | 'type' => 'textarea', 156 | ], 157 | [ 158 | 'name' => 'answers', 159 | 'type' => MultipleInput::class, 160 | 'options' => [ 161 | 'attributeOptions' => $commonAttributeOptions, 162 | 'columns' => [ 163 | [ 164 | 'name' => 'right', 165 | 'type' => MultipleInputColumn::TYPE_CHECKBOX 166 | ], 167 | [ 168 | 'name' => 'answer' 169 | ] 170 | ] 171 | ] 172 | ] 173 | ], 174 | ]); 175 | ``` 176 | 177 | But in this case, you have to pass `attributeOptions` to the widget otherwise, you will not be able to use ajax or client-side validation of data. 178 | 179 | ## Client validation 180 | 181 | Apart from ajax validation, you can use client validation but in this case, you MUST set property `form`. Also, ensure that you set `enableClientValidation` to `true` value in property `attributeOptions`. If you want to use client validation for a particular column you can use the property `attributeOptions`. An example of using client validation is listed below: 182 | 183 | ```php 184 | $models, 186 | 'form' => $form, 187 | 'attributeOptions' => [ 188 | 'enableAjaxValidation' => true, 189 | 'enableClientValidation' => false, 190 | 'validateOnChange' => false, 191 | 'validateOnSubmit' => true, 192 | 'validateOnBlur' => false, 193 | ], 194 | 'columns' => [ 195 | [ 196 | 'name' => 'id', 197 | 'type' => TabularColumn::TYPE_HIDDEN_INPUT 198 | ], 199 | [ 200 | 'name' => 'title', 201 | 'title' => 'Title', 202 | 'type' => TabularColumn::TYPE_TEXT_INPUT, 203 | 'attributeOptions' => [ 204 | 'enableClientValidation' => true, 205 | 'validateOnChange' => true, 206 | ], 207 | 'enableError' => true 208 | ], 209 | [ 210 | 'name' => 'description', 211 | 'title' => 'Description', 212 | ], 213 | ], 214 | ]) ?> 215 | ``` 216 | 217 | In the example above we use client validation for column `title` and ajax validation for column `description`. As you can seee we also enabled `validateOnChange` for column `title` thus you can use all client-side options from the `ActiveField` class. 218 | 219 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | You can find the source code of examples [here](https://github.com/unclead/yii2-multiple-input/tree/master/examples) 4 | 5 | ## One column 6 | 7 | ![Single column example](https://raw.githubusercontent.com/unclead/yii2-multiple-input/master/resources/images/single-column.gif) 8 | 9 | For example, your application contains the model `User` that has the related model `UserEmail` You can add virtual attribute `emails` for collect emails from a form and then you can save them to the database. 10 | 11 | In this case, you can use `yii2-multiple-input` widget for supporting multiple inputs how to describe below. 12 | 13 | First of all, we have to declare a virtual attribute in the model 14 | 15 | ```php 16 | class ExampleModel extends Model 17 | { 18 | /** 19 | * @var array virtual attribute for keeping emails 20 | */ 21 | public $emails; 22 | ``` 23 | 24 | Then we have to use `MultipleInput` widget for rendering form field in the view file 25 | 26 | ```php 27 | use yii\bootstrap\ActiveForm; 28 | use unclead\multipleinput\MultipleInput; 29 | use unclead\multipleinput\examples\models\ExampleModel; 30 | use yii\helpers\Html; 31 | 32 | /* @var $this \yii\base\View */ 33 | /* @var $model ExampleModel */ 34 | ?> 35 | 36 | true, 38 | 'enableClientValidation' => false, 39 | 'validateOnChange' => false, 40 | 'validateOnSubmit' => true, 41 | 'validateOnBlur' => false, 42 | ]);?> 43 | 44 | field($model, 'emails')->widget(MultipleInput::className(), [ 45 | 'max' => 4, 46 | ]); 47 | ?> 48 | 'btn btn-success']);?> 49 | 50 | ``` 51 | 52 | Options `max` means that a user is able to input only 4 emails 53 | 54 | For validation emails, you can use the following code 55 | 56 | ```php 57 | /** 58 | * Email validation. 59 | * 60 | * @param $attribute 61 | */ 62 | public function validateEmails($attribute) 63 | { 64 | $items = $this->$attribute; 65 | 66 | if (!is_array($items)) { 67 | $items = []; 68 | } 69 | 70 | foreach ($items as $index => $item) { 71 | $validator = new EmailValidator(); 72 | $error = null; 73 | $validator->validate($item, $error); 74 | if (!empty($error)) { 75 | $key = $attribute . '[' . $index . ']'; 76 | $this->addError($key, $error); 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | ## Multiple columns 83 | 84 | ![Multiple columns example](https://raw.githubusercontent.com/unclead/yii2-multiple-input/master/resources/images/multiple-column.gif) 85 | 86 | For example, you want to have an interface for manage a user schedule. For simplicity, we will store the schedule in json string. 87 | 88 | In this case, you can use `yii2-multiple-input` widget for supporting multiple inputs how to describe below. 89 | 90 | Our test model can look like as the following snippet 91 | 92 | ```php 93 | class ExampleModel extends Model 94 | { 95 | public $schedule; 96 | 97 | public function init() 98 | { 99 | parent::init(); 100 | 101 | $this->schedule = [ 102 | [ 103 | 'day' => '27.02.2015', 104 | 'user_id' => 1, 105 | 'priority' => 1 106 | ], 107 | [ 108 | 'day' => '27.02.2015', 109 | 'user_id' => 2, 110 | 'priority' => 2 111 | ], 112 | ]; 113 | } 114 | ``` 115 | 116 | Then we have to use `MultipleInput` widget for rendering form field in the view file 117 | 118 | ```php 119 | use yii\bootstrap\ActiveForm; 120 | use unclead\multipleinput\MultipleInput; 121 | use unclead\multipleinput\examples\models\ExampleModel; 122 | use yii\helpers\Html; 123 | 124 | /* @var $this \yii\base\View */ 125 | /* @var $model ExampleModel */ 126 | ?> 127 | 128 | true, 130 | 'enableClientValidation' => false, 131 | 'validateOnChange' => false, 132 | 'validateOnSubmit' => true, 133 | 'validateOnBlur' => false, 134 | ]);?> 135 | 136 | field($model, 'schedule')->widget(MultipleInput::className(), [ 137 | 'max' => 4, 138 | 'columns' => [ 139 | [ 140 | 'name' => 'user_id', 141 | 'type' => 'dropDownList', 142 | 'title' => 'User', 143 | 'defaultValue' => 1, 144 | 'items' => [ 145 | 1 => 'User 1', 146 | 2 => 'User 2' 147 | ] 148 | ], 149 | [ 150 | 'name' => 'day', 151 | 'type' => \kartik\date\DatePicker::className(), 152 | 'title' => 'Day', 153 | 'value' => function($data) { 154 | return $data['day']; 155 | }, 156 | 'items' => [ 157 | '0' => 'Saturday', 158 | '1' => 'Monday' 159 | ], 160 | 'options' => [ 161 | 'pluginOptions' => [ 162 | 'format' => 'dd.mm.yyyy', 163 | 'todayHighlight' => true 164 | ] 165 | ] 166 | ], 167 | [ 168 | 'name' => 'priority', 169 | 'title' => 'Priority', 170 | 'enableError' => true, 171 | 'options' => [ 172 | 'class' => 'input-priority' 173 | ] 174 | ] 175 | ] 176 | ]); 177 | ?> 178 | 'btn btn-success']);?> 179 | 180 | ``` 181 | 182 | For validation of the schedule you can use the following code 183 | 184 | ```php 185 | public function validateSchedule($attribute) 186 | { 187 | $requiredValidator = new RequiredValidator(); 188 | 189 | foreach($this->$attribute as $index => $row) { 190 | $error = null; 191 | $requiredValidator->validate($row['priority'], $error); 192 | if (!empty($error)) { 193 | $key = $attribute . '[' . $index . '][priority]'; 194 | $this->addError($key, $error); 195 | } 196 | } 197 | } 198 | ``` 199 | 200 | For example, you keep some data in json format in an attribute of a model. Imagine that it is an abstract user schedule with keys: user\_id, day, priority 201 | 202 | On the edit page, you want to be able to manage this schedule and you can you yii2-multiple-input widget like in the following code 203 | 204 | ```php 205 | use unclead\multipleinput\MultipleInput; 206 | 207 | ... 208 | 209 | field($model, 'schedule')->widget(MultipleInput::className(), [ 210 | 'max' => 4, 211 | 'columns' => [ 212 | [ 213 | 'name' => 'user_id', 214 | 'type' => 'dropDownList', 215 | 'title' => 'User', 216 | 'defaultValue' => 1, 217 | 'items' => [ 218 | 1 => 'User 1', 219 | 2 => 'User 2' 220 | ] 221 | ], 222 | [ 223 | 'name' => 'day', 224 | 'type' => \kartik\date\DatePicker::className(), 225 | 'title' => 'Day', 226 | 'value' => function($data) { 227 | return $data['day']; 228 | }, 229 | 'items' => [ 230 | '0' => 'Saturday', 231 | '1' => 'Monday' 232 | ], 233 | 'options' => [ 234 | 'pluginOptions' => [ 235 | 'format' => 'dd.mm.yyyy', 236 | 'todayHighlight' => true 237 | ] 238 | ], 239 | 'headerOptions' => [ 240 | 'style' => 'width: 250px;', 241 | 'class' => 'day-css-class' 242 | ] 243 | ], 244 | [ 245 | 'name' => 'priority', 246 | 'enableError' => true, 247 | 'title' => 'Priority', 248 | 'options' => [ 249 | 'class' => 'input-priority' 250 | ] 251 | ], 252 | [ 253 | 'name' => 'comment', 254 | 'type' => 'static', 255 | 'value' => function($data) { 256 | return Html::tag('span', 'static content', ['class' => 'label label-info']); 257 | }, 258 | 'headerOptions' => [ 259 | 'style' => 'width: 70px;', 260 | ] 261 | ] 262 | ] 263 | ]); 264 | ?> 265 | ``` 266 | 267 | ## Tabular input 268 | 269 | For example, you want to have an interface for manage some abstract items via tabular input. 270 | 271 | In this case, you can use `yii2-multiple-input` widget for supporting tabular input how to describe below. 272 | 273 | Our test model can look like as the following snippet 274 | 275 | ```php 276 | namespace unclead\multipleinput\examples\models; 277 | 278 | use Yii; 279 | use yii\base\Model; 280 | // you have to install https://github.com/vova07/yii2-fileapi-widget 281 | use vova07\fileapi\behaviors\UploadBehavior; 282 | 283 | /** 284 | * Class Item 285 | * @package unclead\multipleinput\examples\models 286 | */ 287 | class Item extends Model 288 | { 289 | public $title; 290 | public $description; 291 | public $file; 292 | public $date; 293 | 294 | public function behaviors() 295 | { 296 | return [ 297 | 'uploadBehavior' => [ 298 | 'class' => UploadBehavior::className(), 299 | 'attributes' => [ 300 | 'file' => [ 301 | 'path' => Yii::getAlias('@webroot') . '/images/', 302 | 'tempPath' => Yii::getAlias('@webroot') . '/images/tmp/', 303 | 'url' => '/images/' 304 | ], 305 | ] 306 | ] 307 | ]; 308 | } 309 | 310 | public function rules() 311 | { 312 | return [ 313 | [['title', 'description'], 'required'], 314 | ['file', 'safe'] 315 | ]; 316 | } 317 | } 318 | ``` 319 | 320 | Then we have to use `TabularInput` widget for rendering form field in the view file 321 | 322 | Since version **2.18.0** you can configure `columnOptions` also. 323 | 324 | ```php 325 | 335 | 336 | 'tabular-form', 338 | 'enableAjaxValidation' => true, 339 | 'enableClientValidation' => false, 340 | 'validateOnChange' => false, 341 | 'validateOnSubmit' => true, 342 | 'validateOnBlur' => false, 343 | 'options' => [ 344 | 'enctype' => 'multipart/form-data' 345 | ] 346 | ]) ?> 347 | 348 | $models, 350 | 'attributeOptions' => [ 351 | 'enableAjaxValidation' => true, 352 | 'enableClientValidation' => false, 353 | 'validateOnChange' => false, 354 | 'validateOnSubmit' => true, 355 | 'validateOnBlur' => false, 356 | ], 357 | 'columns' => [ 358 | [ 359 | 'name' => 'title', 360 | 'title' => 'Title', 361 | 'type' => \unclead\multipleinput\MultipleInputColumn::TYPE_TEXT_INPUT, 362 | ], 363 | [ 364 | 'name' => 'description', 365 | 'title' => 'Description', 366 | ], 367 | [ 368 | 'name' => 'file', 369 | 'title' => 'File', 370 | 'type' => \vova07\fileapi\Widget::className(), 371 | 'options' => [ 372 | 'settings' => [ 373 | 'url' => ['site/fileapi-upload'] 374 | ] 375 | ], 376 | 'columnOptions' => [ 377 | 'style' => 'width: 250px;', 378 | 'class' => 'custom-css-class' 379 | ] 380 | ], 381 | [ 382 | 'name' => 'date', 383 | 'type' => \kartik\date\DatePicker::className(), 384 | 'title' => 'Day', 385 | 'options' => [ 386 | 'pluginOptions' => [ 387 | 'format' => 'dd.mm.yyyy', 388 | 'todayHighlight' => true 389 | ] 390 | ], 391 | 'headerOptions' => [ 392 | 'style' => 'width: 250px;', 393 | 'class' => 'day-css-class' 394 | ] 395 | ], 396 | ], 397 | ]) ?> 398 | 399 | 400 | 'btn btn-success']);?> 401 | 402 | ``` 403 | 404 | Your action can look like the following code 405 | 406 | ```php 407 | /** 408 | * Class TabularInputAction 409 | * @package unclead\multipleinput\examples\actions 410 | */ 411 | class TabularInputAction extends Action 412 | { 413 | public function run() 414 | { 415 | Yii::setAlias('@unclead-examples', realpath(__DIR__ . '/../')); 416 | 417 | $models = [new Item()]; 418 | $request = Yii::$app->getRequest(); 419 | if ($request->isPost && $request->post('ajax') !== null) { 420 | $data = Yii::$app->request->post('Item', []); 421 | foreach (array_keys($data) as $index) { 422 | $models[$index] = new Item(); 423 | } 424 | Model::loadMultiple($models, Yii::$app->request->post()); 425 | Yii::$app->response->format = Response::FORMAT_JSON; 426 | $result = ActiveForm::validateMultiple($models); 427 | return $result; 428 | } 429 | 430 | if (Model::loadMultiple($models, Yii::$app->request->post())) { 431 | // your magic 432 | } 433 | 434 | 435 | return $this->controller->render('@unclead-examples/views/tabular-input.php', ['models' => $models]); 436 | } 437 | } 438 | ``` 439 | 440 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Yii2 multiple input 2 | site_description: 'Yii2 widget to handle multiple inputs for an attribute of a model' 3 | site_author: 'Eugene Tupikov' 4 | docs_dir: docs/ 5 | repo_name: 'unclead/yii2-multiple-input' 6 | repo_url: 'https://github.com/unclead/yii2-multiple-input' 7 | nav: 8 | - About: index.md 9 | - Installation: installation.md 10 | - Getting started: getting-started.md 11 | - Configuration: configuration.md 12 | - Renderers: renderers.md 13 | - JS events: javascript-events.md 14 | - Clonning: clonning.md 15 | - Tips and Tricks: tips-and-tricks.md 16 | - Custom icons: icons.md 17 | - Usage: usage.md 18 | theme: 19 | name: 'material' 20 | -------------------------------------------------------------------------------- /src/MultipleInput.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | class MultipleInput extends InputWidget 29 | { 30 | const POS_HEADER = RendererInterface::POS_HEADER; 31 | const POS_ROW = RendererInterface::POS_ROW; 32 | const POS_ROW_BEGIN = RendererInterface::POS_ROW_BEGIN; 33 | const POS_FOOTER = RendererInterface::POS_FOOTER; 34 | 35 | const THEME_DEFAULT = 'default'; 36 | const THEME_BS = 'bootstrap'; 37 | 38 | const ICONS_SOURCE_GLYPHICONS = 'glyphicons'; 39 | const ICONS_SOURCE_FONTAWESOME = 'fa'; 40 | 41 | /** 42 | * @var ActiveRecordInterface[]|array[] input data 43 | */ 44 | public $data; 45 | 46 | /** 47 | * @var array columns configuration 48 | */ 49 | public $columns = []; 50 | 51 | /** 52 | * @var integer maximum number of rows 53 | */ 54 | public $max; 55 | 56 | /** 57 | * @var array client-side attribute options, e.g. enableAjaxValidation. You may use this property in case when 58 | * you use widget without a model, since in this case widget is not able to detect client-side options 59 | * automatically. 60 | */ 61 | public $attributeOptions = []; 62 | 63 | /** 64 | * @var array the HTML options for the `remove` button 65 | */ 66 | public $removeButtonOptions; 67 | 68 | /** 69 | * @var array the HTML options for the `add` button 70 | */ 71 | public $addButtonOptions; 72 | 73 | /** 74 | * @var array the HTML options for the `clone` button 75 | */ 76 | public $cloneButtonOptions; 77 | 78 | /** 79 | * @var bool whether to allow the empty list 80 | */ 81 | public $allowEmptyList = false; 82 | 83 | /** 84 | * @var bool whether to guess column title in case if there is no definition of columns 85 | */ 86 | public $enableGuessTitle = false; 87 | 88 | /** 89 | * @var int minimum number of rows 90 | */ 91 | public $min; 92 | 93 | /** 94 | * @var string|array position of add button. 95 | */ 96 | public $addButtonPosition; 97 | 98 | /** 99 | * @var array|\Closure the HTML attributes for the table body rows. This can be either an array 100 | * specifying the common HTML attributes for all body rows, or an anonymous function that 101 | * returns an array of the HTML attributes. It should have the following signature: 102 | * 103 | * ```php 104 | * function ($model, $index, $context) 105 | * ``` 106 | * 107 | * - `$model`: the current data model being rendered 108 | * - `$index`: the zero-based index of the data model in the model array 109 | * - `$context`: the MultipleInput widget object 110 | * 111 | */ 112 | public $rowOptions = []; 113 | 114 | /** 115 | * @var string the name of column class. You can specify your own class to extend base functionality. 116 | * Defaults to `unclead\multipleinput\MultipleInputColumn` 117 | */ 118 | public $columnClass; 119 | 120 | /** 121 | * @var string the name of renderer class. Defaults to `unclead\multipleinput\renderers\TableRenderer`. 122 | * @since 1.4 123 | */ 124 | public $rendererClass; 125 | 126 | /** 127 | * @var bool whether the widget is embedded or not. 128 | * @internal this property is used for internal purposes. Do not use it in your code. 129 | */ 130 | public $isEmbedded = false; 131 | 132 | /** 133 | * @var ActiveForm an instance of ActiveForm which you have to pass in case of using client validation 134 | * @since 2.1 135 | */ 136 | public $form; 137 | 138 | /** 139 | * @var bool allow sorting. 140 | * @internal this property is used when need to allow sorting rows. 141 | */ 142 | public $sortable = false; 143 | 144 | /** 145 | * @var bool whether to render inline error for all input. Default to `false`. Can be override in `columns` 146 | * @since 2.10 147 | */ 148 | public $enableError = false; 149 | 150 | /** 151 | * @var bool whether to render clone button. Default to `false`. 152 | */ 153 | public $cloneButton = false; 154 | 155 | /** 156 | * @var string|\Closure the HTML content that will be rendered after the buttons. 157 | * 158 | * ```php 159 | * function ($model, $index, $context) 160 | * ``` 161 | * 162 | * - `$model`: the current data model being rendered 163 | * - `$index`: the zero-based index of the data model in the model array 164 | * - `$context`: the MultipleInput widget object 165 | * 166 | */ 167 | public $extraButtons; 168 | 169 | /** 170 | * @var array CSS grid classes for horizontal layout. This must be an array with these keys: 171 | * - 'offsetClass' the offset grid class to append to the wrapper if no label is rendered 172 | * - 'labelClass' the label grid class 173 | * - 'wrapperClass' the wrapper grid class 174 | * - 'errorClass' the error grid class 175 | */ 176 | public $layoutConfig = []; 177 | 178 | /** 179 | * @var array 180 | * --icon library classes mapped for various controls 181 | */ 182 | public $iconMap = [ 183 | self::ICONS_SOURCE_GLYPHICONS => [ 184 | 'drag-handle' => 'glyphicon glyphicon-menu-hamburger', 185 | 'remove' => 'glyphicon glyphicon-remove', 186 | 'add' => 'glyphicon glyphicon-plus', 187 | 'clone' => 'glyphicon glyphicon-duplicate', 188 | ], 189 | self::ICONS_SOURCE_FONTAWESOME => [ 190 | 'drag-handle' => 'fa fa-bars', 191 | 'remove' => 'fa fa-times', 192 | 'add' => 'fa fa-plus', 193 | 'clone' => 'fa fa-files-o', 194 | ], 195 | ]; 196 | /** 197 | * @var string the name of default icon library 198 | */ 199 | public $iconSource = self::ICONS_SOURCE_GLYPHICONS; 200 | 201 | /** 202 | * @var string the CSS theme of the widget 203 | * 204 | * @todo Use bootstrap theme for BC. We can switch to default theme in major release 205 | */ 206 | public $theme = self::THEME_BS; 207 | 208 | /** 209 | * @var bool 210 | */ 211 | public $showGeneralError = false; 212 | 213 | /** 214 | * @var bool add a new line to the beginning of the list, not to the end 215 | */ 216 | public $prepend = false; 217 | 218 | /** 219 | * Initialization. 220 | * 221 | * @throws \yii\base\InvalidConfigException 222 | */ 223 | public function init() 224 | { 225 | if ($this->form !== null && !$this->form instanceof ActiveForm) { 226 | throw new InvalidConfigException('Property "form" must be an instance of yii\widgets\ActiveForm'); 227 | } 228 | 229 | if ($this->showGeneralError && $this->field === null) { 230 | $this->showGeneralError = false; 231 | } 232 | 233 | $this->guessColumns(); 234 | $this->initData(); 235 | 236 | parent::init(); 237 | } 238 | 239 | /** 240 | * Initializes data. 241 | */ 242 | protected function initData() 243 | { 244 | if ($this->data !== null) { 245 | return; 246 | } 247 | 248 | if ($this->value !== null) { 249 | $this->data = $this->value; 250 | return; 251 | } 252 | 253 | if ($this->model instanceof Model) { 254 | $data = ($this->model->hasProperty($this->attribute) || isset($this->model->{$this->attribute})) 255 | ? ArrayHelper::getValue($this->model, $this->attribute, []) 256 | : []; 257 | 258 | if (!is_array($data) && empty($data)) { 259 | return; 260 | } 261 | 262 | if (!($data instanceof \Traversable)) { 263 | $data = (array) $data; 264 | } 265 | 266 | foreach ($data as $index => $value) { 267 | $this->data[$index] = $value; 268 | } 269 | } 270 | } 271 | 272 | /** 273 | * This function tries to guess the columns to show from the given data 274 | * if [[columns]] are not explicitly specified. 275 | */ 276 | protected function guessColumns() 277 | { 278 | if (empty($this->columns)) { 279 | $column = [ 280 | 'name' => $this->hasModel() ? $this->attribute : $this->name, 281 | 'type' => MultipleInputColumn::TYPE_TEXT_INPUT 282 | ]; 283 | 284 | if ($this->enableGuessTitle && $this->hasModel()) { 285 | $column['title'] = $this->model->getAttributeLabel($this->attribute); 286 | } 287 | $this->columns[] = $column; 288 | } 289 | } 290 | 291 | /** 292 | * Run widget. 293 | */ 294 | public function run() 295 | { 296 | $content = ''; 297 | if ($this->isEmbedded === false && $this->hasModel()) { 298 | $content .= Html::hiddenInput(Html::getInputName($this->model, $this->attribute), null); 299 | } 300 | 301 | $content .= $this->createRenderer()->render(); 302 | 303 | return $content; 304 | } 305 | 306 | /** 307 | * @return TableRenderer 308 | */ 309 | protected function createRenderer() 310 | { 311 | if($this->sortable) { 312 | $drag = [ 313 | 'name' => 'drag', 314 | 'type' => MultipleInputColumn::TYPE_DRAGCOLUMN, 315 | 'headerOptions' => [ 316 | 'style' => 'width: 20px;', 317 | ] 318 | ]; 319 | 320 | array_unshift($this->columns, $drag); 321 | } 322 | 323 | $available_themes = [ 324 | self::THEME_BS, 325 | self::THEME_DEFAULT 326 | ]; 327 | 328 | if (!in_array($this->theme, $available_themes, true)) { 329 | $this->theme = self::THEME_BS; 330 | } 331 | 332 | /** 333 | * set default icon map 334 | */ 335 | $iconMap = array_key_exists($this->iconSource, $this->iconMap) 336 | ? $this->iconMap[$this->iconSource] 337 | : $this->iconMap[self::ICONS_SOURCE_GLYPHICONS]; 338 | 339 | $config = [ 340 | 'id' => $this->getId(), 341 | 'columns' => $this->columns, 342 | 'min' => $this->min, 343 | 'max' => $this->max, 344 | 'attributeOptions' => $this->attributeOptions, 345 | 'data' => $this->data, 346 | 'columnClass' => $this->columnClass !== null ? $this->columnClass : MultipleInputColumn::className(), 347 | 'allowEmptyList' => $this->allowEmptyList, 348 | 'addButtonPosition' => $this->addButtonPosition, 349 | 'rowOptions' => $this->rowOptions, 350 | 'context' => $this, 351 | 'form' => $this->form, 352 | 'sortable' => $this->sortable, 353 | 'enableError' => $this->enableError, 354 | 'cloneButton' => $this->cloneButton, 355 | 'extraButtons' => $this->extraButtons, 356 | 'layoutConfig' => $this->layoutConfig, 357 | 'iconMap' => $iconMap, 358 | 'theme' => $this->theme, 359 | 'prepend' => $this->prepend 360 | ]; 361 | 362 | if ($this->showGeneralError) { 363 | $config['jsExtraSettings'] = [ 364 | 'showGeneralError' => true 365 | ]; 366 | } 367 | 368 | if ($this->removeButtonOptions !== null) { 369 | $config['removeButtonOptions'] = $this->removeButtonOptions; 370 | } 371 | 372 | if ($this->addButtonOptions !== null) { 373 | $config['addButtonOptions'] = $this->addButtonOptions; 374 | } 375 | 376 | if ($this->cloneButtonOptions !== null) { 377 | $config['cloneButtonOptions'] = $this->cloneButtonOptions; 378 | } 379 | 380 | $config['class'] = $this->rendererClass ?: TableRenderer::className(); 381 | 382 | return Yii::createObject($config); 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /src/MultipleInputColumn.php: -------------------------------------------------------------------------------- 1 | enableError && !$this->context->model instanceof Model) { 34 | throw new InvalidConfigException('Property "enableError" available only when model is defined.'); 35 | } 36 | } 37 | 38 | /** 39 | * Returns element's name. 40 | * 41 | * @param int|null|string $index current row index 42 | * @param bool $withPrefix whether to add prefix. 43 | * 44 | * @return string 45 | */ 46 | public function getElementName($index, $withPrefix = true) 47 | { 48 | if ($index === null) { 49 | $index = '{' . $this->renderer->getIndexPlaceholder() . '}'; 50 | } 51 | 52 | $elementName = $this->isRendererHasOneColumn() 53 | ? '[' . $this->name . '][' . $index . ']' 54 | : '[' . $index . '][' . $this->name . ']'; 55 | 56 | if (!$withPrefix) { 57 | return $elementName; 58 | } 59 | 60 | $prefix = $this->getInputNamePrefix(); 61 | if ($this->context->isEmbedded && strpos($prefix, $this->context->name) === false) { 62 | $prefix = $this->context->name; 63 | } 64 | 65 | return $prefix . $elementName . (empty($this->nameSuffix) ? '' : ('_' . $this->nameSuffix)); 66 | } 67 | 68 | /** 69 | * @return bool 70 | */ 71 | private function isRendererHasOneColumn() 72 | { 73 | $columns = \array_filter($this->renderer->columns, function(self $column) { 74 | return $column->type !== self::TYPE_DRAGCOLUMN; 75 | }); 76 | 77 | return count($columns) === 1; 78 | } 79 | 80 | /** 81 | * Return prefix for name of input. 82 | * 83 | * @return string 84 | */ 85 | protected function getInputNamePrefix() 86 | { 87 | $model = $this->context->model; 88 | if ($model instanceof Model) { 89 | if (empty($this->renderer->columns) || ($this->isRendererHasOneColumn() && $this->hasModelAttribute($this->name))) { 90 | return $model->formName(); 91 | } 92 | 93 | return Html::getInputName($this->context->model, $this->context->attribute); 94 | } 95 | 96 | return $this->context->name; 97 | } 98 | 99 | protected function hasModelAttribute($name) 100 | { 101 | $model = $this->context->model; 102 | 103 | if ($model->hasProperty($name)) { 104 | return true; 105 | } 106 | 107 | if ($model instanceof ActiveRecordInterface && $model->hasAttribute($name)) { 108 | return true; 109 | } 110 | 111 | if ($model instanceof DynamicModel && isset($model->{$name})) { 112 | return true; 113 | } 114 | 115 | return false; 116 | } 117 | 118 | /** 119 | * @param int|string|null $index 120 | * @return null|string 121 | */ 122 | public function getFirstError($index) 123 | { 124 | if ($index === null) { 125 | return null; 126 | } 127 | 128 | if ($this->isRendererHasOneColumn()) { 129 | $attribute = $this->name . '[' . $index . ']'; 130 | } else { 131 | $attribute = $this->context->attribute . $this->getElementName($index, false); 132 | } 133 | 134 | $model = $this->context->model; 135 | if ($model instanceof Model) { 136 | return $model->getFirstError($attribute); 137 | } 138 | 139 | return null; 140 | } 141 | 142 | /** 143 | * @inheritdoc 144 | */ 145 | protected function renderWidget($type, $name, $value, $options) 146 | { 147 | // Extend options in case of rendering embedded MultipleInput 148 | // We have to pass to the widget an original model and an attribute to be able get a first error from model 149 | // for embedded widget. 150 | if ($type === MultipleInput::className()) { 151 | $model = $this->context->model; 152 | 153 | // in case of embedding level 2 and more 154 | if (preg_match('/^([\w\.]+)(\[.*)$/', $this->context->attribute, $matches)) { 155 | $search = sprintf('%s[%s]%s', $model->formName(), $matches[1], $matches[2]); 156 | } else { 157 | $search = sprintf('%s[%s]', $model->formName(), $this->context->attribute); 158 | } 159 | 160 | $replace = $this->context->attribute; 161 | 162 | $attribute = str_replace($search, $replace, $name); 163 | 164 | $options['model'] = $model; 165 | $options['attribute'] = $attribute; 166 | 167 | // Remember current name and mark the widget as embedded to prevent 168 | // generation of wrong prefix in case the column is associated with AR relation 169 | // @see https://github.com/unclead/yii2-multiple-input/issues/92 170 | $options['name'] = $name; 171 | $options['isEmbedded'] = true; 172 | } 173 | 174 | return parent::renderWidget($type, $name, $value, $options); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/TabularColumn.php: -------------------------------------------------------------------------------- 1 | renderer->getIndexPlaceholder() . '}'; 33 | } 34 | 35 | $elementName = '[' . $index . '][' . $this->name . ']'; 36 | $prefix = $withPrefix ? $this->getModel()->formName() : ''; 37 | 38 | return $prefix . $elementName . (empty($this->nameSuffix) ? '' : ('_' . $this->nameSuffix)); 39 | } 40 | 41 | /** 42 | * Returns first error of the current model. 43 | * 44 | * @param $index 45 | * @return string 46 | */ 47 | public function getFirstError($index) 48 | { 49 | return $this->getModel()->getFirstError($this->name); 50 | } 51 | 52 | /** 53 | * Ensure that model is an instance of yii\base\Model. 54 | * 55 | * @param $model 56 | * @return bool 57 | */ 58 | protected function ensureModel($model) 59 | { 60 | return $model instanceof Model; 61 | } 62 | 63 | /** 64 | * @inheritdoc 65 | */ 66 | public function setModel($model) 67 | { 68 | if ($model === null) { 69 | $model = \Yii::createObject(['class' => $this->context->modelClass]); 70 | } 71 | 72 | parent::setModel($model); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/TabularInput.php: -------------------------------------------------------------------------------- 1 | [ 177 | 'drag-handle' => 'glyphicon glyphicon-menu-hamburger', 178 | 'remove' => 'glyphicon glyphicon-remove', 179 | 'add' => 'glyphicon glyphicon-plus', 180 | 'clone' => 'glyphicon glyphicon-duplicate', 181 | ], 182 | self::ICONS_SOURCE_FONTAWESOME => [ 183 | 'drag-handle' => 'fa fa-bars', 184 | 'remove' => 'fa fa-times', 185 | 'add' => 'fa fa-plus', 186 | 'clone' => 'fa fa-files-o', 187 | ], 188 | ]; 189 | 190 | /** 191 | * @var string the CSS theme of the widget 192 | * 193 | * @todo Use bootstrap theme for BC. We can switch to default theme in major release 194 | */ 195 | public $theme = self::THEME_BS; 196 | 197 | /** 198 | * @var string the name of default icon library 199 | */ 200 | public $iconSource = self::ICONS_SOURCE_GLYPHICONS; 201 | 202 | /** 203 | * @var bool add a new line to the beginning of the list, not to the end 204 | */ 205 | public $prepend = false; 206 | 207 | /** 208 | * Initialization. 209 | * 210 | * @throws \yii\base\InvalidConfigException 211 | */ 212 | public function init() 213 | { 214 | if (empty($this->models) && !$this->modelClass) { 215 | throw new InvalidConfigException('You must at least specify "models" or "modelClass"'); 216 | } 217 | 218 | if ($this->form !== null && !$this->form instanceof ActiveForm) { 219 | throw new InvalidConfigException('Property "form" must be an instance of yii\widgets\ActiveForm'); 220 | } 221 | 222 | if (!is_array($this->models)) { 223 | throw new InvalidConfigException('Property "models" must be an array'); 224 | } 225 | 226 | if ($this->models) { 227 | $modelClasses = []; 228 | foreach ($this->models as $model) { 229 | if (!$model instanceof Model) { 230 | throw new InvalidConfigException('Model has to be an instance of yii\base\Model'); 231 | } 232 | 233 | $modelClasses[get_class($model)] = true; 234 | } 235 | 236 | if (count($modelClasses) > 1) { 237 | throw new InvalidConfigException("You cannot use models of different classes"); 238 | } 239 | 240 | $this->modelClass = key($modelClasses); 241 | } 242 | 243 | parent::init(); 244 | } 245 | 246 | /** 247 | * Run widget. 248 | */ 249 | public function run() 250 | { 251 | return $this->createRenderer()->render(); 252 | } 253 | 254 | /** 255 | * @return TableRenderer 256 | */ 257 | protected function createRenderer() 258 | { 259 | if($this->sortable) { 260 | $drag = [ 261 | 'name' => 'drag', 262 | 'type' => TabularColumn::TYPE_DRAGCOLUMN, 263 | 'headerOptions' => [ 264 | 'style' => 'width: 20px;', 265 | ] 266 | ]; 267 | 268 | array_unshift($this->columns, $drag); 269 | } 270 | 271 | $available_themes = [ 272 | self::THEME_BS, 273 | self::THEME_DEFAULT 274 | ]; 275 | 276 | if (!in_array($this->theme, $available_themes, true)) { 277 | $this->theme = self::THEME_BS; 278 | } 279 | 280 | /** 281 | * set default icon map 282 | */ 283 | $iconMap = array_key_exists($this->iconSource, $this->iconMap) 284 | ? $this->iconMap[$this->iconSource] 285 | : $this->iconMap[self::ICONS_SOURCE_GLYPHICONS]; 286 | 287 | $config = [ 288 | 'id' => $this->getId(), 289 | 'columns' => $this->columns, 290 | 'min' => $this->min, 291 | 'max' => $this->max, 292 | 'attributeOptions' => $this->attributeOptions, 293 | 'data' => $this->models, 294 | 'columnClass' => $this->columnClass !== null ? $this->columnClass : TabularColumn::className(), 295 | 'allowEmptyList' => $this->allowEmptyList, 296 | 'rowOptions' => $this->rowOptions, 297 | 'addButtonPosition' => $this->addButtonPosition, 298 | 'context' => $this, 299 | 'form' => $this->form, 300 | 'sortable' => $this->sortable, 301 | 'enableError' => $this->enableError, 302 | 'cloneButton' => $this->cloneButton, 303 | 'extraButtons' => $this->extraButtons, 304 | 'layoutConfig' => $this->layoutConfig, 305 | 'iconMap' => $iconMap, 306 | 'theme' => $this->theme, 307 | 'prepend' => $this->prepend 308 | ]; 309 | 310 | if ($this->removeButtonOptions !== null) { 311 | $config['removeButtonOptions'] = $this->removeButtonOptions; 312 | } 313 | 314 | if ($this->addButtonOptions !== null) { 315 | $config['addButtonOptions'] = $this->addButtonOptions; 316 | } 317 | 318 | if ($this->cloneButtonOptions !== null) { 319 | $config['cloneButtonOptions'] = $this->cloneButtonOptions; 320 | } 321 | 322 | if (!$this->rendererClass) { 323 | $this->rendererClass = TableRenderer::className(); 324 | } 325 | 326 | $config['class'] = $this->rendererClass ?: TableRenderer::className(); 327 | 328 | return Yii::createObject($config); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/assets/FontAwesomeAsset.php: -------------------------------------------------------------------------------- 1 | 'text/css', 'integrity'=>'sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ', 'crossorigin'=>'anonymous', 'media'=>'all', 'id'=>'font-awesome', 'rel'=>'stylesheet'], 24 | ]; 25 | 26 | } -------------------------------------------------------------------------------- /src/assets/MultipleInputAsset.php: -------------------------------------------------------------------------------- 1 | __DIR__ . '/src/', 27 | 'js' => [ 28 | YII_DEBUG ? 'js/jquery.multipleInput.js' : 'js/jquery.multipleInput.min.js' 29 | ], 30 | 'css' => [ 31 | YII_DEBUG ? 'css/multiple-input.css' : 'css/multiple-input.min.css' 32 | ], 33 | ], $config); 34 | 35 | parent::__construct($config); 36 | } 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/assets/MultipleInputSortableAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = __DIR__ . '/src/'; 26 | 27 | $this->js = [ 28 | YII_DEBUG ? 'js/sortable.js' : 'js/sortable.min.js' 29 | ]; 30 | 31 | $this->css = [ 32 | YII_DEBUG ? 'css/sorting.css' : 'css/sorting.min.css' 33 | ]; 34 | 35 | parent::init(); 36 | } 37 | } -------------------------------------------------------------------------------- /src/assets/src/css/multiple-input.css: -------------------------------------------------------------------------------- 1 | .multiple-input-list { 2 | } 3 | 4 | .multiple-input-list__item { 5 | margin-bottom: 5px; 6 | } 7 | 8 | .multiple-input-list__input { 9 | display: inline-block; 10 | width: 80%; 11 | } 12 | 13 | .multiple-input-list.no-buttons .multiple-input-list__input { 14 | display: block; 15 | width: 100%; 16 | } 17 | 18 | table.multiple-input-list { 19 | margin: 0; 20 | } 21 | 22 | table.multiple-input-list.table-renderer tbody tr > td { 23 | border: 0 !important; 24 | } 25 | 26 | table.multiple-input-list.table-renderer tr > td:first-child { 27 | padding-left: 0; 28 | } 29 | 30 | table.multiple-input-list.table-renderer tr > td:last-child { 31 | padding-right: 0; 32 | } 33 | 34 | table.multiple-input-list tr > th { 35 | border-bottom: 1px solid #dddddd; 36 | } 37 | 38 | .multiple-input-list.table-renderer .form-group { 39 | margin: 0 !important; 40 | } 41 | 42 | .multiple-input-list.list-renderer .form-group { 43 | margin-bottom: 10px !important; 44 | } 45 | 46 | .multiple-input-list.table-renderer .multiple-input-list__item .label { 47 | display: block; 48 | font-size: 13px; 49 | } 50 | 51 | .multiple-input-list.table-renderer .list-cell__button { 52 | width: 40px; 53 | } 54 | 55 | .multiple-input-list.list-renderer .list-cell__button { 56 | width: 40px; 57 | text-align: right; 58 | padding-right: 0; 59 | padding-left: 5px; 60 | } 61 | 62 | .multiple-input-list.list-renderer .list-cell__button:last-child { 63 | padding-right: 15px; 64 | } 65 | 66 | .multiple-input-list.list-renderer tbody .list-cell__button .btn{ 67 | margin-top: 25px; 68 | } 69 | 70 | .multiple-input-list__item .radio, 71 | .multiple-input-list__item .checkbox { 72 | margin: 7px 0 7px 0; 73 | } 74 | 75 | .multiple-input-list__item .radio-list .radio, 76 | .multiple-input-list__item .checkbox-list .checkbox { 77 | margin: 0; 78 | } 79 | 80 | .multiple-input-list .multiple-input-list { 81 | margin-top: -5px; 82 | } 83 | 84 | @media (min-width: 768px) { 85 | .multiple-input-list.list-renderer tbody .list-cell__button .btn { 86 | margin-top: 0; 87 | } 88 | 89 | } -------------------------------------------------------------------------------- /src/assets/src/css/multiple-input.min.css: -------------------------------------------------------------------------------- 1 | .multiple-input-list__item{margin-bottom:5px}.multiple-input-list__input{display:inline-block;width:80%}.multiple-input-list.no-buttons .multiple-input-list__input{display:block;width:100%}table.multiple-input-list{margin:0}table.multiple-input-list.table-renderer tbody tr>td{border:0!important}table.multiple-input-list.table-renderer tr>td:first-child{padding-left:0}table.multiple-input-list.table-renderer tr>td:last-child{padding-right:0}table.multiple-input-list tr>th{border-bottom:1px solid #ddd}.multiple-input-list.table-renderer .form-group{margin:0!important}.multiple-input-list.list-renderer .form-group{margin-bottom:10px!important}.multiple-input-list.table-renderer .multiple-input-list__item .label{display:block;font-size:13px}.multiple-input-list.table-renderer .list-cell__button{width:40px}.multiple-input-list.list-renderer .list-cell__button{width:70px;text-align:right;padding-right:15px}.multiple-input-list__item .checkbox,.multiple-input-list__item .radio{margin:7px 0}.multiple-input-list__item .checkbox-list .checkbox,.multiple-input-list__item .radio-list .radio{margin:0}.multiple-input-list .multiple-input-list{margin-top:-5px} -------------------------------------------------------------------------------- /src/assets/src/css/sorting.css: -------------------------------------------------------------------------------- 1 | .drag-handle, 2 | .dragging, 3 | .dragging * { 4 | cursor: move !important; 5 | } 6 | 7 | .dragged { 8 | position: absolute; 9 | top: 0; 10 | opacity: .5; 11 | z-index: 2000; 12 | } 13 | 14 | .drag-handle { 15 | opacity: .5; 16 | padding: 7.5px; 17 | } 18 | 19 | .drag-handle:hover { 20 | opacity: 1; 21 | } 22 | 23 | tr.placeholder { 24 | display: block; 25 | background: red; 26 | position: relative; 27 | margin: 0; 28 | padding: 0; 29 | border: none; 30 | } 31 | 32 | tr.placeholder:before { 33 | content: ""; 34 | position: absolute; 35 | width: 0; 36 | height: 0; 37 | border: 5px solid transparent; 38 | border-left-color: red; 39 | margin-top: -5px; 40 | left: -5px; 41 | border-right: none; 42 | } 43 | 44 | table.multiple-input-list.table-renderer tr > td.list-cell__drag { 45 | width: 35px; 46 | } -------------------------------------------------------------------------------- /src/assets/src/css/sorting.min.css: -------------------------------------------------------------------------------- 1 | .drag-handle,.dragging,.dragging *{cursor:move!important}.dragged{position:absolute;top:0;opacity:.5;z-index:2000}.drag-handle{opacity:.5;padding:7.5px}.drag-handle:hover{opacity:1}tr.placeholder{display:block;background:red;position:relative;margin:0;padding:0;border:none}tr.placeholder:before{content:"";position:absolute;width:0;height:0;border:5px solid transparent;border-left-color:red;margin-top:-5px;left:-5px;border-right:none}table.multiple-input-list.table-renderer tr>td.list-cell__drag{width:35px} -------------------------------------------------------------------------------- /src/assets/src/js/jquery.multipleInput.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | 'use strict'; 3 | 4 | $.fn.multipleInput = function (method) { 5 | if (methods[method]) { 6 | return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); 7 | } else if (typeof method === 'object' || !method) { 8 | return methods.init.apply(this, arguments); 9 | } else { 10 | $.error('Method ' + method + ' does not exist on jQuery.multipleInput'); 11 | return false; 12 | } 13 | }; 14 | 15 | var events = { 16 | /** 17 | * afterAddRow event is triggered after widget's initialization. 18 | * The signature of the event handler should be: 19 | * function (event) 20 | * where event is an Event object. 21 | * 22 | */ 23 | afterInit: 'afterInit', 24 | /** 25 | * afterAddRow event is triggered after successful adding new row. 26 | * The signature of the event handler should be: 27 | * function (event, row) 28 | * where event is an Event object. 29 | * 30 | */ 31 | beforeAddRow: 'beforeAddRow', 32 | /** 33 | * afterAddRow event is triggered after successful adding new row. 34 | * The signature of the event handler should be: 35 | * function (event, row) 36 | * where event is an Event object. 37 | * 38 | */ 39 | afterAddRow: 'afterAddRow', 40 | /** 41 | * beforeDeleteRow event is triggered before row will be removed. 42 | * The signature of the event handler should be: 43 | * function (event, row) 44 | * where event is an Event object and row is html container of row for removal 45 | * 46 | * If the handler returns a boolean false, it will stop removal the row. 47 | */ 48 | beforeDeleteRow: 'beforeDeleteRow', 49 | 50 | /** 51 | * afterAddRow event is triggered after successful removal the row. 52 | * The signature of the event handler should be: 53 | * function (event) 54 | * where event is an Event object. 55 | * 56 | */ 57 | afterDeleteRow: 'afterDeleteRow', 58 | 59 | /** 60 | * afterDropRow event is triggered after drop the row in sortable mode. 61 | * The signature of the event handler should be: 62 | * function (event, row) 63 | * where event is an Event object and row is html container of dragged row 64 | */ 65 | afterDropRow: 'afterDropRow' 66 | }; 67 | 68 | var defaultOptions = { 69 | /** 70 | * the ID of widget 71 | */ 72 | id: null, 73 | 74 | /** 75 | * the ID of related input in case of using widget for an active field 76 | */ 77 | inputId: null, 78 | 79 | /** 80 | * the template of row 81 | */ 82 | template: null, 83 | 84 | /** 85 | * array that collect js templates of widgets which uses in the columns 86 | */ 87 | jsTemplates: [], 88 | 89 | /** 90 | * array of scripts which need to execute before initialization 91 | */ 92 | jsInit: [], 93 | 94 | /** 95 | * how many row are allowed to render 96 | */ 97 | max: 1, 98 | 99 | /** 100 | * a minimum number of rows 101 | */ 102 | min: 1, 103 | 104 | /** 105 | * active form options of attributes 106 | */ 107 | attributes: {}, 108 | 109 | /** 110 | * default prefix of a widget's placeholder 111 | */ 112 | indexPlaceholder: 'multiple_index', 113 | 114 | /** 115 | * whether need to show general error message or no 116 | */ 117 | showGeneralError: false, 118 | 119 | /** 120 | * if need to prepend new row, not append 121 | */ 122 | prepend: false 123 | }; 124 | 125 | var isActiveFormEnabled = false; 126 | 127 | var methods = { 128 | init: function (options) { 129 | if (typeof options !== 'object') { 130 | console.error('Options must be an object'); 131 | return; 132 | } 133 | 134 | var settings = $.extend(true, {}, defaultOptions, options || {}), 135 | $wrapper = $('#' + settings.id), 136 | form = $wrapper.closest('form'), 137 | inputId = settings.inputId; 138 | 139 | for (i in settings.jsInit) { 140 | window.eval(settings.jsInit[i]); 141 | } 142 | 143 | $wrapper.data('multipleInput', { 144 | settings: settings, 145 | currentIndex: 0 146 | }); 147 | 148 | $wrapper.on('click.multipleInput', '.js-input-remove', function (e) { 149 | e.stopPropagation(); 150 | removeInput($(this)); 151 | }); 152 | 153 | $wrapper.on('click.multipleInput', '.js-input-plus', function (e) { 154 | e.stopPropagation(); 155 | addInput($(this)); 156 | }); 157 | 158 | $wrapper.on('click.multipleInput', '.js-input-clone', function (e) { 159 | e.stopPropagation(); 160 | cloneInput($(this)); 161 | }); 162 | 163 | var i = 0, 164 | event = $.Event(events.afterInit); 165 | 166 | var intervalID = setInterval(function () { 167 | if (typeof form.data('yiiActiveForm') === 'object') { 168 | var attribute = form.yiiActiveForm('find', inputId), 169 | defaultAttributeOptions = { 170 | enableAjaxValidation: false, 171 | validateOnBlur: false, 172 | validateOnChange: false, 173 | validateOnType: false, 174 | validationDelay: 500 175 | }; 176 | 177 | // fetch default attribute options from active from attribute 178 | if (typeof attribute === 'object') { 179 | $.each(attribute, function (key, value) { 180 | if (['id', 'input', 'container'].indexOf(key) === -1) { 181 | defaultAttributeOptions[key] = value; 182 | } 183 | }); 184 | 185 | if (!settings.showGeneralError) { 186 | form.yiiActiveForm('remove', inputId); 187 | } 188 | } 189 | 190 | // append default options to option from settings 191 | $.each(settings.attributes, function (attribute, attributeOptions) { 192 | attributeOptions = $.extend({}, defaultAttributeOptions, attributeOptions); 193 | settings.attributes[attribute] = attributeOptions; 194 | }); 195 | 196 | $wrapper.data('multipleInput').settings = settings; 197 | 198 | $wrapper.find('.multiple-input-list').find('input, select, textarea').each(function () { 199 | addActiveFormAttribute($(this)); 200 | }); 201 | 202 | $wrapper.data('multipleInput').currentIndex = findMaxRowIndex($wrapper); 203 | isActiveFormEnabled = true; 204 | 205 | clearInterval(intervalID); 206 | $wrapper.trigger(event); 207 | } else { 208 | i++; 209 | } 210 | 211 | // wait for initialization of ActiveForm a second 212 | // If after a second system could not detect ActiveForm it means 213 | // that widget is used without ActiveForm and we should just complete initialization of the widget 214 | if (form.length === 0 || i > 10) { 215 | clearInterval(intervalID); 216 | isActiveFormEnabled = false; 217 | 218 | if (typeof $wrapper.data('multipleInput') !== 'undefined') { 219 | $wrapper.data('multipleInput').currentIndex = findMaxRowIndex($wrapper); 220 | } 221 | 222 | $wrapper.trigger(event); 223 | } 224 | }, 100); 225 | }, 226 | 227 | add: function (values) { 228 | addInput($(this), values); 229 | }, 230 | 231 | remove: function (index) { 232 | var row = null; 233 | if (index !== undefined) { 234 | row = $(this).find('.js-input-remove:eq(' + index + ')'); 235 | } else { 236 | row = $(this).find('.js-input-remove').last(); 237 | } 238 | 239 | removeInput(row); 240 | }, 241 | 242 | clear: function () { 243 | $(this).find('.js-input-remove').each(function () { 244 | removeInput($(this)); 245 | }); 246 | }, 247 | 248 | option: function(name, value) { 249 | value = value || null; 250 | 251 | var data = $(this).data('multipleInput'), 252 | settings = data.settings; 253 | if (value === null) { 254 | if (!settings.hasOwnProperty(name)) { 255 | throw new Error('Option "' + name + '" does not exist'); 256 | } 257 | return settings[name]; 258 | } else if (settings.hasOwnProperty(name)) { 259 | settings[name] = value; 260 | data.settings = settings; 261 | $(this).data('multipleInput', data); 262 | } 263 | } 264 | }; 265 | 266 | var cloneInput = function (btn) { 267 | let $wrapper = $(btn).closest('.multiple-input').first(); 268 | let data = $wrapper.data('multipleInput'); 269 | let settings = data.settings; 270 | 271 | let values = {}; 272 | 273 | btn.closest('.multiple-input-list__item').find('input, select, textarea').each(function (k, v) { 274 | let $element = $(v); 275 | 276 | let id = getInputId($element); 277 | if (id) { 278 | // todo still doesn't work for sinlge column 279 | let columnName = id.replace(settings.inputId, '').replace(/-\d+-/, ''); 280 | if ($element.is(':checkbox')) { 281 | if (!values.hasOwnProperty(columnName)) { 282 | values[columnName] = []; 283 | } 284 | 285 | if ($element.is(':checked')) { 286 | values[columnName].push($element.val()); 287 | } 288 | } else { 289 | values[columnName] = $element.val(); 290 | } 291 | } 292 | }); 293 | 294 | addInput(btn, values); 295 | } 296 | 297 | var addInput = function (btn, rowValues) { 298 | rowValues = rowValues || {}; 299 | 300 | let $wrapper = $(btn).closest('.multiple-input').first(); 301 | let data = $wrapper.data('multipleInput'); 302 | let settings = data.settings; 303 | let inputList = $wrapper.children('.multiple-input-list').first(); 304 | 305 | if (settings.max !== null && getRowsCount($wrapper) >= settings.max) { 306 | return; 307 | } 308 | 309 | let newRowIndex = data.currentIndex + 1; 310 | 311 | let template = replaceAll('{' + settings.indexPlaceholder + '}', newRowIndex, settings.template); 312 | let $newRow = $(template); 313 | 314 | var beforeAddEvent = $.Event(events.beforeAddRow); 315 | 316 | $wrapper.trigger(beforeAddEvent, [$newRow, newRowIndex]); 317 | if (beforeAddEvent.result === false) { 318 | return; 319 | } 320 | 321 | $newRow.find('input, select, textarea').each(function (index, element) { 322 | let $element = $(element); 323 | 324 | let id = getInputId($element); 325 | if (id) { 326 | let columnName = id.replace(settings.inputId, '').replace(/-\d+-?/, ''); 327 | 328 | if (rowValues.hasOwnProperty(columnName)) { 329 | let tag = $element.get(0).tagName; 330 | 331 | let inputValue = rowValues[columnName]; 332 | 333 | switch (tag) { 334 | case 'INPUT': 335 | if ($element.is(':checkbox')) { 336 | if (inputValue.indexOf($element.val()) !== -1) { 337 | $element.prop('checked', true); 338 | } 339 | } else { 340 | $element.val(inputValue); 341 | } 342 | 343 | break; 344 | 345 | case 'TEXTAREA': 346 | $element.val(inputValue); 347 | break; 348 | 349 | case 'SELECT': 350 | if (inputValue && inputValue.indexOf('option') !== -1) { 351 | $element.append(inputValue); 352 | } else { 353 | var option = $element.find('option[value="' + inputValue + '"]'); 354 | if (option.length) { 355 | $element.val(inputValue); 356 | } 357 | } 358 | 359 | break; 360 | 361 | default: 362 | break; 363 | } 364 | } 365 | } 366 | }); 367 | 368 | if (settings.prepend) { 369 | $newRow.hide().prependTo(inputList).fadeIn(300); 370 | } else { 371 | $newRow.hide().appendTo(inputList).fadeIn(300); 372 | } 373 | 374 | let jsTemplate = null; 375 | for (var i in settings.jsTemplates) { 376 | jsTemplate = settings.jsTemplates[i]; 377 | jsTemplate = replaceAll('{' + settings.indexPlaceholder + '}', newRowIndex, jsTemplate); 378 | jsTemplate = replaceAll('%7B' + settings.indexPlaceholder + '%7D', newRowIndex, jsTemplate); 379 | 380 | window.eval(jsTemplate); 381 | } 382 | 383 | // in order to initialize an active form attribute we need to find an input wrapper and we can do it 384 | // only after adding a new rows to dom tree 385 | if (isActiveFormEnabled) { 386 | $newRow.find('input, select, textarea').each(function (index, element) { 387 | let $element = $(element); 388 | addActiveFormAttribute($element); 389 | }); 390 | } 391 | 392 | $wrapper.data('multipleInput').currentIndex = newRowIndex; 393 | 394 | var afterAddEvent = $.Event(events.afterAddRow); 395 | $wrapper.trigger(afterAddEvent, [$newRow, newRowIndex]); 396 | }; 397 | 398 | var removeInput = function ($btn) { 399 | var $wrapper = $btn.closest('.multiple-input').first(), 400 | $toDelete = $btn.closest('.multiple-input-list__item'), 401 | data = $wrapper.data('multipleInput'), 402 | settings = data.settings; 403 | 404 | var rowsCount = getRowsCount($wrapper); 405 | if (rowsCount > settings.min) { 406 | var event = $.Event(events.beforeDeleteRow); 407 | $wrapper.trigger(event, [$toDelete, data.currentIndex]); 408 | 409 | if (event.result === false) { 410 | return; 411 | } 412 | 413 | if (isActiveFormEnabled) { 414 | $toDelete.find('input, select, textarea').each(function (index, ele) { 415 | removeActiveFormAttribute($(ele)); 416 | }); 417 | } 418 | 419 | $toDelete.fadeOut(300, function () { 420 | $(this).remove(); 421 | 422 | event = $.Event(events.afterDeleteRow); 423 | $wrapper.trigger(event, [$toDelete, rowsCount]); 424 | }); 425 | } 426 | }; 427 | 428 | /** 429 | * Add an attribute to ActiveForm. 430 | * 431 | * @param input 432 | */ 433 | var addActiveFormAttribute = function (input) { 434 | var id = getInputId(input); 435 | 436 | // skip if we could not get an ID of input 437 | if (id === null) { 438 | return; 439 | } 440 | 441 | var ele = $('#' + id), 442 | wrapper = ele.closest('.multiple-input').first(), 443 | form = ele.closest('form'); 444 | 445 | // do not add attribute which are not the part of widget 446 | if (wrapper.length === 0) { 447 | return; 448 | } 449 | 450 | // check that input has been already added to the activeForm 451 | if (typeof form.yiiActiveForm('find', id) !== 'undefined') { 452 | return; 453 | } 454 | 455 | var data = wrapper.data('multipleInput'), 456 | attributeOptions = {}; 457 | 458 | // try to find options for embedded attribute at first. 459 | // For example the id of new input is example-1-field-0. 460 | // We remove last index and check whether attribute with such id exists or not. 461 | var bareId = id.replace(/-\d+-([^\d]+)$/, '-$1'); 462 | if (data.settings.attributes.hasOwnProperty(bareId)) { 463 | attributeOptions = data.settings.attributes[bareId]; 464 | } else { 465 | // fallback in case of using flatten widget - just remove all digital indexes 466 | // and check whether attribute exists or not. 467 | bareId = replaceAll(/-\d+-/, '-', bareId); 468 | bareId = replaceAll(/-\d+/, '', bareId); 469 | if (data.settings.attributes.hasOwnProperty(bareId)) { 470 | attributeOptions = data.settings.attributes[bareId]; 471 | } 472 | } 473 | 474 | form.yiiActiveForm('add', $.extend({}, attributeOptions, { 475 | 'id': id, 476 | 'input': '#' + id, 477 | 'container': '.field-' + id 478 | })); 479 | }; 480 | 481 | /** 482 | * Removes an attribute from ActiveForm. 483 | */ 484 | var removeActiveFormAttribute = function (ele) { 485 | var id = getInputId(ele); 486 | 487 | if (id === null) { 488 | return; 489 | } 490 | 491 | var form = $('#' + id).closest('form'); 492 | 493 | if (form.length !== 0) { 494 | form.yiiActiveForm('remove', id); 495 | } 496 | }; 497 | 498 | var getInputId = function ($input) { 499 | var id = $input.attr('id'); 500 | 501 | if (typeof id === 'undefined') { 502 | id = $input.data('id'); 503 | } 504 | 505 | if (typeof id === 'undefined') { 506 | return null; 507 | } 508 | 509 | return id; 510 | }; 511 | 512 | var getRowsCount = function($wrapper) { 513 | return findRows($wrapper).length; 514 | }; 515 | 516 | var findRows = function($wrapper) { 517 | return $wrapper 518 | .find('.multiple-input-list .multiple-input-list__item') 519 | .filter(function(){ 520 | return $(this).parents('.multiple-input').first().attr('id') === $wrapper.attr('id'); 521 | }); 522 | } 523 | 524 | var findMaxRowIndex = function($wrapper) { 525 | let maxIndex = 0; 526 | 527 | findRows($wrapper).each(function(key, element) { 528 | let index = $(element).data('index'); 529 | if (index > maxIndex) { 530 | maxIndex = index; 531 | } 532 | }); 533 | 534 | return maxIndex; 535 | }; 536 | 537 | var replaceAll = function (search, replace, subject) { 538 | if (!(subject instanceof String) && typeof subject !== 'string') { 539 | console.warn('Call replaceAll for non-string value: ' + subject); 540 | return subject; 541 | } 542 | 543 | return subject.split(search).join(replace); 544 | }; 545 | })(window.jQuery); 546 | -------------------------------------------------------------------------------- /src/assets/src/js/jquery.multipleInput.min.js: -------------------------------------------------------------------------------- 1 | !function(t){"use strict";t.fn.multipleInput=function(e){return o[e]?o[e].apply(this,Array.prototype.slice.call(arguments,1)):"object"!=typeof e&&e?(t.error("Method "+e+" does not exist on jQuery.multipleInput"),!1):o.init.apply(this,arguments)};var e="afterInit",i="beforeAddRow",n="afterAddRow",l="beforeDeleteRow",r="afterDeleteRow",a={id:null,inputId:null,template:null,jsTemplates:[],jsInit:[],max:1,min:1,attributes:{},indexPlaceholder:"multiple_index",showGeneralError:!1,prepend:!1},u=!1,o={init:function(i){if("object"==typeof i){var n=t.extend(!0,{},a,i||{}),l=t("#"+n.id),r=l.closest("form"),o=n.inputId;for(f in n.jsInit)window.eval(n.jsInit[f]);l.data("multipleInput",{settings:n,currentIndex:0}),l.on("click.multipleInput",".js-input-remove",(function(e){e.stopPropagation(),c(t(this))})),l.on("click.multipleInput",".js-input-plus",(function(e){e.stopPropagation(),p(t(this))})),l.on("click.multipleInput",".js-input-clone",(function(e){e.stopPropagation(),s(t(this))}));var f=0,m=t.Event(e),v=setInterval((function(){if("object"==typeof r.data("yiiActiveForm")){var e=r.yiiActiveForm("find",o),i={enableAjaxValidation:!1,validateOnBlur:!1,validateOnChange:!1,validateOnType:!1,validationDelay:500};"object"==typeof e&&(t.each(e,(function(t,e){-1===["id","input","container"].indexOf(t)&&(i[t]=e)})),n.showGeneralError||r.yiiActiveForm("remove",o)),t.each(n.attributes,(function(e,l){l=t.extend({},i,l),n.attributes[e]=l})),l.data("multipleInput").settings=n,l.find(".multiple-input-list").find("input, select, textarea").each((function(){d(t(this))})),l.data("multipleInput").currentIndex=g(l),u=!0,clearInterval(v),l.trigger(m)}else f++;(0===r.length||f>10)&&(clearInterval(v),u=!1,void 0!==l.data("multipleInput")&&(l.data("multipleInput").currentIndex=g(l)),l.trigger(m))}),100)}else console.error("Options must be an object")},add:function(e){p(t(this),e)},remove:function(e){var i=null;i=void 0!==e?t(this).find(".js-input-remove:eq("+e+")"):t(this).find(".js-input-remove").last(),c(i)},clear:function(){t(this).find(".js-input-remove").each((function(){c(t(this))}))},option:function(e,i){i=i||null;var n=t(this).data("multipleInput"),l=n.settings;if(null===i){if(!l.hasOwnProperty(e))throw new Error('Option "'+e+'" does not exist');return l[e]}l.hasOwnProperty(e)&&(l[e]=i,n.settings=l,t(this).data("multipleInput",n))}},s=function(e){let i=t(e).closest(".multiple-input").first().data("multipleInput").settings,n={};e.closest(".multiple-input-list__item").find("input, select, textarea").each((function(e,l){let r=t(l),a=m(r);if(a){let t=a.replace(i.inputId,"").replace(/-\d+-/,"");r.is(":checkbox")?(n.hasOwnProperty(t)||(n[t]=[]),r.is(":checked")&&n[t].push(r.val())):n[t]=r.val()}})),p(e,n)},p=function(e,l){l=l||{};let r=t(e).closest(".multiple-input").first(),a=r.data("multipleInput"),o=a.settings,s=r.children(".multiple-input-list").first();if(null!==o.max&&v(r)>=o.max)return;let p=a.currentIndex+1,c=I("{"+o.indexPlaceholder+"}",p,o.template),f=t(c);var h=t.Event(i);if(r.trigger(h,[f,p]),!1===h.result)return;f.find("input, select, textarea").each((function(e,i){let n=t(i),r=m(n);if(r){let t=r.replace(o.inputId,"").replace(/-\d+-?/,"");if(l.hasOwnProperty(t)){let e=n.get(0).tagName,i=l[t];switch(e){case"INPUT":n.is(":checkbox")?-1!==i.indexOf(n.val())&&n.prop("checked",!0):n.val(i);break;case"TEXTAREA":n.val(i);break;case"SELECT":if(i&&-1!==i.indexOf("option"))n.append(i);else n.find('option[value="'+i+'"]').length&&n.val(i)}}}})),o.prepend?f.hide().prependTo(s).fadeIn(300):f.hide().appendTo(s).fadeIn(300);let g=null;for(var x in o.jsTemplates)g=o.jsTemplates[x],g=I("{"+o.indexPlaceholder+"}",p,g),g=I("%7B"+o.indexPlaceholder+"%7D",p,g),window.eval(g);u&&f.find("input, select, textarea").each((function(e,i){let n=t(i);d(n)})),r.data("multipleInput").currentIndex=p;var y=t.Event(n);r.trigger(y,[f,p])},c=function(e){var i=e.closest(".multiple-input").first(),n=e.closest(".multiple-input-list__item"),a=i.data("multipleInput"),o=a.settings,s=v(i);if(s>o.min){var p=t.Event(l);if(i.trigger(p,[n,a.currentIndex]),!1===p.result)return;u&&n.find("input, select, textarea").each((function(e,i){f(t(i))})),n.fadeOut(300,(function(){t(this).remove(),p=t.Event(r),i.trigger(p,[n,s])}))}},d=function(e){var i=m(e);if(null!==i){var n=t("#"+i),l=n.closest(".multiple-input").first(),r=n.closest("form");if(0!==l.length&&void 0===r.yiiActiveForm("find",i)){var a=l.data("multipleInput"),u={},o=i.replace(/-\d+-([^\d]+)$/,"-$1");a.settings.attributes.hasOwnProperty(o)?u=a.settings.attributes[o]:(o=I(/-\d+-/,"-",o),o=I(/-\d+/,"",o),a.settings.attributes.hasOwnProperty(o)&&(u=a.settings.attributes[o])),r.yiiActiveForm("add",t.extend({},u,{id:i,input:"#"+i,container:".field-"+i}))}}},f=function(e){var i=m(e);if(null!==i){var n=t("#"+i).closest("form");0!==n.length&&n.yiiActiveForm("remove",i)}},m=function(t){var e=t.attr("id");return void 0===e&&(e=t.data("id")),void 0===e?null:e},v=function(t){return h(t).length},h=function(e){return e.find(".multiple-input-list .multiple-input-list__item").filter((function(){return t(this).parents(".multiple-input").first().attr("id")===e.attr("id")}))},g=function(e){let i=0;return h(e).each((function(e,n){let l=t(n).data("index");l>i&&(i=l)})),i},I=function(t,e,i){return i instanceof String||"string"==typeof i?i.split(t).join(e):(console.warn("Call replaceAll for non-string value: "+i),i)}}(window.jQuery); -------------------------------------------------------------------------------- /src/components/BaseColumn.php: -------------------------------------------------------------------------------- 1 | [ 76 | * ... 77 | * [ 78 | * 'name' => 'column', 79 | * 'items' => function($data) { 80 | * // do your magic 81 | * } 82 | * .... 83 | * ] 84 | * ... 85 | * 86 | * ``` 87 | */ 88 | public $items; 89 | 90 | /** 91 | * @var array 92 | */ 93 | public $options; 94 | 95 | /** 96 | * @var array the HTML attributes for the header cell tag. 97 | */ 98 | public $headerOptions = []; 99 | 100 | /** 101 | * @var bool whether to render inline error for the input. Default to `false` 102 | */ 103 | public $enableError = false; 104 | 105 | /** 106 | * @var array the default options for the error tag 107 | */ 108 | public $errorOptions = ['class' => 'help-block help-block-error']; 109 | 110 | /** 111 | * @var BaseRenderer the renderer instance 112 | */ 113 | public $renderer; 114 | 115 | /** 116 | * @var mixed the context of using a column. It is an instance of widget(MultipleInput or TabularInput). 117 | */ 118 | public $context; 119 | 120 | /** 121 | * @var array client-side options of the attribute, e.g. enableAjaxValidation. 122 | * You can use this property for custom configuration of the column (attribute). 123 | * By default, the column will use options which are defined on widget level. 124 | * 125 | * @since 2.1 126 | */ 127 | public $attributeOptions = []; 128 | 129 | /** 130 | * @var string the unique prefix for attribute's name to avoid id duplication e.g. in case of using Select2 widget. 131 | * @since 2.8 132 | */ 133 | public $nameSuffix; 134 | 135 | /** 136 | * @var array|\Closure the HTML attributes for the indivdual table body column. This can be either an array 137 | * specifying the common HTML attributes for indivdual body column, or an anonymous function that 138 | * returns an array of the HTML attributes. It should have the following signature: 139 | * 140 | * ```php 141 | * function ($model, $index, $context) 142 | * ``` 143 | * 144 | * - `$model`: the current data model being rendered 145 | * - `$index`: the zero-based index of the data model in the model array 146 | * - `$context`: the widget object 147 | * 148 | * @since 2.18.0 149 | */ 150 | public $columnOptions = []; 151 | 152 | /** 153 | * @var string the template of input for customize view. 154 | * For example: '
{input}
' 155 | */ 156 | public $inputTemplate = '{input}'; 157 | 158 | /** 159 | * @var Model|ActiveRecordInterface|array 160 | */ 161 | private $_model; 162 | 163 | 164 | /** 165 | * @return Model|ActiveRecordInterface|array 166 | */ 167 | public function getModel() 168 | { 169 | return $this->_model; 170 | } 171 | 172 | /** 173 | * @param Model|ActiveRecordInterface|array $model 174 | */ 175 | public function setModel($model) 176 | { 177 | if ($this->ensureModel($model)) { 178 | $this->_model = $model; 179 | } 180 | } 181 | 182 | protected function ensureModel($model) 183 | { 184 | return true; 185 | } 186 | 187 | /** 188 | * @inheritdoc 189 | */ 190 | public function init() 191 | { 192 | parent::init(); 193 | 194 | if ($this->type === null) { 195 | $this->type = self::TYPE_TEXT_INPUT; 196 | } 197 | 198 | if ($this->type === self::TYPE_STATIC && empty($this->name)) { 199 | $this->name = self::DEFAULT_STATIC_COLUMN_NAME; 200 | } 201 | 202 | if ($this->isNameEmpty()) { 203 | throw new InvalidConfigException("The 'name' option is required."); 204 | } 205 | 206 | if (empty($this->options)) { 207 | $this->options = []; 208 | } 209 | } 210 | 211 | private function isNameEmpty() 212 | { 213 | if (empty($this->name)) { 214 | if ($this->name === 0 || $this->name === '0') { 215 | return false; 216 | } 217 | 218 | return true; 219 | } 220 | 221 | return false; 222 | } 223 | 224 | /** 225 | * @return bool whether the type of column is hidden input. 226 | */ 227 | public function isHiddenInput() 228 | { 229 | return $this->type === self::TYPE_HIDDEN_INPUT; 230 | } 231 | 232 | 233 | /** 234 | * Prepares the value of column. 235 | * @param array $contextParams the params who passed to closure: 236 | * string $id the id of input element 237 | * string $name the name of input element 238 | * string $indexPlaceholder The index placeholder of multiple input. The {$indexPlaceholder} template will be replace by $index 239 | * int $index The index of multiple input 240 | * int $columnIndex The index of current model attributes 241 | * @return mixed 242 | */ 243 | protected function prepareValue($contextParams = []) 244 | { 245 | $data = $this->getModel(); 246 | if ($this->value instanceof \Closure) { 247 | $value = call_user_func($this->value, $data, $contextParams); 248 | } else { 249 | $valuePreparer = new ValuePreparer($this->name, $this->defaultValue); 250 | $value = $valuePreparer->prepare($data); 251 | } 252 | 253 | return $value; 254 | } 255 | 256 | /** 257 | * Returns element id. 258 | * 259 | * @param null|int $index 260 | * @return mixed 261 | */ 262 | public function getElementId($index = null) 263 | { 264 | return $this->normalize($this->getElementName($index)); 265 | } 266 | 267 | /** 268 | * Returns element's name. 269 | * 270 | * @param int|null $index current row index 271 | * @param bool $withPrefix whether to add prefix. 272 | * @return string 273 | */ 274 | abstract public function getElementName($index, $withPrefix = true); 275 | 276 | /** 277 | * Normalization name. 278 | * 279 | * @param $name 280 | * @return mixed 281 | */ 282 | private function normalize($name) { 283 | return str_replace(['[]', '][', '[', ']', ' ', '.'], ['', '-', '-', '', '-', '-'], strtolower($name)); 284 | } 285 | 286 | /** 287 | * Renders the input. 288 | * 289 | * @param string $name the name of the input 290 | * @param array $options the HTML options of input 291 | * @param array $contextParams the params who passed to closure: 292 | * string $id the id of input element 293 | * string $name the name of input element 294 | * string $indexPlaceholder The index placeholder of multiple input. The {$indexPlaceholder} template will be replace by $index 295 | * int $index The index of multiple input 296 | * int $columnIndex The index of current model attributes 297 | * @return string 298 | * @throws InvalidConfigException 299 | */ 300 | public function renderInput($name, $options, $contextParams = []) 301 | { 302 | if ($this->options instanceof \Closure) { 303 | $optionsExt = call_user_func($this->options, $this->getModel()); 304 | } else { 305 | $optionsExt = $this->options; 306 | } 307 | 308 | $options = ArrayHelper::merge($options, $optionsExt); 309 | $method = 'render' . Inflector::camelize($this->type); 310 | 311 | // @see https://github.com/unclead/yii2-multiple-input/issues/261 312 | if (isset($contextParams['index']) && isset($contextParams['indexPlaceholder'])) { 313 | $options = $this->replaceIndexPlaceholderInOptions($options, $contextParams['indexPlaceholder'], $contextParams['index']); 314 | } 315 | 316 | $value = null; 317 | if ($this->type !== self::TYPE_DRAGCOLUMN) { 318 | $value = $this->prepareValue($contextParams); 319 | } 320 | 321 | if (isset($options['items'])) { 322 | $options['items'] = $this->prepareItems($options['items']); 323 | } 324 | 325 | if (method_exists($this, $method)) { 326 | $input = $this->$method($name, $value, $options); 327 | } else { 328 | $input = $this->renderDefault($name, $value, $options); 329 | } 330 | 331 | return strtr($this->inputTemplate, ['{input}' => $input]); 332 | } 333 | 334 | private function replaceIndexPlaceholderInOptions($options, $indexPlaceholder, $index) 335 | { 336 | $result = []; 337 | foreach ($options as $key => $value) { 338 | if (is_array($value)) { 339 | $result[$key] = $this->replaceIndexPlaceholderInOptions($value, $indexPlaceholder, $index); 340 | } elseif (is_string($value)) { 341 | $result[$key] = str_replace('{' . $indexPlaceholder . '}', $index, $value); 342 | } else { 343 | if ($value instanceof JsExpression) { 344 | $value->expression = str_replace('{' . $indexPlaceholder . '}', $index, $value->expression); 345 | } 346 | 347 | $result[$key] = $value; 348 | } 349 | } 350 | 351 | return $result; 352 | } 353 | 354 | /** 355 | * Renders drop down list. 356 | * 357 | * @param $name 358 | * @param $value 359 | * @param $options 360 | * @return string 361 | */ 362 | protected function renderDropDownList($name, $value, $options) 363 | { 364 | if ($this->renderer->isBootstrapTheme()) { 365 | Html::addCssClass($options, 'form-control'); 366 | } 367 | 368 | return Html::dropDownList($name, $value, $this->prepareItems($this->items), $options); 369 | } 370 | 371 | /** 372 | * Returns the items for list. 373 | * 374 | * @param mixed $items 375 | * @return array|Closure|mixed 376 | */ 377 | private function prepareItems($items) 378 | { 379 | if ($items instanceof \Closure) { 380 | return $items($this->getModel()); 381 | } 382 | 383 | return $items; 384 | } 385 | 386 | /** 387 | * Renders list box. 388 | * 389 | * @param string $name the name of input 390 | * @param mixed $value the value of input 391 | * @param array $options the HTMl options of input 392 | * @return string 393 | */ 394 | protected function renderListBox($name, $value, $options) 395 | { 396 | if ($this->renderer->isBootstrapTheme()) { 397 | Html::addCssClass($options, 'form-control'); 398 | } 399 | 400 | return Html::listBox($name, $value, $this->prepareItems($this->items), $options); 401 | } 402 | 403 | /** 404 | * Renders hidden input. 405 | * 406 | * @param string $name the name of input 407 | * @param mixed $value the value of input 408 | * @param array $options the HTMl options of input 409 | * @return string 410 | */ 411 | protected function renderHiddenInput($name, $value, $options) 412 | { 413 | return Html::hiddenInput($name, $value, $options); 414 | } 415 | 416 | /** 417 | * Renders radio button. 418 | * 419 | * @param string $name the name of input 420 | * @param mixed $value the value of input 421 | * @param array $options the HTMl options of input 422 | * @return string 423 | */ 424 | protected function renderRadio($name, $value, $options) 425 | { 426 | if (!isset($options['label'])) { 427 | $options['label'] = ''; 428 | } 429 | 430 | if (!array_key_exists('uncheck', $options)) { 431 | $options['uncheck'] = 0; 432 | } 433 | 434 | $input = Html::radio($name, $value, $options); 435 | 436 | return Html::tag('div', $input, ['class' => 'radio']); 437 | } 438 | 439 | /** 440 | * Renders radio button list. 441 | * 442 | * @param string $name the name of input 443 | * @param mixed $value the value of input 444 | * @param array $options the HTMl options of input 445 | * @return string 446 | */ 447 | protected function renderRadioList($name, $value, $options) 448 | { 449 | if (!array_key_exists('unselect', $options)) { 450 | $options['unselect'] = ''; 451 | } 452 | 453 | $options['item'] = function ($index, $label, $name, $checked, $value) use ($options) { 454 | $content = Html::radio($name, $checked, [ 455 | 'label' => $label, 456 | 'value' => $value, 457 | 'data-id' => ArrayHelper::getValue($options, 'id'), 458 | ]); 459 | 460 | return Html::tag('div', $content, ['class' => 'radio']); 461 | }; 462 | 463 | $input = Html::radioList($name, $value, $this->prepareItems($this->items), $options); 464 | 465 | return Html::tag('div', $input, ['class' => 'radio-list']); 466 | } 467 | 468 | /** 469 | * Renders checkbox. 470 | * 471 | * @param string $name the name of input 472 | * @param mixed $value the value of input 473 | * @param array $options the HTMl options of input 474 | * @return string 475 | */ 476 | protected function renderCheckbox($name, $value, $options) 477 | { 478 | if (!isset($options['label'])) { 479 | $options['label'] = ''; 480 | } 481 | 482 | if (!array_key_exists('uncheck', $options)) { 483 | $options['uncheck'] = 0; 484 | } 485 | 486 | $input = Html::checkbox($name, $value, $options); 487 | 488 | return Html::tag('div', $input, ['class' => 'checkbox']); 489 | } 490 | 491 | /** 492 | * Renders checkbox list. 493 | * 494 | * @param string $name the name of input 495 | * @param mixed $value the value of input 496 | * @param array $options the HTMl options of input 497 | * @return string 498 | */ 499 | protected function renderCheckboxList($name, $value, $options) 500 | { 501 | if (!array_key_exists('unselect', $options)) { 502 | $options['unselect'] = ''; 503 | } 504 | 505 | $options['item'] = function ($index, $label, $name, $checked, $value) use ($options) { 506 | $content = Html::checkbox($name, $checked, [ 507 | 'label' => $label, 508 | 'value' => $value, 509 | 'data-id' => ArrayHelper::getValue($options, 'id'), 510 | ]); 511 | 512 | return Html::tag('div', $content, ['class' => 'checkbox']); 513 | }; 514 | 515 | $input = Html::checkboxList($name, $value, $this->prepareItems($this->items), $options); 516 | 517 | return Html::tag('div', $input, ['class' => 'checkbox-list']); 518 | } 519 | 520 | /** 521 | * Renders a text. 522 | * 523 | * @param string $name the name of input 524 | * @param mixed $value the value of input 525 | * @param array $options the HTMl options of input 526 | * @return string 527 | */ 528 | protected function renderStatic($name, $value, $options) 529 | { 530 | if ($this->renderer->isBootstrapTheme()) { 531 | Html::addCssClass($options, 'form-control-static'); 532 | } 533 | 534 | return Html::tag('p', $value, $options); 535 | } 536 | 537 | /** 538 | * Renders a drag&drop column. 539 | * 540 | * @param string $name the name of input 541 | * @param mixed $value the value of input 542 | * @param array $options the HTMl options of input 543 | * @return string 544 | */ 545 | protected function renderDragColumn($name, $value, $options) 546 | { 547 | /** 548 | * Class was passed into options by TableRenderer->renderCellContent(), 549 | * we can extract it here 550 | */ 551 | $class = ''; 552 | if (array_key_exists('class', $options)) { 553 | $class = ArrayHelper::remove($options, 'class'); 554 | } 555 | 556 | $dragClass = implode(' ', [$class, 'drag-handle']); 557 | 558 | return Html::tag('span', null, ['class' => $dragClass]); 559 | } 560 | 561 | /** 562 | * Renders an input. 563 | * 564 | * @param string $name the name of input 565 | * @param mixed $value the value of input 566 | * @param array $options the HTMl options of input 567 | * @return string 568 | * @throws InvalidConfigException 569 | */ 570 | protected function renderDefault($name, $value, $options) 571 | { 572 | $type = $this->type; 573 | 574 | if (method_exists('yii\helpers\Html', $type)) { 575 | if ($this->renderer->isBootstrapTheme()) { 576 | Html::addCssClass($options, 'form-control'); 577 | } 578 | 579 | $input = Html::$type($name, $value, $options); 580 | } elseif (class_exists($type) && method_exists($type, 'widget')) { 581 | $input = $this->renderWidget($type, $name, $value, $options); 582 | } else { 583 | throw new InvalidConfigException("Invalid column type '$type'"); 584 | } 585 | 586 | return $input; 587 | } 588 | 589 | /** 590 | * Renders a widget. 591 | * 592 | * @param string $type 593 | * @param string $name the name of input 594 | * @param mixed $value the value of input 595 | * @param array $options the HTMl options of input 596 | * @return mixed 597 | */ 598 | protected function renderWidget($type, $name, $value, $options) 599 | { 600 | if (isset($options['options']['tabindex'])) { 601 | $tabindex = $options['options']['tabindex']; 602 | } elseif (isset($options['tabindex'])) { 603 | $tabindex = $options['tabindex']; 604 | unset($options['tabindex']); 605 | } else { 606 | $tabindex = null; 607 | } 608 | 609 | $id = isset($options['id']) ? $options['id'] : $this->normalize($name); 610 | $model = $this->getModel(); 611 | if ($model instanceof Model) { 612 | $widgetOptions = [ 613 | 'model' => $model, 614 | 'attribute' => $this->name, 615 | 'value' => $value, 616 | 'options' => [ 617 | 'id' => $id, 618 | 'name' => $name, 619 | 'tabindex' => $tabindex, 620 | 'value' => $value 621 | ] 622 | ]; 623 | } else { 624 | $widgetOptions = [ 625 | 'name' => $name, 626 | 'value' => $value, 627 | 'options' => [ 628 | 'id' => $id, 629 | 'name' => $name, 630 | 'tabindex' => $tabindex, 631 | 'value' => $value 632 | ] 633 | ]; 634 | } 635 | 636 | $options = ArrayHelper::merge($options, $widgetOptions); 637 | 638 | return $type::widget($options); 639 | } 640 | 641 | 642 | /** 643 | * Renders an error. 644 | * 645 | * @param string $error 646 | * @return string 647 | */ 648 | public function renderError($error) 649 | { 650 | $options = $this->errorOptions; 651 | $tag = isset($options['tag']) ? $options['tag'] : 'div'; 652 | $encode = !isset($options['encode']) || $options['encode'] !== false; 653 | unset($options['tag'], $options['encode']); 654 | 655 | return Html::tag($tag, $encode ? Html::encode($error) : $error, $options); 656 | } 657 | 658 | /** 659 | * @param $index 660 | * @return mixed 661 | */ 662 | abstract public function getFirstError($index); 663 | } 664 | -------------------------------------------------------------------------------- /src/components/ValuePreparer.php: -------------------------------------------------------------------------------- 1 | name = $name; 41 | $this->defaultValue = $defaultValue; 42 | } 43 | 44 | /** 45 | * @param $data Prepared data 46 | * 47 | * @return int|mixed|null|string 48 | */ 49 | public function prepare($data) 50 | { 51 | $value = null; 52 | if ($data instanceof ActiveRecordInterface) { 53 | if ($data->canGetProperty($this->name)) { 54 | $value = $data->{$this->name}; 55 | } else { 56 | $relation = $data->getRelation($this->name, false); 57 | if ($relation !== null) { 58 | $value = $relation->findFor($this->name, $data); 59 | } else { 60 | $value = $data->{$this->name}; 61 | } 62 | } 63 | } elseif ($data instanceof Model) { 64 | $value = $data->{$this->name}; 65 | } elseif (is_array($data)) { 66 | $value = ArrayHelper::getValue($data, $this->name, null); 67 | } elseif(is_string($data) || is_numeric($data)) { 68 | $value = $data; 69 | } 70 | 71 | if ($this->defaultValue !== null && $this->isEmpty($value)) { 72 | $value = $this->defaultValue; 73 | } 74 | 75 | return $value; 76 | } 77 | 78 | protected function isEmpty($value) 79 | { 80 | return $value === null || $value === [] || $value === ''; 81 | } 82 | } -------------------------------------------------------------------------------- /src/renderers/BaseRenderer.php: -------------------------------------------------------------------------------- 1 | context = $context; 202 | } 203 | 204 | public function init() 205 | { 206 | parent::init(); 207 | 208 | $this->prepareMinOption(); 209 | $this->prepareMaxOption(); 210 | $this->prepareColumnClass(); 211 | $this->prepareButtons(); 212 | $this->prepareIndexPlaceholder(); 213 | } 214 | 215 | private function prepareColumnClass() 216 | { 217 | if (!$this->columnClass) { 218 | throw new InvalidConfigException('You must specify "columnClass"'); 219 | } 220 | 221 | if (!class_exists($this->columnClass)) { 222 | throw new InvalidConfigException('Column class "' . $this->columnClass. '" does not exist'); 223 | } 224 | } 225 | 226 | private function prepareMinOption() 227 | { 228 | // Set value of min option based on value of allowEmptyList for BC 229 | if ($this->min === null) { 230 | $this->min = $this->allowEmptyList ? 0 : 1; 231 | } else { 232 | if ($this->min < 0) { 233 | throw new InvalidConfigException('Option "min" cannot be less 0'); 234 | } 235 | 236 | // Allow empty list in case when minimum number of rows equal 0. 237 | if ($this->min === 0 && !$this->allowEmptyList) { 238 | $this->allowEmptyList = true; 239 | } 240 | 241 | // Deny empty list in case when min number of rows greater then 0 242 | if ($this->min > 0 && $this->allowEmptyList) { 243 | $this->allowEmptyList = false; 244 | } 245 | } 246 | } 247 | 248 | private function prepareMaxOption() 249 | { 250 | if ($this->max === null) { 251 | $this->max = PHP_INT_MAX; 252 | } 253 | 254 | if ($this->max < 1) { 255 | $this->max = 1; 256 | } 257 | 258 | // Maximum number of rows cannot be less then minimum number. 259 | if ($this->max < $this->min) { 260 | $this->max = $this->min; 261 | } 262 | } 263 | 264 | /** 265 | * @throws InvalidConfigException 266 | */ 267 | private function prepareButtons() 268 | { 269 | if ($this->addButtonPosition === null || $this->addButtonPosition === []) { 270 | $this->addButtonPosition = $this->min === 0 ? self::POS_HEADER : self::POS_ROW; 271 | } 272 | 273 | if (!is_array($this->addButtonPosition)) { 274 | $this->addButtonPosition = (array) $this->addButtonPosition; 275 | } 276 | 277 | if (!array_key_exists('class', $this->removeButtonOptions)) { 278 | $this->removeButtonOptions['class'] = $this->isBootstrapTheme() ? 'btn btn-danger' : ''; 279 | } 280 | 281 | if (!array_key_exists('label', $this->removeButtonOptions)) { 282 | $this->removeButtonOptions['label'] = Html::tag('i', null, ['class' => $this->getIconClass('remove')]); 283 | } 284 | 285 | if (!array_key_exists('class', $this->addButtonOptions)) { 286 | $this->addButtonOptions['class'] = $this->isBootstrapTheme() ? 'btn btn-default' : ''; 287 | } 288 | 289 | if (!array_key_exists('label', $this->addButtonOptions)) { 290 | $this->addButtonOptions['label'] = Html::tag('i', null, ['class' => $this->getIconClass('add')]); 291 | } 292 | 293 | if (!array_key_exists('class', $this->cloneButtonOptions)) { 294 | $this->cloneButtonOptions['class'] = $this->isBootstrapTheme() ? 'btn btn-info' : ''; 295 | } 296 | 297 | if (!array_key_exists('label', $this->cloneButtonOptions)) { 298 | $this->cloneButtonOptions['label'] = Html::tag('i', null, ['class' => $this->getIconClass('clone')]); 299 | } 300 | } 301 | 302 | 303 | /** 304 | * Creates column objects and initializes them. 305 | * 306 | * @throws \yii\base\InvalidConfigException 307 | */ 308 | protected function initColumns() 309 | { 310 | foreach ($this->columns as $i => $column) { 311 | $definition = array_merge([ 312 | 'class' => $this->columnClass, 313 | 'renderer' => $this, 314 | 'context' => $this->context, 315 | ], $column); 316 | 317 | $this->addButtonOptions = (array)$this->addButtonOptions; 318 | 319 | if (!isset($definition['attributeOptions'])) { 320 | $definition['attributeOptions'] = $this->attributeOptions; 321 | } 322 | 323 | if (!isset($definition['enableError'])) { 324 | $definition['enableError'] = $this->enableError; 325 | } 326 | 327 | $this->columns[$i] = Yii::createObject($definition); 328 | } 329 | } 330 | 331 | /** 332 | * Render extra content in action column. 333 | * 334 | * @param $index 335 | * @param $item 336 | * 337 | * @return string 338 | */ 339 | protected function getExtraButtons($index, $item) 340 | { 341 | if (!$this->extraButtons) { 342 | return ''; 343 | } 344 | 345 | if (is_callable($this->extraButtons)) { 346 | $content = call_user_func($this->extraButtons, $item, $index, $this->context); 347 | } else { 348 | $content = $this->extraButtons; 349 | } 350 | 351 | if (!is_string($content)) { 352 | throw new InvalidParamException('Property "extraButtons" must return string.'); 353 | } 354 | 355 | return $content; 356 | } 357 | 358 | public function render() 359 | { 360 | $this->initColumns(); 361 | 362 | $view = $this->context->getView(); 363 | MultipleInputAsset::register($view); 364 | 365 | // Collect all js scripts which were added before rendering of our widget 366 | $jsBefore= []; 367 | if (is_array($view->js)) { 368 | foreach ($view->js as $position => $scripts) { 369 | foreach ((array)$scripts as $key => $js) { 370 | if (!isset($jsBefore[$position])) { 371 | $jsBefore[$position] = []; 372 | } 373 | $jsBefore[$position][$key] = $js; 374 | } 375 | } 376 | } 377 | 378 | $content = $this->internalRender(); 379 | 380 | // Collect all js scripts which has to be appended to page before initialization widget 381 | $jsInit = []; 382 | if (is_array($view->js)) { 383 | foreach ($this->jsPositions as $position) { 384 | foreach (ArrayHelper::getValue($view->js, $position, []) as $key => $js) { 385 | if (isset($jsBefore[$position][$key])) { 386 | continue; 387 | } 388 | $jsInit[$key] = $js; 389 | $jsBefore[$position][$key] = $js; 390 | unset($view->js[$position][$key]); 391 | } 392 | } 393 | } 394 | 395 | $template = $this->prepareTemplate(); 396 | 397 | $jsTemplates = []; 398 | if (is_array($view->js)) { 399 | foreach ($this->jsPositions as $position) { 400 | foreach (ArrayHelper::getValue($view->js, $position, []) as $key => $js) { 401 | if (isset($jsBefore[$position][$key])) { 402 | continue; 403 | } 404 | $jsTemplates[$key] = $js; 405 | unset($view->js[$position][$key]); 406 | } 407 | } 408 | } 409 | 410 | $options = Json::encode(array_merge([ 411 | 'id' => $this->id, 412 | 'inputId' => $this->context->options['id'], 413 | 'template' => $template, 414 | 'jsInit' => $jsInit, 415 | 'jsTemplates' => $jsTemplates, 416 | 'max' => $this->max, 417 | 'min' => $this->min, 418 | 'attributes' => $this->prepareJsAttributes(), 419 | 'indexPlaceholder' => $this->getIndexPlaceholder(), 420 | 'prepend' => $this->prepend 421 | ], $this->jsExtraSettings)); 422 | 423 | $js = "jQuery('#{$this->id}').multipleInput($options);"; 424 | $view->registerJs($js); 425 | 426 | if ($this->sortable) { 427 | $this->registerJsSortable(); 428 | } 429 | 430 | return $content; 431 | } 432 | 433 | private function registerJsSortable() 434 | { 435 | $view = $this->context->getView(); 436 | MultipleInputSortableAsset::register($view); 437 | 438 | // todo override when ListRenderer will use div markup 439 | $options = Json::encode($this->getJsSortableOptions()); 440 | $js = "new Sortable(document.getElementById('{$this->id}').querySelector('.multiple-input-list tbody'), {$options});"; 441 | $view->registerJs($js); 442 | } 443 | 444 | /** 445 | * Returns an array of JQuery sortable plugin options. 446 | * You can override this method extend plugin behaviour. 447 | * 448 | * @return array 449 | */ 450 | protected function getJsSortableOptions() 451 | { 452 | return [ 453 | 'handle' => '.drag-handle', 454 | 'draggable' => '.multiple-input-list__item', 455 | 'onEnd' => new \yii\web\JsExpression(" 456 | function(event) { 457 | var item = $(event.item), 458 | wrapper = item.closest('.multiple-input').first(), 459 | trigeredEvent = $.Event('afterDropRow'); 460 | wrapper.trigger(trigeredEvent, [item]); 461 | } 462 | ") 463 | ]; 464 | } 465 | 466 | /** 467 | * @return mixed 468 | * @throws NotSupportedException 469 | */ 470 | abstract protected function internalRender(); 471 | 472 | /** 473 | * @return string 474 | */ 475 | abstract protected function prepareTemplate(); 476 | 477 | /** 478 | * @return mixed 479 | */ 480 | public function getIndexPlaceholder() 481 | { 482 | return $this->indexPlaceholder; 483 | } 484 | 485 | /** 486 | * @return bool 487 | */ 488 | protected function isAddButtonPositionHeader() 489 | { 490 | return in_array(self::POS_HEADER, $this->addButtonPosition); 491 | } 492 | 493 | /** 494 | * @return bool 495 | */ 496 | protected function isAddButtonPositionFooter() 497 | { 498 | return in_array(self::POS_FOOTER, $this->addButtonPosition); 499 | } 500 | 501 | /** 502 | * @return bool 503 | */ 504 | protected function isAddButtonPositionRow() 505 | { 506 | return in_array(self::POS_ROW, $this->addButtonPosition); 507 | } 508 | 509 | /** 510 | * @return bool 511 | */ 512 | protected function isAddButtonPositionRowBegin() 513 | { 514 | return in_array(self::POS_ROW_BEGIN, $this->addButtonPosition); 515 | } 516 | 517 | protected function isFixedNumberOfRows() 518 | { 519 | return $this->max === $this->min; 520 | } 521 | 522 | private function prepareIndexPlaceholder() 523 | { 524 | $this->indexPlaceholder = 'multiple_index_' . $this->id; 525 | } 526 | 527 | /** 528 | * Prepares attributes options for client side. 529 | * 530 | * @return array 531 | */ 532 | protected function prepareJsAttributes() 533 | { 534 | $attributes = []; 535 | foreach ($this->columns as $column) { 536 | $model = $column->getModel(); 537 | $inputID = str_replace(['-0', '-0-'], '', $column->getElementId(0)); 538 | if ($this->form instanceof ActiveForm && $model instanceof Model) { 539 | $field = $this->form->field($model, $column->name); 540 | foreach ($column->attributeOptions as $name => $value) { 541 | if ($field->hasProperty($name)) { 542 | $field->$name = $value; 543 | } 544 | } 545 | $field->render(''); 546 | $attributeOptions = array_pop($this->form->attributes); 547 | if (isset($attributeOptions['name']) && $attributeOptions['name'] === $column->name) { 548 | $attributes[$inputID] = ArrayHelper::merge($attributeOptions, $column->attributeOptions); 549 | } else { 550 | $this->form->attributes[] = $attributeOptions; 551 | } 552 | } else { 553 | $attributes[$inputID] = $column->attributeOptions; 554 | } 555 | } 556 | 557 | return $attributes; 558 | } 559 | 560 | /** 561 | * @param $action - the control parameter, used as key into allowed types 562 | * @return string - the relevant icon class 563 | * 564 | * @throws InvalidConfigException 565 | */ 566 | protected function getIconClass($action) { 567 | if (in_array($action, ['add', 'remove', 'clone', 'drag-handle'])) { 568 | return $this->iconMap[$action]; 569 | } 570 | 571 | if (YII_DEBUG) { 572 | throw new InvalidConfigException('Out of bounds, "' . $action . '" not found in your iconMap'); 573 | } 574 | return ''; 575 | } 576 | 577 | public function isDefaultTheme() 578 | { 579 | return $this->theme === self::THEME_DEFAULT; 580 | } 581 | 582 | public function isBootstrapTheme() 583 | { 584 | return $this->theme === self::THEME_BS; 585 | } 586 | 587 | protected function renderRows() 588 | { 589 | $rows = []; 590 | 591 | $rowIndex = 0; 592 | if ($this->data) { 593 | foreach ($this->data as $index => $item) { 594 | if ($rowIndex <= $this->max) { 595 | $rows[] = $this->renderRowContent($index, $item, $rowIndex); 596 | } else { 597 | break; 598 | } 599 | $rowIndex++; 600 | } 601 | for (; $rowIndex < $this->min; $rowIndex++) { 602 | $rows[] = $this->renderRowContent($rowIndex, null, $rowIndex); 603 | } 604 | } elseif ($this->min > 0) { 605 | for (; $rowIndex < $this->min; $rowIndex++) { 606 | $rows[] = $this->renderRowContent($rowIndex, null, $rowIndex); 607 | } 608 | } 609 | 610 | return $rows; 611 | } 612 | 613 | abstract protected function renderRowContent($index = null, $item = null, $rowIndex = null); 614 | } 615 | -------------------------------------------------------------------------------- /src/renderers/DivRenderer.php: -------------------------------------------------------------------------------- 1 | renderHeader(); 34 | $content[] = $this->renderBody(); 35 | $content[] = $this->renderFooter(); 36 | 37 | $options = []; 38 | Html::addCssClass($options, 'multiple-input-list list-renderer'); 39 | 40 | if ($this->isBootstrapTheme()) { 41 | Html::addCssClass($options, 'form-horizontal'); 42 | } 43 | 44 | $content = Html::tag('div', implode("\n", $content), $options); 45 | 46 | return Html::tag('div', $content, [ 47 | 'id' => $this->id, 48 | 'class' => 'multiple-input' 49 | ]); 50 | } 51 | 52 | /** 53 | * Renders the header. 54 | * 55 | * @return string 56 | */ 57 | public function renderHeader() 58 | { 59 | if (!$this->isAddButtonPositionHeader()) { 60 | return ''; 61 | } 62 | 63 | $options = ['class' => 'list-cell__button']; 64 | $layoutConfig = array_merge([ 65 | 'buttonAddClass' => $this->isBootstrapTheme() ? 'col-sm-offset-9 col-sm-3' : '', 66 | ], $this->layoutConfig); 67 | Html::addCssClass($options, $layoutConfig['buttonAddClass']); 68 | 69 | return Html::tag('div', $this->renderAddButton(), $options); 70 | } 71 | 72 | /** 73 | * Renders the footer. 74 | * 75 | * @return string 76 | */ 77 | public function renderFooter() 78 | { 79 | if (!$this->isAddButtonPositionFooter()) { 80 | return ''; 81 | } 82 | 83 | $options = ['class' => 'list-cell__button']; 84 | $layoutConfig = array_merge([ 85 | 'buttonAddClass' => $this->isBootstrapTheme() ? 'col-sm-offset-9 col-sm-3' : '', 86 | ], $this->layoutConfig); 87 | Html::addCssClass($options, $layoutConfig['buttonAddClass']); 88 | 89 | return Html::tag('div', $this->renderAddButton(), $options); 90 | } 91 | 92 | /** 93 | * Renders the body. 94 | * 95 | * @return string 96 | * @throws \yii\base\InvalidConfigException 97 | * @throws \yii\base\InvalidParamException 98 | */ 99 | protected function renderBody() 100 | { 101 | return implode("\n", $this->renderRows()); 102 | } 103 | 104 | /** 105 | * Renders the row content. 106 | * 107 | * @param int $index 108 | * @param ActiveRecordInterface|array $item 109 | * @return mixed 110 | */ 111 | protected function renderRowContent($index = null, $item = null, $rowIndex = null) 112 | { 113 | $elements = []; 114 | 115 | $columnIndex = 0; 116 | foreach ($this->columns as $column) { 117 | /* @var $column BaseColumn */ 118 | $column->setModel($item); 119 | $elements[] = $this->renderCellContent($column, $index, $columnIndex, $rowIndex); 120 | $columnIndex++; 121 | } 122 | 123 | $content = Html::tag('div', implode("\n", $elements), $this->prepareRowOptions($index, $item)); 124 | if ($index !== null) { 125 | $content = str_replace('{' . $this->getIndexPlaceholder() . '}', $index, $content); 126 | } 127 | 128 | return $content; 129 | } 130 | 131 | /** 132 | * Prepares the row options. 133 | * 134 | * @param int $index 135 | * @param ActiveRecordInterface|array $item 136 | * @return array 137 | */ 138 | protected function prepareRowOptions($index, $item) 139 | { 140 | if (is_callable($this->rowOptions)) { 141 | $options = call_user_func($this->rowOptions, $item, $index, $this->context); 142 | } else { 143 | $options = $this->rowOptions; 144 | } 145 | 146 | $options['data-index'] = '{' . $this->getIndexPlaceholder() . '}'; 147 | 148 | Html::addCssClass($options, 'multiple-input-list__item'); 149 | 150 | return $options; 151 | } 152 | 153 | /** 154 | * Renders the cell content. 155 | * 156 | * @param BaseColumn $column 157 | * @param int|null $index 158 | * @param int|null $columnIndex 159 | * @param int|null $rowIndex 160 | * @return string 161 | * @throws \Exception 162 | */ 163 | public function renderCellContent($column, $index = null, $columnIndex = null, $rowIndex = null) 164 | { 165 | $id = $column->getElementId($index); 166 | $name = $column->getElementName($index); 167 | 168 | /** 169 | * This class inherits iconMap from BaseRenderer 170 | * If the input to be rendered is a drag column, we give it the appropriate icon class 171 | * via the $options array 172 | */ 173 | $options = ['id' => $id]; 174 | if ($column->type === BaseColumn::TYPE_DRAGCOLUMN) { 175 | $options = ArrayHelper::merge($options, ['class' => $this->iconMap['drag-handle']]); 176 | } 177 | 178 | $input = $column->renderInput($name, $options, [ 179 | 'id' => $id, 180 | 'name' => $name, 181 | 'indexPlaceholder' => $this->getIndexPlaceholder(), 182 | 'index' => $index, 183 | 'columnIndex' => $columnIndex, 184 | 'context' => $this->context, 185 | ]); 186 | 187 | if ($column->isHiddenInput()) { 188 | return $input; 189 | } 190 | 191 | $layoutConfig = array_merge([ 192 | 'offsetClass' => $this->isBootstrapTheme() ? 'col-sm-offset-3' : '', 193 | 'labelClass' => $this->isBootstrapTheme() ? 'col-sm-3' : '', 194 | 'wrapperClass' => $this->isBootstrapTheme() ? 'col-sm-6' : '', 195 | 'errorClass' => $this->isBootstrapTheme() ? 'col-sm-offset-3 col-sm-6' : '', 196 | ], $this->layoutConfig); 197 | 198 | Html::addCssClass($column->errorOptions, $layoutConfig['errorClass']); 199 | 200 | $hasError = false; 201 | $error = ''; 202 | 203 | if ($index !== null) { 204 | $error = $column->getFirstError($index); 205 | $hasError = !empty($error); 206 | } 207 | 208 | $wrapperOptions = []; 209 | 210 | if ($hasError) { 211 | Html::addCssClass($wrapperOptions, 'has-error'); 212 | } 213 | 214 | Html::addCssClass($wrapperOptions, $layoutConfig['wrapperClass']); 215 | 216 | $options = [ 217 | 'class' => "field-$id list-cell__$column->name" . ($hasError ? ' has-error' : '') 218 | ]; 219 | 220 | if ($this->isBootstrapTheme()) { 221 | Html::addCssClass($options, 'form-group'); 222 | } 223 | 224 | if (is_callable($column->columnOptions)) { 225 | $columnOptions = call_user_func($column->columnOptions, $column->getModel(), $index, $this->context); 226 | } else { 227 | $columnOptions = $column->columnOptions; 228 | } 229 | 230 | $options = array_merge_recursive($options, $columnOptions); 231 | 232 | $content = Html::beginTag('div', $options); 233 | 234 | if (empty($column->title)) { 235 | Html::addCssClass($wrapperOptions, $layoutConfig['offsetClass']); 236 | } else { 237 | $labelOptions = ['class' => $layoutConfig['labelClass']]; 238 | if ($this->isBootstrapTheme()) { 239 | Html::addCssClass($labelOptions, 'control-label'); 240 | } 241 | 242 | $content .= Html::label($column->title, $id, $labelOptions); 243 | } 244 | 245 | $content .= Html::tag('div', $input, $wrapperOptions); 246 | 247 | // first line 248 | if ($columnIndex == 0) { 249 | if (!$this->isFixedNumberOfRows()) { 250 | $content .= $this->renderActionColumn($index, $column->getModel(), $rowIndex); 251 | } 252 | 253 | if ($this->cloneButton) { 254 | $content .= $this->renderCloneColumn(); 255 | } 256 | } 257 | 258 | if ($column->enableError) { 259 | $content .= "\n" . $column->renderError($error); 260 | } 261 | 262 | $content .= Html::endTag('div'); 263 | 264 | return $content; 265 | } 266 | 267 | /** 268 | * Renders the action column. 269 | * 270 | * @param null|int $index 271 | * @param null|ActiveRecordInterface|array $item 272 | * @return string 273 | */ 274 | private function renderActionColumn($index = null, $item = null, $rowIndex = null) 275 | { 276 | $content = $this->getActionButton($index, $rowIndex) . $this->getExtraButtons($index, $item); 277 | 278 | $options = ['class' => 'list-cell__button']; 279 | $layoutConfig = array_merge([ 280 | 'buttonActionClass' => $this->isBootstrapTheme() ? 'col-sm-offset-0 col-sm-2' : '', 281 | ], $this->layoutConfig); 282 | 283 | Html::addCssClass($options, $layoutConfig['buttonActionClass']); 284 | 285 | return Html::tag('div', $content, $options); 286 | } 287 | 288 | /** 289 | * Renders the clone column. 290 | * 291 | * @return string 292 | */ 293 | private function renderCloneColumn() 294 | { 295 | 296 | $options = ['class' => 'list-cell__button']; 297 | $layoutConfig = array_merge([ 298 | 'buttonCloneClass' => $this->isBootstrapTheme() ? 'col-sm-offset-0 col-sm-1' : '', 299 | ], $this->layoutConfig); 300 | Html::addCssClass($options, $layoutConfig['buttonCloneClass']); 301 | 302 | return Html::tag('div', $this->renderCloneButton(), $options); 303 | } 304 | 305 | private function getActionButton($index, $rowIndex) 306 | { 307 | if ($index === null || $this->min === 0) { 308 | return $this->renderRemoveButton(); 309 | } 310 | 311 | // rowIndex is zero-based, so we have to increment it to properly cpmpare it with min number of rows 312 | $rowIndex++; 313 | 314 | if ($rowIndex < $this->min) { 315 | return ''; 316 | } 317 | 318 | if ($rowIndex === $this->min) { 319 | return $this->isAddButtonPositionRow() ? $this->renderAddButton() : ''; 320 | } 321 | 322 | return $this->renderRemoveButton(); 323 | } 324 | 325 | private function renderAddButton() 326 | { 327 | $options = [ 328 | 'class' => 'multiple-input-list__btn js-input-plus', 329 | ]; 330 | Html::addCssClass($options, $this->addButtonOptions['class']); 331 | 332 | return Html::tag('div', $this->addButtonOptions['label'], $options); 333 | } 334 | 335 | /** 336 | * Renders remove button. 337 | * 338 | * @return string 339 | */ 340 | private function renderRemoveButton() 341 | { 342 | $options = [ 343 | 'class' => 'multiple-input-list__btn js-input-remove', 344 | ]; 345 | Html::addCssClass($options, $this->removeButtonOptions['class']); 346 | 347 | return Html::tag('div', $this->removeButtonOptions['label'], $options); 348 | } 349 | 350 | /** 351 | * Renders clone button. 352 | * 353 | * @return string 354 | */ 355 | private function renderCloneButton() 356 | { 357 | $options = [ 358 | 'class' => 'multiple-input-list__btn js-input-clone', 359 | ]; 360 | Html::addCssClass($options, $this->cloneButtonOptions['class']); 361 | 362 | return Html::tag('div', $this->cloneButtonOptions['label'], $options); 363 | } 364 | 365 | /** 366 | * Returns template for using in js. 367 | * 368 | * @return string 369 | * 370 | * @throws \yii\base\InvalidConfigException 371 | */ 372 | protected function prepareTemplate() 373 | { 374 | return $this->renderRowContent(); 375 | } 376 | 377 | /** 378 | * Returns an array of JQuery sortable plugin options for DivRenderer 379 | * @return array 380 | */ 381 | protected function getJsSortableOptions() 382 | { 383 | return ArrayHelper::merge(parent::getJsSortableOptions(), 384 | [ 385 | 'containerSelector' => '.list-renderer', 386 | 'itemPath' => new UnsetArrayValue, 387 | 'itemSelector' => '.multiple-input-list__item', 388 | ]); 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/renderers/ListRenderer.php: -------------------------------------------------------------------------------- 1 | renderHeader(); 31 | $content[] = $this->renderBody(); 32 | $content[] = $this->renderFooter(); 33 | 34 | $options = []; 35 | Html::addCssClass($options, 'multiple-input-list list-renderer'); 36 | 37 | if ($this->isBootstrapTheme()) { 38 | Html::addCssClass($options, 'table form-horizontal'); 39 | } 40 | 41 | $content = Html::tag('table', implode("\n", $content), $options); 42 | 43 | return Html::tag('div', $content, [ 44 | 'id' => $this->id, 45 | 'class' => 'multiple-input' 46 | ]); 47 | } 48 | 49 | /** 50 | * Renders the header. 51 | * 52 | * @return string 53 | */ 54 | public function renderHeader() 55 | { 56 | if ($this->min !== 0 || !$this->isAddButtonPositionHeader()) { 57 | return ''; 58 | } 59 | 60 | $button = $this->isAddButtonPositionHeader() ? $this->renderAddButton() : ''; 61 | 62 | $content = []; 63 | $content[] = Html::tag('td', ' '); 64 | 65 | if ($this->cloneButton) { 66 | $content[] = Html::tag('td', ' '); 67 | } 68 | 69 | $content[] = Html::tag('td', $button, [ 70 | 'class' => 'list-cell__button', 71 | ]); 72 | 73 | return Html::tag('thead', Html::tag('tr', implode("\n", $content))); 74 | } 75 | 76 | /** 77 | * Renders the footer. 78 | * 79 | * @return string 80 | */ 81 | public function renderFooter() 82 | { 83 | if (!$this->isAddButtonPositionFooter()) { 84 | return ''; 85 | } 86 | 87 | $cells = []; 88 | $cells[] = Html::tag('td', ' '); 89 | $cells[] = Html::tag('td', $this->renderAddButton(), [ 90 | 'class' => 'list-cell__button' 91 | ]); 92 | 93 | return Html::tag('tfoot', Html::tag('tr', implode("\n", $cells))); 94 | } 95 | 96 | /** 97 | * Renders the body. 98 | * 99 | * @return string 100 | * @throws \yii\base\InvalidConfigException 101 | * @throws \yii\base\InvalidParamException 102 | */ 103 | protected function renderBody() 104 | { 105 | return Html::tag('tbody', implode("\n", $this->renderRows())); 106 | } 107 | 108 | /** 109 | * Renders the row content. 110 | * 111 | * @param int $index 112 | * @param ActiveRecordInterface|array $item 113 | * @return mixed 114 | * @throws InvalidConfigException 115 | */ 116 | protected function renderRowContent($index = null, $item = null, $rowIndex = null) 117 | { 118 | $elements = []; 119 | 120 | $columnIndex = 0; 121 | foreach ($this->columns as $column) { 122 | /* @var $column BaseColumn */ 123 | $column->setModel($item); 124 | $elements[] = $this->renderCellContent($column, $index, $columnIndex++); 125 | } 126 | 127 | $content = []; 128 | $content[] = Html::tag('td', implode("\n", $elements)); 129 | if (!$this->isFixedNumberOfRows()) { 130 | $content[] = $this->renderActionColumn($index, $item, $rowIndex); 131 | } 132 | 133 | if ($this->cloneButton) { 134 | $content[] = $this->renderCloneColumn(); 135 | } 136 | 137 | $content = Html::tag('tr', implode("\n", $content), $this->prepareRowOptions($index, $item)); 138 | 139 | if ($index !== null) { 140 | $content = str_replace('{' . $this->getIndexPlaceholder() . '}', $index, $content); 141 | } 142 | 143 | return $content; 144 | } 145 | 146 | /** 147 | * Prepares the row options. 148 | * 149 | * @param int $index 150 | * @param ActiveRecordInterface|array $item 151 | * @return array 152 | */ 153 | protected function prepareRowOptions($index, $item) 154 | { 155 | if (is_callable($this->rowOptions)) { 156 | $options = call_user_func($this->rowOptions, $item, $index, $this->context); 157 | } else { 158 | $options = $this->rowOptions; 159 | } 160 | 161 | $options['data-index'] = '{' . $this->getIndexPlaceholder() . '}'; 162 | 163 | Html::addCssClass($options, 'multiple-input-list__item'); 164 | 165 | return $options; 166 | } 167 | 168 | /** 169 | * Renders the cell content. 170 | * 171 | * @param BaseColumn $column 172 | * @param int|null $index 173 | * @return string 174 | */ 175 | public function renderCellContent($column, $index, $columnIndex = null) 176 | { 177 | $id = $column->getElementId($index); 178 | $name = $column->getElementName($index); 179 | 180 | /** 181 | * This class inherits iconMap from BaseRenderer 182 | * If the input to be rendered is a drag column, we give it the appropriate icon class 183 | * via the $options array 184 | */ 185 | $options = ['id' => $id]; 186 | if ($column->type === BaseColumn::TYPE_DRAGCOLUMN) { 187 | $options = ArrayHelper::merge($options, ['class' => $this->iconMap['drag-handle']]); 188 | } 189 | 190 | $input = $column->renderInput($name, $options, [ 191 | 'id' => $id, 192 | 'name' => $name, 193 | 'indexPlaceholder' => $this->getIndexPlaceholder(), 194 | 'index' => $index, 195 | 'columnIndex' => $columnIndex, 196 | 'context' => $this->context, 197 | ]); 198 | 199 | if ($column->isHiddenInput()) { 200 | return $input; 201 | } 202 | 203 | $layoutConfig = array_merge([ 204 | 'offsetClass' => $this->isBootstrapTheme() ? 'col-sm-offset-3' : '', 205 | 'labelClass' => $this->isBootstrapTheme() ? 'col-sm-3' : '', 206 | 'wrapperClass' => $this->isBootstrapTheme() ? 'col-sm-6' : '', 207 | 'errorClass' => $this->isBootstrapTheme() ? 'col-sm-offset-3 col-sm-6' : '', 208 | ], $this->layoutConfig); 209 | 210 | Html::addCssClass($column->errorOptions, $layoutConfig['errorClass']); 211 | 212 | $hasError = false; 213 | $error = ''; 214 | 215 | if ($index !== null) { 216 | $error = $column->getFirstError($index); 217 | $hasError = !empty($error); 218 | } 219 | 220 | $wrapperOptions = []; 221 | 222 | if ($hasError) { 223 | Html::addCssClass($wrapperOptions, 'has-error'); 224 | } 225 | 226 | Html::addCssClass($wrapperOptions, $layoutConfig['wrapperClass']); 227 | 228 | $options = [ 229 | 'class' => "field-$id list-cell__$column->name" . ($hasError ? ' has-error' : '') 230 | ]; 231 | 232 | if ($this->isBootstrapTheme()) { 233 | Html::addCssClass($options, 'form-group'); 234 | } 235 | 236 | if (is_callable($column->columnOptions)) { 237 | $columnOptions = call_user_func($column->columnOptions, $column->getModel(), $index, $this->context); 238 | } else { 239 | $columnOptions = $column->columnOptions; 240 | } 241 | 242 | $options = array_merge_recursive($options, $columnOptions); 243 | 244 | $content = Html::beginTag('div', $options); 245 | 246 | if (empty($column->title)) { 247 | Html::addCssClass($wrapperOptions, $layoutConfig['offsetClass']); 248 | } else { 249 | $labelOptions = ['class' => $layoutConfig['labelClass']]; 250 | if ($this->isBootstrapTheme()) { 251 | Html::addCssClass($labelOptions, 'control-label'); 252 | } 253 | 254 | $content .= Html::label($column->title, $id, $labelOptions); 255 | } 256 | 257 | $content .= Html::tag('div', $input, $wrapperOptions); 258 | 259 | if ($column->enableError) { 260 | $content .= "\n" . $column->renderError($error); 261 | } 262 | 263 | $content .= Html::endTag('div'); 264 | 265 | return $content; 266 | } 267 | 268 | /** 269 | * Renders the action column. 270 | * 271 | * @param null|int $index 272 | * @param null|ActiveRecordInterface|array $item 273 | * @param null|int $rowIndex 274 | * @return string 275 | * @throws \Exception 276 | */ 277 | private function renderActionColumn($index = null, $item = null, $rowIndex = null) 278 | { 279 | $content = $this->getActionButton($index, $rowIndex) . $this->getExtraButtons($index, $item); 280 | 281 | return Html::tag('td', $content, [ 282 | 'class' => 'list-cell__button', 283 | ]); 284 | } 285 | 286 | /** 287 | * Renders the clone column. 288 | * 289 | * @return string 290 | * @throws \Exception 291 | */ 292 | private function renderCloneColumn() 293 | { 294 | return Html::tag('td', $this->renderCloneButton(), [ 295 | 'class' => 'list-cell__button', 296 | ]); 297 | } 298 | 299 | private function getActionButton($index, $rowIndex) 300 | { 301 | if ($index === null || $this->min === 0) { 302 | return $this->renderRemoveButton(); 303 | } 304 | 305 | // rowIndex is zero-based, so we have to increment it to properly cpmpare it with min number of rows 306 | $rowIndex++; 307 | 308 | if ($rowIndex < $this->min) { 309 | return ''; 310 | } 311 | 312 | if ($rowIndex === $this->min) { 313 | return $this->isAddButtonPositionRow() ? $this->renderAddButton() : ''; 314 | } 315 | 316 | return $this->renderRemoveButton(); 317 | } 318 | 319 | private function renderAddButton() 320 | { 321 | $options = [ 322 | 'class' => 'multiple-input-list__btn js-input-plus', 323 | ]; 324 | Html::addCssClass($options, $this->addButtonOptions['class']); 325 | 326 | return Html::tag('div', $this->addButtonOptions['label'], $options); 327 | } 328 | 329 | /** 330 | * Renders remove button. 331 | * 332 | * @return string 333 | */ 334 | private function renderRemoveButton() 335 | { 336 | $options = [ 337 | 'class' => 'multiple-input-list__btn js-input-remove', 338 | ]; 339 | Html::addCssClass($options, $this->removeButtonOptions['class']); 340 | 341 | return Html::tag('div', $this->removeButtonOptions['label'], $options); 342 | } 343 | 344 | /** 345 | * Renders clone button. 346 | * 347 | * @return string 348 | */ 349 | private function renderCloneButton() 350 | { 351 | $options = [ 352 | 'class' => 'multiple-input-list__btn js-input-clone', 353 | ]; 354 | Html::addCssClass($options, $this->cloneButtonOptions['class']); 355 | 356 | return Html::tag('div', $this->cloneButtonOptions['label'], $options); 357 | } 358 | 359 | /** 360 | * Returns template for using in js. 361 | * 362 | * @return string 363 | * 364 | * @throws \yii\base\InvalidConfigException 365 | */ 366 | protected function prepareTemplate() 367 | { 368 | return $this->renderRowContent(); 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/renderers/RendererInterface.php: -------------------------------------------------------------------------------- 1 | hasHeader()) { 31 | $content[] = $this->renderHeader(); 32 | } 33 | 34 | $content[] = $this->renderBody(); 35 | $content[] = $this->renderFooter(); 36 | 37 | $options = []; 38 | Html::addCssClass($options, 'multiple-input-list'); 39 | 40 | if ($this->isBootstrapTheme()) { 41 | Html::addCssClass($options, 'table table-condensed table-renderer'); 42 | } 43 | 44 | $content = Html::tag('table', implode("\n", $content), $options); 45 | 46 | return Html::tag('div', $content, [ 47 | 'id' => $this->id, 48 | 'class' => 'multiple-input' 49 | ]); 50 | } 51 | 52 | /** 53 | * Renders the header. 54 | * 55 | * @return string 56 | */ 57 | public function renderHeader() 58 | { 59 | $cells = []; 60 | if ($this->isAddButtonPositionRowBegin()) { 61 | $cells[] = $this->renderButtonHeaderCell(); 62 | } 63 | 64 | foreach ($this->columns as $column) { 65 | /* @var $column BaseColumn */ 66 | $cells[] = $this->renderHeaderCell($column); 67 | } 68 | 69 | if ($this->max === null || ($this->max >= 1 && $this->max !== $this->min)) { 70 | $button = $this->isAddButtonPositionHeader() ? $this->renderAddButton() : ''; 71 | 72 | if ($this->cloneButton) { 73 | $cells[] = $this->renderButtonHeaderCell(); 74 | } 75 | 76 | $cells[] = $this->renderButtonHeaderCell($button); 77 | } 78 | 79 | return Html::tag('thead', Html::tag('tr', implode("\n", $cells))); 80 | } 81 | 82 | /** 83 | * Renders the footer. 84 | * 85 | * @return string 86 | */ 87 | public function renderFooter() 88 | { 89 | if (!$this->isAddButtonPositionFooter()) { 90 | return ''; 91 | } 92 | 93 | $columnsCount = 0; 94 | foreach ($this->columns as $column) { 95 | if (!$column->isHiddenInput()) { 96 | $columnsCount++; 97 | } 98 | } 99 | 100 | if ($this->cloneButton) { 101 | $columnsCount++; 102 | } 103 | 104 | if ($this->isAddButtonPositionRowBegin()) { 105 | $columnsCount++; 106 | } 107 | 108 | $cells = []; 109 | $cells[] = Html::tag('td', ' ', ['colspan' => $columnsCount]); 110 | $cells[] = Html::tag('td', $this->renderAddButton(), [ 111 | 'class' => 'list-cell__button' 112 | ]); 113 | 114 | return Html::tag('tfoot', Html::tag('tr', implode("\n", $cells))); 115 | } 116 | 117 | 118 | /** 119 | * Check that at least one column has a header. 120 | * 121 | * @return bool 122 | */ 123 | private function hasHeader() 124 | { 125 | if ($this->min === 0 || $this->isAddButtonPositionHeader()) { 126 | return true; 127 | } 128 | 129 | foreach ($this->columns as $column) { 130 | /* @var $column BaseColumn */ 131 | if ($column->title) { 132 | return true; 133 | } 134 | } 135 | 136 | return false; 137 | } 138 | 139 | /** 140 | * Renders the header cell. 141 | * @param BaseColumn $column 142 | * @return null|string 143 | */ 144 | private function renderHeaderCell($column) 145 | { 146 | if ($column->isHiddenInput()) { 147 | return null; 148 | } 149 | 150 | $options = $column->headerOptions; 151 | Html::addCssClass($options, 'list-cell__' . $column->name); 152 | 153 | return Html::tag('th', $column->title, $options); 154 | } 155 | 156 | /** 157 | * Renders the button header cell. 158 | * @param string 159 | * @return string 160 | */ 161 | private function renderButtonHeaderCell($button = '') 162 | { 163 | return Html::tag('th', $button, [ 164 | 'class' => 'list-cell__button' 165 | ]); 166 | } 167 | 168 | /** 169 | * Renders the body. 170 | * 171 | * @return string 172 | * 173 | * @throws \yii\base\InvalidConfigException 174 | * @throws \yii\base\InvalidParamException 175 | */ 176 | protected function renderBody() 177 | { 178 | return Html::tag('tbody', implode("\n", $this->renderRows())); 179 | } 180 | 181 | /** 182 | * Renders the row content. 183 | * 184 | * @param int $index 185 | * @param ActiveRecordInterface|array $item 186 | * @return mixed 187 | * @throws InvalidConfigException 188 | */ 189 | protected function renderRowContent($index = null, $item = null, $rowIndex = null) 190 | { 191 | $cells = []; 192 | $hiddenInputs = []; 193 | 194 | if (!$this->isFixedNumberOfRows() && $this->isAddButtonPositionRowBegin()) { 195 | $cells[] = $this->renderActionColumn($index, $item, $rowIndex, true); 196 | } 197 | 198 | $columnIndex = 0; 199 | foreach ($this->columns as $column) { 200 | /* @var $column BaseColumn */ 201 | $column->setModel($item); 202 | if ($column->isHiddenInput()) { 203 | $hiddenInputs[] = $this->renderCellContent($column, $index, $columnIndex++); 204 | } else { 205 | $cells[] = $this->renderCellContent($column, $index, $columnIndex++); 206 | } 207 | } 208 | 209 | if ($this->cloneButton) { 210 | $cells[] = $this->renderCloneColumn(); 211 | } 212 | 213 | if (!$this->isFixedNumberOfRows()) { 214 | $cells[] = $this->renderActionColumn($index, $item, $rowIndex); 215 | } 216 | 217 | if ($hiddenInputs) { 218 | $hiddenInputs = implode("\n", $hiddenInputs); 219 | $cells[0] = preg_replace('/^(]+>)(.*)(<\/td>)$/s', '${1}' . $hiddenInputs . '$2$3', $cells[0]); 220 | } 221 | 222 | $content = Html::tag('tr', implode("\n", $cells), $this->prepareRowOptions($index, $item)); 223 | 224 | if ($index !== null) { 225 | $content = str_replace('{' . $this->getIndexPlaceholder() . '}', $index, $content); 226 | } 227 | 228 | return $content; 229 | } 230 | 231 | /** 232 | * Prepares the row options. 233 | * 234 | * @param int $index 235 | * @param ActiveRecordInterface|array $item 236 | * @return array 237 | */ 238 | protected function prepareRowOptions($index, $item) 239 | { 240 | if (is_callable($this->rowOptions)) { 241 | $options = call_user_func($this->rowOptions, $item, $index, $this->context); 242 | } else { 243 | $options = $this->rowOptions; 244 | } 245 | 246 | $options['data-index'] = '{' . $this->getIndexPlaceholder() . '}'; 247 | 248 | Html::addCssClass($options, 'multiple-input-list__item'); 249 | 250 | return $options; 251 | } 252 | 253 | /** 254 | * Renders the cell content. 255 | * 256 | * @param BaseColumn $column 257 | * @param int|null $index 258 | * @param int|null $columnIndex 259 | * @return string 260 | * 261 | * @todo rethink visibility level (make it private) 262 | */ 263 | public function renderCellContent($column, $index, $columnIndex = null) 264 | { 265 | $id = $column->getElementId($index); 266 | $name = $column->getElementName($index); 267 | 268 | /** 269 | * This class inherits iconMap from BaseRenderer 270 | * If the input to be rendered is a drag column, we give it the appropriate icon class 271 | * via the $options array 272 | */ 273 | $options = ['id' => $id]; 274 | if ($column->type === BaseColumn::TYPE_DRAGCOLUMN) { 275 | $options = ArrayHelper::merge($options, ['class' => $this->iconMap['drag-handle']]); 276 | } 277 | 278 | $input = $column->renderInput($name, $options, [ 279 | 'id' => $id, 280 | 'name' => $name, 281 | 'indexPlaceholder' => $this->getIndexPlaceholder(), 282 | 'index' => $index, 283 | 'columnIndex' => $columnIndex, 284 | 'context' => $this->context, 285 | ]); 286 | 287 | if ($column->isHiddenInput()) { 288 | return $input; 289 | } 290 | 291 | $hasError = false; 292 | $error = ''; 293 | 294 | if ($index !== null) { 295 | $error = $column->getFirstError($index); 296 | $hasError = !empty($error); 297 | } 298 | 299 | if ($column->enableError) { 300 | $input .= "\n" . $column->renderError($error); 301 | } 302 | 303 | $wrapperOptions = ['class' => 'field-' . $id]; 304 | if ($this->isBootstrapTheme()) { 305 | Html::addCssClass($wrapperOptions, 'form-group'); 306 | } 307 | 308 | if ($hasError) { 309 | Html::addCssClass($wrapperOptions, 'has-error'); 310 | } 311 | 312 | if (is_callable($column->columnOptions)) { 313 | $columnOptions = call_user_func($column->columnOptions, $column->getModel(), $index, $this->context); 314 | } else { 315 | $columnOptions = $column->columnOptions; 316 | } 317 | 318 | Html::addCssClass($columnOptions, 'list-cell__' . $column->name); 319 | 320 | $input = Html::tag('div', $input, $wrapperOptions); 321 | 322 | return Html::tag('td', $input, $columnOptions); 323 | } 324 | 325 | 326 | /** 327 | * Renders the action column. 328 | * 329 | * @param null|int|string $index 330 | * @param null|ActiveRecordInterface|array $item 331 | * @param int $rowIndex 332 | * @return string 333 | */ 334 | private function renderActionColumn( 335 | $index = null, 336 | $item = null, 337 | $rowIndex = null, 338 | $isFirstColumn = false 339 | ) 340 | { 341 | $content = $this->getActionButton($index, $rowIndex, $isFirstColumn) . $this->getExtraButtons($index, $item); 342 | 343 | return Html::tag('td', $content, [ 344 | 'class' => 'list-cell__button', 345 | ]); 346 | } 347 | 348 | /** 349 | * Renders the clone column. 350 | * 351 | * @return string 352 | */ 353 | private function renderCloneColumn() 354 | { 355 | return Html::tag('td', $this->renderCloneButton(), [ 356 | 'class' => 'list-cell__button', 357 | ]); 358 | } 359 | 360 | /** 361 | * @param int|string|null $index 362 | * @param int $rowIndex 363 | * @return string 364 | */ 365 | private function getActionButton($index = null, $rowIndex = null, $isFirstColumn = false) 366 | { 367 | if ($index === null || $this->min === 0) { 368 | if ($isFirstColumn) { 369 | return $this->isAddButtonPositionRowBegin() ? $this->renderRemoveButton() : ''; 370 | } 371 | 372 | return $this->isAddButtonPositionRowBegin() ? '' : $this->renderRemoveButton(); 373 | } 374 | 375 | // rowIndex is zero-based, so we have to increment it to properly cpmpare it with min number of rows 376 | $rowIndex++; 377 | 378 | if ($rowIndex < $this->min) { 379 | return ''; 380 | } 381 | 382 | if ($rowIndex === $this->min) { 383 | if ($isFirstColumn) { 384 | return $this->isAddButtonPositionRowBegin() ? $this->renderAddButton() : ''; 385 | } 386 | 387 | 388 | return $this->isAddButtonPositionRow() ? $this->renderAddButton() : ''; 389 | } 390 | 391 | if ($isFirstColumn) { 392 | return $this->isAddButtonPositionRowBegin() ? $this->renderRemoveButton() : ''; 393 | } 394 | 395 | return $this->isAddButtonPositionRowBegin() ? '' : $this->renderRemoveButton(); 396 | } 397 | 398 | private function renderAddButton() 399 | { 400 | $options = [ 401 | 'class' => 'multiple-input-list__btn js-input-plus', 402 | ]; 403 | 404 | Html::addCssClass($options, $this->addButtonOptions['class']); 405 | 406 | return Html::tag('div', $this->addButtonOptions['label'], $options); 407 | } 408 | 409 | /** 410 | * Renders remove button. 411 | * 412 | * @return string 413 | */ 414 | private function renderRemoveButton() 415 | { 416 | $options = [ 417 | 'class' => 'multiple-input-list__btn js-input-remove', 418 | ]; 419 | 420 | Html::addCssClass($options, $this->removeButtonOptions['class']); 421 | 422 | return Html::tag('div', $this->removeButtonOptions['label'], $options); 423 | } 424 | 425 | /** 426 | * Renders clone button. 427 | * 428 | * @return string 429 | */ 430 | private function renderCloneButton() 431 | { 432 | $options = [ 433 | 'class' => 'multiple-input-list__btn js-input-clone', 434 | ]; 435 | 436 | Html::addCssClass($options, $this->cloneButtonOptions['class']); 437 | 438 | return Html::tag('div', $this->cloneButtonOptions['label'], $options); 439 | } 440 | 441 | /** 442 | * Returns template for using in js. 443 | * 444 | * @return string 445 | * 446 | * @throws \yii\base\InvalidConfigException 447 | */ 448 | protected function prepareTemplate() 449 | { 450 | return $this->renderRowContent(); 451 | } 452 | } 453 | --------------------------------------------------------------------------------