├── Module.php ├── SynatreeAsset.php ├── controllers └── LoadController.php ├── composer.json ├── views ├── template.php └── layouts │ └── bare.php ├── assets └── js │ └── dynamic-relations.js ├── DynamicRelations.php └── README.md /Module.php: -------------------------------------------------------------------------------- 1 | session->get('dynamic-relations-'.$hash)) 13 | { 14 | echo $this->render( $args['path'], [ 15 | 'model' => new $args['cls'], 16 | ]); 17 | } 18 | 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synatree/yii2-dynamic-relations", 3 | "description": "Allows Yii2 views to contain a dynamically expanding set of fields based on model relations.", 4 | "type": "yii2-extension", 5 | "keywords": ["yii2","extension"], 6 | "license": "GPL-3.0+", 7 | "authors": [ 8 | { 9 | "name": "David Baltusavich, SynaTree", 10 | "email": "david@synatree.com" 11 | } 12 | ], 13 | "require": { 14 | "yiisoft/yii2": "*" 15 | }, 16 | "autoload": { 17 | "psr-4": { 18 | "synatree\\dynamicrelations\\": "" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /views/template.php: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 29 | -------------------------------------------------------------------------------- /views/layouts/bare.php: -------------------------------------------------------------------------------- 1 | appAsset; 9 | $assetClass::register($this); 10 | 11 | $this->beginPage(); 12 | ?> 13 | 14 | 15 | 16 | 17 | 18 | 19 | <?= Html::encode($this->title) ?> 20 | "> 21 | head() ?> 22 | 23 | 24 | beginBody() ?> 25 |
26 | 27 | 28 |
29 | endBody() ?> 30 | 31 | 32 | endPage() ?> 33 | -------------------------------------------------------------------------------- /assets/js/dynamic-relations.js: -------------------------------------------------------------------------------- 1 | // http://stackoverflow.com/questions/9659265/check-if-javascript-script-exists-on-page 2 | function scriptLoaded(url) { 3 | var scripts = document.getElementsByTagName('script'); 4 | for (var i = scripts.length; i--;) { 5 | if (scripts[i].src == url) return true; 6 | } 7 | return false; 8 | } 9 | 10 | jQuery(document).ready(function () { 11 | 12 | var removeFn = function(sel){ 13 | jQuery('.remove-dynamic-relation').on('click', function(event){ 14 | event.preventDefault(); 15 | var me = this; 16 | var myLi = jQuery(me).closest('li'); 17 | removeRoute = jQuery(this).parent().find("[data-dynamic-relation-remove-route]").attr("data-dynamic-relation-remove-route"); 18 | if(removeRoute) 19 | { 20 | jQuery.post(removeRoute, function(result){ 21 | myLi.remove() 22 | }); 23 | } 24 | else 25 | { 26 | myLi.remove(); 27 | } 28 | }); 29 | }; 30 | 31 | jQuery('.add-dynamic-relation').on('click', function(event){ 32 | event.preventDefault(); 33 | var me = this; 34 | view = jQuery(me).closest('[data-related-view]').attr('data-related-view') + "&t=" + ( new Date().getTime() ); 35 | jQuery.get(view, function(result){ 36 | $result = jQuery(result); 37 | li = jQuery(me).closest('li').clone().empty(); ul = jQuery(me).closest('ul'); 38 | ul.append( li ); 39 | li.append( $result.filter("#root") ); 40 | $result.filter('script').each(function(k,scriptNode){ 41 | if(!scriptNode.src || !scriptLoaded( scriptNode.src ) ) 42 | { 43 | jQuery("body").append( scriptNode); 44 | } 45 | }); 46 | removeFn( li.find('.remove-dynamic-relation') ); 47 | }); 48 | }); 49 | removeFn('.remove-dynamic-relation'); 50 | }); 51 | -------------------------------------------------------------------------------- /DynamicRelations.php: -------------------------------------------------------------------------------- 1 | collection) && is_object($this->collection[0]) ) 24 | { 25 | $type = get_class( $this->collection[0] ); 26 | } 27 | elseif( is_object($this->collectionType)) { 28 | $type = get_class($this->collectionType); 29 | } 30 | else{ 31 | throw new \yii\web\HttpException(500, "No Collection Type Specified, and Collection Empty."); 32 | } 33 | $key = "dynamic-relations-$type"; 34 | $hash = crc32($key); 35 | Yii::$app->session->set('dynamic-relations-'.$hash, [ 'path'=>$this->viewPath, 'cls'=>$type ]); 36 | 37 | return $this->render('template', [ 38 | 'title' => $this->title, 39 | 'collection' => $this->collection, 40 | 'viewPath' => $this->viewPath, 41 | 'ajaxAddRoute' => Url::toRoute(['dynamicrelations/load/template', 'hash'=>$hash]), 42 | ]); 43 | } 44 | 45 | public function uniqueOptions($field, $uniq) 46 | { 47 | return [ 48 | 'id' => "$field-$uniq-id", 49 | 'name' => "$field-$uniq-id", 50 | 'pluginOptions' => [ 51 | 'uniq' => $uniq 52 | ], 53 | ]; 54 | } 55 | 56 | public static function relate($model, $attr, $request, $name, $clsname) 57 | { 58 | if($request[$name]) 59 | { 60 | if($new = $request[$name]['new']) 61 | { 62 | foreach( $new as $useless=>$newattrs) 63 | { 64 | $newmodel = new $clsname; 65 | $newmodel->load( $new,$useless ); 66 | $model->link($attr, $newmodel); 67 | } 68 | unset( $request[$name]['new'] ); 69 | } 70 | foreach($request[$name] as $id=>$relatedattr) 71 | { 72 | $existingmodel = $clsname::findOne( $id ); 73 | $existingmodel->load([$name=>$relatedattr]); 74 | $existingmodel->save(); 75 | } 76 | } 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Dynamic Relations Extension 2 | =========================== 3 | 4 | This extension came about after I googled in vain for such things as: 5 | 6 | - Create 'child' models from a 'master' view dynamically 7 | - Add Inputs to Yii2 views dynamically 8 | - Use Yii2 widgets in related models 9 | - Add fields to a form dynamically 10 | 11 | Hopefully the above saves somebody else some time searching. 12 | 13 | What does this do? 14 | ------------------ 15 | Sometimes a picture is worth 1000 words: 16 | ![Screenshot of Basic Functionality](http://synatree.com/assets/persist/yii2-dynamic-relations-example.png "Screenshot of Basic Functionality") 17 | 18 | Allows Yii2 views to contain a dynamically expanding set of fields based on model relations. 19 | 20 | This system allows you to define a view, conventionally called _inline.php, that will be auto-loaded each time a user hits the "add" button on your form. 21 | 22 | Behind the scenes, the module takes care of saving the related records if they are new, updating them if they have been changed, and removing them if deleted. 23 | 24 | Basically this is a way to add an arbitrarily expanding set of related records to your model using Ajax. 25 | 26 | It's also been designed to intellegently move the JavaScripts and event bindings that your view's widgets may use so as not to conflict with each other when multiple "identical" views are added via ajax. Frankly, it's managing the JavaScript conflicts that arise that is the largest time-saver here. 27 | 28 | I'm not sure how big of a deal the security implications actually are, but to be safe I've also implemented the ajax request portions of the code tied to the user's session, so replaying the same requests after the user has lost their session should not be possible. The way it's setup also basically hides the real name and path of the inline view; I just didn't like the idea of the script leaking server-side file paths in the HTML code. 29 | 30 | Installation 31 | ------------ 32 | 33 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 34 | 35 | Either run 36 | 37 | ``` 38 | php composer.phar require synatree/yii2-dynamic-relations "dev-master" 39 | ``` 40 | 41 | or add 42 | 43 | ``` 44 | "synatree/yii2-dynamic-relations": "dev-master" 45 | ``` 46 | 47 | to the require section of your `composer.json` file. 48 | 49 | Next, you must add the following to your module config: 50 | 51 | ```php 52 | 'modules' => [ 53 | ... 54 | 'dynamicrelations' => [ 55 | 'class' => '\synatree\dynamicrelations\Module' 56 | ], 57 | ... 58 | ``` 59 | 60 | 61 | Usage 62 | ----- 63 | 64 | The first thing you should do is to create a view called _inline.php for the model you which to use dynamically. This view can include arbitrary widgets, it's been tested with some widgets from Krajee. 65 | 66 | This is the most complicated part, because we have to ensure that every time this view is invoked, the HTML and the script generated are unique. 67 | 68 | You'll also have to tell DynamicRelations how to add and remove models by providing routes. 69 | 70 | Finally, you'll have to maintain a certain structure in your field names so that the widget can pick up new vs existing models upon submit. Example: 71 | 72 | 73 | ```php 74 | use synatree\dynamicrelations\DynamicRelations; 75 | use kartik\widgets\ActiveForm; 76 | use kartik\datecontrol\DateControl; 77 | use yii\helpers\Url; 78 | 79 | /* @var $this yii\web\View */ 80 | /* @var $model app\models\BusinessHours */ 81 | /* @var $form kartik\widgets\ActiveForm */ 82 | 83 | // generate something globally unique. 84 | $uniq = uniqid(); 85 | 86 | if( $model->primaryKey ) 87 | { 88 | // you must define an attribute called "data-dynamic-relation-remove-route" if you plan to allow inline deletion of models from the form. 89 | 90 | $removeAttr = 'data-dynamic-relation-remove-route="' . 91 | Url::toRoute(['business-hours/delete', 'id'=>$model->primaryKey]) . '"'; 92 | $frag = "BusinessHours[{$model->primaryKey}]"; 93 | } 94 | else 95 | { 96 | $removeAttr = ""; 97 | // new models must go under a key called "[new]" 98 | $frag = "BusinessHours[new][$uniq]"; 99 | } 100 | 101 | ?> 102 |
> 103 | 104 | DateControl::FORMAT_DATE, 106 | 'name' => $frag.'[day]', // expanded, this ends up being something like BusinessHours[1][day] or BusinessHours[new][random][day] 107 | 'value' => $model->day, 108 | // for Kartik widgets, include the following line. This basically generates a globally unique set of pluginOptions, which is important to prevent 109 | // javascript errors and make sure everything works as expected. 110 | 'options' => DynamicRelations::uniqueOptions('day',$uniq) 111 | ]);?> 112 | .... More widgets use the same structure as above .... 113 |
114 | ``` 115 | The next step is to setup the controller to save the related models you're expecting to receive. In the below example, we only have to add one small line to each of the create and update action methods. 116 | 117 | ```php 118 | use synatree\dynamicrelations\DynamicRelations; 119 | use app\models\BusinessHours; 120 | use yii\web\Controller; 121 | 122 | class SomeController extends Controller 123 | { 124 | /** 125 | * Creates a new SomethingModel model. 126 | * If creation is successful, the browser will be redirected to the 'view' page. 127 | * @return mixed 128 | */ 129 | 130 | public function actionCreate() 131 | { 132 | $model = new SomethingModel(); 133 | 134 | if ($model->load(Yii::$app->request->post()) && $model->save()) { 135 | // this next line is the only one added to a standard Gii-created controller action: 136 | DynamicRelations::relate($model, 'hours', Yii::$app->request->post(), 'BusinessHours', BusinessHours::className()); 137 | // Parent Model --^ ^-- Attribute ^-- Array to search ^-- Root Key ^-- Model Class Name 138 | return $this->redirect(['view', 'id' => $model->primaryKey]); 139 | } else { 140 | return $this->render('create', [ 141 | 'model' => $model, 142 | ]); 143 | } 144 | } 145 | 146 | public function actionUpdate($id) 147 | { 148 | ... 149 | if ($model->save()) { 150 | // this next line exactly the same as in actionCreate: 151 | DynamicRelations::relate($model, 'hours', Yii::$app->request->post(), 'BusinessHours', BusinessHours::className()); 152 | return $this->redirect(['view', 'id' => $model->boatShowId]); 153 | } else { 154 | return $this->render('update', [ 155 | 'model' => $model, 156 | ]); 157 | } 158 | ... 159 | } 160 | } 161 | ``` 162 | 163 | In order to support Ajax delete of related records, modify your related model controller: 164 | 165 | ```php 166 | /** 167 | * Deletes an existing BusinessHours model. 168 | * If deletion is successful, the browser will be redirected to the 'index' page. 169 | * @param integer $id 170 | * @return mixed 171 | */ 172 | public function actionDelete($id) 173 | { 174 | $this->findModel($id)->delete(); 175 | if(! Yii::$app->request->isAjax){ 176 | return $this->redirect(['index']); 177 | } 178 | else 179 | { 180 | return "OK"; 181 | } 182 | } 183 | 184 | 185 | ``` 186 | 187 | 188 | Finally, in your view for the parent model, include lines like the following for each related model you want to add dynamically. 189 | 190 | ```php 191 | use synatree\dynamicrelations\DynamicRelations; 192 | 'Business Hours', 194 | 'collection' => $model->hours, 195 | 'viewPath' => '@app/views/business-hours/_inline.php', 196 | 197 | // this next line is only needed if there is a chance that the collection above will be empty. This gives the script a prototype to work with. 198 | 'collectionType' => new \app\models\BusinessHours, 199 | 200 | ]); ?> 201 | ``` 202 | That should do it. I hope this helps people, I really wanted this feature. 203 | --------------------------------------------------------------------------------