[Catalyst] RFC: Catalyst::Controller::RHTMLO

Jason Gottshall jgottshall at capwiz.com
Thu Jan 22 22:52:31 GMT 2009


[Cross-posted to catalyst-users and rhtmlo lists]

I know there are several modules out there that hook up rhtmlo and 
catalyst, but none of them do what I want. They all seem to do too much: 
connect to a CRUD API, interface with rdbo, build a form object from a 
config file, etc. I really just need a simple glue to load 
rhtmlo-derived form classes into my catalyst controller actions and 
initialize them with any query params, so I created a base controller 
and an action class that take care of the details. It works for me, but 
I thought it might be useful to the larger community, so I made it 
configurable and documented it pretty thoroughly. But before I pollute 
CPAN with one more piece of cruft, I want to be sure it's 
sensible/useful. The two packages are defined below. Comments would be 
appreciated.

package Catalyst::Controller::RHTMLO;

use strict;
use warnings;

use base 'Catalyst::Controller';
use MRO::Compat; # to get $self->next::method() right

=head1 NAME

Catalyst::Controller::RHTMLO - Catalyst Base Controller for 
Rose::HTML::Objects forms

=head1 SYNOPSIS

     package MyApp::Controller::Books;
     use base 'Catalyst::Controller::RHTMLO';

     # loads MyApp::Form::Book (which isa Rose::HTML::Form)
     sub edit : Local Form('Book') {
         my ( $self, $c ) = @_;

         # form object is already init'ed with params and stashed
         my $form = $c->stash->{form};

         if ( $form->was_submitted ) {
             if ( $form->validate ) {
                 # write to db or whatever
             }
             else {
                 # show errors or whatever
             }
         }
     }

     # display two search forms on same page
     sub search : Local Form('ByAuthor,ByTitle') {
         my ( $self, $c ) = @_;

         if ( $c->stash->{forms}->{ByAuthor}->was_submitted ) {
             # look up books by author
         }
         elsif { $c->stash->{forms}->{ByTitle}->was_submitted ) {
             # look up books by title, duh
         }
     }

=head1 DESCRIPTION

This base controller glues Catalyst actions to form classes derived from
L<Rose::HTML::Form>, a component of John Siracusa's excellent 
L<Rose::Object>
framework. Unlike some other form-loading modules (see L</"PRIOR ART">), 
this
one does not include any mechanism for defining form structures; it merely
loads, instantiates, and initializes pre-written form classes for use in 
your
controllers.

In order to utilize a particular form in a particular Catalyst action, 
simply declare an attribute on the subroutine:

     sub edit : Local Form('Book') { }

This will ensure that MyApp::Form::Book is loaded and initialized, 
basically
equivalent to the following:

     my $form = MyApp::Form::Book->new();
     $form->params($c->req->params);
     $form->init_fields;
     $c->stash->{form} = $form;

The namespace used to complete the form class name is
L<configurable|/CONFIGURATION>, or you can specify a full package name by
prepending a 'plus' sign:

     sub edit : Local Form('+My::FormClasses::Book') { }

To display more than one distinct form on a page, just list them all in the
attribute, delimited with commas or spaces:

     sub search : Local Form('ByAuthor,ByTitle,BySubject') {
         my ($self, $c) = @_;

 
$c->stash->{forms}{ByAuthor}->action($c->uri_for('/search/byauthor'));
         $c->stash->{forms}{ByTitle}->method('GET');
         $c->stash->{forms}{BySubject}->name('bytopic');
     }

(Note that you must put all the form names inside one set of quotes; 
I<DO NOT>
try to quote each individual form name. This is a limitation of perl5's
subroutine attributes.) The first form listed will still be stored in 
the stash
in the usual location; I<all> the forms (including the first) will be 
stored
under a separate stash key, in a hash keyed to the name used to load them.

If for some reason you need to render the same form more than once on a 
page,
just list it again:

     sub search : Local Form('Search,Search') {
         my ($self, $c) = @_;

         $c->stash->{forms}{Search}[0]->name('search_0');
         $c->stash->{forms}{Search}[1]->name('search_1');
     }

In this (weird but possible) case, the forms will be put into an 
arrayref in
the expected location. I haven't actually attempted to use this 
technique in
production, so I don't know what else you might have to do to make it 
work...

=head1 CONFIGURATION

You can override many defaults using Catalyst's configuration mechanism:

     __PACKAGE__->config(
         'Controller::RHTMLO' => {
             form_attr       => 'HasForm',
             action_class    => 'MyApp::Action::RoseForm',
             stash_name      => 'formobj',
             stash_hash_name => 'allforms',
             form_prefix     => 'MyApp::RoseForm',
         }
     );

=over

=item C<action_class> (default 'Catalyst::Controller::RHTMLO::Action')

