From 7af2b12887c4b1cb0cb427960c57f5b777b85315 Mon Sep 17 00:00:00 2001 From: Moritz Bunkus Date: Wed, 6 Mar 2013 10:14:05 +0100 Subject: [PATCH] =?utf8?q?Serverseitiges=20Erzeugen=20von=20im=20Client=20?= =?utf8?q?ausgef=C3=BChrten=20JavaScript-Befehlen?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- SL/ClientJS.pm | 274 ++++++++++++++++++++++++++ SL/Controller/Base.pm | 3 + js/client_js.js | 66 +++++++ scripts/generate_client_js_actions.pl | 44 +++++ 4 files changed, 387 insertions(+) create mode 100644 SL/ClientJS.pm create mode 100644 js/client_js.js create mode 100755 scripts/generate_client_js_actions.pl diff --git a/SL/ClientJS.pm b/SL/ClientJS.pm new file mode 100644 index 000000000..47f8a225b --- /dev/null +++ b/SL/ClientJS.pm @@ -0,0 +1,274 @@ +package SL::ClientJS; + +use strict; + +use parent qw(Rose::Object); + +use Carp; +use SL::JSON (); + +use Rose::Object::MakeMethods::Generic +( + 'scalar --get_set_init' => [ qw(_actions) ], +); + +my %supported_methods = ( + # Basic effects + hide => 1, + show => 1, + toggle => 1, + + # DOM insertion, around + unwrap => 1, + wrap => 2, + wrapAll => 2, + wrapInner => 2, + + # DOM insertion, inside + append => 2, + appendTo => 2, + html => 2, + prepend => 2, + prependTo => 2, + text => 2, + + # DOM insertion, outside + after => 2, + before => 2, + insertAfter => 2, + insertBefore => 2, + + # DOM removal + empty => 1, + remove => 1, + + # DOM replacement + replaceAll => 2, + replaceWith => 2, + + # General attributes + attr => 3, + prop => 3, + removeAttr => 2, + removeProp => 2, + val => 2, + + # Data storage + data => 3, + removeData => 2, +); + +sub AUTOLOAD { + our $AUTOLOAD; + + my ($self, @args) = @_; + + my $method = $AUTOLOAD; + $method =~ s/.*:://; + return if $method eq 'DESTROY'; + + my $num_args = $supported_methods{$method}; + $::lxdebug->message(0, "autoload method $method"); + + croak "Unsupported jQuery action: $method" unless defined $num_args; + croak "Parameter count mismatch for $method(actual: " . scalar(@args) . " wanted: $num_args)" if scalar(@args) != $num_args; + + if ($num_args) { + # Force flattening from SL::Presenter::EscapedText: "" . $... + $args[0] = "" . $args[0]; + $args[0] =~ s/^\s+//; + } + + push @{ $self->_actions }, [ $method, @args ]; + + return $self; +} + +sub init__actions { + return []; +} + +sub to_json { + my ($self) = @_; + return SL::JSON::to_json({ eval_actions => $self->_actions }); +} + +sub to_array { + my ($self) = @_; + return $self->_actions; +} + +1; +__END__ + +=pod + +=encoding utf8 + +=head1 NAME + +SL::ClientJS - Easy programmatic client-side JavaScript generation +with jQuery + +=head1 SYNOPSIS + +First some JavaScript code: + + // In the client generate an AJAX request whose 'success' handler + // calls "eval_json_response(data)": + var data = { + action: "SomeController/the_action", + id: $('#some_input_field').val() + }; + $.post("controller.pl", data, eval_json_response); + +Now some Perl code: + + # In the controller itself. First, make sure that the "client_js.js" + # is loaded. This must be done when the whole side is loaded, so + # it's not in the action called by the AJAX request shown above. + $::request->layout->use_javascript('client_js.js'); + + # Now in that action called via AJAX: + sub action_the_action { + my ($self) = @_; + + # Create a new client-side JS object and do stuff with it! + my $js = SL::ClientJS->new; + + # Show some element on the page: + $js->show('#usually_hidden'); + + # Set to hidden inputs. Yes, calls can be chained! + $js->val('#hidden_id', $self->new_id) + ->val('#other_type', 'Unicorn'); + + # Replace some HTML code: + my $html = $self->render('SomeController/the_action', { output => 0 }); + $js->html('#id_with_new_content', $html); + + # Finally render the JSON response: + $self->render($js); + } + +=head1 OVERVIEW + +This module enables the generation of jQuery-using JavaScript code on +the server side. That code is then evaluated in a safe way on the +client side. + +The workflow is usally that the client creates an AJAX request, the +server creates some actions and sends them back, and the client then +implements each of these actions. + +There are three things that need to be done for this to work: + +=over 2 + +=item 1. The "client_js.js" has to be loaded before the AJAX request is started. + +=item 2. The client code needs to call C with the result returned from the server. + +=item 3. The server must use this module. + +=back + +The functions called on the client side are mostly jQuery +functions. Other functionality may be added later. + +Note that L is aware of this module which saves +you some boilerplate. The following two calls are equivalent: + + $controller->render($client_js); + $controller->render(\$client_js->to_json, { type => 'json' }); + +=head1 FUNCTIONS NOT PASSED TO THE CLIENT SIDE + +=over 4 + +=item C + +Returns the actions gathered so far as an array reference. Each +element is an array reference containing at least two items: the +function's name and what it is called on. Additional array elements +are the function parameters. + +=item C + +Returns the actions gathered so far as a JSON string ready to be sent +to the client. + +=back + +=head1 FUNCTIONS EVALUATED ON THE CLIENT SIDE + +=head2 JQUERY FUNCTIONS + +The following jQuery functions are supported: + +=over 4 + +=item Basic effects + +C, C, C + +=item DOM insertion, around + +C, C, C, C + +=item DOM insertion, inside + +C, C, C, C, C, C + +=item DOM insertion, outside + +C, C, C, C + +=item DOM removal + +C, C + +=item DOM replacement + +C, C + +=item General attributes + +C, C, C, C, C + +=item Data storage + +C, C + +=back + +=head1 ADDING SUPPORT FOR ADDITIONAL FUNCTIONS + +In order not having to maintain two files (this one and +C) there's a script that can parse this file's +C<%supported_methods> definition and convert it into the appropriate +code ready for manual insertion into C. The steps +are: + +=over 2 + +=item 1. Add lines in this file to the C<%supported_methods> hash. The +key is the function name and the value is the number of expected +parameters. + +=item 2. Run C + +=item 3. Edit C and replace the type casing code with +the output generated in step 2. + +=back + +=head1 BUGS + +Nothing here yet. + +=head1 AUTHOR + +Moritz Bunkus Em.bunkus@linet-services.deE + +=cut diff --git a/SL/Controller/Base.pm b/SL/Controller/Base.pm index 2d86f0f96..876aac30f 100644 --- a/SL/Controller/Base.pm +++ b/SL/Controller/Base.pm @@ -60,6 +60,9 @@ sub render { my $template = shift; my ($options, %locals) = (@_ && ref($_[0])) ? @_ : ({ }, @_); + # Special handling/shortcut for an instance of SL::ClientJS: + return $self->render(\$template->to_json, { type => 'json' }) if ref($template) eq 'SL::ClientJS'; + # Set defaults for all available options. my %defaults = ( type => 'html', diff --git a/js/client_js.js b/js/client_js.js new file mode 100644 index 000000000..3354fe7ca --- /dev/null +++ b/js/client_js.js @@ -0,0 +1,66 @@ +// NOTE NOTE NOTE NOTE NOTE NOTE NOTE NOTE NOTE: + +// Generate the dispatching lines in this script by running +// "scripts/generate_client_js_actions.pl". See the documentation for +// SL/ClientJS.pm for instructions. + +function eval_json_result(data) { + if (!data) + return; + + if ((data.js || '') != '') + eval(data.js); + + if (data.eval_actions) + $(data.eval_actions).each(function(idx, action) { + // console.log("ACTION " + action[0] + " ON " + action[1]); + + // Basic effects + if (action[0] == 'hide') $(action[1]).hide(); + else if (action[0] == 'show') $(action[1]).show(); + else if (action[0] == 'toggle') $(action[1]).toggle(); + + // DOM insertion, around + else if (action[0] == 'unwrap') $(action[1]).unwrap(); + else if (action[0] == 'wrap') $(action[1]).wrap(action[2]); + else if (action[0] == 'wrapAll') $(action[1]).wrapAll(action[2]); + else if (action[0] == 'wrapInner') $(action[1]).wrapInner(action[2]); + + // DOM insertion, inside + else if (action[0] == 'append') $(action[1]).append(action[2]); + else if (action[0] == 'appendTo') $(action[1]).appendTo(action[2]); + else if (action[0] == 'html') $(action[1]).html(action[2]); + else if (action[0] == 'prepend') $(action[1]).prepend(action[2]); + else if (action[0] == 'prependTo') $(action[1]).prependTo(action[2]); + else if (action[0] == 'text') $(action[1]).text(action[2]); + + // DOM insertion, outside + else if (action[0] == 'after') $(action[1]).after(action[2]); + else if (action[0] == 'before') $(action[1]).before(action[2]); + else if (action[0] == 'insertAfter') $(action[1]).insertAfter(action[2]); + else if (action[0] == 'insertBefore') $(action[1]).insertBefore(action[2]); + + // DOM removal + else if (action[0] == 'empty') $(action[1]).empty(); + else if (action[0] == 'remove') $(action[1]).remove(); + + // DOM replacement + else if (action[0] == 'replaceAll') $(action[1]).replaceAll(action[2]); + else if (action[0] == 'replaceWith') $(action[1]).replaceWith(action[2]); + + // General attributes + else if (action[0] == 'attr') $(action[1]).attr(action[2], action[3]); + else if (action[0] == 'prop') $(action[1]).prop(action[2], action[3]); + else if (action[0] == 'removeAttr') $(action[1]).removeAttr(action[2]); + else if (action[0] == 'removeProp') $(action[1]).removeProp(action[2]); + else if (action[0] == 'val') $(action[1]).val(action[2]); + + // Data storage + else if (action[0] == 'data') $(action[1]).data(action[2], action[3]); + else if (action[0] == 'removeData') $(action[1]).removeData(action[2]); + + else console.log("Unknown action: " + action[0]); + }); + + console.log("current_content_type " + $('#current_content_type').val() + ' ID ' + $('#current_content_id').val()); +} diff --git a/scripts/generate_client_js_actions.pl b/scripts/generate_client_js_actions.pl new file mode 100755 index 000000000..b2598b39e --- /dev/null +++ b/scripts/generate_client_js_actions.pl @@ -0,0 +1,44 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use File::Slurp; +use List::Util qw(first max); + +my $file_name = (first { -f } qw(SL/ClientJS.pm ../SL/ClientJS.pm)) || die "ClientJS.pm not found"; +my @actions; + +foreach (read_file($file_name)) { + chomp; + + next unless (m/^my \%supported_methods/ .. m/^\);/); + + push @actions, [ 'action', $1, $2 ] if m/^\s+([a-zA-Z]+)\s*=>\s*(\d+),$/; + push @actions, [ 'comment', $1 ] if m/^\s+#\s+(.+)/; +} + +my $longest = max map { length($_->[1]) } grep { $_->[0] eq 'action' } @actions; +my $first = 1; +my $output; + +# else if (action[0] == 'hide') $(action[1]).hide(); +foreach my $action (@actions) { + if ($action->[0] eq 'comment') { + print "\n" unless $first; + print " // ", $action->[1], "\n"; + + } else { + my $args = $action->[2] == 1 ? '' : join(', ', map { "action[$_]" } (2..$action->[2])); + + printf(' %s if (action[0] == \'%s\')%s $(action[1]).%s(%s);' . "\n", + $first ? ' ' : 'else', + $action->[1], + ' ' x ($longest - length($action->[1])), + $action->[1], + $args); + $first = 0; + } +} + +printf "\n else\%sconsole.log('Unknown action: ' + action[0]);\n", ' ' x (4 + 2 + 6 + 3 + 4 + 2 + $longest + 1); -- 2.20.1