diff options
author | matthewt <matthewt@03d0b0b2-0e1a-0410-a411-fdb2f4bd65d7> | 2007-09-12 18:11:34 +0000 |
---|---|---|
committer | matthewt <matthewt@03d0b0b2-0e1a-0410-a411-fdb2f4bd65d7> | 2007-09-12 18:11:34 +0000 |
commit | 7adfd53f17f66ffe93763e944ed1d3fc52a369dc (patch) | |
tree | 19e599e74419b41cbbe651fd226b81e8b73551d3 /lib/Reaction/UI | |
parent | c728c97cb1061330e63c7cc048e768ef74988fe6 (diff) | |
download | reaction-7adfd53f17f66ffe93763e944ed1d3fc52a369dc.tar.gz reaction-7adfd53f17f66ffe93763e944ed1d3fc52a369dc.zip |
moved shit to trunk
Diffstat (limited to 'lib/Reaction/UI')
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 |