TopQuickSearch: erste version
authorSven Schöling <s.schoeling@linet-services.de>
Tue, 23 Feb 2016 10:46:27 +0000 (11:46 +0100)
committerSven Schöling <s.schoeling@linet-services.de>
Tue, 22 Mar 2016 16:36:24 +0000 (17:36 +0100)
SL/Controller/TopQuickSearch.pm [new file with mode: 0644]
SL/Controller/TopQuickSearch/Assembly.pm [new file with mode: 0644]
SL/Controller/TopQuickSearch/Base.pm [new file with mode: 0644]
SL/Controller/TopQuickSearch/Contact.pm [new file with mode: 0644]
SL/Controller/TopQuickSearch/GLTransaction.pm [new file with mode: 0644]
SL/Layout/Top.pm
js/kivi.QuickSearch.js [new file with mode: 0644]
templates/webpages/menu/header.html

diff --git a/SL/Controller/TopQuickSearch.pm b/SL/Controller/TopQuickSearch.pm
new file mode 100644 (file)
index 0000000..63d9b5b
--- /dev/null
@@ -0,0 +1,190 @@
+package SL::Controller::TopQuickSearch;
+
+use strict;
+use parent qw(SL::Controller::Base);
+
+use SL::ClientJS;
+use SL::JSON;
+use SL::Locale::String qw(t8);
+
+use Rose::Object::MakeMethods::Generic (
+ 'scalar --get_set_init' => [ qw(module js) ],
+);
+
+my @available_modules = qw(
+  SL::Controller::TopQuickSearch::Assembly
+  SL::Controller::TopQuickSearch::Contact
+  SL::Controller::TopQuickSearch::GLTransaction
+);
+my %modules_by_name;
+
+sub action_query_autocomplete {
+  my ($self) = @_;
+
+  my $hashes = $self->module->query_autocomplete;
+
+  $self->render(\ SL::JSON::to_json($hashes), { layout => 0, type => 'json', process => 0 });
+}
+
+sub action_select_autocomplete {
+  my ($self) = @_;
+
+  my $redirect_url = $self->module->select_autocomplete;
+
+  $self->js->redirect_to($redirect_url)->render;
+}
+
+sub action_do_search {
+  my ($self) = @_;
+
+  my $redirect_url = $self->module->do_search;
+
+  if ($redirect_url) {
+    $self->js->redirect_to($redirect_url)
+  }
+
+  $self->js->render;
+}
+
+sub available_modules {
+  my ($self) = @_;
+
+  $self->require_modules;
+
+  map { $_->new } @available_modules;
+}
+
+sub active_modules {
+  grep {
+    $::auth->assert($_->auth, 1)
+  } $_[0]->available_modules
+}
+
+sub init_module {
+  my ($self) = @_;
+
+  $self->require_modules;
+
+  die t8('Need module') unless $::form->{module};
+
+  $::lxdebug->dump(0,  "modules", \%modules_by_name);
+
+  die t8('Unknown module #1', $::form->{module}) unless my $class = $modules_by_name{$::form->{module}};
+
+  $::lxdebug->dump(0,  "auth:", $class->auth);
+
+  $::auth->assert($class->auth);
+
+  return $class->new;
+}
+
+sub init_js {
+  SL::ClientJS->new(controller => $_[0])
+}
+
+sub require_modules {
+  my ($self) = @_;
+
+  if (!$self->{__modules_required}) {
+    for my $class (@available_modules) {
+      eval "require $class" or die $@;
+      $modules_by_name{ $class->name } = $class;
+    }
+    $self->{__modules_required} = 1;
+  }
+}
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Controller::TopQuickSearch - Framework for pluggable quicksearch fields in the layout top header.
+
+=head1 SYNOPSIS
+
+use SL::Controller::TopQuickSearch;
+my $search = SL::Controller::TopQuickSearch->new;
+
+# in layout
+[%- FOREACH module = search.available_modules %]
+<input type='text' id='top-search-[% module.name %]'>
+[%- END %]
+
+=head1 DESCRIPTION
+
+This controller provides abstraction for different search plugins, and ensures
+that all follow a common useability scheme.
+
+Modules should be configurable, but currently are not. Diabling modules can be
+done by removing them from available_modules.
+
+=head1 BEHAVIOUR REQUIREMENTS
+
+=over 4
+
+=item *
+
+A single text input field with the html5 placeholder containing a small
+description of the target will be rendered from the plugin information.
+
+=item *
+
+On typing, the autocompletion must be enabled.
+
+=item *
+
+On C<Enter>, the search should redirect to an appropriate listing of matching
+results.
+
+If only one item matches the result, the plugin should instead redirect
+directly to the matched item.
+
+=item *
+
+Search terms should accept the broadest possible matching, and if possible with
+C<multi> parsing.
+
+=item *
+
+In case nothing is found, a visual indicator should be given, but no actual
+redirect should occur.
+
+=item *
+
+Each search must check rights and must not present a backdoor into data that
+the user should not see.
+
+=back
+
+=head1 INTERFACE
+
+Plugins need to provide:
+
+ - name
+ - localized description for config
+ - localized description for textfield
+ - autocomplete callback
+ - redirect callback
+
+the frontend will only generate urls of the forms:
+  action=TopQuickSearch/autocomplete&module=<module>&term=<term>
+  action=TopQuickSearch/search&module=<module>&term=<term>
+
+=head1 TODO
+
+ - filter available searches with auth
+ - toggling with cofiguration doesn't work yet
+
+=head1 BUGS
+
+None yet :)
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut
diff --git a/SL/Controller/TopQuickSearch/Assembly.pm b/SL/Controller/TopQuickSearch/Assembly.pm
new file mode 100644 (file)
index 0000000..7622e66
--- /dev/null
@@ -0,0 +1,93 @@
+package SL::Controller::TopQuickSearch::Assembly;
+
+use strict;
+use parent qw(Rose::Object);
+
+use SL::Locale::String qw(t8);
+use SL::DB::Part;
+use SL::Controller::Helper::GetModels;
+use SL::Controller::Base;
+
+use Rose::Object::MakeMethods::Generic (
+  'scalar --get_set_init' => [ qw(parts models part) ],
+);
+
+sub auth { 'part_service_assembly_edit' }
+
+sub name { 'assembly' }
+
+sub description_config { t8('Assemblies') }
+
+sub description_field { t8('Assemblies') }
+
+sub query_autocomplete {
+  my ($self) = @_;
+
+  my $objects = $self->models->get;
+
+  [
+    map {
+     value       => $_->displayable_name,
+     label       => $_->displayable_name,
+     id          => $_->id,
+    }, @$objects
+  ];
+}
+
+sub select_autocomplete {
+  redirect_to_part($::form->{id});
+}
+
+sub do_search {
+  my ($self) = @_;
+
+  my $objects = $self->models->get;
+
+  return !@$objects     ? ()
+       : @$objects == 1 ? redirect_to_part($objects->[0]->id)
+       :                  redirect_to_search($::form->{term});
+}
+
+sub redirect_to_search {
+  SL::Controller::Base->new->url_for(
+    controller  => 'ic.pl',
+    action      => 'generate_report',
+    searchitems => 'assembly',
+    all         => $_[0],
+  );
+}
+
+sub redirect_to_part {
+  SL::Controller::Base->new->url_for(
+    controller => 'ic.pl',
+    action     => 'edit',
+    id         => $_[0],
+  );
+}
+
+sub init_models {
+  my ($self) = @_;
+
+  SL::Controller::Helper::GetModels->new(
+    controller => $self,
+    model      => 'Part',
+    source     => {
+      filter => {
+        type                      => 'assembly',
+        'all:substr:multi::ilike' => $::form->{term},
+      },
+    },
+    sorted     => {
+      _default   => {
+        by  => 'partnumber',
+        dir => 1,
+      },
+      partnumber => t8('Partnumber'),
+    },
+    paginated  => {
+      per_page => 10,
+    },
+  )
+}
+
+1;
diff --git a/SL/Controller/TopQuickSearch/Base.pm b/SL/Controller/TopQuickSearch/Base.pm
new file mode 100644 (file)
index 0000000..18c3b88
--- /dev/null
@@ -0,0 +1,87 @@
+package SL::Controller::TopQuickSearch::Base;
+
+use strict;
+use parent qw(Rose::Object);
+
+sub auth { ... }
+
+sub name { ... }
+
+sub description_config { ... }
+
+sub description_field { ... }
+
+sub query_autocomplete { ... }
+
+sub select_autocomplete { ... }
+
+sub do_search { ... }
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Controller::TopQuickSearch::Base - base interface class for quick search plugins
+
+=head1 DESCRIPTION
+
+see L<SL::Controller::TopQuickSearch>
+
+=head1 INTERFACE
+
+An implementation must provide these functions.
+
+=over 4
+
+=item C<auth>
+
+Must return a string used for access checks. Empty string or undef will mean
+unrestricted access.
+
+=item C<name>
+
+Internal name, must be plain ASCII.
+
+=item C<description_config>
+
+Localized name used in the configuration (NYI)
+
+=item C<description_field>
+
+Localized name used in the search field as hint. Should fit into an input of
+length 20.
+
+=item C<query_autocomplete>
+
+Needs to take C<term> from C<$::form> and must return an arrayref of JSON
+serializable matches fit for jquery autocomplete.
+
+=item C<select_autocomplete>
+
+Needs to take C<id> from C<$::form> and must return a redirect string to be
+used with C<SL::Controller::Base::redirect_to> pointing to a representation of
+the selected object.
+
+=item C<do_search>
+
+Needs to take C<term> from C<$::form> and must return a redirect string to be
+used with C<SL::Controller::Base::redirect_to> pointing to a representation of
+the search results. If the search will display only only one match, it should
+instead return the same result as if that object was selected directly using
+C<select_autocomplete>.
+
+=back
+
+=head1 BUGS
+
+None yet :)
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut
diff --git a/SL/Controller/TopQuickSearch/Contact.pm b/SL/Controller/TopQuickSearch/Contact.pm
new file mode 100644 (file)
index 0000000..5607a5b
--- /dev/null
@@ -0,0 +1,85 @@
+package SL::Controller::TopQuickSearch::Contact;
+
+use strict;
+use parent qw(SL::Controller::TopQuickSearch::Base);
+
+use SL::Controller::CustomerVendor;
+use SL::DB::Vendor;
+use SL::DBUtils qw(selectfirst_array_query);
+use SL::Locale::String qw(t8);
+
+sub auth { 'customer_vendor_edit' }
+
+sub name { 'contact' }
+
+sub description_config { t8('Contact') }
+
+sub description_field { t8('Contacts') }
+
+sub query_autocomplete {
+  my ($self) = @_;
+
+  my $result = SL::DB::Manager::Contact->get_all(
+    query => [
+      or => [
+        cp_name      => { ilike => "%$::form->{term}%" },
+        cp_givenname => { ilike => "%$::form->{term}%" },
+        cp_email     => { ilike => "%$::form->{term}%" },
+      ],
+      cp_cv_id => [ \'SELECT id FROM customer UNION SELECT id FROM vendor' ],
+    ],
+    limit => 10,
+    sort_by => 'cp_name',
+  );
+
+  return [
+    map {
+     value       => $_->full_name,
+     label       => $_->full_name,
+     id          => $_->cp_id,
+    }, @$result
+  ];
+}
+
+sub select_autocomplete {
+  my ($self) = @_;
+
+  my $contact = SL::DB::Manager::Contact->find_by(cp_id => $::form->{id});
+
+  SL::Controller::CustomerVendor->new->url_for(action => 'edit', id => $contact->cp_cv_id, db => db_for_contact($contact));
+}
+
+sub do_search {
+  my ($self) = @_;
+
+  my $results = $self->query_autocomplete;
+
+  if (@$results != 1) {
+    return SL::Controller::CustomerVendor->new->url_for(
+      controller      => 'ct.pl',
+      action          => 'list_contacts',
+      'filter.status' => 'active',
+      search_term     => $::form->{term},
+    );
+  } else {
+    $::form->{id} = $results->[0]{id};
+    return $self->select_autocomplete;
+  }
+}
+
+
+sub db_for_contact {
+  my ($contact) = @_;
+
+  my ($customer, $vendor) = selectfirst_array_query($::form, $::form->get_standard_dbh, <<SQL, ($contact->cp_cv_id)x2);
+    SELECT (SELECT COUNT(id) FROM customer WHERE id = ?), (SELECT COUNT(id) FROM vendor WHERE id = ?);
+SQL
+
+  die 'Contact is orphaned, cannot link to it'         if !$customer && !$vendor;
+
+  $customer ? 'customer' : 'vendor';
+}
+
+# TODO: multi search
+
+1;
diff --git a/SL/Controller/TopQuickSearch/GLTransaction.pm b/SL/Controller/TopQuickSearch/GLTransaction.pm
new file mode 100644 (file)
index 0000000..2b4facd
--- /dev/null
@@ -0,0 +1,118 @@
+package SL::Controller::TopQuickSearch::GLTransaction;
+
+use strict;
+use parent qw(Rose::Object);
+
+use SL::DB::GLTransaction;
+use SL::DB::Invoice;
+use SL::DB::PurchaseInvoice;
+use SL::DB::AccTransaction;
+use SL::Locale::String qw(t8);
+use List::Util qw(sum);
+
+sub auth { 'general_ledger' }
+
+sub name { 'gl_transction' }
+
+sub description_config { t8('GL search') }
+
+sub description_field { t8('GL search') }
+
+sub query_autocomplete {
+  my ($self, %params) = @_;
+
+  my $limit = $::form->{limit} || 40; # max number of results per type (AR/AP/GL)
+  my $term  = $::form->{term}  || '';
+
+  my $descriptionquery = { ilike => '%' . $term . '%' };
+  my $referencequery   = { ilike => '%' . $term . '%' };
+  my $apinvnumberquery = { ilike => '%' . $term . '%' };
+  my $namequery        = { ilike => '%' . $term . '%' };
+  my $arinvnumberquery = { ilike => '%' . $term       };
+  # ar match is more restrictive. Left fuzzy beginning so it also matches "Storno zu $INVNUMBER"
+  # and numbers like 000123 if you only enter 123.
+  # When used in quicksearch short numbers like 1 or 11 won't match because of the
+  # ajax autocomplete minlimit of 3 characters
+
+  my (@glfilter, @arfilter, @apfilter);
+
+  push( @glfilter, (or => [ description => $descriptionquery, reference => $referencequery ] ) );
+  push( @arfilter, (or => [ invnumber   => $arinvnumberquery, name      => $namequery ] ) );
+  push( @apfilter, (or => [ invnumber   => $apinvnumberquery, name      => $namequery ] ) );
+
+  my $gls = SL::DB::Manager::GLTransaction->get_all(  query => [ @glfilter ], limit => $limit, sort_by => 'transdate DESC');
+  my $ars = SL::DB::Manager::Invoice->get_all(        query => [ @arfilter ], limit => $limit, sort_by => 'transdate DESC', with_objects => [ 'customer' ]);
+  my $aps = SL::DB::Manager::PurchaseInvoice->get_all(query => [ @apfilter ], limit => $limit, sort_by => 'transdate DESC', with_objects => [ 'vendor' ]);
+
+  # use the sum of all credit amounts as the "amount" of the gl transaction
+  foreach my $gl ( @$gls ) {
+    $gl->{'amount'} = sum map { $_->amount if $_->amount > 0 } @{$gl->transactions};
+  };
+
+  my $gldata = [
+    map(
+      {
+        {
+           transdate => DateTime->from_object(object => $_->transdate)->ymd(),
+           label     => $_->abbreviation. ": " . $_->description . " " . $_->reference . " " . $::form->format_amount(\%::myconfig, $_->{'amount'},2). " (" . $_->transdate->to_lxoffice . ")" ,
+           id        => 'gl.pl?action=edit&id=' . $_->id,
+        }
+      }
+      @{$gls}
+    ),
+  ];
+
+  my $ardata = [
+    map(
+      {
+        {
+           transdate => DateTime->from_object(object => $_->transdate)->ymd(),
+           label     => $_->abbreviation . ": " . $_->invnumber . "   " . $_->customer->name . " " . $::form->format_amount(\%::myconfig, $_->amount,2)  . " (" . $_->transdate->to_lxoffice . ")" ,
+           id        => ($_->invoice ? "is" : "ar" ) . '.pl?action=edit&id=' . $_->id,
+        }
+      }
+      @{$ars}
+    ),
+  ];
+
+  my $apdata = [
+    map(
+      {
+        {
+           transdate => DateTime->from_object(object => $_->transdate)->ymd(),
+           label     => $_->abbreviation . ": " . $_->invnumber . " " . $_->vendor->name . " " . $::form->format_amount(\%::myconfig, $_->amount,2)  . " (" . $_->transdate->to_lxoffice . ")" ,
+           value     => "",
+           id        => ($_->invoice ? "ir" : "ap" ) . '.pl?action=edit&id=' . $_->id,
+        }
+      }
+      @{$aps}
+    ),
+  ];
+
+  my $data;
+  push(@{$data},@{$gldata});
+  push(@{$data},@{$ardata});
+  push(@{$data},@{$apdata});
+
+  @$data = reverse sort { $a->{'transdate'} cmp $b->{'transdate'} } @$data;
+
+  $data;
+}
+
+sub select_autocomplete {
+  $::form->{id}
+}
+
+sub do_search {
+  my ($self) = @_;
+
+  my $results = $self->query_autocomplete;
+
+  return @$results == 1
+    ? $results->[0]{id}
+    : undef;
+}
+
+# TODO: result overview page
+
+1;
index e509da1..2ae61cb 100644 (file)
@@ -3,6 +3,8 @@ package SL::Layout::Top;
 use strict;
 use parent qw(SL::Layout::Base);
 
