File Coverage

blib/lib/Mojolicious/Plugin/Toto.pm
Criterion Covered Total %
statement 193 217 88.9
branch 57 90 63.3
condition 21 36 58.3
subroutine 25 29 86.2
pod 1 1 100.0
total 297 373 79.6


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Mojolicious::Plugin::Toto - A simple tab and object based site structure
4              
5             =head1 SYNOPSIS
6              
7             #!/usr/bin/env perl
8              
9             use Mojolicious::Lite;
10              
11             plugin 'toto' =>
12             nav => [ qw{brewery pub beer} ],
13             sidebar => {
14             brewery => [ qw{brewery/list brewery/search brewery} ],
15             pub => [ qw{pub/list pub/search pub} ],
16             beer => [ qw{beer/list beer/search beer} ],
17             },
18             tabs => {
19             brewery => [qw/view edit delete/],
20             pub => [qw/view edit delete/],
21             beer => [qw/view edit delete/],
22             };
23              
24             app->start;
25              
26             =head1 DESCRIPTION
27              
28             This plugin provides a navigational structure and a default set
29             of routes for a Mojolicious or Mojolicious::Lite app
30              
31             The navigational structure is a slight variation of
32             L
33             example used by twitter's L.
34              
35             The plugin provides a sidebar, a nav bar, and also a
36             row of tabs underneath the name of an object.
37              
38             The row of tabs is an extension of BREAD or CRUD -- in a BREAD
39             application, browse and add are operations on zero or many objects,
40             while edit, add, and delete are operations on one object. In
41             the toto structure, these two types of operations are distinguished
42             by placing the former in the side nav bar, and the latter in
43             a row of tabs underneath the object to which the action applies.
44              
45             Additionally, a top nav bar contains menu items to take the user
46             to a particular side bar.
47              
48             =head1 HOW DOES IT WORK
49              
50             After loading the toto plugin, the default layout is set to 'toto'.
51              
52             Defaults routes are generated for every sidebar entry and tab entry.
53              
54             The names of the routes are of the form "controller/action", where
55             controller is both the controller class and the model class.
56              
57             The following templates will be automagically used, if found
58             (in order of preference) :
59              
60             - templates///.html.ep
61             - templates//.html.ep
62             - templates/.html.ep
63              
64             Or if no object is selected :
65              
66             - templates//none_selected.html.ep
67              
68             (This one links connects to "list" and "search" if
69             these routes exist, and provides an autocomplete
70             form if the model class has an autocomplete() method.)
71              
72             Also the templates "single" and "plural" are built-in
73             fallbacks for the two cases described above.
74              
75             The stash values "object" and "tab" are set for each auto-generated route.
76             Also "noun" is set as an alias to "object".
77              
78             A version of twitter's L is
79             included in this distribution.
80              
81             =head1 OPTIONS
82              
83             In addition to "menu", "nav/sidebar/tabs", the following options are recognized :
84              
85             =over
86              
87             =item prefix
88              
89             prefix => /my/subpath
90              
91             A prefix to prepend to the path for the toto routes.
92              
93             =item head_route
94              
95             head_route => $app->routes->find('top_route");
96              
97             A Mojolicious::Route::Route object to use as the parent for all routes.
98              
99             =item model_namespace
100              
101             model_namespace => "Myapp::Model'
102              
103             A namespace for model classes : the model class will be camelized and appended to this.
104              
105             =back
106              
107             =head1 EXAMPLE
108              
109             There are two different structures that toto will accept.
110             One is intended for a simple CRUD structure, where each
111             object has its own top level navigational item and
112             a variety of possible actions. The other form is intended
113             for a more complex situation in which the list of objects
114             does not correspond to the list of choices in the navigation
115             bar.
116              
117             =head2 Simple structure
118              
119             The "menu" format can be used to automatically generate
120             the nav bar, side bar and rows of tabs, using actions
121             which correspond to many objects or actions which
122             correspond to one object.
123              
124             #!/usr/bin/env perl
125              
126             use Mojolicious::Lite;
127              
128             plugin 'toto' =>
129             menu => [
130             beer => {
131             many => [qw/search browse/],
132             one => [qw/picture ingredients pubs/],
133             },
134             pub => {
135             many => [qw/map list search/],
136             one => [qw/info comments/],
137             }
138             ];
139              
140             app->start;
141              
142             =head2 Complex structure
143              
144             The "nav/sidebar/tabs" format can be used
145             for a more versatile structure, in which the
146             nav bar and side bar are less constrained.
147              
148             use Mojolicious::Lite;
149              
150             get '/my/url/to/list/beers' => sub {
151             shift->render_text("Here is a page for listing beers.");
152             } => "beer/list";
153              
154             get '/beer/create' => sub {
155             shift->render_text("Here is a page to create a beer.");
156             } => "beer/create";
157              
158             plugin 'toto' =>
159              
160             # top nav bar items
161             nav => [
162             'brewpub', # Refers to a sidebar entry below
163             'beverage' # Refers to a sidebar entry below
164             ],
165              
166             # possible sidebars, keyed on nav entries
167             sidebar => {
168             brewpub => [
169             'brewery/phonelist',
170             'brewery/mailing_list',
171             'pub/search',
172             'pub/map',
173             'brewery', # Refers to a "tab" entry below
174             'pub', # Refers to a "tab" entry below
175             ],
176             beverage =>
177             [ 'beer/list', # This will use the route defined above named "beer/list"
178             'beer/create',
179             'beer/search',
180             'beer/browse', # This will use the controller at the top (Beer::browse)
181             'beer' # Refers to a "tab" entry below
182             ],
183             },
184              
185             # possible rows of tabs, keyed on sidebar entries without a /
186             tabs => {
187             brewery => [ 'view', 'edit', 'directions', 'beers', 'info' ],
188             pub => [ 'view', 'info', 'comments', 'hours' ],
189             beer => [ 'view', 'edit', 'pictures', 'notes' ],
190             };
191             ;
192              
193             app->start;
194              
195              
196             =head1 NOTES
197              
198             To create pages outside of the toto framework, just set the layout to
199             something other than "toto', e.g.
200              
201             get '/no/toto' => { layout => 'default' } => ...
202              
203             This module is experimental. The API may change without notice. Feedback is welcome!
204              
205             =head1 TODO
206              
207             Document the autcomplete API.
208              
209             =head1 AUTHOR
210              
211             Brian Duggan C
212              
213             =cut
214              
215             package Mojolicious::Plugin::Toto;
216 4     4   8475 use Mojo::Base 'Mojolicious::Plugin';
  4         11  
  4         35  
217 4     4   950 use Mojo::ByteStream qw/b/;
  4         10  
  4         288  
218 4     4   37 use File::Basename 'dirname';
  4         8  
  4         239  
219 4     4   22 use File::Spec::Functions 'catdir';
  4         25  
  4         222  
220 4     4   2681 use Mojolicious::Plugin::Toto::Model;
  4         12  
  4         31  
221 4     4   121 use Cwd qw/abs_path/;
  4         7  
  4         205  
222              
223 4     4   20 use strict;
  4         8  
  4         120  
224 4     4   18 use warnings;
  4         9  
  4         18807  
225              
226             our $VERSION = "0.25";
227              
228             sub _render_static {
229 0     0   0 my $c = shift;
230 0         0 my $what = shift;
231 0         0 $c->render_static($what);
232             }
233              
234             sub _cando {
235 34     34   71 my ($namespace,$controller,$action) = @_;
236 34   66     214 my $package = join '::', ( $namespace || () ), b($controller)->camelize;
237 34 100       1930 return $package->can($action) ? 1 : 0;
238             }
239              
240             sub _to_noun {
241 8     8   19 my $word = shift;
242 8         25 $word =~ s/_/ /g;
243 8         32 $word;
244             }
245              
246             sub _add_sidebar {
247 14     14   21 my $self = shift;
248 14         20 my $app = shift;
249 14         21 my $routes = shift;
250 14         37 my ($prefix, $nav_item, $object, $tab) = @_;
251 14 50       43 die "no tab for $object" unless $tab;
252 14 50       36 die "no nav item" unless $nav_item;
253              
254 28 50       1322 my ($template) = (
255 14 50       345 ( map { (-e "$_/$object/$tab.html.ep") ? "$object/$tab" : () } @{ $app->renderer->paths } ),
  28         2361  
256 14         23 ( map { (-e "$_/$tab.html.ep" ) ? "$tab" : () } @{ $app->renderer->paths } ),
  14         340  
257             );
258 14 50       495 $template = $tab if $app->renderer->get_data_template({template => $tab, format => 'html', handler => 'ep'});
259 14 100       10677 $template = "$object/$tab" if $app->renderer->get_data_template({template => "$object/$tab", format => "html", handler => "ep"});
260              
261 14 50       1048 my $namespaces = $routes->can('namespaces') ? $routes->namespaces : $routes->root->namespaces;
262 14 100 66     206 $namespaces = [ '' ] unless $namespaces && @$namespaces;
263 14         31 my $found_controller = grep { _cando($_,$object,$tab) } @$namespaces;
  14         59  
264              
265 14         424 $app->log->debug("Adding sidebar route for $prefix/$object/$tab");
266 14 100       612 $app->log->debug("found template $template for $object/$tab ($nav_item)") if $template;
267              
268             my $r = $routes->under(
269             "$prefix/$object/$tab" => sub {
270 10     10   248990 my $c = shift;
271 10   100     103 $c->stash->{template} = $template || "plural";
272 10         125 $c->stash(object => $object);
273 10         204 $c->stash(noun => $object);
274 10         164 $c->stash(tab => $tab);
275 10         158 $c->stash(nav_item => $nav_item);
276 14         207 })->any;
277              
278 14 100       34566 $app->log->debug("found controller for $object/$tab (controller : $object, action : $tab)") if $found_controller;
279 14 100       132 $r = $r->to(controller => $object, action => $tab) if $found_controller;
280 14         275 $r->name("$object/$tab");
281             }
282              
283             sub _add_tab {
284 20     20   59 my $self = shift;
285 20         29 my $app = shift;
286 20         22 my $routes = shift;
287 20         53 my ($prefix, $nav_item, $object, $tab) = @_;
288 40 50       1689 my ($default_template) = (
289 20 50       714 ( map { (-e "$_/$object/$tab.html.ep") ? "$object/$tab" : () } @{ $app->renderer->paths } ),
  40         1703  
290 20         29 ( map { (-e "$_/$tab.html.ep" ) ? "$tab" : () } @{ $app->renderer->paths } ),
  20         489  
291             );
292 20 50       695 $default_template = $tab if $app->renderer->get_data_template({template => $tab, format => "html", handler => "ep"});
293 20 100       1893 $default_template = "$object/$tab" if $app->renderer->get_data_template({template => "$object/$tab", format => "html", handler => "ep"});
294              
295 20 50       1765 my $namespaces = $routes->can('namespaces') ? $routes->namespaces : $routes->root->namespaces;
296 20 100 66     255 $namespaces = [ '' ] unless $namespaces && @$namespaces;
297 20         39 my $found_controller = grep { _cando($_,$object,$tab) } @$namespaces;
  20         142  
298 20         557 $app->log->debug("Adding route for $prefix/$object/$tab/*key");
299 20 100       665 $app->log->debug("Found controller class for $object/$tab/key") if $found_controller;
300 20 100       99 $app->log->debug("Found default template for $object/$tab/key ($default_template)") if $default_template;
301             my $r = $routes->under("$prefix/$object/$tab/(*key)"
302             => { key => '', show_tabs => 1 }
303             => sub {
304 8     8   226388 my $c = shift;
305 8         19 my $template = $default_template;
306 8         38 my $key = lc $c->stash('key');
307 8         125 $c->stash(object => $object);
308 8         155 $c->stash(noun => _to_noun($object));
309 8         133 $c->stash(tab => $tab);
310 8 50       124 if ( $key ) {
311 8 50 33     17 if ( ( grep { -e "$_/$object/$key/$tab.html.ep" } @{ $app->renderer->paths } )
  16         969  
  8         275  
312             || $c->app->renderer->get_data_template( {template => "$object/$key/$tab", format => "html", handler => "ep"}) ) {
313 0         0 $template = "$object/$key/$tab";
314             }
315             } else {
316 0         0 $template = "none_selected";
317 0 0       0 $template = "$object/none_selected" if grep { -e "$_/$object/none_selected.html.ep" } @{ $app->renderer->paths };
  0         0  
  0         0  
318             }
319 8   100     635 $c->stash->{template} = $template || "single";
320 8         167 my $instance = $c->current_instance;
321 8         109 $c->stash( instance => $instance );
322 8         136 $c->stash( nav_item => $nav_item );
323 8         123 $c->stash( $object => $instance );
324 8 50       118 $c->render unless $key;
325 8 50       87 $key ? 1 : 0;
326             }
327 20         362 )->any;
328 20 100       22722 $r = $r->to("$object#$tab") if $found_controller;
329 20         316 $r->name("$object/$tab");
330             }
331              
332             sub _menu_to_nav {
333 2     2   4 my $self = shift;
334 2         7 my ($conf,$menu) = @_;
335 2         3 my $nav;
336             my $sidebar;
337 0         0 my $tabs;
338 0         0 my $object;
339 2         8 for (@$menu) {
340 8 100       28 unless (ref $_) {
341 4         10 $object = $_;
342 4         8 push @$nav, $object;
343 4         9 next;
344             }
345 4 50       8 for my $action (@{ $_->{many} || [] }) {
  4         25  
346 8         12 push @{$sidebar->{$object}}, "$object/$action";
  8         34  
347             }
348 4         12 push @{$sidebar->{$object}}, $object;
  4         10  
349 4 50       10 for my $action (@{ $_->{one} || [] }) {
  4         17  
350 7         9 push @{$tabs->{$object}}, $action;
  7         28  
351             }
352             }
353 2         7 $conf->{nav} = $nav;
354 2         6 $conf->{sidebar} = $sidebar;
355 2         43 $conf->{tabs} = $tabs;
356             }
357              
358             sub register {
359 4     4 1 263 my ($self, $app, $conf) = @_;
360 4         143 $app->log->debug("registering plugin");
361              
362 4 100       704 if (my $menu = $conf->{menu}) {
363 2         9 $self->_menu_to_nav($conf,$menu);
364             }
365 4         10 for (qw/nav sidebar tabs/) {
366 12 50       42 die "missing $_" unless $conf->{$_};
367             }
368 4         49 my ($nav,$sidebar,$tabs) = @$conf{qw/nav sidebar tabs/};
369              
370 4   50     38 my $prefix = $conf->{prefix} || '';
371 4   33     267 my $routes = $conf->{head_route} || $app->routes;
372              
373 4         1197 my $base = catdir(abs_path(dirname(__FILE__)), qw/Toto Assets/);
374 4         24 my $default_path = catdir($base,'templates');
375 4         9 push @{$app->renderer->paths}, catdir($base, 'templates');
  4         137  
376 4         187 push @{$app->static->paths}, catdir($base, 'public');
  4         115  
377 4         309 $app->defaults(layout => "toto", toto_prefix => $prefix);
378              
379 4         225 $app->log->debug("Adding routes");
380              
381 4         124 my %tab_done;
382              
383 4 50       18 die "toto plugin needs a 'nav' entry, please read the pod for more information" unless $nav;
384 4         13 for my $nav_item ( @$nav ) {
385 8         3166 $app->log->debug("Adding routes for $nav_item");
386 8         303 my $first;
387 8 50       158 my $items = $sidebar->{$nav_item} or die "no sidebar for $nav_item";
388 8         158 for my $subnav_item ( @$items ) {
389 23         1577 $app->log->debug("routes for $subnav_item");
390 23         846 my ( $object, $action ) = split '/', $subnav_item;
391 23 100       64 if ($action) {
392 14   66     60 $first ||= $subnav_item;
393 14         51 $self->_add_sidebar($app,$routes,$prefix,$nav_item,$object,$action);
394             } else {
395 9         21 my $first_tab;
396 9   33     41 $first ||= "$object/default";
397             my $tabs = $tabs->{$subnav_item} or
398 9 50       71 do { warn "# no tabs for $subnav_item"; next; };
  0         0  
  0         0  
399 9 50       48 die "tab row for '$subnav_item' appears more than once" if $tab_done{$subnav_item}++;
400 9         29 for my $tab (@$tabs) {
401 20   66     168 $first_tab ||= $tab;
402 20         72 $self->_add_tab($app,$routes,$prefix,$nav_item,$object,$tab);
403             }
404 9         409 $app->log->debug("Will redirect $prefix/$object/default/key to $object/$first_tab/\$key");
405              
406             $routes->get("$prefix/$object/default/*key" => { key => '' } => sub {
407 0     0   0 my $c = shift;
408 0         0 my $key = $c->stash("key");
409 0         0 $c->redirect_to("$object/$first_tab", key => $key);
410 9         474 } => "$object/default");
411              
412             $routes->get(
413             "$prefix/$object/autocomplete" => { layout => "default" } => sub {
414 0     0   0 my $c = shift;
415 0         0 my $query = $c->param('q');
416 0 0       0 return $c->render_not_found unless $c->model_class->can("autocomplete");
417 0         0 my $results = $c->model_class->autocomplete( q => $query, object => $object, c => $c, tab => $c->param('tab') );
418             # Expects an array ref of the form
419             # [ { name => 'foo', href => 'bar' }, ]
420 0         0 $c->render( json => $results );
421 9         7754 } => "$object/autocomplete");
422             }
423             }
424 8 50       5967 die "Could not find first route for nav item '$nav_item' : all entries have tabs\n" unless $first;
425             $routes->get(
426             $nav_item => sub {
427 5     5   157373 my $c = shift;
428 5         31 $c->redirect_to($first);
429 8         61 } => $nav_item );
430             }
431              
432 4         2704 my $first_object = $conf->{nav}[0];
433 4     2   59 $routes->get("$prefix/" => sub { shift->redirect_to($first_object) } );
  2         126511  
434              
435 4         1742 for ($app) {
436 4     38   74 $_->helper( toto_config => sub { $conf } );
  38         615213  
437             $_->helper( model_class => sub {
438 30     30   3478 my $c = shift;
439 30 50       138 if (my $ns = $conf->{model_namespace}) {
440 0         0 return join '::', $ns, b($c->current_object)->camelize;
441             }
442 30 50       455 $conf->{model_class} || "Mojolicious::Plugin::Toto::Model"
443             }
444 4         467 );
445             $_->helper(
446             tabs => sub {
447 25     25   3650 my $c = shift;
448 25 50 66     192 my $for = shift || $c->current_object or return;
449 25 50       131 @{ $conf->{tabs}{$for} || [] };
  25         209  
450             }
451 4         391 );
452             $_->helper( current_object => sub {
453 59     59   123944 my $c = shift;
454 59 100       205 $c->stash('object') || [ split '\/', $c->current_route ]->[0]
455 4         416 } );
456             $_->helper( current_tab => sub {
457 22     22   12929 my $c = shift;
458 22 50       72 $c->stash('tab') || [ split '\/', $c->current_route ]->[1]
459 4         363 } );
460             $_->helper( current_instance => sub {
461 30     30   65484 my $c = shift;
462 30   33     98 my $key = $c->stash("key") || [ split '\/', $c->current_route ]->[2];
463 30         561 return $c->model_class->new(key => $key);
464 4         547 } );
465             $_->helper( printable => sub {
466 45     45   63786 my $c = shift;
467 45         90 my $what = shift;
468 45         98 $what =~ s/_/ /g;
469 4         374 $what } );
  45         192  
470             $_->helper( a_printable => sub {
471 0     0   0 my $c = shift;
472 0         0 my $what = shift;
473 0 0       0 return ( ($what =~ /^[aeiou]/ ? "an " : "a ").$c->printable($what));
474 4         390 } );
475             }
476              
477 4         397 $self;
478             }
479              
480             1;