NAME
Catalyst::Manual::Tutorial::AdvancedCRUD - Catalyst Tutorial - Part 8: Advanced CRUD
OVERVIEW
This is Part 8 of 9 for the Catalyst tutorial.
AdvancedCRUD
DESCRIPTION
This part of the tutorial explores more advanced functionality for Create, Read, Update, and Delete (CRUD) than we saw in Part 3. In particular, it looks at a number of techniques that can be useful for the Update portion of CRUD, such as automated form generation, validation of user-entered data, and automated transfer of data between forms and model objects.
In keeping with the Catalyst (and Perl) spirit of flexibility, there are many different ways to approach advanced CRUD operations in a Catalyst environment. One alternative is to use Catalyst::Helper::Controller::Scaffold to instantly construct a set of Controller methods and templates for basic CRUD operations. Although a popular subject in Quicktime movies that serve as promotional material for various frameworks, real-world applications generally require more control. Other options include Data::FormValidator and HTML::FillInForm.
Here, we will make use of the HTML::Widget to not only ease form creation, but to also provide validation of the submitted data. The approached used by this part of the tutorial is to slowly incorporate additional HTML::Widget functionality in a step-wise fashion (we start with fairly simple form creation and then move on to more complex and "magical" features such as validation and auto-population/auto-saving).
Note: Part 8 of the tutorial is optional. Users who do not wish to use HTML::Widget may skip this part.
You can checkout the source code for this example from the catalyst subversion repository as per the instructions in Catalyst::Manual::Tutorial::Intro
HTML::WIDGET
FORM CREATION
This section looks at how HTML::Widget can be used to add additional functionality to the manually created form from Part 3.
Add the HTML::Widget
Plugin
Open lib/MyApp.pm
in your editor and add the following to the list of plugins (be sure to leave the existing plugins enabled):
HTML::Widget
Add a Form Creation Helper Method
Open lib/MyApp/Controller/Books.pm
in your editor and add the following method:
=head2 make_book_widget
Build an HTML::Widget form for book creation and updates
=cut
sub make_book_widget {
my ($self, $c) = @_;
# Create an HTML::Widget to build the form
my $w = $c->widget('book_form')->method('post');
# Get authors
my @authorObjs = $c->model("MyAppDB::Author")->all();
my @authors = map {$_->id => $_->last_name }
sort {$a->last_name cmp $b->last_name} @authorObjs;
# Create the form feilds
$w->element('Textfield', 'title' )->label('Title')->size(60);
$w->element('Textfield', 'rating' )->label('Rating')->size(1);
$w->element('Select', 'authors')->label('Authors')
->options(@authors);
$w->element('Submit', 'submit' )->value('submit');
# Return the widget
return $w;
}
This method provides a central location that builds an HTML::Widget-based form with the appropriate fields. The "Get authors" code uses DBIC to retrieve a list of model objects and then uses map
to create a hash where the hash keys are the database primary keys from the authors table and the associated values are the last names of the authors.
Add Actions to Display and Save the Form
Open lib/MyApp/Controller/Books.pm
in your editor and add the following methods:
=head2 hw_create
Build an HTML::Widget form for book creation and updates
=cut
sub hw_create : Local {
my ($self, $c) = @_;
# Create the widget and set the action for the form
my $w = $self->make_book_widget($c);
$w->action($c->uri_for('hw_create_do'));
# Write form to stash variable for use in template
$c->stash->{widget_result} = $w->result;
# Set the template
$c->stash->{template} = 'books/hw_form.tt2';
}
=head2 hw_create_do
Build an HTML::Widget form for book creation and updates
=cut
sub hw_create_do : Local {
my ($self, $c) = @_;
# Retrieve the data from the form
my $title = $c->request->params->{title};
my $rating = $c->request->params->{rating};
my $authors = $c->request->params->{authors};
# Call create() on the book model object. Pass the table
# columns/field values we want to set as hash values
my $book = $c->model('MyAppDB::Book')->create({
title => $title,
rating => $rating
});
# Add a record to the join table for this book, mapping to
# appropriate author
$book->add_to_book_authors({author_id => $authors});
# Set a status message for the user
$c->stash->{status_msg} = 'Book created';
# Use 'hw_create' to redisplay the form. As discussed in
# Part 3, 'detach' is like 'forward', but it does not return
$c->detach('hw_create');
}
Note how we use make_book_widget
to build the core parts of the form in one location, but we set the action (the URL the form is sent to when the user clicks the 'Submit' button) separately in hw_create
. Doing so allows us to have the same form submit the data to different actions (e.g., hw_create_do
for a create operation but hw_update_do
to update an existing book object).
NOTE: If you receive an error about Catalyst not being able to find the template hw_create_do.tt2
, please verify that you followed the instructions in the final section of Catalyst Basics where you returned to a manually-specified template. You can either use forward
/detach
OR default template names, but the two cannot be used together.
Update the CSS
Edit root/src/ttsite.css
and add the following lines to the bottom of the file:
label {
display: block;
width: 10em;
position: relative;
margin: .5em 0em;
}
label input {
position: absolute;
left: 100%;
}
label select {
position: absolute;
left: 100%;
}
.submit {
margin-top: 2em;;
}
.error_messages {
color: [% site.col.error %];
}
These changes will display form elements vertically and also show error messages in red. Note that we are pulling the color scheme settings from the root/lib/config/col
file that was created by the TTSite helper. This allows us to change the color used by various error styles in the CSS from a single location.
Create a Template Page To Display The Form
Open root/src/books/hw_form.tt2
in your editor and enter the following:
[% META title = 'Create/Update Book' %]
[% widget_result.as_xml %]
<p><a href="[% Catalyst.uri_for('list') %]">Return to book list</a></p>
Add Links for Create and Update via HTML::Widget
Open root/src/books/list.tt2
in your editor and add the following to the bottom of the existing file:
<p>
HTML::Widget:
<a href="[% Catalyst.uri_for('hw_create') %]">Create</a>
</p>
Test The <HTML::Widget> Create Form
Press Ctrl-C
to kill the previous server instance (if it's still running) and restart it:
$ script/myapp_server.pl
Login as test01
. Once at the Book List page, click the HTML::Widget "Create" link to display for form produced by make_book_widget
. Fill out the form with the following values: Title = "Internetworking with TCP/IP Vol. II", Rating = "4", and Author = "Comer". Click Submit, and you will be returned to the Create/Update Book page with a "Book created" status message displayed. Click "Return to book list" to view the newly created book on the main list.
Also note that this implementation allows you to can create books with bogus information. Although we have constrained the authors with the drop-down list, there are no restrictions on items such as the length of the title (for example, you can create a one-letter title) and value for the rating (you can use any number you want, and even non-numeric values with SQLite). The next section will address this concern.
Note: Depending on the database you are using and how you established the columns in your tables, the database could obviously provide various levels of "type enforcement" on your data. The key point being made in the previous paragraph is that the web application itself is not performing any validation.
HTML::WIDGET
VALIDATION AND FILTERING
Although the use of HTML::Widget in the previous section did provide an automated mechanism to build the form, the real power of this module stems from functionality that can automatically validate and filter the user input. Validation uses constraints to be sure that users input appropriate data (for example, that the email field of a form contains a valid email address). Filtering can be used to remove extraneous whitespace from fields or to escape meta-characters in user input.
Add Constraints and Filters to the Widget Creation Method
Open lib/MyApp/Controller/Books.pm
in your editor and update the make_book_widget
method to match the following (new sections have been marked with a *** NEW:
comment):
sub make_book_widget {
my ($self, $c) = @_;
# Create an HTML::Widget to build the form
my $w = $c->widget('book_form')->method('post');
# Get authors
my @authorObjs = $c->model("MyAppDB::Author")->all();
my @authors = map {$_->id => $_->last_name }
sort {$a->last_name cmp $b->last_name} @authorObjs;
# Create the form feilds
$w->element('Textfield', 'title' )->label('Title')->size(60);
$w->element('Textfield', 'rating' )->label('Rating')->size(1);
# ***NEW: Convert to multi-select list
$w->element('Select', 'authors')->label('Authors')
->options(@authors)->multiple(1)->size(3);
$w->element('Submit', 'submit' )->value('submit');
# ***NEW: Set constraints
$w->constraint(All => qw/title rating authors/)
->message('Required. ');
$w->constraint(Integer => qw/rating/)
->message('Must be an integer. ');
$w->constraint(Range => qw/rating/)->min(1)->max(5)
->message('Must be a number between 1 and 5. ');
$w->constraint(Length => qw/title/)->min(5)->max(50)
->message('Must be between 5 and 50 characters. ');
# ***NEW: Set filters
for my $column (qw/title rating authors/) {
$w->filter( HTMLEscape => $column );
$w->filter( TrimEdges => $column );
}
# Return the widget
return $w;
}
The main changes are:
The
Select
element forauthors
is changed from a single-select drop-down to a multi-select list by adding calls tomultiple
(set totrue
) andsize
(set to the number of rows to display).Four sets of constraints are added to provide validation of the user input.
Two filters are run on every field to remove and escape unwanted input.
Rebuild the Form Submission Method to Include Validation
Edit lib/MyApp/Controller/Books.pm
and change hw_create_do
to match the following code (enough of the code is different that you probably want to cut and paste this over code the existing method):
sub hw_create_do : Local {
my ($self, $c) = @_;
# Retrieve the data from the form
my $title = $c->request->params->{title};
my $rating = $c->request->params->{rating};
my $authors = $c->request->params->{authors};
# Create the widget and set the action for the form
my $w = $self->make_book_widget($c);
$w->action($c->uri_for('hw_create_do'));
# Validate the form parameters
my $result = $w->process($c->req);
# Write form (including validation error messages) to
# stash variable for use in template
$c->stash->{widget_result} = $result;
# Were their validation errors?
if ($result->has_errors) {
# Warn the user at the top of the form that there were errors.
# Note that there will also be per-field feedback on
# validation errors because of '$w->process($c->req)' above.
$c->stash->{error_msg} = 'Validation errors!';
} else {
# Everything validated OK, so do the create
# Call create() on the book model object. Pass the table
# columns/field values we want to set as hash values
my $book = $c->model('MyAppDB::Book')->create({
title => $title,
rating => $rating
});
# Add a record to the join table for this book, mapping to
# appropriate author. Note that $authors will be 1 author as
# a scalar or ref to list of authors depending on how many the
# user selected; the 'ref $authors ?...' handles both cases
foreach my $author (ref $authors ? @$authors : $authors) {
$book->add_to_book_authors({author_id => $author});
}
# Set a status message for the user
$c->stash->{status_msg} = 'Book created';
}
# Set the template
$c->stash->{template} = 'books/hw_form.tt2';
}
The key changes to hw_create_do
are:
hw_create_do
no longer does adetach
tohw_create
to redisplay the form. Now thathw_create_do
has to process the form in order to perform the validation, we go ahead and build a complete set of form presentation logic intohw_create_do
(for example,hw_create_do
now has a$c->stash->{template}
line). Note that if we process the form inhw_create_do
and forward/detach back to <hw_create>, we would end up withmake_book_widget
being called twice, resulting in a duplicate set of elements being added to the form. (There are other ways to address the "duplicate form rendering" issue -- just be aware that it exists.)$w->process($c->req)
is called to run the validation logic. Not only does this set thehas_errors
flag if validation errors are encountered, it returns a string containing any field-specific warning messages.An
if
statement checks if any validation errors were encountered. If so,$c->stash->{error_msg}
is set and the input form is redisplayed. If no errors were found, the object is created in a manner similar to the prior version of thehw_create_do
method.
Try Out the Form
Press Ctrl-C
to kill the previous server instance (if it's still running) and restart it:
$ script/myapp_server.pl
Now try adding a book with various errors: title less than 5 characters, non-numeric rating, a rating of 0 or 6, etc. Also try selecting one, two, and zero authors. When you click Submit, the HTML::Widget constraint
items will validate the logic and insert feedback as appropriate.
Enable DBIx::Class::HTMLWidget
Support
In this section we will take advantage of some of the "auto-population" features of DBIx::Class::HTMLWidget
. Enabling DBIx::Class::HTMLWidget
provides two additional methods to your DBIC model classes:
fill_widget()
Takes data from the database and transfers it to your form widget.
populate_from_widget()
Takes data from a form widget and uses it to update the corresponding records in the database.
In other words, the two methods are a mirror image of each other: one reads from the database while the other writes to the database.
Add DBIx::Class::HTMLWidget
to DBIC Model
In order to use DBIx::Class::HTMLWidget, we need to add HTMLWidget
to the load_components
line of DBIC result source files that need to use the fill_widget
and populate_from_widget
methods. In this case, open lib/MyAppDB/Book.pm
and update the load_components
line to match:
__PACKAGE__->load_components(qw/PK::Auto Core HTMLWidget/);
Use populate_from_widget
in hw_create_do
Edit lib/MyApp/Controller/Books.pm
and update hw_create_do
to match the following code:
=head2 hw_create_do
Build an HTML::Widget form for book creation and updates
=cut
sub hw_create_do : Local {
my ($self, $c) = @_;
# Create the widget and set the action for the form
my $w = $self->make_book_widget($c);
$w->action($c->uri_for('hw_create_do'));
# Validate the form parameters
my $result = $w->process($c->req);
# Write form (including validation error messages) to
# stash variable for use in template
$c->stash->{widget_result} = $result;
# Were their validation errors?
if ($result->has_errors) {
# Warn the user at the top of the form that there were errors.
# Note that there will also be per-field feedback on
# validation errors because of '$w->process($c->req)' above.
$c->stash->{error_msg} = 'Validation errors!';
} else {
my $book = $c->model('MyAppDB::Book')->new({});
$book->populate_from_widget($result);
# Add a record to the join table for this book, mapping to
# appropriate author. Note that $authors will be 1 author as
# a scalar or ref to list of authors depending on how many the
# user selected; the 'ref $authors ?...' handles both cases
my $authors = $c->request->params->{authors};
foreach my $author (ref $authors ? @$authors : $authors) {
$book->add_to_book_authors({author_id => $author});
}
# Set a status message for the user
$c->flash->{status_msg} = 'Book created';
# Redisplay an empty form for another
$c->stash->{widget_result} = $w->result;
}
# Set the template
$c->stash->{template} = 'books/hw_form.tt2';
}
In this version of hw_create_do
we removed the logic that manually pulled the form variables and used them to call $c->model('MyAppDB::Book')->create
and replaced it with a single call to $book->populate_from_widget
. Note that we still have to call $book->add_to_book_authors
once per author because populate_from_widget
does not currently handle the relationships between tables. Also, we reset the form to an empty fields by adding another call to $w->result
and storing the output in the stash (if we don't override the output from $w->process($c->req)
, the form values already entered will be retained on redisplay -- although this could be desirable for some applications, we avoid it here to help avoid the creation of duplicate records).
Try Out the Form
Press Ctrl-C
to kill the previous server instance (if it's still running) and restart it:
$ script/myapp_server.pl
Try adding a book that validates. Return to the book list and the book you added should be visible.
Rendering HTMLWidget
Forms in a Table
Some developers my wish to use the "old-fashioned" table style of rendering a form in lieu of the default HTML::Widget
rendering that assumes you will use CSS for formatting. This section demonstrates some techniques that can override the default rendering with a custom class.
Add a New "Element Container"
Open lib/FormElementContainer.pm
in your editor and enter:
package FormElementContainer;
use base 'HTML::Widget::Container';
sub _build_element {
my ($self, $element) = @_;
return () unless $element;
if (ref $element eq 'ARRAY') {
return map { $self->_build_element($_) } @{$element};
}
my $e = $element->clone;
$e = new HTML::Element('span', class => 'fields_with_errors')->push_content($e)
if $self->error && $e->tag eq 'input';
return $e ? ($e) : ();
}
1;
This simply dumps the HTML code for a given form element, followed by a span
that can contain validation error message.
Enable the New Element Container When Building the Form
Open lib/MyApp/Controller/Books.pm
in your editor. First add a use
for your element container class:
use FormElementContainer;
Note: If you forget to use
your container class in your controller, then your form will not be displayed and no error messages will be generated. Don't forget this important step!
Then tell HTML::Widget
to use that class during rendering by updating make_book_widget
to match the following:
sub make_book_widget {
my ($self, $c) = @_;
# Create an HTML::Widget to build the form
my $w = $c->widget('book_form')->method('post');
# ***New: Use custom class to render each element in the form
$w->element_container_class('FormElementContainer');
# Get authors
my @authorObjs = $c->model("MyAppDB::Author")->all();
my @authors = map {$_->id => $_->last_name }
sort {$a->last_name cmp $b->last_name} @authorObjs;
# Create the form feilds
$w->element('Textfield', 'title' )->label('Title')->size(60);
$w->element('Textfield', 'rating' )->label('Rating')->size(1);
# Convert to multi-select list
$w->element('Select', 'authors')->label('Authors')
->options(@authors)->multiple(1)->size(3);
$w->element('Submit', 'submit' )->value('submit');
# Set constraints
$w->constraint(All => qw/title rating authors/)
->message('Required. ');
$w->constraint(Integer => qw/rating/)
->message('Must be an integer. ');
$w->constraint(Range => qw/rating/)->min(1)->max(5)
->message('Must be a number between 1 and 5. ');
$w->constraint(Length => qw/title/)->min(5)->max(50)
->message('Must be between 5 and 50 characters. ');
# Set filters
for my $column (qw/title rating authors/) {
$w->filter( HTMLEscape => $column );
$w->filter( TrimEdges => $column );
}
# Return the widget
return $w;
}
The two new lines are marked with ***New:
.
Update the TT Template
Open root/src/books/hw_form.tt2
and edit it to match:
[% META title = 'Create/Update Book' %]
[%# Comment out the auto-rendered form %]
[%# widget_result.as_xml %]
[%# Iterate over the form elements and display each -%]
<form name="book_form" action="[% widget_result.action %]" method="post">
<table border="0">
[% FOREACH element = widget_result.elements %]
<tr>
<td class="form-label">
[% element.label.as_text %]
</td>
<td class="form-element">
[% element.element_xml %]
<span class="form-error">
[% element.error_xml %]
</span>
</td>
</tr>
[% END %]
</table>
</form>
<p><a href="[% Catalyst.uri_for('list') %]">Return to book list</a></p>
[%# A little JavaScript to move the cursor to the first field %]
<script LANGUAGE="JavaScript">
document.book_form.book_form_title.focus();
</script>
This represents three changes:
The existing
widget_result.as_xml
has been commented out.It loops through each form element, displaying the field name in the first table cell along with the form element and validation errors in the second field.
JavaScript to position the user's cursor in the first field of the form.
Try Out the Form
Press Ctrl-C
to kill the previous server instance (if it's still running) and restart it:
$ script/myapp_server.pl
Try adding a book that validates. Return to the book list and the book you added should be visible.
AUTHOR
Kennedy Clark, hkclark@gmail.com
Please report any errors, issues or suggestions to the author. The most recent version of the Catalyst Tutorial can be found at http://dev.catalyst.perl.org/repos/Catalyst/trunk/Catalyst-Runtime/lib/Catalyst/Manual/Tutorial/.
Copyright 2006, Kennedy Clark, under Creative Commons License (http://creativecommons.org/licenses/by-nc-sa/2.5/).