If you want to add more functionality to the automatic form loading and 
initialization, you can create your own action class:

     package MyApp::Action::RoseForm;
     use base 'Catalyst::Controller::RHTMLO::Action';

     sub execute {
         my $self = shift;
         my ($controller, $c, @args) = @_;

         # load forms via base class
         $self->next::method(@_);

         # do cool stuff
         $c->stash->{form}->add_fields(
             secure_token => {
                 type  => 'hidden',
                 value => $c->some_cool_security_token
             }
         );
         return;
     }

=item C<form_attr>

Default: 'Form'. Set this to alter the subroutine attribute used to 
indicate
one or more forms to be loaded by a given action, e.g.:

     sub edit : Local HasForm('Books') { }

=item C<form_prefix>

Default: 'MyApp::Form' (using your app's actual name). Set this to the
namespace where your Rose::HTML::Form subclasses live.

=item C<stash_hash_name>

Default: 'forms'. Sets the stash key under which all forms for a given 
action
will be stored by class name.

=item C<stash_name>

Default: 'form'. Sets the stash key under which the first form for a given
action will be stored.

=head1 PRIOR ART

There are several other modules on CPAN that do similar things, many having
inspired this module in various ways.

=over

=item L<Catalyst::Controller::FormBuilder>

Provided a lot of insight into how to trigger the form loading process 
with a
custom subroutine attribute. Based on L<CGI::FormBuilder> rather than
Rose::HTML::Form.

=item L<CatalystX::RoseIntegrator>

Looks like it uses a CGI::FormBuilder-style config file to construct
Rose::HTML::Form objects on the fly, rather than having static 
subclasses. Also
seems to include direct model integration with L<Rose::DB::Object>.

=item L<CatalystX::CRUD::Controller::RHTMLO>

A component that enables use of Rose::HTML::Form objects with Peter 
Karman's
cool L<CatalystX::CRUD> API.

=back

=head1 SEE ALSO

L<Rose::HTML::Form>, L<Rose::HTML::Objects>, L<Rose::Object>,
L<Catalyst::Controller>, L<Catalyst::Action>, L<Catalyst>

=head1 AUTHOR

Jason Gottshall <jgottshall att capwiz dott com>

=head1 LICENSE

This program is free software; you can redistribute it and/or modify it 
under the same terms as Perl itself.

=cut

sub create_action {
     my ($self, %args) = @_;

     my $config = $self->config->{'Controller::RHTMLO'};
     my $form_attr = $config->{'form_attr'} || 'Form';

     if( exists $args{attributes}{$form_attr} ) {
         my $action_class = $config->{'action_class'} || 
'Catalyst::Controller::RHTMLO::Action';
         push @{ $args{attributes}{ActionClass} }, $action_class;

         if(my $val = delete $args{attributes}{$form_attr}) {
             $args{_form_class} = $val;
         }
     }

     return $self->next::method(%args);
}

package Catalyst::Controller::RHTMLO::Action;

use strict;
use warnings;

use base 'Catalyst::Action';
use MRO::Compat;
use Catalyst::Utils;

__PACKAGE__->mk_accessors(qw/_form_class/);

sub execute {
     my $self = shift;
     my ($controller, $c, @args) = @_;

     $self->get_forms($c);

     return $self->next::method(@_);
}

sub get_forms {
     my ($self, $c) = @_;

     # sanity check; ensure we actually declared form class
     return unless $self->_form_class && ref $self->_form_class eq 'ARRAY';

     # form classes are delimited by spaces or commas
     my @classes = split /[ ,]+/, $self->_form_class->[0];
     unless(@classes) {
         $c->log->warn('No form class specified for action "' . 
$self->reverse . '"');
         return;
     }

     my $config      = $c->config->{'Controller::RHTMLO'};
     my $stash_name  = $config->{'stash_name'}      || 'form';
     my $stash_hash  = $config->{'stash_hash_name'} || 'forms';
     my $form_prefix = $config->{'form_prefix'}     || 
$c->config->{'name'} . '::Form';
     $form_prefix .= '::';

     foreach my $name (@classes) {
         next unless $name; # ignore leading/trailing delimiters

         my $class = $name;
         # allow for full class names with leading '+'
         $class = $form_prefix . $class unless $class =~ s/^\+//;
         Catalyst::Utils::ensure_class_loaded($class);
         $c->log->debug("Loading form '$class'");

         # setup form
         my $form = $class->new();
         $form->params($c->req->params);
         $form->init_fields;

         # put form in stash under its name
         if(my $prev_form = $c->stash->{$stash_hash}->{$name}) {
             # multiple instances of same form class are stored in arrayref
             $c->stash->{$stash_hash}->{$name} = [$prev_form]
                 unless ref $prev_form eq 'ARRAY';

             push @{$c->stash->{$stash_hash}->{$name}}, $form;
         }
         else {
             $c->stash->{$stash_hash}->{$name} = $form;
         }

         # create shortcut to "main" form
         $c->stash->{$stash_name} ||= $form;
     }

     return;
}

1;





More information about the Catalyst mailing list