aboutsummaryrefslogtreecommitdiffstats
path: root/lib/Reaction/UI
diff options
context:
space:
mode:
authormatthewt <matthewt@03d0b0b2-0e1a-0410-a411-fdb2f4bd65d7>2007-09-12 18:11:34 +0000
committermatthewt <matthewt@03d0b0b2-0e1a-0410-a411-fdb2f4bd65d7>2007-09-12 18:11:34 +0000
commit7adfd53f17f66ffe93763e944ed1d3fc52a369dc (patch)
tree19e599e74419b41cbbe651fd226b81e8b73551d3 /lib/Reaction/UI
parentc728c97cb1061330e63c7cc048e768ef74988fe6 (diff)
downloadreaction-7adfd53f17f66ffe93763e944ed1d3fc52a369dc.tar.gz
reaction-7adfd53f17f66ffe93763e944ed1d3fc52a369dc.zip
moved shit to trunk
Diffstat (limited to 'lib/Reaction/UI')
-rw-r--r--lib/Reaction/UI/CRUDController.pm115
-rw-r--r--lib/Reaction/UI/Controller.pm73
-rw-r--r--lib/Reaction/UI/FocusStack.pm241
-rw-r--r--lib/Reaction/UI/LayoutSet.pm52
-rw-r--r--lib/Reaction/UI/LayoutSet/TT.pm44
-rw-r--r--lib/Reaction/UI/Renderer/XHTML.pm89
-rw-r--r--lib/Reaction/UI/RenderingContext.pm13
-rw-r--r--lib/Reaction/UI/RenderingContext/TT.pm91
-rw-r--r--lib/Reaction/UI/RootController.pm97
-rw-r--r--lib/Reaction/UI/View.pm133
-rw-r--r--lib/Reaction/UI/View/TT.pm41
-rw-r--r--lib/Reaction/UI/ViewPort.pm389
-rw-r--r--lib/Reaction/UI/ViewPort/ActionForm.pm400
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField.pm90
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/Boolean.pm31
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/Collection.pm29
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/DateTime.pm28
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/List.pm31
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/Number.pm10
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/RelatedObject.pm26
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/String.pm11
-rw-r--r--lib/Reaction/UI/ViewPort/DisplayField/Text.pm11
-rw-r--r--lib/Reaction/UI/ViewPort/Field.pm166
-rw-r--r--lib/Reaction/UI/ViewPort/Field/Boolean.pm32
-rw-r--r--lib/Reaction/UI/ViewPort/Field/ChooseMany.pm139
-rw-r--r--lib/Reaction/UI/ViewPort/Field/ChooseOne.pm138
-rw-r--r--lib/Reaction/UI/ViewPort/Field/DateTime.pm89
-rw-r--r--lib/Reaction/UI/ViewPort/Field/File.pm45
-rw-r--r--lib/Reaction/UI/ViewPort/Field/HiddenArray.pm42
-rw-r--r--lib/Reaction/UI/ViewPort/Field/Number.pm31
-rw-r--r--lib/Reaction/UI/ViewPort/Field/Password.pm32
-rw-r--r--lib/Reaction/UI/ViewPort/Field/String.pm34
-rw-r--r--lib/Reaction/UI/ViewPort/Field/Text.pm32
-rw-r--r--lib/Reaction/UI/ViewPort/Field/TimeRange.pm151
-rw-r--r--lib/Reaction/UI/ViewPort/ListView.pm465
-rw-r--r--lib/Reaction/UI/ViewPort/ObjectView.pm182
-rw-r--r--lib/Reaction/UI/ViewPort/TimeRangeCollection.pm390
-rw-r--r--lib/Reaction/UI/Widget.pm41
-rw-r--r--lib/Reaction/UI/Widget/ListView.pm54
-rw-r--r--lib/Reaction/UI/WidgetClass.pm283
-rw-r--r--lib/Reaction/UI/Window.pm292
41 files changed, 4683 insertions, 0 deletions
diff --git a/lib/Reaction/UI/CRUDController.pm b/lib/Reaction/UI/CRUDController.pm
new file mode 100644
index 0000000..8841281
--- /dev/null
+++ b/lib/Reaction/UI/CRUDController.pm
@@ -0,0 +1,115 @@
+package Reaction::UI::CRUDController;
+
+use strict;
+use warnings;
+use base 'Reaction::UI::Controller';
+use Reaction::Class;
+
+use aliased 'Reaction::UI::ViewPort::ListView';
+use aliased 'Reaction::UI::ViewPort::ActionForm';
+use aliased 'Reaction::UI::ViewPort::ObjectView';
+
+has 'model_base' => (isa => 'Str', is => 'rw', required => 1);
+has 'model_name' => (isa => 'Str', is => 'rw', required => 1);
+
+has 'ActionForm_class' => (isa => 'Str', is => 'rw', required => 1,
+ lazy => 1, default => sub{ ActionForm });
+has 'ListView_class' => (isa => 'Str', is => 'rw', required => 1,
+ lazy => 1, default => sub{ ListView });
+has 'ObjectView_class' => (isa => 'Str', is => 'rw', required => 1,
+ lazy => 1, default => sub{ ObjectView });
+
+sub base :Action :CaptureArgs(0) {
+ my ($self, $c) = @_;
+}
+
+sub get_collection {
+ my ($self, $c) = @_;
+ return $c->model(join('::', $self->model_base, $self->model_name));
+}
+
+sub get_model_action {
+ my ($self, $c, $name, $target) = @_;
+
+ if ($target->can('action_for')) {
+ return $target->action_for($name, ctx => $c);
+ }
+
+ my $model_name = "Action::${name}".$self->model_name;
+ my $model = $c->model($model_name);
+ confess "no such Model $model_name" unless $model;
+ return $model->new(target_model => $target, ctx => $c);
+}
+
+sub list :Chained('base') :PathPart('') :Args(0) {
+ my ($self, $c) = @_;
+
+ $self->push_viewport(
+ $self->ListView_class,
+ collection => $self->get_collection($c)
+ );
+}
+
+sub create :Chained('base') :PathPart('create') :Args(0) {
+ my ($self, $c) = @_;
+ my $action = $self->get_model_action($c, 'Create', $self->get_collection($c));
+ $self->push_viewport(
+ $self->ActionForm_class,
+ action => $action,
+ next_action => 'list',
+ on_apply_callback => sub { $self->after_create_callback($c => @_); },
+ );
+}
+
+sub after_create_callback {
+ my ($self, $c, $vp, $result) = @_;
+ return $self->redirect_to(
+ $c, 'update', [ @{$c->req->captures}, $result->id ]
+ );
+}
+
+sub object :Chained('base') :PathPart('id') :CaptureArgs(1) {
+ my ($self, $c, $key) = @_;
+ my $object :Stashed = $self->get_collection($c)
+ ->find($key);
+ confess "Object? what object?" unless $object; # should be a 404.
+}
+
+sub update :Chained('object') :Args(0) {
+ my ($self, $c) = @_;
+ my $object :Stashed;
+ my $action = $self->get_model_action($c, 'Update', $object);
+ my @cap = @{$c->req->captures};
+ pop(@cap); # object id
+ $self->push_viewport(
+ $self->ActionForm_class,
+ action => $action,
+ next_action => [ $self, 'redirect_to', 'list', \@cap ]
+ );
+}
+
+sub delete :Chained('object') :Args(0) {
+ my ($self, $c) = @_;
+ my $object :Stashed;
+ my $action = $self->get_model_action($c, 'Delete', $object);
+ my @cap = @{$c->req->captures};
+ pop(@cap); # object id
+ $self->push_viewport(
+ $self->ActionForm_class,
+ action => $action,
+ next_action => [ $self, 'redirect_to', 'list', \@cap ]
+ );
+}
+
+sub view :Chained('object') :Args(0) {
+ my ($self, $c) = @_;
+ my $object :Stashed;
+ my @cap = @{$c->req->captures};
+ pop(@cap); # object id
+ $self->push_viewport(
+ $self->ObjectView_class,
+ object => $object
+ );
+}
+
+1;
diff --git a/lib/Reaction/UI/Controller.pm b/lib/Reaction/UI/Controller.pm
new file mode 100644
index 0000000..e0e1423
--- /dev/null
+++ b/lib/Reaction/UI/Controller.pm
@@ -0,0 +1,73 @@
+package Reaction::UI::Controller;
+
+use base qw/Catalyst::Controller::BindLex Reaction::Object/;
+use Reaction::Class;
+
+sub push_viewport {
+ my $self = shift;
+ my $focus_stack :Stashed;
+ my ($class, @proto_args) = @_;
+ my %args;
+ my $c = Catalyst::Controller::BindLex::_get_c_obj(4);
+ if (my $vp_attr = $c->stack->[-1]->attributes->{ViewPort}) {
+ if (ref($vp_attr) eq 'ARRAY') {
+ $vp_attr = $vp_attr->[0];
+ }
+ if (ref($vp_attr) eq 'HASH') {
+ if (my $conf_class = delete $vp_attr->{class}) {
+ $class = $conf_class;
+ }
+ %args = (%$vp_attr, @proto_args);
+ } else {
+ $class = $vp_attr;
+ %args = @proto_args;
+ }
+ } else {
+ %args = @proto_args;
+ }
+
+ $args{ctx} = $c;
+
+ if (exists $args{next_action} && !ref($args{next_action})) {
+ $args{next_action} = [ $self, 'redirect_to', $args{next_action} ];
+ }
+ $focus_stack->push_viewport($class, %args);
+}
+
+sub pop_viewport {
+ my $focus_stack :Stashed;
+ return $focus_stack->pop_viewport;
+}
+
+sub pop_viewports_to {
+ my ($self, $vp) = @_;
+ my $focus_stack :Stashed;
+ return $focus_stack->pop_viewports_to($vp);
+}
+
+sub redirect_to {
+ my ($self, $c, $to, $cap, $args, $attrs) = @_;
+
+ #the confess calls could be changed later to $c->log ?
+ my $action;
+ if(!ref $to){
+ $action = $self->action_for($to);
+ confess("Failed to locate action ${to} in " . $self->blessed) unless $action;
+ }
+ elsif( blessed $to && $to->isa('Catalyst::Action') ){
+ $action = $to;
+ } elsif(ref $action eq 'ARRAY' && @$action == 2){ #is that overkill / too strict?
+ $action = $c->controller($to->[0])->action_for($to->[1]);
+ confess("Failed to locate action $to->[1] in $to->[0]" ) unless $action;
+ } else{
+ confess("Failed to locate action from ${to}");
+ }
+
+ $cap ||= $c->req->captures;
+ $args ||= $c->req->args;
+ $attrs ||= {};
+ my $uri = $c->uri_for($action, $cap, @$args, $attrs);
+ $c->res->redirect($uri);
+}
+
+1;
diff --git a/lib/Reaction/UI/FocusStack.pm b/lib/Reaction/UI/FocusStack.pm
new file mode 100644
index 0000000..5a458fa
--- /dev/null
+++ b/lib/Reaction/UI/FocusStack.pm
@@ -0,0 +1,241 @@
+package Reaction::UI::FocusStack;
+
+use Reaction::Class;
+
+class FocusStack which {
+
+ has vp_head => (isa => 'Reaction::UI::ViewPort', is => 'rw');
+ has vp_tail => (isa => 'Reaction::UI::ViewPort', is => 'rw');
+ has vp_count => (
+ isa => 'Int', is => 'rw', required => 1, default => sub { 0 }
+ );
+ has loc_prefix => (isa => 'Str', is => 'rw', predicate => 'has_loc_prefix');
+
+ implements push_viewport => as {
+ my ($self, $class, %create) = @_;
+ my $tail = $self->vp_tail;
+ my $loc = $self->vp_count;
+ if ($self->has_loc_prefix) {
+ $loc = join('.', $self->loc_prefix, $loc);
+ }
+ my $vp = $class->new(
+ %create,
+ location => $loc,
+ focus_stack => $self,
+ (defined $tail ? ( outer => $tail ) : ()), # XXX possibly a bug in
+ #immutable?
+ );
+ if ($tail) { # if we already have a tail (non-empty vp stack)
+ $tail->inner($vp); # set the current tail's inner vp to the new vp
+ } else { # else we're currently an empty stack
+ $self->vp_head($vp); # so set the head to the new vp
+ }
+ $self->vp_count($self->vp_count + 1);
+ $self->vp_tail($vp);
+ return $vp;
+ };
+
+ implements pop_viewport => as {
+ my ($self) = @_;
+ my $head = $self->vp_head;
+ confess "Can't pop from empty focus stack" unless defined($head);
+ my $vp = $self->vp_tail;
+ if ($vp eq $head) {
+ $self->vp_head(undef);
+ }
+ $self->vp_tail($vp->outer);
+ $self->vp_count($self->vp_count - 1);
+ return $vp;
+ };
+
+ implements pop_viewports_to => as {
+ my ($self, $vp) = @_;
+ 1 while ($self->pop_viewport ne $vp);
+ return $vp;
+ };
+
+ implements apply_events => as {
+ my $self = shift;
+ my $vp = $self->vp_tail;
+ while (defined $vp) {
+ $vp->apply_events(@_);
+ $vp = $vp->outer;
+ }
+ };
+
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::FocusStack - A linked list of ViewPort-based objects
+
+=head1 SYNOPSIS
+
+ my $stack = Reaction::UI::FocusStack->new();
+
+ # Or more commonly, in a Reaction::UI::RootController based
+ # Catalyst Controller:
+ my $stack = $ctx->focus_stack;
+
+ # Add a new basic viewport inside the last viewport on the stack:
+ my $vp = $stack->push_viewport('Reaction::UI::ViewPort' =>
+ layout => 'xhtml'
+ );
+
+ # Fetch the innermost viewport from the stack:
+ my $vp = $stack->pop_viewport();
+
+ # Remove all viewports inside a given viewport:
+ $stack->pop_viewports_to($vp);
+
+ # Create a named stack as a tangent to an existing viewport:
+ my $newstack = $vp->create_tangent('somename');
+
+ # Resolve current events using your stack:
+ # This is called by Reaction::UI::RootController in the end action.
+ $stack->apply_events($ctx, $param_hash);
+
+=head1 DESCRIPTION
+
+A FocusStack represents a list of related L<ViewPort|Reaction::UI::ViewPort>
+objects. The L<Reaction::UI::RootController> creates an empty stack for you in
+it's begin action, which represents the main thread/container of the page.
+Typically you add new ViewPorts to this stack as the main parts of your page.
+To add multiple parallel page subparts, create a tangent from the outer
+viewport, and add more viewports as normal.
+
+=head1 METHODS
+
+=head2 new
+
+=over
+
+=item Arguments: none
+
+=back
+
+Create a new empty FocusStack. This is done for you in
+L<Reaction::UI::RootController>.
+
+=head2 push_viewport
+
+=over
+
+=item Arguments: $class, %options
+
+=back
+
+Creates a new L<Reaction::UI::ViewPort> based object and adds it to the stack.
+
+The following attributes of the new ViewPort are set:
+
+=over
+
+=item outer
+
+Is set to the preceding ViewPort in the stack.
+
+=item focus_stack
+
+Is set to the FocusStack object that created the ViewPort.
+
+=item location
+
+Is set to the location of the ViewPort in the stack.
+
+=back
+
+=head2 pop_viewport
+
+=over
+
+=item Arguments: none
+
+=back
+
+Removes the last/innermost ViewPort from the stack and returns it.
+
+=head2 pop_viewports_to
+
+=over
+
+=item Arguments: $viewport
+
+=back
+
+Pops all ViewPorts off the stack until the given ViewPort object
+remains as the last item. If passed a $viewport not on the stack, this
+will empty the stack completely (and then die complainingly).
+
+TODO: Should pop_viewports_to check $vp->focus_stack eq $self first?
+
+=head2 vp_head
+
+=over
+
+=item Arguments: none
+
+=back
+
+Retrieve the first ViewPort in this stack. Useful for calling
+L<Reaction::UI::Window/render_viewport> on a
+L<Reaction::UI::ViewPort/focus_tangent>.
+
+=head2 vp_head
+
+=over
+
+=item Arguments: none
+
+=back
+
+Retrieve the first ViewPort in this stack. Useful for calling
+L<Reaction::UI::Window/render_viewport> on a
+L<Reaction::UI::ViewPort/focus_tangent>.
+
+=head2 vp_tail
+
+=over
+
+=item Arguments: none
+
+=back
+
+Retrieve the last ViewPort in this stack. Useful for calling
+L<Reaction::UI::Window/render_viewport> on a
+L<Reaction::UI::ViewPort/focus_tangent>.
+
+=head2 vp_count
+
+=over
+
+=item Arguments: none
+
+=back
+
+=head2 loc_prefix
+
+=head2 apply_events
+
+=over
+
+=item Arguments: $ctx, $params_hashref
+
+=back
+
+Instruct each of the ViewPorts in the stack to apply the given events
+to each of it's tangent stacks, and then to itself. These are applied
+starting with the last/innermost ViewPort first.
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/LayoutSet.pm b/lib/Reaction/UI/LayoutSet.pm
new file mode 100644
index 0000000..793568f
--- /dev/null
+++ b/lib/Reaction/UI/LayoutSet.pm
@@ -0,0 +1,52 @@
+package Reaction::UI::LayoutSet;
+
+use Reaction::Class;
+use File::Spec;
+
+class LayoutSet which {
+
+ has 'fragments' => (is => 'ro', default => sub { {} });
+
+ has 'name' => (is => 'ro', required => 1);
+
+ has 'source_file' => (is => 'rw', lazy_fail => 1);
+
+ implements 'BUILD' => as {
+ my ($self, $args) = @_;
+ my @path = @{$args->{search_path}||[]};
+ confess "No search_path provided" unless @path;
+ my $found;
+ SEARCH: foreach my $path (@path) {
+ my $cand = $path->file($self->name);
+ if ($cand->stat) {
+ $self->_load_file($cand);
+ $found = 1;
+ last SEARCH;
+ }
+ }
+ confess "Unable to load file for LayoutSet ".$self->name unless $found;
+ };
+
+ implements '_load_file' => as {
+ my ($self, $file) = @_;
+ my $data = $file->slurp;
+ my $fragments = $self->fragments;
+ # cheesy match for "=for layout fragmentname ... =something"
+ # final split group also handles last in file, (?==) is lookahead
+ # assertion for '=' so "=for layout fragment1 ... =for layout fragment2"
+ # doesn't have the match pos go past the latter = and lose fragment2
+ while ($data =~ m/=for layout (.*?)\n(.+?)(?:\n(?==)|$)/sg) {
+ my ($fname, $text) = ($1, $2);
+ $fragments->{$fname} = $text;
+ }
+ $self->source_file($file);
+ };
+
+ implements 'widget_type' => as {
+ my ($self) = @_;
+ return join('', map { ucfirst($_) } split('_', $self->name));
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/LayoutSet/TT.pm b/lib/Reaction/UI/LayoutSet/TT.pm
new file mode 100644
index 0000000..72d3fad
--- /dev/null
+++ b/lib/Reaction/UI/LayoutSet/TT.pm
@@ -0,0 +1,44 @@
+package Reaction::UI::LayoutSet::TT;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::LayoutSet';
+use aliased 'Template::View';
+
+class TT is LayoutSet, which {
+
+ has 'tt_view' => (is => 'rw', isa => View, lazy_fail => 1);
+
+ implements 'BUILD' => as {
+ my ($self, $args) = @_;
+
+ # Do this at build time rather than on demand so any exception if it
+ # goes wrong gets thrown sometime sensible
+
+ $self->tt_view($self->_build_tt_view($args));
+ };
+
+ implements '_build_tt_view' => as {
+ my ($self, $args) = @_;
+ my $tt_object = $args->{tt_object}
+ || confess "tt_object not provided to new()";
+ my $tt_args = { data => {} };
+ my $name = $self->name;
+ my $fragments = $self->fragments;
+ my $tt_source = qq{[% VIEW ${name};\n\n}.
+ join("\n\n",
+ map {
+ qq{BLOCK $_; -%]\n}.$fragments->{$_}.qq{\n[% END;};
+ } keys %$fragments
+ ).qq{\nEND; # End view\ndata.view = ${name};\n %]};
+ $tt_object->process(\$tt_source, $tt_args)
+ || confess "Template processing error: ".$tt_object->error
+ ." processing:\n${tt_source}";
+ confess "View template processed but no view object found"
+ ." after processing:\n${tt_source}"
+ unless $tt_args->{data}{view};
+ return $tt_args->{data}{view};
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/Renderer/XHTML.pm b/lib/Reaction/UI/Renderer/XHTML.pm
new file mode 100644
index 0000000..af98521
--- /dev/null
+++ b/lib/Reaction/UI/Renderer/XHTML.pm
@@ -0,0 +1,89 @@
+package Reaction::UI::Renderer::XHTML;
+
+use strict;
+use base qw/Catalyst::View::TT Reaction::Object/;
+use Reaction::Class;
+
+use HTML::Entities;
+
+__PACKAGE__->config({
+ CATALYST_VAR => 'ctx',
+ RECURSION => 1,
+});
+
+sub render_window {
+ my ($self, $window) = @_;
+ my $root_vp = $window->focus_stack->vp_head;
+ confess "Can't flush view for window with empty focus stack"
+ unless defined($root_vp);
+ $self->render_viewport($window, $root_vp);
+}
+
+sub render_viewport {
+ my ($self, $window, $vp) = @_;
+ my $ctx = $window->ctx;
+ my %args = (
+ self => $vp,
+ ctx => $ctx,
+ window => $window,
+ type => $vp->layout
+ );
+ unless (length $args{type}) {
+ my $type = (split('::', ref($vp)))[-1];
+ $args{type} = lc($type);
+ }
+ return $self->render($ctx, 'component', \%args);
+}
+
+around 'render' => sub {
+ my $super = shift;
+ my ($self,$args) = @_[0,3];
+ local $self->template->{SERVICE}{CONTEXT}{BLKSTACK};
+ local $self->template->{SERVICE}{CONTEXT}{BLOCKS};
+ $args->{process_attrs} = \&process_attrs;
+ return $super->(@_);
+};
+
+sub process_attrs{
+ my $attrs = shift;
+ return $attrs unless ref $attrs eq 'HASH';
+
+ my @processed_attrs;
+ while( my($k,$v) = each(%$attrs) ){
+ my $enc_v = $v;
+ next if ($enc_v eq "");
+ if ($k eq 'class' && ref $v eq 'ARRAY'){
+ $enc_v = join ' ', map { encode_entities($_) } @$v;
+ } elsif ($k eq 'style' && ref $v eq 'HASH'){
+ $enc_v = join '; ', map{ "${_}: ".encode_entities($v->{$_}) } keys %{$v};
+ }
+ push(@processed_attrs, "${k}=\"${enc_v}\"");
+ }
+
+ return ' '.join ' ', @processed_attrs if (scalar(@processed_attrs) > 0);
+ return;
+}
+
+1;
+
+=head1 NAME
+
+Reaction::UI::Renderer::XHTML
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=head2 render
+
+=head2 process_attrs
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/RenderingContext.pm b/lib/Reaction/UI/RenderingContext.pm
new file mode 100644
index 0000000..1c990b9
--- /dev/null
+++ b/lib/Reaction/UI/RenderingContext.pm
@@ -0,0 +1,13 @@
+package Reaction::UI::RenderingContext;
+
+use Reaction::Class;
+
+class RenderingContext which {
+
+ implements 'render' => as {
+ confess "abstract method";
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/RenderingContext/TT.pm b/lib/Reaction/UI/RenderingContext/TT.pm
new file mode 100644
index 0000000..07c700b
--- /dev/null
+++ b/lib/Reaction/UI/RenderingContext/TT.pm
@@ -0,0 +1,91 @@
+package Reaction::UI::RenderingContext::TT;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::RenderingContext';
+use aliased 'Template::View';
+
+class TT is RenderingContext, which {
+
+ has 'tt_view' => ( is => 'ro', required => 1, isa => View);
+
+ has 'iter_class' => (
+ is => 'ro', required => 1,
+ default => sub { 'Reaction::UI::Renderer::TT::Iter'; },
+ );
+
+ implements 'render' => as {
+ my ($self, $fname, $args) = @_;
+
+ # foreach non-_ prefixed key in the args
+ # build a subref for this key that passes self so the generator has a
+ # rendering context when [% key %] is evaluated by TT as $val->()
+ # (assuming it's a subref - if not just pass through)
+
+ my $tt_args = {
+ map {
+ my $arg = $args->{$_};
+ ($_ => (ref $arg eq 'CODE' ? sub { $arg->($self) } : $arg))
+ } grep { !/^_/ } keys %$args
+ };
+
+ # if there's an _ key that's our current topic (decalarative syntax
+ # sees $_ as $_{_}) so build an iterator around it.
+
+ # There's possibly a case for making everything an iterator but I think
+ # any fragment should only have a single multiple arg
+
+ # we also create a 'pos' shortcut to content.pos for brevity
+
+ if (my $topic = $args->{_}) {
+ my $iter = $self->iter_class->new(
+ $topic, $self
+ );
+ $tt_args->{content} = $iter;
+ $tt_args->{pos} = sub { $iter->pos };
+ }
+ $self->tt_view->include($fname, $tt_args);
+ };
+
+};
+
+package Reaction::UI::Renderer::TT::Iter;
+
+use overload (
+ q{""} => 'stringify',
+ fallback => 1
+);
+
+sub pos { shift->{pos} }
+
+sub new {
+ my ($class, $cr, $rctx) = @_;
+ bless({ rctx => $rctx, cr => $cr, pos => 0 }, $class);
+}
+
+sub next {
+ my $self = shift;
+ $self->{pos}++;
+ my $next = $self->{cr}->();
+ return unless $next;
+ return sub { $next->($self->{rctx}) };
+}
+
+sub all {
+ my $self = shift;
+ my @all;
+ while (my $e = $self->next) {
+ push(@all, $e);
+ }
+ \@all;
+}
+
+sub stringify {
+ my $self = shift;
+ my $res = '';
+ foreach my $e (@{$self->all}) {
+ $res .= $e->();
+ }
+ $res;
+}
+
+1;
diff --git a/lib/Reaction/UI/RootController.pm b/lib/Reaction/UI/RootController.pm
new file mode 100644
index 0000000..89f1a0f
--- /dev/null
+++ b/lib/Reaction/UI/RootController.pm
@@ -0,0 +1,97 @@
+package Reaction::UI::RootController;
+
+use base qw/Reaction::UI::Controller/;
+use Reaction::Class;
+use Reaction::UI::Window;
+
+__PACKAGE__->config(
+ view_name => 'XHTML',
+ content_type => 'text/html',
+);
+
+has 'view_name' => (isa => 'Str', is => 'rw');
+has 'content_type' => (isa => 'Str', is => 'rw');
+has 'window_title' => (isa => 'Str', is => 'rw');
+
+sub begin :Private {
+ my ($self, $ctx) = @_;
+ my $window :Stashed = Reaction::UI::Window->new(
+ ctx => $ctx,
+ view_name => $self->view_name,
+ content_type => $self->content_type,
+ title => $self->window_title,
+ );
+ my $focus_stack :Stashed = $window->focus_stack;
+}
+
+sub end :Private {
+ my $window :Stashed;
+ $window->flush;
+}
+
+1;
+
+=head1 NAME
+
+Reaction::UI::RootController - Base component for the Root Controller
+
+=head1 SYNOPSIS
+
+ package MyApp::Controller::Root;
+ use base 'Reaction::UI::RootController';
+
+ # Create UI elements:
+ $c->stash->{focus_stack}->push_viewport('Reaction::UI::ViewPort');
+
+ # Access the window title in a template:
+ [% window.title %]
+
+=head1 DESCRIPTION
+
+Using this module as a base component for your L<Catalyst> Root
+Controller provides automatic creation of a L<Reaction::UI::Window>
+object containing an empty L<Reaction::UI::FocusStack> for your UI
+elements. The stack is also resolved and rendered for you in the
+C<end> action.
+
+=head1 METHODS
+
+=head2 view_name
+
+=over
+
+=item Arguments: $viewname?
+
+=back
+
+Set or retrieve the classname of the view used to render the UI.
+
+=head2 content_type
+
+=over
+
+=item Arguments: $contenttype?
+
+=back
+
+Set or retrieve the content type of the page created.
+
+=head2 window_title
+
+=over
+
+=item Arguments: $windowtitle?
+
+=back
+
+Set or retrieve the title of the page created.
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/View.pm b/lib/Reaction/UI/View.pm
new file mode 100644
index 0000000..4fc40c6
--- /dev/null
+++ b/lib/Reaction/UI/View.pm
@@ -0,0 +1,133 @@
+package Reaction::UI::View;
+
+use Reaction::Class;
+
+# declaring dependencies
+
+use Reaction::UI::LayoutSet;
+use Reaction::UI::RenderingContext;
+
+class View which {
+
+ has '_layout_set_cache' => (is => 'ro', default => sub { {} });
+
+ has 'app' => (is => 'ro', required => 1);
+
+ has 'skin_name' => (is => 'ro', required => 1);
+
+ has 'layout_set_class' => (is => 'ro', lazy_build => 1);
+
+ has 'rendering_context_class' => (is => 'ro', lazy_build => 1);
+
+ implements 'COMPONENT' => as {
+ my ($class, $app, $args) = @_;
+ return $class->new(%{$args||{}}, app => $app);
+ };
+
+ implements 'render_window' => as {
+ my ($self, $window) = @_;
+ my $root_vp = $window->focus_stack->vp_head;
+ $self->render_viewport(undef, $root_vp);
+ };
+
+ implements 'render_viewport' => as {
+ my ($self, $outer_rctx, $vp) = @_;
+ my $layout_set = $self->layout_set_for($vp);
+ my $rctx = $self->create_rendering_context(
+ layouts => $layout_set,
+ outer => $outer_rctx,
+ );
+ my $widget = $self->widget_for($vp, $layout_set);
+ $widget->render($rctx);
+ };
+
+ implements 'widget_for' => as {
+ my ($self, $vp, $layout_set) = @_;
+ return $self->widget_class_for($layout_set)
+ ->new(view => $self, viewport => $vp);
+ };
+
+ implements 'widget_class_for' => as {
+ my ($self, $layout_set) = @_;
+ my $base = ref($self);
+ my $tail = $layout_set->widget_type;
+ my $class = join('::', $base, 'Widget', $tail);
+ Class::MOP::load_class($class);
+ return $class;
+ };
+
+ implements 'layout_set_for' => as {
+ my ($self, $vp) = @_;
+ my $lset_name = eval { $vp->layout };
+ confess "Couldn't call layout method on \$vp arg ${vp}: $@" if $@;
+ unless (length($lset_name)) {
+ my $last = (split('::',ref($vp)))[-1];
+ $lset_name = join('_', map { lc($_) } split(/(?=[A-Z])/, $last));
+ }
+ my $cache = $self->_layout_set_cache;
+ return $cache->{$lset_name} ||= $self->create_layout_set($lset_name);
+ };
+
+ implements 'create_layout_set' => as {
+ my ($self, $name) = @_;
+ return $self->layout_set_class->new(
+ $self->layout_set_args_for($name),
+ );
+ };
+
+ implements 'find_related_class' => as {
+ my ($self, $rel) = @_;
+ my $own_class = ref($self)||$self;
+ confess View." is abstract, you must subclass it" if $own_class eq View;
+ foreach my $super ($own_class->meta->class_precedence_list) {
+ next if $super eq View;
+ if ($super =~ /::View::/) {
+ (my $class = $super) =~ s/::View::/::${rel}::/;
+ if (eval { Class::MOP::load_class($class) }) {
+ return $class;
+ }
+ }
+ }
+ confess "Unable to find related ${rel} class for ${own_class}";
+ };
+
+ implements 'build_layout_set_class' => as {
+ my ($self) = @_;
+ return $self->find_related_class('LayoutSet');
+ };
+
+ implements 'layout_set_args_for' => as {
+ my ($self, $name) = @_;
+ return (name => $name, search_path => $self->layout_search_path);
+ };
+
+ implements 'layout_search_path' => as {
+ my ($self) = @_;
+ return $self->search_path_for_type('layout');
+ };
+
+ implements 'search_path_for_type' => as {
+ my ($self, $type) = @_;
+ return [ $self->app->path_to('share','skin',$self->skin_name,$type) ];
+ };
+
+ implements 'create_rendering_context' => as {
+ my ($self, @args) = @_;
+ return $self->rendering_context_class->new(
+ $self->rendering_context_args_for(@args),
+ @args,
+ );
+ };
+
+ implements 'build_rendering_context_class' => as {
+ my ($self) = @_;
+ return $self->find_related_class('RenderingContext');
+ };
+
+ implements 'rendering_context_args_for' => as {
+ return ();
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/View/TT.pm b/lib/Reaction/UI/View/TT.pm
new file mode 100644
index 0000000..d57b522
--- /dev/null
+++ b/lib/Reaction/UI/View/TT.pm
@@ -0,0 +1,41 @@
+package Reaction::UI::View::TT;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::View';
+use Template;
+
+class TT is View, which {
+
+ has '_tt' => (isa => 'Template', is => 'rw', lazy_fail => 1);
+
+ implements 'BUILD' => as {
+ my ($self, $args) = @_;
+ my $tt_args = $args->{tt}||{};
+ $self->_tt(Template->new($tt_args));
+ };
+
+ overrides 'layout_set_args_for' => sub {
+ my ($self) = @_;
+ return (super(), tt_object => $self->_tt);
+ };
+
+ overrides 'rendering_context_args_for' => sub {
+ my ($self, %args) = @_;
+ return (super(), tt_view => $args{layouts}->tt_view);
+ };
+
+ implements 'serve_static_file' => as {
+ my ($self, $c, $args) = @_;
+ foreach my $path (@{$self->search_path_for_type('web')}) {
+ my $cand = $path->file(@$args);
+ if ($cand->stat) {
+ $c->serve_static_file($cand);
+ return 1;
+ }
+ }
+ return 0;
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort.pm b/lib/Reaction/UI/ViewPort.pm
new file mode 100644
index 0000000..4c5ac5a
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort.pm
@@ -0,0 +1,389 @@
+package Reaction::UI::ViewPort;
+
+use Reaction::Class;
+
+class ViewPort which {
+
+ has location => (isa => 'Str', is => 'rw', required => 1);
+ has layout => (isa => 'Str', is => 'rw', lazy_build => 1);
+ has outer => (isa => 'Reaction::UI::ViewPort', is => 'rw', weak_ref => 1);
+ has inner => (isa => 'Reaction::UI::ViewPort', is => 'rw');
+ has focus_stack => (
+ isa => 'Reaction::UI::FocusStack', is => 'rw', weak_ref => 1
+ );
+ has _tangent_stacks => (
+ isa => 'HashRef', is => 'ro', default => sub { {} }
+ );
+ has ctx => (isa => 'Catalyst', is => 'ro', required => 1);
+ has column_order => (is => 'rw');
+
+ implements build_layout => as {
+ '';
+ };
+
+ implements create_tangent => as {
+ my ($self, $name) = @_;
+ my $t_map = $self->_tangent_stacks;
+ if (exists $t_map->{$name}) {
+ confess "Can't create tangent with already existing name ${name}";
+ }
+ my $loc = join('.', $self->location, $name);
+ my $tangent = Reaction::UI::FocusStack->new(loc_prefix => $loc);
+ $t_map->{$name} = $tangent;
+ return $tangent;
+ };
+
+ implements focus_tangent => as {
+ my ($self, $name) = @_;
+ if (my $tangent = $self->_tangent_stacks->{$name}) {
+ return $tangent;
+ } else {
+ return;
+ }
+ };
+
+ implements focus_tangents => as {
+ return keys %{shift->_tangent_stacks};
+ };
+
+ implements child_event_sinks => as {
+ my $self = shift;
+ return values %{$self->_tangent_stacks};
+ };
+
+ implements apply_events => as {
+ my ($self, $ctx, $events) = @_;
+ $self->apply_child_events($ctx, $events);
+ $self->apply_our_events($ctx, $events);
+ };
+
+ implements apply_child_events => as {
+ my ($self, $ctx, $events) = @_;
+ foreach my $child ($self->child_event_sinks) {
+ $child->apply_events($ctx, $events);
+ }
+ };
+
+ implements apply_our_events => as {
+ my ($self, $ctx, $events) = @_;
+ my $loc = $self->location;
+ my %our_events;
+ foreach my $key (keys %$events) {
+ if ($key =~ m/^${loc}:(.*)$/) {
+ $our_events{$1} = $events->{$key};
+ }
+ }
+ if (keys %our_events) {
+ #warn "$self: events ".join(', ', %our_events)."\n";
+ $self->handle_events(\%our_events);
+ }
+ };
+
+ implements handle_events => as {
+ my ($self, $events) = @_;
+ foreach my $event ($self->accept_events) {
+ if (exists $events->{$event}) {
+ $self->$event($events->{$event});
+ }
+ }
+ };
+
+ implements accept_events => as { () };
+
+ implements event_id_for => as {
+ my ($self, $name) = @_;
+ return join(':', $self->location, $name);
+ };
+
+ implements sort_by_spec => as {
+ my ($self, $spec, $items) = @_;
+ return $items if not defined $spec;
+
+ my @order;
+ if (ref $spec eq 'ARRAY') {
+ @order = @$spec;
+ }
+ elsif (not ref $spec) {
+ return $items unless length $spec;
+ @order = split /\s+/, $spec;
+ }
+
+ my %order_map = map {$_ => 0} @$items;
+ for my $order_num (0..$#order) {
+ $order_map{ $order[$order_num] } = ($#order - $order_num) + 1;
+ }
+
+ return [sort {$order_map{$b} <=> $order_map{$a}} @$items];
+ };
+
+};
+
+1;
+
+
+=head1 NAME
+
+Reaction::UI::ViewPort - Page layout building block
+
+=head1 SYNOPSIS
+
+ # Create a new ViewPort:
+ # $stack isa Reaction::UI::FocusStack object
+ my $vp = $stack->push_viewport('Reaction::UI::ViewPort', layout => 'xthml');
+
+ # Fetch ViewPort higher up the stack (further out)
+ my $outer = $vp->outer();
+
+ # Fetch ViewPort lower down (further in)
+ my $inner = $vp->inner();
+
+ # Create a named tangent stack for this ViewPort
+ my $substack = $vp->create_tangent('name');
+
+ # Retrieve a tangent stack for this ViewPort
+ my $substack = $vp->forcus_tangent('name');
+
+ # Get the names of all the tangent stacks for this ViewPort
+ my @names = $vp->focus_tangents();
+
+ # Fetch all the tangent stacks for this ViewPort
+ # This is called by apply_events
+ my $stacks = $vp->child_event_sinks();
+
+
+ ### The following methods are all called automatically when using
+ ### Reaction::UI::Controller(s)
+ # Resolve current events with this ViewPort
+ $vp->apply_events($ctx, $param_hash);
+
+ # Apply current events to all tangent stacks
+ # This is called by apply_events
+ $vp->apply_child_events($ctx, $params_hash);
+
+ # Apply current events to this ViewPort
+ # This is called by apply_events
+ $vp->apply_our_events($ctx, $params_hash);
+
+=head1 DESCRIPTION
+
+A ViewPort describes part of a page, it can be a field, a form or
+an entire page. ViewPorts are created on a
+L<Reaction::UI::FocusStack>, usually belonging to a controller or
+another ViewPort. Each ViewPort knows it's own position in the stack
+it is in, as well as the stack containing it.
+
+Each ViewPort has a specific location in the heirarchy of viewports
+making up a page. The hierarchy is determined as follows: The first
+ViewPort in a stack is labeled C<0>, the second is C<1> and so on. If
+a ViewPort is in a named tangent, it's location will contain the name
+of the tangent in it's location.
+
+For example, the first ViewPort in the 'left' tangent of the main
+ViewPort has location C<0.left.0>.
+
+Several ViewPort attributes are set by
+L<Reaction::UI::FocusStack/push_viewport> when new ViewPorts are
+created, these are as follows:
+
+=over
+
+=item Automatic:
+
+=over
+
+=item outer
+
+The outer attribute is set to the previous ViewPort in the stack when
+creating a ViewPort, if the ViewPort is the first in the stack, it
+will be undef.
+
+=item inner
+
+The inner attribute is set to the next ViewPort down in the stack when
+it is created, if this is the last ViewPort in the stack, it will be
+undef.
+
+=item focus_stack
+
+The focus_stack attribute is set to the L<Reaction::UI::FocusStack>
+object that created the ViewPort.
+
+=item ctx
+
+The ctx attribute will be passed automatically when using
+L<Reaction::UI::Controller/push_viewport> to create a ViewPort in the
+base stack of a controller. When creating tangent stacks, you may have
+to pass it in yourself.
+
+=back
+
+=item Optional:
+
+=over
+
+=item location
+
+=item layout
+
+The layout attribute can either be specifically passed when calling
+C<push_viewport>, or it will be determined using the last part of the
+ViewPorts classname.
+
+=item column_order
+
+This is generally used by more specialised ViewPorts such as the
+L<ListView|Reaction::UI::ViewPort::ListView> or
+L<ActionForm|Reaction::UI::ViewPort::ActionForm>. It can be either a
+space separated list of column names, or an arrayref of column names.
+
+=back
+
+=back
+
+=head1 METHODS
+
+=head2 outer
+
+=over
+
+=item Arguments: none
+
+=back
+
+Fetch the ViewPort outside this one in the page hierarchy.
+
+=head2 inner
+
+=over
+
+=item Arguments: none
+
+=back
+
+Fetch the ViewPort inside this one in the page hierarchy.
+
+=head2 create_tangent
+
+=over
+
+=item Arguments: $tangent_name
+
+=back
+
+Create a new named L<Reaction::UI::FocusStack> inside this
+ViewPort. The created FocusStack is returned.
+
+=head2 focus_tangent
+
+=over
+
+=item Arguments: $tangent_name
+
+=back
+
+Fetch a named FocusStack from this ViewPort.
+
+=head2 focus_tangents
+
+=over
+
+=item Arguments: none
+
+=back
+
+Returns a list of names of all the known tangents in this ViewPort.
+
+=head2 focus_stack
+
+Return the L<Reaction::UI::FocusStack> object that this ViewPort is in.
+
+=head2 apply_events
+
+=over
+
+=item Arguments: $ctx, $params_hashref
+
+=back
+
+This method is called by the FocusStack object to resolve all events
+for the ViewPort.
+
+=head2 apply_child_events
+
+=over
+
+=item Arguments: $ctx, $params_hashref
+
+=back
+
+Resolve the given events for all the tangents of this ViewPort. Called
+by L<apply_events>.
+
+=head2 apply_our_events
+
+=over
+
+=item Arguments: $ctx, $events
+
+=back
+
+Resolve the given events that match the location of this
+ViewPort. Called by L<apply_events>.
+
+=head2 handle_events
+
+=over
+
+=item Arguments: $events
+
+=back
+
+Actually call the event handlers for this ViewPort. Called by
+L<apply_our_events>. By default this will do nothing, subclass
+ViewPort and implement L<accept_events>.
+
+=head2 accept_events
+
+=over
+
+=item Arguments: none
+
+=back
+
+Implement this method in a subclass and return a list of events that
+your ViewPort is accepting.
+
+=head2 event_id_for
+
+=over
+
+=item Arguments: $name
+
+=back
+
+Create an id for the given event name and this ViewPort. Generally
+returns the location and the name, joined with a colon.
+
+=head2 sort_by_spec
+
+=over
+
+=item Arguments: $spec, $items
+
+=back
+
+Sorts the given list of items such that the ones that also appear in
+the spec are at the beginning. This is called by
+L<Reaction::UI::ViewPort::ActionForm> and
+L<Reaction::UI::ViewPort::ListView>, and gets passed L<column_order>
+as the spec argument.
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/ActionForm.pm b/lib/Reaction/UI/ViewPort/ActionForm.pm
new file mode 100644
index 0000000..0a413db
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/ActionForm.pm
@@ -0,0 +1,400 @@
+package Reaction::UI::ViewPort::ActionForm;
+
+use Reaction::Class;
+
+use aliased 'Reaction::UI::ViewPort::Field::Text';
+use aliased 'Reaction::UI::ViewPort::Field::Number';
+use aliased 'Reaction::UI::ViewPort::Field::Boolean';
+use aliased 'Reaction::UI::ViewPort::Field::File';
+use aliased 'Reaction::UI::ViewPort::Field::String';
+use aliased 'Reaction::UI::ViewPort::Field::Password';
+use aliased 'Reaction::UI::ViewPort::Field::DateTime';
+use aliased 'Reaction::UI::ViewPort::Field::ChooseOne';
+use aliased 'Reaction::UI::ViewPort::Field::ChooseMany';
+use aliased 'Reaction::UI::ViewPort::Field::HiddenArray';
+use aliased 'Reaction::UI::ViewPort::Field::TimeRange';
+
+class ActionForm is 'Reaction::UI::ViewPort', which {
+ has action => (
+ isa => 'Reaction::InterfaceModel::Action', is => 'ro', required => 1
+ );
+
+ has field_names => (isa => 'ArrayRef', is => 'rw', lazy_build => 1);
+
+ has _field_map => (
+ isa => 'HashRef', is => 'rw', init_arg => 'fields',
+ predicate => '_has_field_map', set_or_lazy_build('field_map'),
+ );
+
+ has changed => (
+ isa => 'Int', is => 'rw', reader => 'is_changed', default => sub { 0 }
+ );
+
+ has next_action => (
+ isa => 'ArrayRef', is => 'rw', required => 0, predicate => 'has_next_action'
+ );
+
+ has on_apply_callback => (
+ isa => 'CodeRef', is => 'rw', required => 0,
+ predicate => 'has_on_apply_callback'
+ );
+
+ has ok_label => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { 'ok' }
+ );
+
+ has apply_label => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { 'apply' }
+ );
+
+ has close_label => (isa => 'Str', is => 'rw', lazy_fail => 1);
+
+ has close_label_close => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { 'close' }
+ );
+
+ has close_label_cancel => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { 'cancel' }
+ );
+
+ sub fields { shift->_field_map }
+
+ implements BUILD => as {
+ my ($self, $args) = @_;
+ unless ($self->_has_field_map) {
+ my @field_map;
+ my $action = $self->action;
+ foreach my $attr ($action->parameter_attributes) {
+ push(@field_map, $self->build_fields_for($attr => $args));
+ }
+
+ my %field_map = @field_map;
+ my @field_names = @{ $self->sort_by_spec(
+ $args->{column_order}, [keys %field_map] )};
+
+ $self->_field_map(\%field_map);
+ $self->field_names(\@field_names);
+ }
+ $self->close_label($self->close_label_close);
+ };
+
+ implements build_fields_for => as {
+ my ($self, $attr, $args) = @_;
+ my $attr_name = $attr->name;
+ #TODO: DOCUMENT ME!!!!!!!!!!!!!!!!!
+ my $builder = "build_fields_for_name_${attr_name}";
+ my @fields;
+ if ($self->can($builder)) {
+ @fields = $self->$builder($attr, $args); # re-use coderef from can()
+ } elsif ($attr->has_type_constraint) {
+ my $constraint = $attr->type_constraint;
+ my $base_name = $constraint->name;
+ my $tried_isa = 0;
+ CONSTRAINT: while (defined($constraint)) {
+ my $name = $constraint->name;
+ if (eval { $name->can('meta') } && !$tried_isa++) {
+ foreach my $class ($name->meta->class_precedence_list) {
+ my $mangled_name = $class;
+ $mangled_name =~ s/:+/_/g;
+ my $builder = "build_fields_for_type_${mangled_name}";
+ if ($self->can($builder)) {
+ @fields = $self->$builder($attr, $args);
+ last CONSTRAINT;
+ }
+ }
+ }
+ if (defined($name)) {
+ unless (defined($base_name)) {
+ $base_name = "(anon subtype of ${name})";
+ }
+ my $mangled_name = $name;
+ $mangled_name =~ s/:+/_/g;
+ my $builder = "build_fields_for_type_${mangled_name}";
+ if ($self->can($builder)) {
+ @fields = $self->$builder($attr, $args);
+ last CONSTRAINT;
+ }
+ }
+ $constraint = $constraint->parent;
+ }
+ if (!defined($constraint)) {
+ confess "Can't build field ${attr_name} of type ${base_name} without $builder method or build_fields_for_type_<type> method for type or any supertype";
+ }
+ } else {
+ confess "Can't build field ${attr} without $builder method or type constraint";
+ }
+ return @fields;
+ };
+
+ implements build_field_map => as {
+ confess "Lazy field map building not supported by default";
+ };
+
+ implements can_apply => as {
+ my ($self) = @_;
+ foreach my $field (values %{$self->_field_map}) {
+ return 0 if $field->needs_sync;
+ # if e.g. a datetime field has an invalid value that can't be re-assembled
+ # into a datetime object, the action may be in a consistent state but
+ # not synchronized from the fields; in this case, we must not apply
+ }
+ return $self->action->can_apply;
+ };
+
+ implements do_apply => as {
+ my $self = shift;
+ return $self->action->do_apply;
+ };
+
+ implements ok => as {
+ my $self = shift;
+ if ($self->apply(@_)) {
+ $self->close(@_);
+ }
+ };
+
+ implements apply => as {
+ my $self = shift;
+ if ($self->can_apply && (my $result = $self->do_apply)) {
+ $self->changed(0);
+ $self->close_label($self->close_label_close);
+ if ($self->has_on_apply_callback) {
+ $self->on_apply_callback->($self => $result);
+ }
+ return 1;
+ } else {
+ $self->changed(1);
+ $self->close_label($self->close_label_cancel);
+ return 0;
+ }
+ };
+
+ implements close => as {
+ my $self = shift;
+ my ($controller, $name, @args) = @{$self->next_action};
+ $controller->pop_viewport;
+ $controller->$name($self->action->ctx, @args);
+ };
+
+ sub can_close { 1 }
+
+ override accept_events => sub {
+ (($_[0]->has_next_action ? ('ok', 'close') : ()), 'apply', super());
+ }; # can't do a close-type operation if there's nowhere to go afterwards
+
+ override child_event_sinks => sub {
+ my ($self) = @_;
+ return ((grep { ref($_) =~ 'Hidden' } values %{$self->_field_map}),
+ (grep { ref($_) !~ 'Hidden' } values %{$self->_field_map}),
+ super());
+ };
+
+ after apply_child_events => sub {
+ # interrupt here because fields will have been updated
+ my ($self) = @_;
+ $self->sync_action_from_fields;
+ };
+
+ implements sync_action_from_fields => as {
+ my ($self) = @_;
+ my $field_map = $self->_field_map;
+ my @fields = values %{$field_map};
+ foreach my $field (@fields) {
+ $field->sync_to_action; # get the field to populate the $action if possible
+ }
+ $self->action->sync_all;
+ foreach my $field (@fields) {
+ $field->sync_from_action; # get errors from $action if applicable
+ }
+ };
+
+ implements build_simple_field => as {
+ my ($self, $class, $attr, $args) = @_;
+ my $attr_name = $attr->name;
+ my %extra;
+ if (my $config = $args->{Field}{$attr_name}) {
+ %extra = %$config;
+ }
+ my $field = $class->new(
+ action => $self->action,
+ attribute => $attr,
+ name => $attr->name,
+ location => join('-', $self->location, 'field', $attr->name),
+ ctx => $self->ctx,
+ %extra
+ );
+ return ($attr_name => $field);
+ };
+
+ implements build_fields_for_type_Num => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Number, $attr, $args);
+ };
+
+ implements build_fields_for_type_Int => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Number, $attr, $args);
+ };
+
+ implements build_fields_for_type_Bool => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Boolean, $attr, $args);
+ };
+
+ implements build_fields_for_type_File => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(File, $attr, $args);
+ };
+
+ implements build_fields_for_type_Str => as {
+ my ($self, $attr, $args) = @_;
+ if ($attr->has_valid_values) { # There's probably a better way to do this
+ return $self->build_simple_field(ChooseOne, $attr, $args);
+ }
+ return $self->build_simple_field(Text, $attr, $args);
+ };
+
+ implements build_fields_for_type_SimpleStr => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(String, $attr, $args);
+ };
+
+ implements build_fields_for_type_Password => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Password, $attr, $args);
+ };
+
+ implements build_fields_for_type_DateTime => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(DateTime, $attr, $args);
+ };
+
+ implements build_fields_for_type_Enum => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(ChooseOne, $attr, $args);
+ };
+
+ implements build_fields_for_type_DBIx_Class_Row => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(ChooseOne, $attr, $args);
+ };
+
+ implements build_fields_for_type_ArrayRef => as {
+ my ($self, $attr, $args) = @_;
+ if ($attr->has_valid_values) {
+ return $self->build_simple_field(ChooseMany, $attr, $args)
+ } else {
+ return $self->build_simple_field(HiddenArray, $attr, $args)
+ }
+ };
+
+ implements build_fields_for_type_DateTime_Spanset => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(TimeRange, $attr, $args);
+ };
+
+ no Moose;
+
+ no strict 'refs';
+ delete ${__PACKAGE__ . '::'}{inner};
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::ActionForm
+
+=head1 SYNOPSIS
+
+ use aliased 'Reaction::UI::ViewPort::ActionForm';
+
+ $self->push_viewport(ActionForm,
+ layout => 'register',
+ action => $action,
+ next_action => [ $self, 'redirect_to', 'accounts', $c->req->captures ],
+ ctx => $c,
+ column_order => [
+ qw / contact_title company_name email address1 address2 address3
+ city country post_code telephone mobile fax/ ],
+ );
+
+=head1 DESCRIPTION
+
+This subclass of viewport is used for rendering a collection of
+L<Reaction::UI::ViewPort::Field> objects for user editing.
+
+=head1 ATTRIBUTES
+
+=head2 action
+
+L<Reaction::InterfaceModel::Action>
+
+=head2 ok_label
+
+Default: 'ok'
+
+=head2 apply_label
+
+Default: 'apply'
+
+=head2 close_label_close
+
+Default: 'close'
+
+=head2 close_label_cancel
+
+This label is only shown when C<changed> is true.
+
+Default: 'cancel'
+
+=head2 fields
+
+=head2 field_names
+
+Returns: Arrayref of field names.
+
+=head2 can_apply
+
+=head2 can_close
+
+=head2 changed
+
+Returns true if a field has been edited.
+
+=head2 next_action
+
+=head2 on_apply_callback
+
+CodeRef.
+
+=head1 METHODS
+
+=head2 ok
+
+Calls C<apply>, and then C<close> if successful.
+
+=head2 close
+
+Pop viewport and proceed to C<next_action>.
+
+=head2 apply
+
+Attempt to save changes and update C<changed> attribute if required.
+
+=head1 SEE ALSO
+
+L<Reaction::UI::ViewPort>
+
+L<Reaction::InterfaceModel::Action>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/DisplayField.pm b/lib/Reaction/UI/ViewPort/DisplayField.pm
new file mode 100644
index 0000000..9f9f727
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField.pm
@@ -0,0 +1,90 @@
+package Reaction::UI::ViewPort::DisplayField;
+
+use Reaction::Class;
+
+class DisplayField is 'Reaction::UI::ViewPort', which {
+
+ has name => (
+ isa => 'Str', is => 'rw', required => 1
+ );
+
+ has object => (
+ isa => 'Reaction::InterfaceModel::Object',
+ is => 'ro', required => 0, predicate => 'has_object',
+ );
+
+ has attribute => (
+ isa => 'Reaction::Meta::InterfaceModel::Object::ParameterAttribute',
+ is => 'ro', predicate => 'has_attribute',
+ );
+
+ has value => (
+ is => 'rw', lazy_build => 1, trigger_adopt('value'),
+ clearer => 'clear_value',
+ );
+
+ has label => (isa => 'Str', is => 'rw', lazy_build => 1);
+
+ implements BUILD => as {
+ my ($self) = @_;
+ if (!$self->has_attribute != !$self->has_object) {
+ confess "Should have both object and attribute or neither"; }
+ };
+
+ implements build_label => as {
+ my ($self) = @_;
+ return join(' ', map { ucfirst } split('_', $self->name));
+ };
+
+ implements build_value => as {
+ my ($self) = @_;
+ if ($self->has_attribute) {
+ my $reader = $self->attribute->get_read_method;
+ return $self->object->$reader;
+ }
+ return '';
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::DisplayField
+
+=head1 DESCRIPTION
+
+Base class for displaying non user-editable fields.
+
+=head1 ATTRIBUTES
+
+=head2 name
+
+=head2 object
+
+L<Reaction::InterfaceModel::Object>
+
+=head2 attribute
+
+L<Reaction::Meta::InterfaceModel::Object::ParameterAttribute>
+
+=head2 value
+
+=head2 label
+
+User friendly label, by default is based on the name.
+
+=head1 SEE ALSO
+
+L<Reaction::UI::ViewPort>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/Boolean.pm b/lib/Reaction/UI/ViewPort/DisplayField/Boolean.pm
new file mode 100644
index 0000000..9389436
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/Boolean.pm
@@ -0,0 +1,31 @@
+package Reaction::UI::ViewPort::DisplayField::Boolean;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::ViewPort::DisplayField';
+
+class Boolean, is DisplayField, which {
+ has '+value' => (isa => 'Bool');
+ has '+layout' => (default => 'displayfield/value_string');
+
+ has value_string => (isa => 'Str', is => 'rw', lazy_build => 1);
+
+ has value_string_format =>
+ (isa => 'HashRef', is => 'rw', required => 1,
+ default => sub { {true => 'Yes', false => 'No'} }
+ );
+
+ implements build_value_string => as {
+ my $self = shift;
+ my $val = $self->value;
+ if(!defined $val || $val eq "" || "$val" eq '0'){
+ return $self->value_string_format->{false};
+ } elsif("$val" eq '1'){
+ return $self->value_string_format->{true};
+ } else{ #this will hopefully never happen
+ confess "Not supporting some type of Bool value";
+ }
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/Collection.pm b/lib/Reaction/UI/ViewPort/DisplayField/Collection.pm
new file mode 100644
index 0000000..0c06d4b
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/Collection.pm
@@ -0,0 +1,29 @@
+package Reaction::UI::ViewPort::DisplayField::Collection;
+
+use Reaction::Class;
+use Scalar::Util 'blessed';
+
+class Collection is 'Reaction::UI::ViewPort::DisplayField', which {
+ has '+value' => (isa => 'ArrayRef');
+ has '+layout' => (default => 'displayfield/list');
+
+ has value_names => (isa => 'ArrayRef', is => 'ro', lazy_build => 1);
+
+ has value_map_method => (
+ isa => 'Str', is => 'ro', required => 1, default => sub { 'display_name' },
+ );
+
+ override build_value => sub {
+ return [super()->all];
+ };
+
+ implements build_value_names => as {
+ my $self = shift;
+ my @all = @{$self->value||[]};
+ my $meth = $self->value_map_method;
+ my @names = map { blessed $_ ? $_->$meth : $_ } @all;
+ return [ sort @names ];
+ };
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/DateTime.pm b/lib/Reaction/UI/ViewPort/DisplayField/DateTime.pm
new file mode 100644
index 0000000..92d5b81
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/DateTime.pm
@@ -0,0 +1,28 @@
+package Reaction::UI::ViewPort::DisplayField::DateTime;
+
+use Reaction::Class;
+use Reaction::Types::DateTime;
+use aliased 'Reaction::UI::ViewPort::DisplayField';
+
+class DateTime is DisplayField, which {
+ has '+value' => (isa => 'DateTime');
+ has '+layout' => (default => 'displayfield/value_string');
+
+ has value_string => (isa => 'Str', is => 'rw', lazy_build => 1);
+
+ has value_string_default_format => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { "%F %H:%M:%S" }
+ );
+
+ implements build_value_string => as {
+ my $self = shift;
+ my $value = eval { $self->value };
+ return '' unless $self->has_value;
+ my $format = $self->value_string_default_format;
+ return $value->strftime($format) if $value;
+ return '';
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/List.pm b/lib/Reaction/UI/ViewPort/DisplayField/List.pm
new file mode 100644
index 0000000..d70f1ed
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/List.pm
@@ -0,0 +1,31 @@
+package Reaction::UI::ViewPort::DisplayField::List;
+
+use Reaction::Class;
+use Scalar::Util 'blessed';
+use aliased 'Reaction::UI::ViewPort::DisplayField';
+
+class List is DisplayField, which {
+ has '+value' => (isa => 'ArrayRef');
+ has '+layout' => (default => 'displayfield/list');
+
+ has value_names => (isa => 'ArrayRef', is => 'ro', lazy_build => 1);
+
+ has value_map_method => (
+ isa => 'Str', is => 'ro', required => 1, default => sub { 'display_name' },
+ );
+
+ override build_value => sub {
+ return super() || [];
+ };
+
+ implements build_value_names => as {
+ my $self = shift;
+ my @all = @{$self->value||[]};
+ my $meth = $self->value_map_method;
+ my @names = map { blessed $_ ? $_->$meth : $_ } @all;
+ return [ sort @names ];
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/Number.pm b/lib/Reaction/UI/ViewPort/DisplayField/Number.pm
new file mode 100644
index 0000000..7c46d06
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/Number.pm
@@ -0,0 +1,10 @@
+package Reaction::UI::ViewPort::DisplayField::Number;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::ViewPort::DisplayField';
+
+class Number is DisplayField, which {
+ has '+layout' => (default => 'displayfield/string');
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/RelatedObject.pm b/lib/Reaction/UI/ViewPort/DisplayField/RelatedObject.pm
new file mode 100644
index 0000000..3cd217c
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/RelatedObject.pm
@@ -0,0 +1,26 @@
+package Reaction::UI::ViewPort::DisplayField::RelatedObject;
+
+use Reaction::Class;
+use Scalar::Util 'blessed';
+use aliased 'Reaction::UI::ViewPort::DisplayField';
+
+class RelatedObject is DisplayField, which {
+
+ has '+layout' => (default => 'displayfield/value_string');
+
+ has value_string => (isa => 'Str', is => 'ro', lazy_build => 1);
+
+ has value_map_method => (
+ isa => 'Str', is => 'ro', required => 1, default => sub { 'display_name' },
+ );
+
+ implements build_value_string => as {
+ my $self = shift;
+ my $meth = $self->value_map_method;
+ my $value = $self->value;
+ return blessed $value ? $value->$meth : $value;
+ };
+
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/String.pm b/lib/Reaction/UI/ViewPort/DisplayField/String.pm
new file mode 100644
index 0000000..3aab498
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/String.pm
@@ -0,0 +1,11 @@
+package Reaction::UI::ViewPort::DisplayField::String;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::ViewPort::DisplayField';
+
+class String is DisplayField, which {
+ has '+value' => (isa => 'Str');
+ has '+layout' => (default => 'displayfield/string');
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/DisplayField/Text.pm b/lib/Reaction/UI/ViewPort/DisplayField/Text.pm
new file mode 100644
index 0000000..c9e2c27
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/DisplayField/Text.pm
@@ -0,0 +1,11 @@
+package Reaction::UI::ViewPort::DisplayField::Text;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::ViewPort::DisplayField';
+
+class Text is DisplayField, which {
+ has '+value' => (isa => 'Str');
+ has '+layout' => (default => 'displayfield/text');
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/Field.pm b/lib/Reaction/UI/ViewPort/Field.pm
new file mode 100644
index 0000000..41a7c42
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field.pm
@@ -0,0 +1,166 @@
+package Reaction::UI::ViewPort::Field;
+
+use Reaction::Class;
+
+class Field is 'Reaction::UI::ViewPort', which {
+
+ has name => (
+ isa => 'Str', is => 'rw', required => 1
+ );
+
+ has action => (
+ isa => 'Reaction::InterfaceModel::Action',
+ is => 'ro', required => 0, predicate => 'has_action',
+ );
+
+ has attribute => (
+ isa => 'Reaction::Meta::InterfaceModel::Action::ParameterAttribute',
+ is => 'ro', predicate => 'has_attribute',
+ );
+
+ has value => (
+ is => 'rw', lazy_build => 1, trigger_adopt('value'),
+ clearer => 'clear_value',
+ );
+
+ has needs_sync => (
+ isa => 'Int', is => 'rw', default => 0
+ );
+
+ has label => (isa => 'Str', is => 'rw', lazy_build => 1);
+
+ has message => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { '' }
+ );
+
+ implements BUILD => as {
+ my ($self) = @_;
+ if (!$self->has_attribute != !$self->has_action) {
+ confess "Should have both action and attribute or neither";
+ }
+ };
+
+ implements build_label => as {
+ my ($self) = @_;
+ return join(' ', map { ucfirst } split('_', $self->name));
+ };
+
+ implements build_value => as {
+ my ($self) = @_;
+ if ($self->has_attribute) {
+ my $reader = $self->attribute->get_read_method;
+ my $predicate = $self->attribute->predicate;
+ if (!$predicate || $self->action->$predicate) {
+ return $self->action->$reader;
+ }
+ }
+ return '';
+ };
+
+ implements adopt_value => as {
+ my ($self) = @_;
+ $self->needs_sync(1) if $self->has_attribute;
+ };
+
+ implements sync_to_action => as {
+ my ($self) = @_;
+ return unless $self->needs_sync && $self->has_attribute && $self->has_value;
+ my $attr = $self->attribute;
+ if (my $tc = $attr->type_constraint) {
+ my $value = $self->value;
+ if ($tc->has_coercion) {
+ $value = $tc->coercion->coerce($value);
+ }
+ my $error = $tc->validate($self->value);
+ if (defined $error) {
+ $self->message($error);
+ return;
+ }
+ }
+ my $writer = $attr->get_write_method;
+ confess "No writer for attribute" unless defined($writer);
+ $self->action->$writer($self->value);
+ $self->needs_sync(0);
+ };
+
+ implements sync_from_action => as {
+ my ($self) = @_;
+ return unless !$self->needs_sync && $self->has_attribute;
+ $self->message($self->action->error_for($self->attribute)||'');
+ };
+
+ override accept_events => sub { ('value', super()) };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field
+
+=head1 DESCRIPTION
+
+This viewport is the base class for all field types.
+
+=head1 ATTRIBUTES
+
+=head2 name
+
+=head2 action
+
+L<Reaction::InterfaceModel::Action>
+
+=head2 attribute
+
+L<Reaction::Meta::InterfaceModel::Action::ParameterAttribute>
+
+=head2 value
+
+=head2 needs_sync
+
+=head2 label
+
+User friendly label, by default is based on the name.
+
+=head2 message
+
+Optional string relating to the field.
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort>
+
+=head2 L<Reaction::UI::ViewPort::DisplayField>
+
+=head2 L<Reaction::UI::ViewPort::Field::Boolean>
+
+=head2 L<Reaction::UI::ViewPort::Field::ChooseMany>
+
+=head2 L<Reaction::UI::ViewPort::Field::ChooseOne>
+
+=head2 L<Reaction::UI::ViewPort::Field::DateTime>
+
+=head2 L<Reaction::UI::ViewPort::Field::File>
+
+=head2 L<Reaction::UI::ViewPort::Field::HiddenArray>
+
+=head2 L<Reaction::UI::ViewPort::Field::Number>
+
+=head2 L<Reaction::UI::ViewPort::Field::Password>
+
+=head2 L<Reaction::UI::ViewPort::Field::String>
+
+=head2 L<Reaction::UI::ViewPort::Field::Text>
+
+=head2 L<Reaction::UI::ViewPort::Field::TimeRange>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/Boolean.pm b/lib/Reaction/UI/ViewPort/Field/Boolean.pm
new file mode 100644
index 0000000..34f7aae
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/Boolean.pm
@@ -0,0 +1,32 @@
+package Reaction::UI::ViewPort::Field::Boolean;
+
+use Reaction::Class;
+
+class Boolean is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+value' => (isa => 'Bool');
+ has '+layout' => (default => 'checkbox');
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::Boolean
+
+=head1 DESCRIPTION
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/ChooseMany.pm b/lib/Reaction/UI/ViewPort/Field/ChooseMany.pm
new file mode 100644
index 0000000..0ea4ed0
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/ChooseMany.pm
@@ -0,0 +1,139 @@
+package Reaction::UI::ViewPort::Field::ChooseMany;
+
+use Reaction::Class;
+
+class ChooseMany is 'Reaction::UI::ViewPort::Field::ChooseOne', which {
+
+ has '+layout' => (default => 'dual_select_group');
+
+ has '+value' => (isa => 'ArrayRef');
+
+ has available_value_names =>
+ (isa => 'ArrayRef', is => 'ro', lazy_build => 1);
+
+ has value_names => (isa => 'ArrayRef', is => 'ro', lazy_build => 1);
+
+ my $listify = sub { # quick utility function, $listify->($arg)
+ return (defined($_[0])
+ ? (ref($_[0]) eq 'ARRAY'
+ ? $_[0] # \@arr => \@arr
+ : [$_[0]]) # $scalar => [$scalar]
+ : []); # undef => []
+ };
+
+ around value => sub {
+ my $orig = shift;
+ my $self = shift;
+ if (@_) {
+ my $value = $listify->(shift);
+ if (defined $value) {
+ $_ = $self->str_to_ident($_) for @$value;
+ my $checked = $self->attribute->check_valid_value($self->action, $value);
+ # i.e. fail if any of the values fail
+ confess "Not a valid set of values"
+ if (@$checked < @$value || grep { !defined($_) } @$checked);
+
+ $value = $checked;
+ }
+ $orig->($self, $value);
+ } else {
+ $orig->($self);
+ }
+ };
+
+ override build_value => sub {
+ return super() || [];
+ };
+
+ implements is_current_value => as {
+ my ($self, $check_value) = @_;
+ my @our_values = @{$self->value||[]};
+ #$check_value = $check_value->id if ref($check_value);
+ #return grep { $_->id eq $check_value } @our_values;
+ $check_value = $self->obj_to_str($check_value) if ref($check_value);
+ return grep { $self->obj_to_str($_) eq $check_value } @our_values;
+ };
+
+ implements current_values => as {
+ my $self = shift;
+ my @all = grep { $self->is_current_value($_) } @{$self->valid_values};
+ return [ @all ];
+ };
+
+ implements available_values => as {
+ my $self = shift;
+ my @all = grep { !$self->is_current_value($_) } @{$self->valid_values};
+ return [ @all ];
+ };
+
+ implements build_available_value_names => as {
+ my $self = shift;
+ my @all = @{$self->available_values};
+ my $meth = $self->value_map_method;
+ my @names = map { $_->$meth } @all;
+ return [ sort @names ];
+ };
+
+ implements build_value_names => as {
+ my $self = shift;
+ my @all = @{$self->value||[]};
+ my $meth = $self->value_map_method;
+ my @names = map { $_->$meth } @all;
+ return [ sort @names ];
+ };
+
+ around handle_events => sub {
+ my $orig = shift;
+ my ($self, $events) = @_;
+ my $ev_value = $listify->($events->{value});
+ if (delete $events->{add_all_values}) {
+ $events->{value} = $self->valid_values;
+ }
+ if (delete $events->{do_add_values} && exists $events->{add_values}) {
+ my $add = $listify->(delete $events->{add_values});
+ $events->{value} = [ @{$ev_value}, @$add ];
+ }
+ if (delete $events->{remove_all_values}) {
+ $events->{value} = [];
+ }
+ if (delete $events->{do_remove_values} && exists $events->{remove_values}) {
+ my $remove = $listify->(delete $events->{remove_values});
+ my %r = map { ($_ => 1) } @$remove;
+ $events->{value} = [ grep { !$r{$_} } @{$ev_value} ];
+ }
+ return $orig->(@_);
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::ChooseMany
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=head2 is_current_value
+
+=head2 current_values
+
+=head2 available_values
+
+=head2 available_value_names
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/ChooseOne.pm b/lib/Reaction/UI/ViewPort/Field/ChooseOne.pm
new file mode 100644
index 0000000..ea0db1d
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/ChooseOne.pm
@@ -0,0 +1,138 @@
+package Reaction::UI::ViewPort::Field::ChooseOne;
+
+use Reaction::Class;
+use URI;
+use Scalar::Util 'blessed';
+
+class ChooseOne is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+layout' => (default => 'select');
+
+ has valid_value_names => (isa => 'ArrayRef', is => 'ro', lazy_build => 1);
+
+ has valid_values => (isa => 'ArrayRef', is => 'ro', lazy_build => 1);
+
+ has name_to_value_map => (isa => 'HashRef', is => 'ro', lazy_build => 1);
+
+ has value_to_name_map => (isa => 'HashRef', is => 'ro', lazy_build => 1);
+
+ has value_map_method => (
+ isa => 'Str', is => 'ro', required => 1, default => sub { 'display_name' },
+ );
+
+ around value => sub {
+ my $orig = shift;
+ my $self = shift;
+ if (@_) {
+ my $value = shift;
+ if (defined $value) {
+ if (!ref $value) {
+ $value = $self->str_to_ident($value);
+ }
+ my $checked = $self->attribute->check_valid_value($self->action, $value);
+ confess "${value} is not a valid value" unless defined($checked);
+ $value = $checked;
+ }
+ $orig->($self, $value);
+ } else {
+ $orig->($self);
+ }
+ };
+
+ implements build_valid_values => as {
+ my $self = shift;
+ return [ $self->attribute->all_valid_values($self->action) ];
+ };
+
+ implements build_valid_value_names => as {
+ my $self = shift;
+ my $all = $self->valid_values;
+ my $meth = $self->value_map_method;
+ my @names = map { $_->$meth } @$all;
+ return [ sort @names ];
+ };
+
+ implements build_name_to_value_map => as {
+ my $self = shift;
+ my $all = $self->valid_values;
+ my $meth = $self->value_map_method;
+ my %map;
+ $map{$_->$meth} = $self->obj_to_str($_) for @$all;
+ return \%map;
+ };
+
+ implements build_value_to_name_map => as {
+ my $self = shift;
+ my $all = $self->valid_values;
+ my $meth = $self->value_map_method;
+ my %map;
+ $map{$self->obj_to_str($_)} = $_->$meth for @$all;
+ return \%map;
+ };
+
+ implements is_current_value => as {
+ my ($self, $check_value) = @_;
+ my $our_value = $self->value;
+ return unless ref($our_value);
+ $check_value = $self->obj_to_str($check_value) if ref($check_value);
+ return $self->obj_to_str($our_value) eq $check_value;
+ };
+
+ implements str_to_ident => as {
+ my ($self, $str) = @_;
+ my $u = URI->new('','http');
+ $u->query($str);
+ return { $u->query_form };
+ };
+
+ implements obj_to_str => as {
+ my ($self, $obj) = @_;
+ return $obj unless ref($obj);
+ confess "${obj} not an object" unless blessed($obj);
+ my $ident = $obj->ident_condition;
+ my $u = URI->new('', 'http');
+ $u->query_form(%$ident);
+ return $u->query;
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::ChooseOne
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=head2 is_current_value
+
+=head2 value
+
+=head2 valid_values
+
+=head2 valid_value_names
+
+=head2 value_to_name_map
+
+=head2 name_to_value_map
+
+=head2 str_to_ident
+
+=head2 obj_to_str
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/DateTime.pm b/lib/Reaction/UI/ViewPort/Field/DateTime.pm
new file mode 100644
index 0000000..2b8509f
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/DateTime.pm
@@ -0,0 +1,89 @@
+package Reaction::UI::ViewPort::Field::DateTime;
+
+use Reaction::Class;
+use Reaction::Types::DateTime;
+use Time::ParseDate ();
+
+class DateTime is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+value' => (isa => 'DateTime');
+
+ has '+layout' => (default => 'dt_textfield');
+
+ has value_string => (
+ isa => 'Str', is => 'rw', lazy_build => 1,
+ trigger_adopt('value_string')
+ );
+
+ has value_string_default_format => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { "%F %H:%M:%S" }
+ );
+
+ implements build_value_string => as {
+ my $self = shift;
+
+ # XXX
+ #<mst> aha, I know why the fucker's lazy
+ #<mst> it's because if value's calculated
+ #<mst> it needs to be possible to clear it
+ #<mst> eval { $self->value } ... is probably the best solution atm
+ my $value = eval { $self->value };
+ return '' unless $self->has_value;
+ my $format = $self->value_string_default_format;
+ return $value->strftime($format) if $value;
+ return '';
+ };
+
+ implements adopt_value_string => as {
+ my ($self) = @_;
+ my $value = $self->value_string;
+ my ($epoch) = Time::ParseDate::parsedate($value, UK => 1);
+ if (defined $epoch) {
+ my $dt = 'DateTime'->from_epoch( epoch => $epoch );
+ $self->value($dt);
+ } else {
+ $self->message("Could not parse date or time");
+ $self->clear_value;
+ $self->needs_sync(1);
+ }
+ };
+
+ override accept_events => sub {
+ ('value_string', super());
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::DateTime
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=head2 value_string
+
+Accessor for the string representation of the DateTime object.
+
+=head2 value_string_default_format
+
+By default it is set to "%F %H:%M:%S".
+
+=head1 SEE ALSO
+
+=head2 L<DateTime>
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/File.pm b/lib/Reaction/UI/ViewPort/Field/File.pm
new file mode 100644
index 0000000..557826d
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/File.pm
@@ -0,0 +1,45 @@
+package Reaction::UI::ViewPort::Field::File;
+
+use Reaction::Class;
+use Reaction::Types::File;
+
+class File is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+value' => (isa => 'File', required => 0);
+
+ has '+layout' => (default => 'file');
+
+ override apply_our_events => sub {
+ my ($self, $ctx, $events) = @_;
+ my $value_key = join(':', $self->location, 'value');
+ if (my $upload = $ctx->req->upload($value_key)) {
+ local $events->{$value_key} = $upload;
+ return super();
+ } else {
+ return super();
+ }
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::File
+
+=head1 DESCRIPTION
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/HiddenArray.pm b/lib/Reaction/UI/ViewPort/Field/HiddenArray.pm
new file mode 100644
index 0000000..7f8cc73
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/HiddenArray.pm
@@ -0,0 +1,42 @@
+package Reaction::UI::ViewPort::Field::HiddenArray;
+
+use Reaction::Class;
+
+class HiddenArray is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+value' => (isa => 'ArrayRef');
+
+ around value => sub {
+ my $orig = shift;
+ my $self = shift;
+ if (@_) {
+ $orig->($self, (ref $_[0] eq 'ARRAY' ? $_[0] : [ $_[0] ]));
+ $self->sync_to_action;
+ } else {
+ $orig->($self);
+ }
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::HiddenArray
+
+=head1 DESCRIPTION
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/Number.pm b/lib/Reaction/UI/ViewPort/Field/Number.pm
new file mode 100644
index 0000000..e4e925f
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/Number.pm
@@ -0,0 +1,31 @@
+package Reaction::UI::ViewPort::Field::Number;
+
+use Reaction::Class;
+
+class Number is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+layout' => (default => 'textfield');
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::Number
+
+=head1 DESCRIPTION
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/Password.pm b/lib/Reaction/UI/ViewPort/Field/Password.pm
new file mode 100644
index 0000000..d70ed62
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/Password.pm
@@ -0,0 +1,32 @@
+package Reaction::UI::ViewPort::Field::Password;
+
+use Reaction::Class;
+
+class Password is 'Reaction::UI::ViewPort::Field::String', which {
+
+ has '+value' => (isa => 'SimpleStr');
+ has '+layout' => (default => 'password');
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::Password
+
+=head1 DESCRIPTION
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/String.pm b/lib/Reaction/UI/ViewPort/Field/String.pm
new file mode 100644
index 0000000..4be6bdc
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/String.pm
@@ -0,0 +1,34 @@
+package Reaction::UI::ViewPort::Field::String;
+
+use Reaction::Class;
+
+class String is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+value' => (isa => 'Str'); # accept over 255 chars in case, upstream
+ # constraint from model should catch it
+
+ has '+layout' => (default => 'textfield');
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::String
+
+=head1 DESCRIPTION
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/Text.pm b/lib/Reaction/UI/ViewPort/Field/Text.pm
new file mode 100644
index 0000000..d4e89f8
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/Text.pm
@@ -0,0 +1,32 @@
+package Reaction::UI::ViewPort::Field::Text;
+
+use Reaction::Class;
+
+class Text is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+value' => (isa => 'Str');
+ has '+layout' => (default => 'textarea');
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::Text
+
+=head1 DESCRIPTION
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/Field/TimeRange.pm b/lib/Reaction/UI/ViewPort/Field/TimeRange.pm
new file mode 100644
index 0000000..3619b5e
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/Field/TimeRange.pm
@@ -0,0 +1,151 @@
+package Reaction::UI::ViewPort::Field::TimeRange;
+
+use Reaction::Class;
+use Reaction::Types::DateTime;
+use DateTime;
+use DateTime::SpanSet;
+use Time::ParseDate ();
+
+class TimeRange is 'Reaction::UI::ViewPort::Field', which {
+
+ has '+value' => (isa => 'DateTime::SpanSet');
+
+ has '+layout' => (default => 'timerange');
+
+ has value_string =>
+ (isa => 'Str', is => 'rw', lazy_fail => 1, trigger_adopt('value_string'));
+
+ has delete_label => (
+ isa => 'Str', is => 'rw', required => 1, default => sub { 'Delete' },
+ );
+
+ has parent => (
+ isa => 'Reaction::UI::ViewPort::TimeRangeCollection',
+ is => 'ro',
+ required => 1,
+ is_weak_ref => 1
+ );
+
+ implements build_value_string => as {
+ my $self = shift;
+ #return '' unless $self->has_value;
+ #return $self->value_string;
+ };
+
+ implements value_array => as {
+ my $self = shift;
+ return split(',', $self->value_string);
+ };
+
+ implements adopt_value_string => as {
+ my ($self) = @_;
+ my @values = $self->value_array;
+ for my $idx (0 .. 3) { # last value is repeat
+ if (length $values[$idx]) {
+ my ($epoch) = Time::ParseDate::parsedate($values[$idx], UK => 1);
+ $values[$idx] = DateTime->from_epoch( epoch => $epoch );
+ }
+ }
+ $self->value($self->range_to_spanset(@values));
+ };
+
+ implements range_to_spanset => as {
+ my ($self, $time_from, $time_to, $repeat_from, $repeat_to, $pattern) = @_;
+ my $spanset = DateTime::SpanSet->empty_set;
+ if (!$pattern || $pattern eq 'none') {
+ my $span = DateTime::Span->from_datetimes(
+ start => $time_from, end => $time_to
+ );
+ $spanset = $spanset->union( $span );
+ } else {
+ my $duration = $time_to - $time_from;
+ my %args = ( days => $time_from->day + 2,
+ hours => $time_from->hour,
+ minutes => $time_from->minute,
+ seconds => $time_from->second );
+
+ delete $args{'days'} if ($pattern eq 'daily');
+ delete @args{qw/hours days/} if ($pattern eq 'hourly');
+ $args{'days'} = $time_from->day if ($pattern eq 'monthly');
+ my $start_set = DateTime::Event::Recurrence->$pattern( %args );
+ my $iter = $start_set->iterator( start => $repeat_from, end => $repeat_to );
+ while ( my $dt = $iter->next ) {
+ my $endtime = $dt + $duration;
+ my $new_span = DateTime::Span->from_datetimes(
+ start => $dt,
+ end => $endtime
+ );
+ $spanset = $spanset->union( $new_span );
+ }
+ }
+ return $spanset;
+ };
+
+ implements delete => as {
+ my ($self) = @_;
+ $self->parent->remove_range_vp($self);
+ };
+
+ override accept_events => sub { ('value_string', 'delete', super()) };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::Field::TimeRange
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=head2 value
+
+ Accessor for a L<DateTime::SpanSet> object.
+
+=head2 value_string
+
+ Returns: Encoded range string representing the value.
+
+=head2 value_array
+
+ Returns: Arrayref of the elements of C<value_string>.
+
+=head2 parent
+
+ L<Reaction::UI::ViewPort::TimeRangeCollection> object.
+
+=head2 range_to_spanset
+
+ Arguments: $self, $time_from, $time_to, $repeat_from, $repeat_to, $pattern
+ where $time_from, $time_to, $repeat_from, $repeat_to are L<DateTime>
+ objects, and $pattern is a L<DateTime::Event::Recurrence> method name
+
+ Returns: $spanset
+
+=head2 delete
+
+ Removes TimeRange from C<parent> collection.
+
+=head2 delete_label
+
+ Label for the delete option. Default: 'Delete'.
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort::Field>
+
+=head2 L<Reaction::UI::ViewPort::TimeRangeCollection>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/ListView.pm b/lib/Reaction/UI/ViewPort/ListView.pm
new file mode 100644
index 0000000..d5ddfba
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/ListView.pm
@@ -0,0 +1,465 @@
+package Reaction::UI::ViewPort::ListView;
+
+use Reaction::Class;
+use Data::Page;
+use Text::CSV_XS;
+use Scalar::Util qw/blessed/;
+
+class ListView is 'Reaction::UI::ViewPort', which {
+ has collection => (isa => 'DBIx::Class::ResultSet',
+ is => 'rw', required => 1);
+
+ has current_collection => (
+ isa => 'DBIx::Class::ResultSet', is => 'rw',
+ lazy_build => 1, clearer => 'clear_current_collection',
+ );
+
+ has current_page_collection => (
+ isa => 'DBIx::Class::ResultSet', is => 'rw',
+ lazy_build => 1, clearer => 'clear_current_page_collection',
+ );
+
+ has page => (
+ isa => 'Int', is => 'rw', required => 1,
+ default => sub { 1 }, trigger_adopt('page'),
+ );
+
+ has pager => (
+ isa => 'Data::Page', is => 'rw',
+ lazy_build => 1, clearer => 'clear_pager',
+ );
+
+ has per_page => (
+ isa => 'Int', is => 'rw', predicate => 'has_per_page',
+ default => sub { 10 }, trigger_adopt('page'),
+ clearer => 'clear_per_page',
+ );
+
+ has field_names => (is => 'rw', isa => 'ArrayRef', lazy_build => 1);
+
+ has field_label_map => (is => 'rw', isa => 'HashRef', lazy_build => 1);
+
+ has order_by => (
+ isa => 'Str', is => 'rw', predicate => 'has_order_by',
+ trigger_adopt('order_by')
+ );
+
+ has order_by_desc => (
+ isa => 'Int', is => 'rw', default => sub { 0 },
+ trigger_adopt('order_by')
+ );
+
+ has row_action_prototypes => (isa => 'ArrayRef', is => 'ro', lazy_build => 1);
+
+ has exclude_columns =>
+ ( is => 'rw', isa => 'ArrayRef', required => 1, default => sub{ [] } );
+
+ implements BUILD => as {
+ my ($self, $args) = @_;
+ if ($args->{unpaged}) {
+ $self->clear_per_page;
+ }
+ };
+
+ sub field_label { shift->field_label_map->{+shift}; }
+
+ implements build_pager => as {
+ my ($self) = @_;
+ return $self->current_page_collection->pager;
+ };
+
+ implements adopt_page => as {
+ my ($self) = @_;
+ $self->clear_current_page_collection;
+ $self->clear_pager;
+ };
+
+ implements adopt_order_by => as {
+ my ($self) = @_;
+ $self->clear_current_collection;
+ $self->clear_current_page_collection;
+ };
+
+ implements build_current_collection => as {
+ my ($self) = @_;
+ my %attrs;
+ if ($self->has_order_by) {
+ $attrs{order_by} = $self->order_by;
+ if ($self->order_by_desc) {
+ $attrs{order_by} .= ' DESC';
+ }
+ }
+ return $self->collection
+ ->search(undef, \%attrs);
+ };
+
+ implements build_current_page_collection => as {
+ my ($self) = @_;
+ my %attrs;
+ return $self->current_collection unless $self->has_per_page;
+ $attrs{rows} = $self->per_page;
+ return $self->current_collection
+ ->search(undef, \%attrs)
+ ->page($self->page);
+ };
+
+ implements all_current_rows => as {
+ return shift->current_collection->all;
+ };
+
+ implements current_rows => as {
+ return shift->current_page_collection->all;
+ };
+
+ implements build_field_names => as {
+ my ($self) = @_;
+ #candidate for future optimization
+ my %excluded = map { $_ => undef } @{ $self->exclude_columns };
+
+ return
+ $self->sort_by_spec( $self->column_order,
+ [ map { (($_->get_read_method) || ()) }
+ grep { !($_->has_type_constraint
+ && ($_->type_constraint->is_a_type_of('ArrayRef')
+ || eval { $_->type_constraint->name->isa(
+ 'DBIx::Class::ResultSet') })) }
+ grep { !exists $excluded{$_->name} }
+ grep { $_->name !~ /^_/ }
+ $self->current_collection
+ ->result_class
+ ->meta
+ ->compute_all_applicable_attributes
+ ] );
+ };
+
+ implements build_field_label_map => as {
+ my ($self) = @_;
+ my %labels;
+ foreach my $name (@{$self->field_names}) {
+ $labels{$name} = join(' ', map { ucfirst } split('_', $name));
+ }
+ return \%labels;
+ };
+
+ implements build_row_action_prototypes => as {
+ my $self = shift;
+ my $ctx = $self->ctx;
+ return [
+ { label => 'View', action => sub {
+ [ '', 'view', [ @{$ctx->req->captures}, $_[0]->id ] ] } },
+ { label => 'Edit', action => sub {
+ [ '', 'update', [ @{$ctx->req->captures}, $_[0]->id ] ] } },
+ { label => 'Delete', action => sub {
+ [ '', 'delete', [ @{$ctx->req->captures}, $_[0]->id ] ] } },
+ ];
+ };
+
+ implements row_actions_for => as {
+ my ($self, $row) = @_;
+ my @act;
+ my $c = $self->ctx;
+ foreach my $proto (@{$self->row_action_prototypes}) {
+ my %new = %$proto;
+ my ($c_name, $a_name, @rest) = @{delete($new{action})->($row)};
+ $new{label} = delete($new{label})->($row) if ref $new{label} eq 'CODE';
+ $new{uri} = $c->uri_for(
+ $c->controller($c_name)->action_for($a_name),
+ @rest
+ );
+ push(@act, \%new);
+ }
+ return \@act;
+ };
+
+ implements export_to_csv => as {
+ my ($self) = @_;
+ my $csv = Text::CSV_XS->new( { binary => 1 } );
+ my $output;
+ my $exporter = sub {
+ $csv->combine( @_ );
+ $output .= $csv->string."\r\n";
+ };
+ $self->export_to_data($exporter);
+ my $res = $self->ctx->res;
+ $res->content_type('text/csv');
+ my $path = $self->ctx->req->path;
+ my @parts = split(/\//, $path);
+ $res->header(
+ 'Content-disposition' => 'attachment; filename='.pop(@parts).'.csv'
+ );
+ $res->body($output);
+ };
+
+ implements export_to_data => as {
+ my ($self, $exporter) = @_;
+ $self->export_header_data($exporter);
+ $self->export_body_data($exporter);
+ };
+
+ implements export_header_data => as {
+ my ($self, $exporter) = @_;
+ my @names = @{$self->field_names};
+ my %labels = %{$self->field_label_map};
+ $exporter->( map { $labels{$_} } @names );
+ };
+
+ implements export_body_data => as {
+ my ($self, $exporter) = @_;
+ my @names = @{$self->field_names};
+ foreach my $row ($self->all_current_rows) {
+ my @row_data;
+ foreach $_ (@names) {
+ my $data = $row->$_;
+ if (blessed($data) && $data->can("display_name")) {
+ $data = $data->display_name;
+ }
+ push(@row_data, $data);
+ }
+ $exporter->( @row_data );
+ }
+ };
+
+ override accept_events => sub { ('page', 'order_by', 'order_by_desc', 'export_to_csv', super()); };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::ListView - Page layout block for rows of DBIx::Class::ResultSets
+
+=head1 SYNOPSIS
+
+ # Create a new ListView
+ # $stack isa Reaction::UI::FocusStack object
+ # Assuming you have a DBIC model with an Actors table
+ my $lv = $stack->push_viewport(
+ 'Reaction::UI::ViewPort::ListView',
+ collection => $ctx->model('DBIC::Actors'), # a DBIx::Class::ResultSet
+ page => 1, # 1 is default
+ per_page => 10, # 10 is default
+ field_names => [qw/name age/],
+ field_label_map => {
+ 'name' => 'Name',
+ 'age' => 'Age',
+ },
+ order_by => 'name',
+ );
+
+=head1 DESCRIPTION
+
+Use this ViewPort to display the contents of a
+L<DBIx::Class::ResultSet> as paged sets of rows. The default display
+shows 10 rows per page, unsorted.
+
+TODO: Add a filter_by which allows us to restrict the content?
+(Scenario: user has a paged display of data, user selects one value in
+a column and clicks "filter by this value", and then only rows
+containing that value are shown.
+
+=head1 ATTRIIBUTES
+
+=head2 collection
+
+This mandatory attribute must be an object derived from
+L<DBIx::Class::ResultSet> representing the search result or result
+source(Table) you wish to display in the ListView.
+
+The collection is used as the basis to create a refined set of data to
+show in the current ListView, this is stored in
+L<current_collection>. The data can further be refined and restricted
+by passing in or later changing the L<order_by> or L<page>
+attributes. The
+
+=head2 order_by
+
+A string representing the C<ORDER BY> part of the SQL statement, for
+more info see L<DBIx::Class::ResultSet/Attributes>
+
+=head2 order_by_desc
+
+By default, sorting is done in ascending order, set this to true to
+sort in descending order. Changing this attribute will cause the
+L<current_collection> to be cleared and recreated on the next access .
+
+=head2 exclude_columns
+
+
+
+=head2 page
+
+The page number of the current search result, this will default to
+1. If set explicitly on the ListView object, the current search result
+and the pager will be cleared and recreated on the next access.
+
+=head2 per_page
+
+The number of rows of data to list on each page. Changing this value
+on the ListView object will cause the L<current_page_collection> and
+the L<pager> to be cleared and recreated on the next access. This will
+default to 10 if unset.
+
+=head2 unpaged
+
+Set this to a true value if you really don't want your results shown
+in pages.
+
+=head2 field_names
+
+An array reference of field names to show in the ListView. These must
+exist as accessors in the L<DBIx::Class::ResultSource> describing the
+L<DBIx::Class::ResultSet> passed to L<collection>.
+
+If not set, this will default to the list of attributes in the
+L<DBIx::Class::ResultSource> which do not begin with an underscore,
+and don't have a type of either ArrayRef or
+C<DBIx::Class::ResultSet>. In short, all the non-private and
+non-relation attributes.
+
+=head2 field_label_map
+
+A hash reference mapping the L<field_names> to the column labels used
+to describe them in the ListView display.
+
+If not set, the label values will default to the L<field_names> with
+the initial characters capitalised and underscores turned into spaces.
+
+=head2 row_action_prototypes
+
+ row_action_prototypes => [
+ { label => 'Edit', action => sub { [ '', 'update', [ $_[0]->id ] ] } },
+ { label => 'Delete', action => sub { [ '', 'delete', [ $_[0]->id ] ] } },
+ ];
+
+Prototypes describing the actions that can be done on the rows of
+ListView data. This is an array reference of hash refs describing the
+name of each action with a C<label>, and the actual C<action> that
+takes place. The code reference stored in the C<action > will be
+called with a L<DBIx::Class::Row> object, it should return a list of a
+L<Catalyst::Controller> name, the name of an action in that
+controller, and any other parameters that need to be passed to
+it. C<label> may be a scalar value or a code reference, in the later case
+it will be called with the same parameters as C<action> and the return value
+will be used as the C<label> value.
+
+The example above shows the default actions if this attribute is not set.
+
+=head2 current_collection
+
+This contains the currently used L<DBIx::Class::ResultSet>
+representing the ListViews data, it is based on the L<collection>
+ResultSet, refined using the L<order_by> and L<order_by_desc> attributes.
+
+The current_collection will be cleared and recreated if the
+L<order_by> or L<order_by_desc> attributes are changed on the ListView
+object.
+
+=head2 current_rows
+
+=head2 all_current_rows
+
+=head2 pager
+
+A L<Data::Page> object representing the data for the current search
+result, it is cleared and reset when either L<page> or L<order_by> are
+changed.
+
+=head2 current_page_collection
+
+This contains contains a single page of the contents of the
+L<current_collection>, with the L<per_page> number of rows
+requested. If the L<page>, L<per_page>, L_order_by> or
+L<order_by_desc> attributes are changed on the ListView object, the
+current_page_collection is cleared and recreated.
+
+=head1 METHODS
+
+=head2 row_actions_for
+
+=over 4
+
+=item Arguments: none
+
+=back
+
+Returns an array reference of uris and labels representing the actions
+set in L<row_action_prototypes>. L<Catalyst/uri_for> is used to
+construct these.
+
+=head2 export_header_data
+
+=over 4
+
+=item Arguments: $exporter
+
+=back
+
+ $lv->export_head_data($exporter);
+
+C<$exporter> should be a code reference which will export lists of
+data passed to it. This method calls the C<exporter> code reference
+passing it the labels from the L<field_label_map> using the current
+set of L<field_names>.
+
+=head2 export_body_data
+
+=over 4
+
+=item Arguments: $exporter
+
+=back
+
+ $lv->export_body_data($exporter);
+
+C<$exporter> should be a code reference which will export lists of
+data passed to it. This method calls the C<exporter> code reference
+with an array of rows containing the data values of each of the
+current L<field_values>.
+
+=head2 export_to_data
+
+=over 4
+
+=item Arguments: $exporter
+
+=back
+
+ $lv->export_to_data($exporter);
+
+C<$exporter> should be a code reference which will export lists of
+data passed to it. This method calls L<export_header_data> and
+L<export_body_data> with C<exporter>.
+
+=head2 export_to_csv
+
+=over 4
+
+=item Arguments: none
+
+=back
+
+ $lv->export_to_csv();
+
+Fills the L<Catalyst::Response> body with CSV data of the
+L<current_collection> using L<export_to_data> and L<Text::CSV_XS>.
+
+=head2 field_label
+
+=over 4
+
+=item Arguments: $field_name
+
+=back
+
+Returns the label for the given C<field_name>, using L<field_label_map>.
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/ViewPort/ObjectView.pm b/lib/Reaction/UI/ViewPort/ObjectView.pm
new file mode 100644
index 0000000..e33ba5d
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/ObjectView.pm
@@ -0,0 +1,182 @@
+package Reaction::UI::ViewPort::ObjectView;
+
+use Reaction::Class;
+
+use aliased 'Reaction::UI::ViewPort::DisplayField::Text';
+use aliased 'Reaction::UI::ViewPort::DisplayField::Number';
+use aliased 'Reaction::UI::ViewPort::DisplayField::Boolean';
+use aliased 'Reaction::UI::ViewPort::DisplayField::String';
+use aliased 'Reaction::UI::ViewPort::DisplayField::DateTime';
+use aliased 'Reaction::UI::ViewPort::DisplayField::RelatedObject';
+use aliased 'Reaction::UI::ViewPort::DisplayField::List';
+use aliased 'Reaction::UI::ViewPort::DisplayField::Collection';
+
+class ObjectView is 'Reaction::UI::ViewPort', which {
+ has object => (
+ isa => 'Reaction::InterfaceModel::Object', is => 'ro', required => 1
+ );
+
+ has field_names => (isa => 'ArrayRef', is => 'rw', lazy_build => 1);
+
+ has _field_map => (
+ isa => 'HashRef', is => 'rw', init_arg => 'fields',
+ predicate => '_has_field_map', set_or_lazy_build('field_map'),
+ );
+
+ has exclude_fields =>
+ ( is => 'rw', isa => 'ArrayRef', required => 1, default => sub{ [] } );
+
+ sub fields { shift->_field_map }
+
+ implements BUILD => as {
+ my ($self, $args) = @_;
+ unless ($self->_has_field_map) {
+ my @field_map;
+ my $object = $self->object;
+ my %excluded = map{$_ => 1} @{$self->exclude_fields};
+ for my $attr (grep { !$excluded{$_->name} } $object->parameter_attributes) {
+ push(@field_map, $self->build_fields_for($attr => $args));
+ }
+
+ my %field_map = @field_map;
+ my @field_names = @{ $self->sort_by_spec(
+ $args->{column_order}, [keys %field_map] )};
+
+ $self->_field_map(\%field_map);
+ $self->field_names(\@field_names);
+ }
+ };
+
+ implements build_fields_for => as {
+ my ($self, $attr, $args) = @_;
+ my $attr_name = $attr->name;
+ my $builder = "build_fields_for_name_${attr_name}";
+ my @fields;
+ if ($self->can($builder)) {
+ @fields = $self->$builder($attr, $args); # re-use coderef from can()
+ } elsif ($attr->has_type_constraint) {
+ my $constraint = $attr->type_constraint;
+ my $base_name = $constraint->name;
+ my $tried_isa = 0;
+ CONSTRAINT: while (defined($constraint)) {
+ my $name = $constraint->name;
+ if (eval { $name->can('meta') } && !$tried_isa++) {
+ foreach my $class ($name->meta->class_precedence_list) {
+ my $mangled_name = $class;
+ $mangled_name =~ s/:+/_/g;
+ my $builder = "build_fields_for_type_${mangled_name}";
+ if ($self->can($builder)) {
+ @fields = $self->$builder($attr, $args);
+ last CONSTRAINT;
+ }
+ }
+ }
+ if (defined($name)) {
+ unless (defined($base_name)) {
+ $base_name = "(anon subtype of ${name})";
+ }
+ my $mangled_name = $name;
+ $mangled_name =~ s/:+/_/g;
+ my $builder = "build_fields_for_type_${mangled_name}";
+ if ($self->can($builder)) {
+ @fields = $self->$builder($attr, $args);
+ last CONSTRAINT;
+ }
+ }
+ $constraint = $constraint->parent;
+ }
+ if (!defined($constraint)) {
+ confess "Can't build field ${attr_name} of type ${base_name} without $builder method or build_fields_for_type_<type> method for type or any supertype";
+ }
+ } else {
+ confess "Can't build field ${attr} without $builder method or type constraint";
+ }
+ return @fields;
+ };
+
+ implements build_field_map => as {
+ confess "Lazy field map building not supported by default";
+ };
+
+ implements build_simple_field => as {
+ my ($self, $class, $attr, $args) = @_;
+ my $attr_name = $attr->name;
+ my %extra;
+ if (my $config = $args->{Field}{$attr_name}) {
+ %extra = %$config;
+ }
+ my $field = $class->new(
+ object => $self->object,
+ attribute => $attr,
+ name => $attr->name,
+ location => join('-', $self->location, 'field', $attr->name),
+ ctx => $self->ctx,
+ %extra
+ );
+ return ($attr_name => $field);
+ };
+
+ implements build_fields_for_type_Num => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Number, $attr, $args);
+ };
+
+ implements build_fields_for_type_Int => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Number, $attr, $args);
+ };
+
+ implements build_fields_for_type_Bool => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Boolean, $attr, $args);
+ };
+
+ implements build_fields_for_type_Password => as { return };
+
+ implements build_fields_for_type_Str => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(String, $attr, $args);
+ };
+
+ implements build_fields_for_type_SimpleStr => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(String, $attr, $args);
+ };
+
+ implements build_fields_for_type_DateTime => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(DateTime, $attr, $args);
+ };
+
+ implements build_fields_for_type_Enum => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(String, $attr, $args);
+ };
+
+
+ implements build_fields_for_type_ArrayRef => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(List, $attr, $args)
+ };
+
+ #todo dirty hack need generic collection object
+ #if a collection wasnt a resultset that'd be good.
+ implements build_fields_for_type_Reaction_InterfaceModel_DBIC_Collection => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(Collection, $attr, $args)
+ };
+
+ implements build_fields_for_type_Reaction_InterfaceModel_Object => as {
+ my ($self, $attr, $args) = @_;
+ return $self->build_simple_field(RelatedObject, $attr, $args);
+ };
+
+
+ no Moose;
+
+ no strict 'refs';
+ delete ${__PACKAGE__ . '::'}{inner};
+
+};
+
+1;
diff --git a/lib/Reaction/UI/ViewPort/TimeRangeCollection.pm b/lib/Reaction/UI/ViewPort/TimeRangeCollection.pm
new file mode 100644
index 0000000..eb1b680
--- /dev/null
+++ b/lib/Reaction/UI/ViewPort/TimeRangeCollection.pm
@@ -0,0 +1,390 @@
+package Reaction::UI::ViewPort::TimeRangeCollection;
+
+use Reaction::Class;
+use Reaction::Types::DateTime;
+use Moose::Util::TypeConstraints ();
+use DateTime::Event::Recurrence;
+use aliased 'Reaction::UI::ViewPort::Field::String';
+use aliased 'Reaction::UI::ViewPort::Field::DateTime';
+use aliased 'Reaction::UI::ViewPort::Field::HiddenArray';
+use aliased 'Reaction::UI::ViewPort::Field::TimeRange';
+
+class TimeRangeCollection is 'Reaction::UI::ViewPort', which {
+
+ has '+layout' => (default => 'timerangecollection');
+
+ has '+column_order' => (
+ default => sub{[ qw/ time_from time_to pattern repeat_from repeat_to / ]},
+ );
+
+ has time_from => (
+ isa => 'Reaction::UI::ViewPort::Field::DateTime',
+ is => 'rw', lazy_build => 1,
+ );
+
+ has time_to => (
+ isa => 'Reaction::UI::ViewPort::Field::DateTime',
+ is => 'rw', lazy_build => 1,
+ );
+
+ has repeat_from => (
+ isa => 'Reaction::UI::ViewPort::Field::DateTime',
+ is => 'rw', lazy_build => 1,
+ );
+
+ has repeat_to => (
+ isa => 'Reaction::UI::ViewPort::Field::DateTime',
+ is => 'rw', lazy_build => 1,
+ );
+
+ has pattern => (
+ isa => 'Reaction::UI::ViewPort::Field::String',
+ # valid_values => [ qw/none daily weekly monthly/ ],
+ is => 'rw', lazy_build => 1,
+ );
+
+ has range_vps => (isa => 'ArrayRef', is => 'rw', lazy_build => 1,);
+
+ has max_range_vps => (isa => 'Int', is => 'rw', lazy_build => 1,);
+
+ has error => (
+ isa => 'Str',
+ is => 'rw',
+ required => 0,
+ );
+
+ has field_names => (
+ isa => 'ArrayRef', is => 'rw',
+ lazy_build => 1, clearer => 'clear_field_names',
+ );
+
+ has _field_map => (
+ isa => 'HashRef', is => 'rw', init_arg => 'fields',
+ clearer => '_clear_field_map',
+ predicate => '_has_field_map',
+ set_or_lazy_build('field_map'),
+ );
+
+ has on_next_callback => (
+ isa => 'CodeRef',
+ is => 'rw',
+ predicate => 'has_on_next_callback',
+ );
+
+ implements fields => as { shift->_field_map };
+
+ implements build_range_vps => as { [] };
+
+ implements spanset => as {
+ my ($self) = @_;
+ my $spanset = DateTime::SpanSet->empty_set;
+ $spanset = $spanset->union($_->value) for @{$self->range_vps};
+ return $spanset;
+ };
+
+ implements range_strings => as {
+ my ($self) = @_;
+ return [ map { $_->value_string } @{$self->range_vps} ];
+ };
+
+ implements remove_range_vp => as {
+ my ($self, $to_remove) = @_;
+ $self->range_vps([ grep { $_ != $to_remove } @{$self->range_vps} ]);
+ $self->_clear_field_map;
+ $self->clear_field_names;
+ };
+
+ implements add_range_vp => as {
+ my ($self) = @_;
+ if ($self->can_add) {
+ $self->_clear_field_map;
+ $self->clear_field_names;
+ my @span_info = (
+ $self->time_from->value,
+ $self->time_to->value,
+ (map { $_->has_value ? $_->value : '' }
+ map { $self->$_ } qw/repeat_from repeat_to/),
+ $self->pattern->value,
+ );
+ my $encoded_spanset = join ',', @span_info;
+ my $args = {
+ value_string => $encoded_spanset,
+ parent => $self
+ };
+ my $count = scalar(@{$self->range_vps});
+ my $field = $self->build_simple_field(TimeRange, 'range-'.$count, $args);
+ my $d = DateTime::Format::Duration->new( pattern => '%s' );
+ if ($d->format_duration( $self->spanset->intersection($field->value)->duration ) > 0) {
+ # XXX - Stop using the stash here?
+ $self->ctx->stash->{warning} = 'Warning: Most recent time range overlaps '.
+ 'with existing time range in this booking.';
+ }
+ #warn "encoded spanset = $encoded_spanset\n";
+ #warn "current range = ".join(', ', (@{$self->range_vps}))."\n";
+ push(@{$self->range_vps}, $field);
+ }
+ };
+
+ implements build_field_map => as {
+ my ($self) = @_;
+ my %map;
+ foreach my $field (@{$self->range_vps}) {
+ $map{$field->name} = $field;
+ }
+ foreach my $name (@{$self->column_order}) {
+ $map{$name} = $self->$name;
+ }
+ return \%map;
+ };
+
+ implements build_field_names => as {
+ my ($self) = @_;
+ return [
+ (map { $_->name } @{$self->range_vps}),
+ @{$self->column_order}
+ ];
+ };
+
+ implements can_add => as {
+ my ($self) = @_;
+ my $error;
+ if ($self->time_to->has_value && $self->time_from->has_value) {
+ my $time_to = $self->time_to->value;
+ my $time_from = $self->time_from->value;
+
+ my ($pattern, $repeat_from, $repeat_to) = ('','','');
+ $pattern = $self->pattern->value if $self->pattern->has_value;
+ $repeat_from = $self->repeat_from->value if $self->repeat_from->has_value;
+ $repeat_to = $self->repeat_to->value if $self->repeat_to->has_value;
+
+ my $duration = $time_to - $time_from;
+ if ($time_to < $time_from) {
+ $error = 'Please make sure that the Time To is after the Time From.';
+ } elsif ($time_to == $time_from) {
+ $error = 'Your desired booking slot is too small.';
+ } elsif ($pattern && $pattern ne 'none') {
+ my %pattern = (hourly => [ hours => 1 ],
+ daily => [ days => 1 ],
+ weekly => [ days => 7 ],
+ monthly => [ months => 1 ]);
+ my $pattern_comp = DateTime::Duration->compare(
+ $duration, DateTime::Duration->new( @{$pattern{$pattern}} )
+ );
+ if (!$repeat_to || !$repeat_from) {
+ $error = 'Please make sure that you enter a valid range for the '.
+ 'repetition period.';
+ } elsif ($time_to == $time_from) {
+ $error = 'Your desired repetition period is too short.';
+ } elsif ($repeat_to && ($repeat_to < $repeat_from)) {
+ $error = 'Please make sure that the Repeat To is after the Repeat From.';
+ } elsif ( ( ($pattern eq 'hourly') && ($pattern_comp > 0) ) ||
+ ( ($pattern eq 'daily') && ($pattern_comp > 0) ) ||
+ ( ($pattern eq 'weekly') && ($pattern_comp > 0) ) ||
+ ( ($pattern eq 'monthly') && ($pattern_comp > 0) ) ) {
+ $error = "Your repetition pattern ($pattern) is too short for your ".
+ "desired booking length.";
+ }
+ }
+ } else {
+ $error = 'Please complete both the Time To and Time From fields.';
+ }
+ $self->error($error);
+ return !defined($error);
+ };
+
+ implements build_simple_field => as {
+ my ($self, $class, $name, $args) = @_;
+ return $class->new(
+ name => $name,
+ location => join('-', $self->location, 'field', $name),
+ ctx => $self->ctx,
+ %$args
+ );
+ };
+
+ implements build_time_to => as {
+ my ($self) = @_;
+ return $self->build_simple_field(DateTime, 'time_to', {});
+ };
+
+ implements build_time_from => as {
+ my ($self) = @_;
+ return $self->build_simple_field(DateTime, 'time_from', {});
+ };
+
+ implements build_repeat_to => as {
+ my ($self) = @_;
+ return $self->build_simple_field(DateTime, 'repeat_to', {});
+ };
+
+ implements build_repeat_from => as {
+ my ($self) = @_;
+ return $self->build_simple_field(DateTime, 'repeat_from', {});
+ };
+
+ implements build_pattern => as {
+ my ($self) = @_;
+ return $self->build_simple_field(String, 'pattern', {});
+ };
+
+ implements next => as {
+ $_[0]->on_next_callback->(@_);
+ };
+
+ override accept_events => sub {
+ my $self = shift;
+ ('add_range_vp', ($self->has_on_next_callback ? ('next') : ()), super());
+ };
+
+ override child_event_sinks => sub {
+ my ($self) = @_;
+ return ((grep { ref($_) =~ 'Hidden' } values %{$self->_field_map}),
+ (grep { ref($_) !~ 'Hidden' } values %{$self->_field_map}),
+ super());
+ };
+
+ override apply_events => sub {
+ my ($self, $ctx, $events) = @_;
+
+ # auto-inflate range fields based on number from hidden field
+
+ my $max = $events->{$self->location.':max_range_vps'};
+ my @range_vps = map {
+ TimeRange->new(
+ name => "range-$_",
+ location => join('-', $self->location, 'field', 'range', $_),
+ ctx => $self->ctx,
+ parent => $self,
+ )
+ } ($max ? (0 .. $max - 1) : ());
+ $self->range_vps(\@range_vps);
+ $self->_clear_field_map;
+ $self->clear_field_names;
+
+ # call original event handling
+
+ super();
+
+ # repack range VPs in case of deletion
+
+ my $prev_idx = -1;
+
+ foreach my $vp (@{$self->range_vps}) {
+ my $cur_idx = ($vp->name =~ m/range-(\d+)/);
+ if (($cur_idx - $prev_idx) > 1) {
+ $cur_idx--;
+ my $name = "range-${cur_idx}";
+ $vp->name($name);
+ $vp->location(join('-', $self->location, 'field', $name));
+ }
+ $prev_idx = $cur_idx;
+ }
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::ViewPort::TimeRangeCollection
+
+=head1 SYNOPSIS
+
+ my $trc = $self->push_viewport(TimeRangeCollection,
+ layout => 'avail_search_form',
+ on_apply_callback => $search_callback,
+ name => 'TRC',
+ );
+
+=head1 DESCRIPTION
+
+=head1 ATTRIBUTES
+
+=head2 can_add
+
+=head2 column_order
+
+=head2 error
+
+=head2 field_names
+
+=head2 fields
+
+=head2 layout
+
+=head2 pattern
+
+Typically either: none, daily, weekly or monthly
+
+=head2 max_range_vps
+
+=head2 range_vps
+
+=head2 repeat_from
+
+A DateTime field.
+
+=head2 repeat_to
+
+A DateTime field.
+
+=head2 time_from
+
+A DateTime field.
+
+=head2 time_to
+
+A DateTime field.
+
+=head1 METHODS
+
+=head2 spanset
+
+Returns: $spanset consisting of all the TimeRange spans combined
+
+=head2 range_strings
+
+Returns: ArrayRef of Str consisting of the value_strings of all TimeRange
+VPs
+
+=head2 remove_range_vp
+
+Arguments: $to_remove
+
+=head2 add_range_vp
+
+Arguments: $to_add
+
+=head2 build_simple_field
+
+Arguments: $class, $name, $args
+where $class is an object, $name is a scalar and $args is a hashref
+
+=head2 next
+
+=head2 on_next_callback
+
+=head2 clear_field_names
+
+=head2 child_event_sinks
+
+=head1 SEE ALSO
+
+=head2 L<Reaction::UI::ViewPort>
+
+=head2 L<Reaction::UI::ViewPort::Field::TimeRange>
+
+=head2 L<Reaction::UI::ViewPort::Field::DateTime>
+
+=head2 L<DateTime::Event::Recurrence>
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/Widget.pm b/lib/Reaction/UI/Widget.pm
new file mode 100644
index 0000000..75cfae4
--- /dev/null
+++ b/lib/Reaction/UI/Widget.pm
@@ -0,0 +1,41 @@
+package Reaction::UI::Widget;
+
+use Reaction::Class;
+use aliased 'Reaction::UI::ViewPort';
+use aliased 'Reaction::UI::View';
+
+class Widget which {
+
+ has 'viewport' => (isa => ViewPort, is => 'ro'); # required?
+ has 'view' => (isa => View, is => 'ro', required => 1);
+
+ implements 'render' => as {
+ my ($self, $rctx) = @_;
+ $self->render_widget($rctx, { self => $self });
+ };
+
+ implements 'render_viewport' => as {
+ my ($self, $rctx, $args) = @_;
+ my $vp = $args->{'_'};
+ $self->view->render_viewport($rctx, $vp);
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::Widget
+
+=head1 DESCRIPTION
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/Widget/ListView.pm b/lib/Reaction/UI/Widget/ListView.pm
new file mode 100644
index 0000000..ab80d93
--- /dev/null
+++ b/lib/Reaction/UI/Widget/ListView.pm
@@ -0,0 +1,54 @@
+package Reaction::UI::Widget::ListView;
+
+use Reaction::UI::WidgetClass;
+use aliased 'Reaction::UI::ViewPort::ListView' => 'ListView_VP';
+
+class ListView which {
+
+ has 'viewport' => (isa => ListView_VP, is => 'ro', required => 1);
+
+ widget renders [
+ qw(header body) => { viewport => func(self => 'viewport') }
+ ];
+
+ header renders [ header_entry over func(viewport => 'field_names') ];
+
+ header_entry renders [ string { $_{viewport}->field_label_map->{ $_ } } ];
+
+ body renders [ row over func(viewport => 'current_page_collection') ];
+
+ row renders [
+ col_entry over func(viewport => 'field_names') => { row => $_ }
+ ];
+
+ col_entry renders [
+ string {
+ my $proto = $_{row}->$_;
+ if (blessed($proto) && $proto->can('display_name')) {
+ return $proto->display_name;
+ }
+ return "${proto}";
+ }
+ ];
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::Widget::ListView
+
+=head1 DESCRIPTION
+
+=head2 viewport
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/WidgetClass.pm b/lib/Reaction/UI/WidgetClass.pm
new file mode 100644
index 0000000..9eadc35
--- /dev/null
+++ b/lib/Reaction/UI/WidgetClass.pm
@@ -0,0 +1,283 @@
+package Reaction::UI::WidgetClass;
+
+use Reaction::ClassExporter;
+use Reaction::Class;
+use Reaction::UI::Widget;
+use Data::Dumper;
+
+no warnings 'once';
+
+class WidgetClass, which {
+
+ overrides exports_for_package => sub {
+ my ($self, $package) = @_;
+ return (super(),
+ func => sub {
+ my ($k, $m) = @_;
+ my $sig = "should be: func(data_key => 'method_name')";
+ confess "Data key not present, ${sig}" unless defined($k);
+ confess "Data key must be string, ${sig}" unless !ref($k);
+ confess "Method name not present, ${sig}" unless defined($m);
+ confess "Method name must be string, ${sig}" unless !ref($m);
+ [ $k, $m ];
+ }, # XXX zis is not ze grand design. OBSERVABLE.
+ string => sub (&) { -string => [ @_ ] }, # meh (maybe &;@ later?)
+ wrap => sub { $self->do_wrap_sub($package, @_); }, # should have class.
+ );
+ };
+
+ overrides default_base => sub { ('Reaction::UI::Widget') };
+
+ overrides do_class_sub => sub {
+ my ($self, $package, $class) = @_;
+ # intercepts 'foo renders ...'
+ local *renders::AUTOLOAD = sub {
+ our $AUTOLOAD;
+ shift;
+ $AUTOLOAD =~ /^renders::(.*)$/;
+ $self->do_renders_meth($package, $class, $1, @_);
+ };
+ # intercepts 'foo over ...'
+ local *over::AUTOLOAD = sub {
+ our $AUTOLOAD;
+ shift;
+ $AUTOLOAD =~ /^over::(.*)$/;
+ $self->do_over_meth($package, $class, $1, @_);
+ };
+ # $_ returns '-topic:_', $_{foo} returns '-topic:foo'
+ local $_ = '-topic:_';
+ my %topichash;
+ tie %topichash, 'Reaction::UI::WidgetClass::TopicHash';
+ local *_ = \%topichash;
+ super;
+ };
+
+ implements do_wrap_sub => as { confess "Unimplemented" };
+
+ implements do_renders_meth => as {
+ my ($self, $package, $class, $fname, $content, $args, $extra) = @_;
+
+ my $sig = 'should be: renders [ <content spec> ], \%args?';
+
+ confess "Too many args to renders, ${sig}" if defined($extra);
+ confess "First arg not an arrayref, ${sig}" unless ref($content) eq 'ARRAY';
+ confess "Args must be hashref, ${sig}"
+ if (defined($args) && (ref($args) ne 'HASH'));
+
+ $sig .= '
+ where content spec is [ fragment_name over func(...), \%args? ]
+ or [ qw(list of fragment names), \%args ]'; # explain the mistake, yea
+
+ my $inner_args = ((ref($content->[-1]) eq 'HASH') ? pop(@$content) : {});
+ # [ blah over func(...), { ... } ] or [ qw(foo bar), { ... } ]
+
+ # predeclare since content_gen gets populated somewhere in an if
+ # and inner_args_gen wants to be closed over by content_gen
+
+ my ($content_gen, $inner_args_gen);
+
+ my %args_extra; # again populated (possibly) within the if
+
+ confess "Content spec invalid, ${sig}"
+ unless defined($content->[0]) && !ref($content->[0]);
+
+ if (my ($key) = ($content->[0] =~ /^-(.*)?/)) {
+
+ # if first content value is -foo, pull it off the front and then
+ # figure out is it's a type we know how to handle
+
+ shift(@$content);
+ if ($key eq 'over') { # fragment_name over func
+ my ($fragment, $func) = @$content;
+ confess "Fragment name invalid, ${sig}" if ref($fragment);
+ my $content_meth = "render_${fragment}";
+ # grab result of func
+ # - if arrayref, render fragment per entry
+ # - if obj and can('next') call that until undef
+ # - else scream loudly
+ my ($func_key, $func_meth) = @$func;
+ $content_gen = sub {
+ my ($widget, $args) = @_;
+ my $topic = eval { $args->{$func_key}->$func_meth };
+ confess "Error calling ${func_meth} on ${func_key} argument "
+ .($args->{$func_key}||'').": $@"
+ if $@;
+ my $iter_sub;
+ if (ref $topic eq 'ARRAY') {
+ my @copy = @$topic; # non-destructive on original data
+ $iter_sub = sub { shift(@copy); };
+ } elsif (Scalar::Util::blessed($topic) && $topic->can('next')) {
+ $iter_sub = sub { $topic->next };
+ } else {
+ #confess "func(${func_key} => ${func_meth}) for topic within fragment ${fname} did not return arrayref or iterator object";
+ # Coercing to a single-arg list instead for the mo. Mistake?
+ my @copy = ($topic);
+ $iter_sub = sub { shift(@copy); };
+ }
+ my $inner_args = $inner_args_gen->($args);
+ return sub {
+ my $next = $iter_sub->();
+ return undef unless $next;
+ return sub {
+ my ($rctx) = @_;
+ local $inner_args->{'_'} = $next; # ala local $_, why copy?
+ $widget->$content_meth($rctx, $inner_args);
+ };
+ };
+ };
+ } elsif ($key eq 'string') {
+
+ # string { ... }
+
+ my $sub = $content->[0]->[0]; # string {} returns (-string => [ $cr ])
+ $content_gen = sub {
+ my ($widget, $args) = @_;
+ my $done = 0;
+ my $inner_args = $inner_args_gen->($args);
+ return sub {
+ return if $done++; # a string content only happens once
+ return sub { # setup $_{foo} etc. and alias $_ to $_{_}
+ my ($rctx) = @_;
+ local *_ = \%{$inner_args};
+ local $_ = $inner_args->{'_'};
+ $sub->($rctx);
+ };
+ };
+ };
+
+ # must also handle just $_ later for wrap
+ } else {
+ # unrecognised -foo
+ confess "Unrecognised content spec type ${key}, ${sig}";
+ }
+ } else {
+
+ # handling the renders [ qw(list of frag names), \%args ] case
+
+#warn @$content;
+ confess "Invalid content spec, ${sig}"
+ if grep { ref($_) } @$content;
+ $content_gen = sub {
+ my ($widget, $args) = @_;
+ my @fragment_methods = map { "render_${_}" } @$content;
+ my $inner_args = $inner_args_gen->($args);
+ return sub {
+ my $next = shift(@fragment_methods);
+ return undef unless $next;
+ return sub {
+ my ($rctx) = @_;
+ $widget->$next($rctx, $inner_args);
+ };
+ };
+ };
+
+ foreach my $key (@$content) {
+ my $frag_meth = "render_${key}";
+ $args_extra{$key} = sub {
+ my ($widget, $args) = @_;
+ my $inner_args = $inner_args_gen->($args);
+ return sub {
+ my ($rctx) = @_;
+ $widget->$frag_meth($rctx, $inner_args);
+ };
+ };
+ }
+ }
+
+ # populate both args generators here primarily for clarity
+
+ my $args_gen = $self->mk_args_generator($args);
+ $inner_args_gen = $self->mk_args_generator($inner_args);
+
+ my $methname = "render_${fname}";
+
+ $args_extra{'_'} = $content_gen;
+
+ my @extra_keys = keys %args_extra;
+ my @extra_gen = values %args_extra;
+
+ my $meth = sub {
+ my ($self, $rctx, $args) = @_;
+ confess "No rendering context passed" unless $rctx;
+ my $r_args = $args_gen->($args);
+#warn Dumper($r_args).' ';
+ @{$r_args}{@extra_keys} = map { $_->($self, $args); } @extra_gen;
+ $r_args->{'_'} = $content_gen->($self, $args);
+#warn Dumper($r_args).' ';
+ $rctx->render($fname, $r_args);
+ };
+
+ $class->meta->add_method($methname => $meth);
+ };
+
+ implements do_over_meth => as {
+ my ($self, $package, $class, @args) = @_;
+ #warn Dumper(\@args);
+ return (-over => @args);
+ };
+
+ implements mk_args_generator => as {
+ my ($self, $argspec) = @_;
+#warn Dumper($argspec);
+ # only handling [ $k, $v ] (func()) and -topic:$x ($_{$x}) for the moment
+
+ my $sig = 'should be: key => $_ or key => $_{name} or key => func("name", "method")';
+
+ my (@func_to, @func_spec, @copy_from, @copy_to);
+ foreach my $key (keys %$argspec) {
+ my $val = $argspec->{$key};
+ if (ref($val) eq 'ARRAY') {
+ push(@func_spec, $val);
+ push(@func_to, $key);
+ } elsif (!ref($val) && ($val =~ /^-topic:(.*)$/)) {
+ my $topic_key = $1;
+ push(@copy_from, $topic_key);
+ push(@copy_to, $key);
+ } else {
+ confess "Invalid args member for ${key}, ${sig}";
+ }
+ }
+#warn Dumper(\@func_to, \@func_spec, \@copy_from, \@copy_to);
+ return sub {
+ my ($outer_args) = @_;
+ my $args = { %$outer_args };
+#warn Dumper(\@func_to, \@func_spec, \@copy_from, \@copy_to).' ';
+ @{$args}{@copy_to} = @{$outer_args}{@copy_from};
+ @{$args}{@func_to} = (map {
+ my ($key, $meth) = @{$_};
+ $outer_args->{$key}->$meth; # [ 'a, 'b' ] ~~ ->{'a'}->b
+ } @func_spec);
+#warn Dumper($args).' ';
+ return $args;
+ };
+ };
+
+};
+
+package Reaction::UI::WidgetClass::TopicHash;
+
+use Tie::Hash;
+use base qw(Tie::StdHash);
+
+sub FETCH {
+ my ($self, $key) = @_;
+ return "-topic:${key}";
+}
+
+1;
+
+=head1 NAME
+
+Reaction::UI::WidgetClass
+
+=head1 DESCRIPTION
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut
diff --git a/lib/Reaction/UI/Window.pm b/lib/Reaction/UI/Window.pm
new file mode 100644
index 0000000..ecf1e57
--- /dev/null
+++ b/lib/Reaction/UI/Window.pm
@@ -0,0 +1,292 @@
+package Reaction::UI::Window;
+
+use Reaction::Class;
+use Reaction::UI::FocusStack;
+
+class Window which {
+
+ has ctx => (isa => 'Catalyst', is => 'ro', required => 1);
+ has view_name => (isa => 'Str', is => 'ro', lazy_fail => 1);
+ has content_type => (isa => 'Str', is => 'ro', lazy_fail => 1);
+ has title => (isa => 'Str', is => 'rw', default => sub { 'Untitled window' });
+ has view => (
+ # XXX compile failure because the Catalyst::View constraint would be
+ # auto-generated which doesn't work with unions. ::Types::Catalyst needed.
+ #isa => 'Catalyst::View|Reaction::UI::View',
+ isa => 'Object', is => 'ro', lazy_build => 1
+ );
+ has focus_stack => (
+ isa => 'Reaction::UI::FocusStack',
+ is => 'ro', required => 1,
+ default => sub { Reaction::UI::FocusStack->new },
+ );
+
+ implements build_view => as {
+ my ($self) = @_;
+ return $self->ctx->view($self->view_name);
+ };
+
+ implements flush => as {
+ my ($self) = @_;
+ $self->flush_events;
+ $self->flush_view;
+ };
+
+ implements flush_events => as {
+ my ($self) = @_;
+ my $ctx = $self->ctx;
+ foreach my $type (qw/query body/) {
+ my $meth = "${type}_parameters";
+ my $param_hash = $ctx->req->$meth;
+ $self->focus_stack->apply_events($ctx, $param_hash);
+ }
+ };
+
+ implements flush_view => as {
+ my ($self) = @_;
+ return if $self->ctx->res->status =~ /^3/ || length($self->ctx->res->body);
+ $self->ctx->res->body(
+ $self->view->render_window($self)
+ );
+ $self->ctx->res->content_type($self->content_type);
+ };
+
+ # required by old Renderer::XHTML
+
+ implements render_viewport => as {
+ my ($self, $vp) = @_;
+ return unless $vp;
+ return $self->view->render_viewport($self, $vp);
+ };
+
+};
+
+1;
+
+=head1 NAME
+
+Reaction::UI::Window - Container for rendering the UI elements in
+
+=head1 SYNOPSIS
+
+ my $window = Reaction::UI::Window->new(
+ ctx => $ctx,
+ view_name => $view_name,
+ content_type => $content_type,
+ title => $window_title,
+ );
+
+ # More commonly, as Reaction::UI::RootController creates one for you:
+ my $window = $ctx->stash->{window};
+
+ # Resolve current events and render the view of the UI
+ # elements of this Window:
+ # This is called by the end action of Reaction::UI::RootController
+ $window->flush();
+
+ # Resolve current events:
+ $window->flush_events();
+
+ # Render the top ViewPort in the FocusStack of this Window:
+ $window->flush_view();
+
+ # Render a particular ViewPort:
+ $window->render_viewport($viewport);
+
+ # Or in a template:
+ [% window.render_viewport(self.inner) %]
+
+ # Add a ViewPort to the UI:
+ $window->focus_stack->push_viewport('Reaction::UI::ViewPort');
+
+=head1 DESCRIPTION
+
+A Window object is created and stored in the stash by
+L<Reaction::UI::RootController>, it is used to contain all the
+elements (ViewPorts) that make up the UI. The Window is rendered in
+the end action of the RootController to make up the page.
+
+To add L<ViewPorts|Reaction::UI::ViewPort> to the stack, read the
+L<Reaction::UI::FocusStack> and L<Reaction::UI::ViewPort> documentation.
+
+Several Window attributes are set by
+L<Reaction::UI::RootController/begin> when a new Window is created,
+these are as follows:
+
+=over
+
+=item ctx
+
+The current L<Catalyst> context object is set.
+
+=item view_name
+
+The view_name is set from the L<Reaction::UI::RootController> attributes.
+
+=item content_type
+
+The content_type is set from the L<Reaction::UI::RootController> attributes.
+
+=item window_title
+
+The window_title is set from the L<Reaction::UI::RootController> attributes.
+
+=back
+
+=head1 METHODS
+
+=head2 ctx
+
+=over
+
+=item Arguments: none
+
+=back
+
+Retrieve the current L<Catalyst> context object.
+
+=head2 view_name
+
+=over
+
+=item Arguments: none
+
+=back
+
+Retrieve the name of the L<Catalyst::View> component used to render
+this Window. If this has not been set, rendering the Window will fail.
+
+=head2 content_type
+
+=over
+
+=item Arguments: none
+
+=back
+
+Retrieve the content_type for the page. If this has not been set,
+rendering the Window will fail.
+
+=head2 title
+
+=over
+
+=item Arguments: $title?
+
+=back
+
+ [% window.title %]
+
+Retrieve the title of this page, if not set, it will default to
+"Untitled window".
+
+=head2 view
+
+=over
+
+=item Arguments: none
+
+=back
+
+Retrieve the L<Catalyst::View> instance, this can be set, or will be
+instantiated using the L<view_name> class.
+
+=head2 focus_stack
+
+=over
+
+=item Arguments: none
+
+=back
+
+ $window->focus_stack->push_viewport('Reaction::UI::ViewPort');
+
+Retrieve the L<stack|Reaction::UI::FocusStack> of
+L<ViewPorts|Reaction::UI::ViewPorts> that contains all the UI elements
+for this Window. Use L<Reaction::UI::FocusStack/push_viewport> on this
+to create more elements. An empty FocusStack is created by the
+RootController when the Window is created.
+
+=head2 render_viewport
+
+=over
+
+=item Arguments: $viewport
+
+=back
+
+ $window->render_viewport($viewport);
+
+ [% window.render_viewport(self.inner) %]
+
+Calls render on the L<view> object used by this Window. The following
+arguments are given:
+
+=over
+
+=item ctx
+
+The L<Catalyst> context object.
+
+=item self
+
+The ViewPort object to be rendered.
+
+=item window
+
+The Window object.
+
+=item type
+
+The string that describes the layout from L<Reaction::UI::ViewPort/layout>.
+
+=back
+
+=head2 flush
+
+=over
+
+=item Arguments: none
+
+=back
+
+Synchronize the current events with all the L<Reaction::UI::ViewPort>
+objects in the UI, then render the root ViewPort. This is called for
+you by L<Reaction::UI::RootController/end>.
+
+=head2 flush_events
+
+=over
+
+=item Arguments: none
+
+=back
+
+Resolves all the current events, first the query parameters then the
+body parameters, with all the L<Reaction::UI::ViewPort> objects in the
+UI. This calls L<Reaction::UI::FocusStack/apply_events>. This method
+is called by L<flush>.
+
+=head2 flush_view
+
+=over
+
+=item Arguments: none
+
+=back
+
+Renders the page into the L<Catalyst::Response> body, unless the
+response status is already set to 3xx, or the body has already been
+filled. This calls L<render_viewport> with the root
+L<Reaction::UI::ViewPort> from the L<focus_stack>. This method is
+called by L<flush>.
+
+=head1 AUTHORS
+
+See L<Reaction::Class> for authors.
+
+=head1 LICENSE
+
+See L<Reaction::Class> for the license.
+
+=cut