From 6c63020409f486043d63c3a324db96a4a162ff67 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Sven=20Sch=C3=B6ling?= Date: Tue, 23 Feb 2016 11:46:27 +0100 Subject: [PATCH] TopQuickSearch: erste version --- SL/Controller/TopQuickSearch.pm | 190 ++++++++++++++++++ SL/Controller/TopQuickSearch/Assembly.pm | 93 +++++++++ SL/Controller/TopQuickSearch/Base.pm | 87 ++++++++ SL/Controller/TopQuickSearch/Contact.pm | 85 ++++++++ SL/Controller/TopQuickSearch/GLTransaction.pm | 118 +++++++++++ SL/Layout/Top.pm | 7 +- js/kivi.QuickSearch.js | 54 +++++ templates/webpages/menu/header.html | 12 +- 8 files changed, 636 insertions(+), 10 deletions(-) create mode 100644 SL/Controller/TopQuickSearch.pm create mode 100644 SL/Controller/TopQuickSearch/Assembly.pm create mode 100644 SL/Controller/TopQuickSearch/Base.pm create mode 100644 SL/Controller/TopQuickSearch/Contact.pm create mode 100644 SL/Controller/TopQuickSearch/GLTransaction.pm create mode 100644 js/kivi.QuickSearch.js diff --git a/SL/Controller/TopQuickSearch.pm b/SL/Controller/TopQuickSearch.pm new file mode 100644 index 000000000..63d9b5b8b --- /dev/null +++ b/SL/Controller/TopQuickSearch.pm @@ -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 %] + +[%- 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, 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 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=&term= + action=TopQuickSearch/search&module=&term= + +=head1 TODO + + - filter available searches with auth + - toggling with cofiguration doesn't work yet + +=head1 BUGS + +None yet :) + +=head1 AUTHOR + +Sven Schöling Es.schoeling@linet-services.deE + +=cut diff --git a/SL/Controller/TopQuickSearch/Assembly.pm b/SL/Controller/TopQuickSearch/Assembly.pm new file mode 100644 index 000000000..7622e66c0 --- /dev/null +++ b/SL/Controller/TopQuickSearch/Assembly.pm @@ -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 index 000000000..18c3b88e8 --- /dev/null +++ b/SL/Controller/TopQuickSearch/Base.pm @@ -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 + +=head1 INTERFACE + +An implementation must provide these functions. + +=over 4 + +=item C + +Must return a string used for access checks. Empty string or undef will mean +unrestricted access. + +=item C + +Internal name, must be plain ASCII. + +=item C + +Localized name used in the configuration (NYI) + +=item C + +Localized name used in the search field as hint. Should fit into an input of +length 20. + +=item C + +Needs to take C from C<$::form> and must return an arrayref of JSON +serializable matches fit for jquery autocomplete. + +=item C + +Needs to take C from C<$::form> and must return a redirect string to be +used with C pointing to a representation of +the selected object. + +=item C + +Needs to take C from C<$::form> and must return a redirect string to be +used with C 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. + +=back + +=head1 BUGS + +None yet :) + +=head1 AUTHOR + +Sven Schöling Es.schoeling@linet-services.deE + +=cut diff --git a/SL/Controller/TopQuickSearch/Contact.pm b/SL/Controller/TopQuickSearch/Contact.pm new file mode 100644 index 000000000..5607a5b82 --- /dev/null +++ b/SL/Controller/TopQuickSearch/Contact.pm @@ -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, <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 index 000000000..2b4facdec --- /dev/null +++ b/SL/Controller/TopQuickSearch/GLTransaction.pm @@ -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; diff --git a/SL/Layout/Top.pm b/SL/Layout/Top.pm index e509da1d7..2ae61cb05 100644 --- a/SL/Layout/Top.pm +++ b/SL/Layout/Top.pm @@ -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 index 000000000..95c275abb --- /dev/null +++ b/js/kivi.QuickSearch.js @@ -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') }) + }) +}) diff --git a/templates/webpages/menu/header.html b/templates/webpages/menu/header.html index 533f4a430..8e7367182 100644 --- a/templates/webpages/menu/header.html +++ b/templates/webpages/menu/header.html @@ -5,15 +5,11 @@ [[% 'New window/tab' | $T8 %]] [[% 'Print' | $T8 %]] -[%- IF AUTH.assert('part_service_assembly_edit', 1) %] - [] -[%- END %] -[%- IF AUTH.assert('customer_vendor_edit|customer_vendor_edit_all', 1) %] - [] -[%- END %] -[%- IF AUTH.assert('general_ledger', 1) %] - [] + +[%- FOREACH search = quick_search.active_modules %] + [] [%- END %] + [%- END %] -- 2.20.1