+use SL::Controller::TopQuickSearch;
+
 sub pre_content {
   my ($self) = @_;
 
@@ -10,6 +12,7 @@ sub pre_content {
     now        => DateTime->now_local,
     is_fastcgi => $::dispatcher ? scalar($::dispatcher->interface_type =~ /fastcgi/i) : 0,
     is_links   => scalar($ENV{HTTP_USER_AGENT}         =~ /links/i),
+    quick_search => SL::Controller::TopQuickSearch->new,
   );
 }
 
@@ -18,8 +21,8 @@ sub stylesheets {
 }
 
 sub javascripts {
-  ('jquery-ui.js', 'quicksearch_input.js') x!! $::auth->assert('customer_vendor_edit|part_service_assembly_edit', 1),
-  ('jquery-ui.js', 'glquicksearch.js')     x!! $::auth->assert('general_ledger', 1)
+  'jquery-ui.js',
+  'kivi.QuickSearch.js',
 }
 
 1;
diff --git a/js/kivi.QuickSearch.js b/js/kivi.QuickSearch.js
new file mode 100644 (file)
index 0000000..95c275a
--- /dev/null
@@ -0,0 +1,54 @@
+namespace('kivi', function(k){
+  k.QuickSearch = function($real, options) {
+    if ($real.data("quick_search"))
+      return $real.data("quick_search");
+
+    var KEY = {
+      ENTER:     13,
+    };
+    var o = $.extend({
+      limit: 20,
+      delay: 50,
+    }, options);
+
+    function send_query(action, term, id, success) {
+      var data = { module: o.module };
+      if (term != undefined) data.term = term;
+      if (id   != undefined) data.id   = id;
+      $.ajax($.extend(o, {
+        url:      'controller.pl?action=TopQuickSearch/' + action,
+        dataType: "json",
+        data:     data,
+        success:  success
+      }));
+    }
+
+    function submit_search(term) {
+      send_query('do_search', term, undefined, kivi.eval_json_result);
+    }
+
+    $real.autocomplete({
+      source: function(req, rsp) {
+        send_query('query_autocomplete', req.term, undefined, function (data){ rsp(data) });
+      },
+      select: function(event, ui) {
+        send_query('select_autocomplete', undefined, ui.item.id, kivi.eval_json_result);
+      },
+    });
+    $real.keydown(function(event){
+      if (event.which == KEY.ENTER) {
+        if ($real.val() != '') {
+          submit_search($real.val());
+        }
+      }
+    });
+
+    $real.data('quick_search', {});
+  }
+});
+
+$(function(){
+  $('input[id^=top-quick-search]').each(function(_,e){
+    kivi.QuickSearch($(e), { module: $(e).attr('module') })
+  })
+})
index 533f4a4..8e73671 100644 (file)
@@ -5,15 +5,11 @@
  <span class="frame-header-element frame-header-left">
     [<a href="controller.pl?action=LoginScreen/user_login" target="_blank" title="[% 'Open a further kivitendo window or tab' | $T8 %]">[% 'New window/tab' | $T8 %]</a>]
     [<a href="JavaScript:top.print();" title="[% 'Hardcopy' | $T8 %]">[% 'Print' | $T8 %]</a>]
-[%- IF AUTH.assert('part_service_assembly_edit', 1) %]
-    [<input name="frame_header_parts_search" id="frame_header_parts_search" placeholder="[% 'Search parts' | $T8 %]" size="14">]
-[%- END %]
-[%- IF AUTH.assert('customer_vendor_edit|customer_vendor_edit_all', 1) %]
-    [<input name="frame_header_contact_search" id="frame_header_contact_search" placeholder="[% 'Search contacts' | $T8 %]" size="14">]
-[%- END %]
-[%- IF AUTH.assert('general_ledger', 1) %]
-    [<input id="glquicksearch" name="glquicksearch" type="text" class="ui-widget" placeholder="[% 'GL search' | $T8 %]" maxlength="20">]
+
+[%- FOREACH search = quick_search.active_modules %]
+    [<input id="top-quick-search-[% search.name %]" module="[% search.name %]" placeholder="[% search.description_field %]" maxlength="20">]
 [%- END %]
+
  </span>
 [%- END %]
  <span class="frame-header-element frame-header-right">