├── composer.json ├── HandsontableAsset.php ├── LICENSE ├── README.md ├── HandsontableWidget.php └── actions └── HandsontableActiveAction.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "himiklab/yii2-handsontable-widget", 3 | "description": "A minimalist Excel-like grid widget for Yii2", 4 | "keywords": ["yii2", "handsontable", "grid", "widget"], 5 | "type": "yii2-extension", 6 | "license": "MIT", 7 | "support": { 8 | "source": "https://github.com/himiklab/yii2-handsontable-widget", 9 | "issues": "https://github.com/himiklab/yii2-handsontable-widget/issues" 10 | }, 11 | "authors": [ 12 | { 13 | "name": "HimikLab", 14 | "homepage": "https://github.com/himiklab/" 15 | } 16 | ], 17 | "require": { 18 | "yiisoft/yii2": "*", 19 | "bower-asset/handsontable": "*" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "himiklab\\handsontable\\": "" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /HandsontableAsset.php: -------------------------------------------------------------------------------- 1 | js[] = 'moment/moment.js'; 21 | $this->js[] = 'numbro/numbro.js'; 22 | $this->js[] = 'pikaday/pikaday.js'; 23 | $this->js[] = YII_DEBUG ? 'handsontable.full.js' : 'handsontable.full.min.js'; 24 | $this->js[] = YII_DEBUG ? 'languages/all.js' : 'languages/all.min.js'; 25 | 26 | $this->css[] = 'pikaday/pikaday.css'; 27 | $this->css[] = YII_DEBUG ? 'handsontable.full.css' : 'handsontable.full.min.css'; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 HimikLab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Handsontable Widget for Yii2 2 | ============================ 3 | A minimalist Excel-like grid widget for Yii2 based on [Handsontable](https://github.com/handsontable/handsontable). 4 | 5 | [![Packagist](https://img.shields.io/packagist/dt/himiklab/yii2-handsontable-widget.svg)]() [![Packagist](https://img.shields.io/packagist/v/himiklab/yii2-handsontable-widget.svg)]() [![license](https://img.shields.io/badge/License-MIT-yellow.svg)]() 6 | 7 | Installation 8 | ------------ 9 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 10 | 11 | Either run 12 | 13 | ``` 14 | php composer.phar require --prefer-dist "himiklab/yii2-handsontable-widget" "*" 15 | ``` 16 | 17 | or add 18 | 19 | ```json 20 | "himiklab/yii2-handsontable-widget" : "*" 21 | ``` 22 | 23 | to the require section of your application's `composer.json` file. 24 | 25 | Usage 26 | ----- 27 | 28 | ```php 29 | use himiklab\handsontable\HandsontableWidget; 30 | 31 | [ 33 | 'data' => [ 34 | ['A1', 'B1', 'C1'], 35 | ['A2', 'B2', 'C2'], 36 | ], 37 | 'colHeaders' => true, 38 | 'rowHeaders' => true, 39 | ] 40 | ]) ?> 41 | ``` 42 | 43 | or with ActiveRecord 44 | 45 | in view: 46 | ```php 47 | use himiklab\handsontable\HandsontableWidget; 48 | 49 | 'hts', 51 | 'isRemoteChange' => true, 52 | ]); ?> 53 | ``` 54 | 55 | in controller: 56 | ```php 57 | use app\models\Page; 58 | use himiklab\handsontable\actions\HandsontableActiveAction; 59 | 60 | public function actions() 61 | { 62 | return [ 63 | 'hts' => [ 64 | 'class' => HandsontableActiveAction::className(), 65 | 'model' => Page::className(), 66 | 'isChangeable' => true, 67 | ], 68 | ]; 69 | } 70 | ``` 71 | -------------------------------------------------------------------------------- /HandsontableWidget.php: -------------------------------------------------------------------------------- 1 | [ 23 | * 'data' => [ 24 | * ['A1', 'B1', 'C1'], 25 | * ['A2', 'B2', 'C2'], 26 | * ], 27 | * 'colHeaders' => true, 28 | * 'rowHeaders' => true, 29 | * ] 30 | * ]); 31 | * ``` 32 | * 33 | * @author HimikLab 34 | * @package himiklab\handsontable 35 | */ 36 | class HandsontableWidget extends Widget 37 | { 38 | /** 39 | * @var array $settings 40 | * @see https://github.com/handsontable/handsontable 41 | */ 42 | public $settings = []; 43 | 44 | /** @var string */ 45 | public $varPrefix = 'hst_'; 46 | 47 | /** @var string|null */ 48 | public $requestUrl; 49 | 50 | /** @var bool */ 51 | public $isRemoteChange = false; 52 | 53 | /** @var string */ 54 | protected $jsVarName; 55 | 56 | public function run() 57 | { 58 | $view = $this->getView(); 59 | 60 | if (\in_array(Yii::$app->language, ['de-DE', 'de-CH', 'es-MX', 'fr-FR', 'ja-JP', 'nb-NO', 'pl-PL', 61 | 'pt-BR', 'ru-RU', 'zh-CN', 'zh-TW']) 62 | ) { 63 | $this->settings = \array_merge(['language' => Yii::$app->language], $this->settings); 64 | } 65 | $settings = Json::encode( 66 | $this->settings, 67 | (YII_DEBUG ? JSON_PRETTY_PRINT : 0) | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_NUMERIC_CHECK 68 | ); 69 | 70 | HandsontableAsset::register($view); 71 | $this->jsVarName = $this->varPrefix . $this->id; 72 | $view->registerJs( 73 | "var {$this->jsVarName} = new Handsontable(document.getElementById('handsontable-{$this->id}'), {$settings})", 74 | $view::POS_READY 75 | ); 76 | 77 | if ($this->requestUrl) { 78 | $this->prepareRemoteSettings(); 79 | } 80 | 81 | echo "
" . PHP_EOL; 82 | } 83 | 84 | protected function prepareRemoteSettings() 85 | { 86 | $view = $this->getView(); 87 | $requestUrl = Url::to([$this->requestUrl, 'action' => 'request']); 88 | $changeUrl = Url::to([$this->requestUrl, 'action' => 'change']); 89 | 90 | $view->registerJs( 91 | <<jsVarName}.updateSettings({ 104 | rowHeaders: true, 105 | colHeaders: result.headers 106 | }); 107 | {$this->jsVarName}.loadData(result.data); 108 | } 109 | }); 110 | JS 111 | ); 112 | 113 | if ($this->isRemoteChange) { 114 | $view->registerJs( 115 | <<jsVarName}.updateSettings({ 118 | afterChange: function (change, source) { 119 | if (source === "loadData") { 120 | return; 121 | } 122 | 123 | var result = {}; 124 | change.forEach(function(item) { 125 | var pkKey = pkData[item[0]]; 126 | var attributeKey = attributesData[item[1]]; 127 | if (result[pkKey] === undefined) { 128 | result[pkKey] = {}; 129 | } 130 | 131 | result[pkKey][attributeKey] = item[3]; 132 | }); 133 | 134 | jQuery.ajax({ 135 | url: "{$changeUrl}", 136 | method: "POST", 137 | data: {data: JSON.stringify(result)}, 138 | success: function(result) { 139 | if (result.errors !== undefined) { 140 | alert(result.errors); 141 | } 142 | } 143 | }); 144 | } 145 | }); 146 | JS 147 | ); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /actions/HandsontableActiveAction.php: -------------------------------------------------------------------------------- 1 | [ 29 | * 'class' => HandsontableActiveAction::className(), 30 | * 'model' => Page::className(), 31 | * ], 32 | * ]; 33 | * } 34 | * ``` 35 | * 36 | * @author HimikLab 37 | * @package himiklab\handsontable\actions 38 | */ 39 | class HandsontableActiveAction extends Action 40 | { 41 | const COMPOSITE_KEY_DELIMITER = '%'; 42 | 43 | /** @var string|ActiveRecord $model */ 44 | public $model; 45 | 46 | /** 47 | * @var array|callable $columns the columns being selected. 48 | * If not set, it means selecting all columns. 49 | */ 50 | public $columns = []; 51 | 52 | /** @var callable */ 53 | public $scope; 54 | 55 | /** @var bool */ 56 | public $isChangeable = false; 57 | 58 | public function run() 59 | { 60 | if (!\is_subclass_of($this->model, ActiveRecord::className())) { 61 | throw new InvalidConfigException('The `model` param must be object or class extends \yii\db\ActiveRecord.'); 62 | } 63 | if (\is_string($this->model)) { 64 | $this->model = new $this->model; 65 | } 66 | if (!$getActionParam = Yii::$app->request->get('action')) { 67 | throw new BadRequestHttpException('GET param `action` isn\'t set.'); 68 | } 69 | 70 | if (\is_callable($this->columns)) { 71 | $this->columns = \call_user_func($this->columns); 72 | } 73 | 74 | $model = $this->model; 75 | if (empty($this->columns)) { 76 | $this->columns = $model->attributes(); 77 | } 78 | 79 | switch ($getActionParam) { 80 | case 'request': 81 | Yii::$app->response->format = Response::FORMAT_JSON; 82 | return $this->requestAction(); 83 | case 'change': 84 | if ($this->isChangeable) { 85 | Yii::$app->response->format = Response::FORMAT_JSON; 86 | return $this->changeAction(Json::decode(Yii::$app->request->post('data'))); 87 | } 88 | break; 89 | default: 90 | throw new BadRequestHttpException('Unsupported GET `action` param.'); 91 | } 92 | } 93 | 94 | /** 95 | * @return array JSON answer 96 | */ 97 | protected function requestAction() 98 | { 99 | $model = $this->model; 100 | $query = $model::find(); 101 | if (\is_callable($this->scope)) { 102 | \call_user_func($this->scope, $query); 103 | } 104 | 105 | $dataProvider = new ActiveDataProvider(['query' => $query]); 106 | $response = [ 107 | 'headers' => [], 108 | 'attributes' => [], 109 | 'pk' => [], 110 | 'data' => [], 111 | ]; 112 | $column = 0; 113 | foreach ($this->columns as $modelAttribute) { 114 | $response['headers'][$column] = $model->getAttributeLabel($modelAttribute); 115 | $response['attributes'][$column] = $modelAttribute; 116 | ++$column; 117 | } 118 | 119 | $row = 0; 120 | foreach ($dataProvider->getModels() as $record) { 121 | if (\is_array($record->primaryKey)) { 122 | $response['pk'][$row] = \implode(self::COMPOSITE_KEY_DELIMITER, $record->primaryKey); 123 | } else { 124 | $response['pk'][$row] = $record->primaryKey; 125 | } 126 | 127 | $column = 0; 128 | /** @var \yii\db\ActiveRecord $record */ 129 | foreach ($this->columns as $modelAttribute) { 130 | $response['data'][$row][$column] = $record->{$modelAttribute}; 131 | ++$column; 132 | } 133 | ++$row; 134 | } 135 | 136 | return $response; 137 | } 138 | 139 | /** 140 | * @param array $requestData 141 | * @throws \Exception 142 | * @throws \yii\db\Exception 143 | * @return array 144 | */ 145 | protected function changeAction($requestData) 146 | { 147 | $model = $this->model; 148 | $modelPK = $model::primaryKey(); 149 | 150 | $transaction = $model::getDb()->beginTransaction(); 151 | try { 152 | foreach ($requestData as $pk => $modelData) { 153 | if (\count($modelPK) > 1) { 154 | $pkParts = \explode(self::COMPOSITE_KEY_DELIMITER, $pk); 155 | $recordCondition = \array_combine($modelPK, $pkParts); 156 | } else { 157 | $recordCondition = $pk; 158 | } 159 | 160 | /** @var \yii\db\ActiveRecord $record */ 161 | if (($record = $model::findOne($recordCondition)) === null) { 162 | continue; 163 | } 164 | 165 | foreach ($modelData as $attribute => $attributeValue) { 166 | $record->{$attribute} = $attributeValue; 167 | } 168 | 169 | if (!$record->save()) { 170 | $transaction->rollBack(); 171 | return ['errors' => $this->renderModelErrors($record)]; 172 | } 173 | } 174 | } catch (\Exception $e) { 175 | $transaction->rollBack(); 176 | throw $e; 177 | } 178 | $transaction->commit(); 179 | 180 | return []; 181 | } 182 | 183 | /** 184 | * @param \yii\db\ActiveRecord $model 185 | * @return string 186 | */ 187 | protected function renderModelErrors($model) 188 | { 189 | $errors = ''; 190 | foreach ($model->errors as $error) { 191 | $errors .= (\implode(' ', $error) . ' '); 192 | } 193 | 194 | return $errors; 195 | } 196 | } 197 | --------------------------------------------------------------------------------