We start slowly and simple...
class Artist extends PcBaseArModel { // more code... /** * @return array relational rules. */ public function relations() { return array( 'songs' => array(self::HAS_MANY, 'Song', 'artist_id'), ); } // more code... }
class Song extends PcBaseArModel { // more code... /** * @return array relational rules. */ public function relations() { return array( 'artist' => array(self::BELONGS_TO, 'Artist', 'artist_id'), ); } // more code... }
The first thing we'll do is to create an artist. Artist creation form will enable creating song records as well so we need to implement ArtistController.actionCreate()
class ArtistController extends Controller { public function actionCreate() { /* * first, do some permission's check. I use Yii's RBAC facility. YMMV, and it doesn't matter. Just do a * permissions check here. */ if (!Yii::app()->user->checkAccess('create artist')) { // not allowed... . throw new CHttpException(401); } $model = new Artist('insert'); $song = new Song('insert'); // by default, if we already submitted the form and if we didn't, we allow adding more songs // this will be updated down below if needed to $enable_add_more_songs = true; if (isset($_POST['Artist'])) { // start optimistically by settings a few variables that we'll need $all_songs_valid = true; $songs_to_save = array(); $success_saving_all = true; $model->attributes = $_POST['Artist']; // use aux variable for manipulating the image file. 'icon_filename' is the attribute name in the Artist model $image = CUploadedFile::getInstance($model, 'icon_filename'); // we need to put something into the icon_filename of the model since otherwise validation will fail if its // a 'required' field (this is 'create' scenario). if ($image) { // if its not required field and image wasn't supplied this block will not be run so // this is safe for both 'required field' and 'non required field' use cases. $model->icon_filename = "(TEMP) " . $image->name; } // lets start handle related models that were submitted, if any if (isset($_POST['Song'])) { if (count($_POST['Song']) > Song::MAX_SONGS_PER_ARTIST) { /* * server side protection against attempt to submit more than MAX_SONGS_PER_ARTIST * this should be accompanied with a client side (JS) protection. * If its accompanied with client side protection then going into this code block means our system * is being abused/"tested". No need to give a polite error message. */ throw new CHttpException(500, Yii::t("MusicModule.forms", "The max amount of allowed songs is {max_songs_num}", array('{max_songs_num}' => Song::MAX_SONGS_PER_ARTIST))); } // now handle each submitted song: foreach ($_POST['Song'] as $index => $submitted_song) { // We could have empty songs which means empty submitted forms in POST. Ignore those: if ($submitted_song['title'] == '') { // empty one - skip it, if you please. continue; } // validate each submitted song instance $song = new Song(); $song->attributes = $submitted_song; if (!$song->validate()) { // at least one invalid song. We'll need to remember this fact in order to render the create // form back to the user, with the error message: $all_songs_valid = false; // we do not 'break' here since we want validation on all songs at the same shot } else { // put aside the new *and valid* Song to be saved $songs_to_save[] = $song; } } // while we know that songs were submitted, determine if the number of songs has exceeded its limit. // this will be goof when rendering back the 'create' form so no more songs-forms will be available. if (count($_POST['Song']) == Song::MAX_SONGS_PER_ARTIST) { $enable_add_more_songs = false; } } // Done validation. Summarize all valid/invalid information thus far and act accordingly if ($all_songs_valid && $model->validate()) { /* * all songs (if any) were valid and artist model is valid too. Save it all in one transaction. Save first the * artist as we need its 'id' attribute for its songs records (Song.artist_id is NOT NULL in the DB level * and a 'required' attribute in our model). */ $trans = Yii::app()->db->beginTransaction(); try { // save the artist $model->save(false); // handle image of artist, if supplied: if ($image) { // Song.getImageFsFilename() encapsulates the image filename setting details. $image->saveAs($model->getImageFsFilename($image)); /** * now update the model itself again with the full filename of the image file. * this is the disadvantage of using filenames that include the PK of the entry - we need it first saved * in the DB before we know what it is... . */ // icon_filename is a VARCHAR(512) in the DB (for long filenames) and 'string' in the Artist model $model->icon_filename = $model->getImageFsFilename($image); $model->save(false); } // save the songs foreach ($songs_to_save as $song) { $song->artist_id = $model->id; $song->save(false); } // here, it means no exception was thrown during saving of artist and its songs (from the DB, for example). // good - now commit it all...: $trans->commit(); } catch (Exception $e) { // oops, saving artist or its songs failed. rollback, report us, and show the user an error. $trans->rollback(); Yii::log("Error occurred while saving artist or its 'songs'. Rolling back... . Failure reason as reported in exception: " . $e->getMessage(), CLogger::LEVEL_ERROR, __METHOD__); Yii::app()->user->setFlash('error', Yii::t("MusicModule.forms", "Error occurred")); $success_saving_all = false; } if ($success_saving_all) { // everything's done. Would you believe it?! Go to 'view' page :) $success_msg = (count($songs_to_save) > 0) ? "Artist and song records has been created successfully!" : "Artist record has been created successfully!"; Yii::app()->user->setFlash('success', Yii::t("MusicModule.forms", $success_msg)); $this->redirect(array("view", "id" => $model->id)); } } } $this->render('create', array( 'artist' => $model, 'songs' => (isset($songs_to_save)) ? $songs_to_save : array(new Song('insert')), 'enable_add_more_songs' => $enable_add_more_songs, )); } }
Now we need to implement an update action. Updating will also update both the artist and its existing songs, and will allow to add more songs (if limit not exceeded).
class ArtistController extends Controller { public function actionUpdate($id) { /* @var Artist $model */ $model = $this->loadModel($id); // check access if (!Yii::app()->user->checkAccess('edit artist')) { throw new CHttpException(401); } // does this artists exists in our DB? if ($model === null) { Yii::log("Artist update requested with id $id but no such artist found!", CLogger::LEVEL_INFO, __METHOD__); throw new CHttpException(404, Yii::t("MusicModule.general", 'The requested page does not exist.')); } // enable adding songs by default (will be changed below if needed to) $enable_add_more_songs = true; if (isset($_POST['Artist'])) { // start optimistically $all_songs_valid = true; $songs_to_save = array(); $success_saving_all = true; $model->attributes = $_POST['Artist']; if (isset($_POST['Song'])) { if (count($_POST['Song']) > Song::MAX_SONGS_PER_ARTIST) { /* * server side protection against attempt to submit more than MAX_SONGS_PER_ARTIST * this should be accompanied with a client side (JS) protection. * If its accompanied with client side protection then going into this code block means our system * is being abused/"tested". No need to give a polite error message. */ throw new CHttpException(500, Yii::t("MusicModule.forms", "The max amount of allowed songs is {max_songs_num}", array('{max_songs_num}' => Song::MAX_SONGS_PER_ARTIST))); } foreach ($_POST['Song'] as $index => $submitted_song) { // We could have empty songs which means empty submitted forms in POST. Ignore those: if ($submitted_song['title'] == '') { // empty one - skip it, if you please. continue; } // next, validate each submitted song instance if ((int)$submitted_song['id'] > 0) { /* Validate that the submitted song belong to this artist */ $song = Song::model()->findByPk($submitted_song['id']); if ($song->artist->id != $model->id) { Yii::log("Attempts to update Song with an id of {$song->id} but it belongs to an Artist with an id of {$song->model->id}" . " and not 'this' artist with id = {$model->id}", CLogger::LEVEL_ERROR, __METHOD__); throw new CHttpException(500, "Error occurred"); } } else { // this submitted song object is a new model. instantiate one: $song = new Song(); } $song->attributes = $submitted_song; if (!$song->validate()) { // at least one invalid song: $all_songs_valid = false; // we do not 'break' here since we want validation on all song at the same shot } else { // put aside the valid song to be saved $songs_to_save[] = $song; } } // while we know that songs were submitted, determine if to show 'adding songs' or no. // a check whether the max songs per artist was exceeded was performed already above. if (count($_POST['Song']) == Song::MAX_SONGS_PER_ARTIST) { $enable_add_more_songs = false; } } if ($all_songs_valid && $model->validate()) { /* all songs (if any) were valid and artist object is valid too. Save it all in one transaction. Save first the * artist as we need its id for its songs records */ $trans = Yii::app()->db->beginTransaction(); try { // use aux variable for manipulating the image file. $image = CUploadedFile::getInstance($model, 'icon_filename'); // check if a new image was submitted or not: if ($image) { /* the only thing that might have changed in the update is the extension name of the image (if you support more than 'only jpeg'). * therefore, if something was submitted, and since we already know the ID of the artist (this is an update scenario), we can * determine the full updated icon_filename attribute of the model prior to its save() (unlike in create action - see there...). */ $model->icon_filename = $model->getImageFsFilename($image); } $model->save(false); // save the updated image, if any if ($image) { $image->saveAs($model->getImageFsFilename($image)); } // save songs foreach ($songs_to_save as $song) { $song->save(false); } $trans->commit(); } catch (Exception $e) { // oops, saving artist or its songs failed. rollback, report us, and show the user an error. $trans->rollback(); Yii::log("Error occurred while saving (update scenario) artist or its 'songs'. Rolling back... . Failure reason as reported in exception: " . $e->getMessage(), CLogger::LEVEL_ERROR, __METHOD__); Yii::app()->user->setFlash('error', Yii::t("MusicModule.forms", "Error occurred")); $success_saving_all = false; } if ($success_saving_all) { $success_msg = (count($songs_to_save) > 0) ? "Artist and song records have been updated" : "Artist record have been updated"; Yii::app()->user->setFlash('success', Yii::t("MusicModule.forms", $success_msg)); $this->redirect(array("view", "id" => $model->id)); } } } else { // initial rendering of update form. prepare songs for printing. // we put it in the same variable as used for saving (that's the reason for the awkward variable naming). $songs_to_save = $model->songs; } $this->render('update', array( 'artist' => $model, 'songs' => (isset($songs_to_save)) ? $songs_to_save : array(new Song('insert')), 'enable_add_more_songs' => $enable_add_more_songs, )); } }
Ok, now to the last member of the party - the view files:
/* @var $this ArtistController */ /* @var $artist Artist */ /* @var $songs array */ /* @var $form CActiveForm */ /* @var $enable_add_more_songs bool */ <div class="form"> <?php $form = $this->beginWidget('CActiveForm', array( 'id' => 'artist-form', 'enableAjaxValidation' => false, // we need the next one for transmission of files in the form. 'htmlOptions' => array('enctype' => 'multipart/form-data'), )); <p class="note">Fields with <span class="required">*</span> are required.</p> <?php echo $form->errorSummary($artist); <?php // All 'regular' artist form fields were omitted... <fieldset> <legend><?php echo Yii::t("MusicModule.forms", "Songs"); </legend> <div id="songs-multiple"> <?php $i = 0; foreach ($songs as $song) { /* @var Song $song */ $this->renderPartial('/songs/_form', array('model' => $song, 'counter' => $i)); $i++; } <?php // add button to 'add' a song if adding more songs is enabled if ($enable_add_more_songs) { // print the button here, which replicates the form but advances its counters // add the blank, extra 'song' form, with display=none... : echo '<div id="extra-song" style="display: none;">'; $this->renderPartial('/song/_form', array('model' => new Song(), 'counter' => $i)); echo "</div>"; } </div> </fieldset> <div class="row"> <?php echo $form->labelEx($artist, 'icon_filename'); <?php if (isset($update) && ($update === true)) { <div> echo 'Choose new file or leave empty to keep current image.';</div> } <?php echo $form->fileField($artist, 'icon_filename', array('size' => 20, 'maxlength' => 512)); <?php echo $form->error($artist, 'icon_filename'); </div> <div class="row buttons"> <?php echo CHtml::submitButton($artist->isNewRecord ? 'Create' : 'Save'); </div> <?php $this->endWidget(); </div>
/* @var Song $song */ /* @var int $counter */ /* * Design note: in order to prevent nested forms that will possibly confuse yii we render this form using simple CHtml methods * (like beginForm()) and not using CActiveForm */ <div class="form"> <?php echo CHtml::beginForm(); <div class="song-<?php echo $counter ?>"> <div class="row"> <?php // if this is an 'update' use case (form re-use), render also the id of the song itself (so upon submission we'll // know which song to update. Check if $song->id exists... </div> <?php /* * Rest of the form fields should be rendered here, using CHtml::... */ </div> <?php CHtml::endForm(); </div>
Total 8 comments
Hi Boaz, I am really curious about this solution but failed to get work yet after two days. I received the error; Fatal error: Call to a member function getErrors() on a non-object in C:\xampp\htdocs\app\framework\web\helpers\CHtml.php on line 1705
Can you Kindly tell me what I did wrong?
Here is the information. I have two models Children ( PK=> children_no) and Family (PK=> id, FK=> children_no)
I created module, models and controller files by Gii.
Then edit the Children Controller ActionCreate as follows.
Then I edited children/_form
<?php /* @var $this ChildrenController */ /* @var $model Children */ /* @var $form CActiveForm */ /* @var $family array */ /* @var $enable_add_more_family bool */ ?> <div class="form"> <?php $form=$this->beginWidget('CActiveForm', array( 'id'=>'children-form', 'enableAjaxValidation'=>false, 'htmlOptions' => array('enctype' => 'multipart/form-data'), )); ?> <p class="note">Fields with <span class="required">*</span> are required.</p> <?php echo $form->errorSummary($children); ?> <fieldset> <legend><?php echo Yii::t("ChildrenModule.forms", "Family"); ?></legend> <div id="family-multiple"> <?php $i = 0; foreach ($family as $family) { /* @var Song $song */ $this->renderPartial('/family/_form', array('model' => $family, 'counter' => $i)); $i++; } ?> <?php // add button to 'add' a song if adding more songs is enabled if ($enable_add_more_family) { // print the button here, which replicates the form but advances its counters // add the blank, extra 'song' form, with display=none... : echo '<div id="extra-family" style="display: none;">'; $this->renderPartial('/family/_form', array('model' => new Family(), 'counter' => $i)); echo "</div>"; } ?> </div> </fieldset> <div class="row"> <?php echo $form->labelEx($children, 'photo'); ?> <?php if (isset($update) && ($update === true)) { ?> <div><?php echo 'Choose new file or leave empty to keep current image.';?></div> <?php } ?> <?php echo $form->fileField($children, 'photo', array('size' => 20, 'maxlength' => 512)); ?> <?php echo $form->error($children, 'photo'); ?> </div> <div class="row buttons"> <?php echo CHtml::submitButton($children->isNewRecord ? 'Create' : 'Save'); ?> </div> <?php $this->endWidget(); ?> </div>Then Family/_form
Thanks
Thanks for sharing your feedback. Strange, I get the form values ok on submission in Yii with that code. Yet, the section which was problematic for you was lightly tested by me (since its relating little JS which the is not written yet - man power issues... :) . Soon I'll need to make sure everything is working ok and then I'll add more code here (probably the JS) and fix the things that need fixing (like what you noted).
I have finally figured out how to have the submit button of the form send both the Artist and Songs data to the create action of the Artist controller. What I did is remove the CHtml::beginForm command from the Song create view and manually populated the name attribute of the Song form fields as Song[$counter][name] so Yii would generate the correct name and id attributes for the "sub-form" elements.
Hi,
Thank you for this great tutorial. I'm exactly in the same situation as the one you are describing in this tutorial and it has helped me a lot. The only place where I am stuck is that even when I use CHtml::beginForm for my "song" sub-forms, Yii is still inserting form tags within the main Artist form, and the artist form submit button only POSTs my artist fields. None of the values I enter in the song fields get passed. Any tip on how I should modify my code so that the Artist submit button sends all the fields at once? Thanks in advance.
Lothaire
Hi,
I'm sorry but I cannot release the site at its entirety. The site I'm developing at the moment is a commercial project site which I cannot release as is. I've release 12 extensions from the work I've done on it thus far and the work is not over yet. I have some very exciting extensions (IMHO) to be developed and released.
The code above is pretty full and not fragmented. The only things which I didn't include are the 'create.php' and 'update.php' view files but those are really basic and don't go much beyond what Gii gives you when you use its CRUD generator.
Can you please zip and upload the whole site.
Thanks for the feedback!
I agree that if the article includes complete (or almost complete...) code then it can be even better with success messages. I've added those.
As for the other things you mention:
a parsing and validating method in the controller with a boolean parameter that toggles create or update mode
I think that such a refactor will result in a code that includes too many if-else (or switch case) constructs and I find it less readable and less intuitive. That's MHO at least. I prefer simplicity over length in this case.
a saving method in the Artist model that would save the model and its attached objects ($songs_to_save as an optional parameter).
That's do-able.
naming convention uses camelCase instead of names_with_underscores
I prefer method-internal variables to use underscores as separators and in class and inter-class to use camelCase. Also, I consider data variables passed to view as 'simple variables' and thus choose naming convention like the one used for internal variable (e.g.: $songs_to_save). I guess this is a matter of personal taste.
I think reading code is always a good way to improve, so I like this kind of full example. IMHO, some points could be largely improved, though.
Your actions in the controller are very long and duplicate most of their code. I suggest you to factorize your code with:
$songs_to_saveas an optional parameter).If you want to be complete, then before redirecting, you should add a flash message. As a user, I hate it when I get no message responding to my action.
A last detail: in modern PHP (e.g. PDO) and all the PHP frameworks that I know (including Yii, see also PSR-1), the naming convention uses
camelCaseinstead ofnames_with_underscores.Leave a comment
Please login to leave your comment.