Serverseitiges Erzeugen von im Client ausgeführten JavaScript-Befehlen
authorMoritz Bunkus <m.bunkus@linet-services.de>
Wed, 6 Mar 2013 09:14:05 +0000 (10:14 +0100)
committerMoritz Bunkus <m.bunkus@linet-services.de>
Wed, 6 Mar 2013 09:23:17 +0000 (10:23 +0100)
SL/ClientJS.pm [new file with mode: 0644]
SL/Controller/Base.pm
js/client_js.js [new file with mode: 0644]
scripts/generate_client_js_actions.pl [new file with mode: 0755]

diff --git a/SL/ClientJS.pm b/SL/ClientJS.pm
new file mode 100644 (file)
index 0000000..47f8a22
--- /dev/null
@@ -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<eval_json_response()> 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<SL::Controller/render> 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<to_array>
+
+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<to_json>
+
+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<hide>, C<show>, C<toggle>
+
+=item DOM insertion, around
+
+C<unwrap>, C<wrap>, C<wrapAll>, C<wrapInner>
+
+=item DOM insertion, inside
+
+C<append>, C<appendTo>, C<html>, C<prepend>, C<prependTo>, C<text>
+
+=item DOM insertion, outside
+
+C<after>, C<before>, C<insertAfter>, C<insertBefore>
+
+=item DOM removal
+
+C<empty>, C<remove>
+
+=item DOM replacement
+
+C<replaceAll>, C<replaceWith>
+
+=item General attributes
+
+C<attr>, C<prop>, C<removeAttr>, C<removeProp>, C<val>
+
+=item Data storage
+
+C<data>, C<removeData>
+
+=back
+
+=head1 ADDING SUPPORT FOR ADDITIONAL FUNCTIONS
+
+In order not having to maintain two files (this one and
+C<js/client_js.js>) 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<js/client_js.js>. 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<scripts/generate_client_js_actions.pl>
+
+=item 3. Edit C<js/client_js.js> and replace the type casing code with
+the output generated in step 2.
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut
index 2d86f0f..876aac3 100644 (file)
@@ -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 (file)
index 0000000..3354fe7
--- /dev/null
@@ -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 (executable)
index 0000000..b2598b3
--- /dev/null
@@ -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);