From fac8417d136f89b1ce0fed8c2ef35ee089228ac7 Mon Sep 17 00:00:00 2001 From: Moritz Bunkus Date: Tue, 22 Sep 2015 13:01:30 +0200 Subject: [PATCH] Project-Picker basierend auf Part-Picker --- SL/Controller/Project.pm | 63 +++++- SL/DB/Manager/Project.pm | 5 + SL/DB/Project.pm | 15 ++ SL/Presenter/Project.pm | 21 +- SL/Template/Plugin/L.pm | 1 + css/kivitendo/main.css | 1 + js/autocomplete_project.js | 223 ++++++++++++++++++++++ templates/webpages/project/test_page.html | 6 + 8 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 js/autocomplete_project.js create mode 100644 templates/webpages/project/test_page.html diff --git a/SL/Controller/Project.pm b/SL/Controller/Project.pm index 6cffe360e..fc72036ee 100644 --- a/SL/Controller/Project.pm +++ b/SL/Controller/Project.pm @@ -23,15 +23,17 @@ use SL::Helper::Flash; use SL::Locale::String; use Data::Dumper; +use JSON; +use Rose::DB::Object::Helpers qw(as_tree); use Rose::Object::MakeMethods::Generic ( scalar => [ qw(project linked_records) ], - 'scalar --get_set_init' => [ qw(models customers project_types project_statuses) ], + 'scalar --get_set_init' => [ qw(models customers project_types project_statuses projects) ], ); -__PACKAGE__->run_before('check_auth'); -__PACKAGE__->run_before('load_project', only => [ qw(edit update destroy) ]); +__PACKAGE__->run_before('check_auth', except => [ qw(ajax_autocomplete) ]); +__PACKAGE__->run_before('load_project', only => [ qw(edit update destroy) ]); # # actions @@ -102,6 +104,52 @@ sub action_destroy { $self->redirect_to(action => 'search'); } +sub action_ajax_autocomplete { + my ($self, %params) = @_; + + $::form->{filter}{'all:substr:multi::ilike'} =~ s{[\(\)]+}{}g; + + # if someone types something, and hits enter, assume he entered the full name. + # if something matches, treat that as sole match + # unfortunately get_models can't do more than one per package atm, so we d it + # the oldfashioned way. + if ($::form->{prefer_exact}) { + my $exact_matches; + if (1 == scalar @{ $exact_matches = SL::DB::Manager::Project->get_all( + query => [ + obsolete => 0, + SL::DB::Manager::Project->type_filter($::form->{filter}{type}), + or => [ + description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} }, + projectnumber => { ilike => $::form->{filter}{'all:substr:multi::ilike'} }, + ] + ], + limit => 2, + ) }) { + $self->projects($exact_matches); + } + } + + $::form->{sort_by} = 'customer_and_description'; + + my @hashes = map { + +{ + value => $_->full_description(style => 'full'), + label => $_->full_description(style => 'full'), + id => $_->id, + projectnumber => $_->projectnumber, + description => $_->description, + cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } }, + } + } @{ $self->projects }; # neato: if exact match triggers we don't even need the init_projects + + $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 }); +} + +sub action_test_page { + $_[0]->render('project/test_page'); +} + # # filters # @@ -117,6 +165,14 @@ sub check_auth { sub init_project_statuses { SL::DB::Manager::ProjectStatus->get_all_sorted } sub init_project_types { SL::DB::Manager::ProjectType->get_all_sorted } +sub init_projects { + if ($::form->{no_paginate}) { + $_[0]->models->disable_plugin('paginated'); + } + + $_[0]->models->get; +} + sub init_customers { my ($self) = @_; my @customer_id = $self->project && $self->project->customer_id ? (id => $self->project->customer_id) : (); @@ -251,6 +307,7 @@ sub init_models { projectnumber => t8('Project Number'), project_type => t8('Project Type'), project_status => t8('Project Status'), + customer_and_description => 1, }, with_objects => [ 'customer', 'project_status', 'project_type' ], ); diff --git a/SL/DB/Manager/Project.pm b/SL/DB/Manager/Project.pm index 56a7e0b86..2cc50f278 100644 --- a/SL/DB/Manager/Project.pm +++ b/SL/DB/Manager/Project.pm @@ -30,6 +30,10 @@ __PACKAGE__->add_filter_specs( return () if $value ne 'orphaned'; return __PACKAGE__->is_not_used_filter($prefix); }, + all => sub { + my ($key, $value, $prefix) = @_; + return or => [ map { $prefix . $_ => $value } qw(projectnumber description customer.name) ] + } ); our %project_id_column_prefixes = ( @@ -49,6 +53,7 @@ sub _sort_spec { customer => 'customer.name', project_type => 'project_type.description', project_status => 'project_status.description', + customer_and_description => [ qw(customer.name project.description) ], }); } diff --git a/SL/DB/Project.pm b/SL/DB/Project.pm index 3f3e88ddb..aa97f764e 100644 --- a/SL/DB/Project.pm +++ b/SL/DB/Project.pm @@ -60,6 +60,14 @@ sub full_description { } elsif ($params{style} =~ m/description/) { $description = $self->description; + } elsif ($params{style} =~ m/full/) { + $description = $self->projectnumber; + if ($self->description && do { my $desc = quotemeta $self->description; $self->projectnumber !~ m/$desc/ }) { + $description .= ' ' . $self->description; + } + + $description = $self->customer->name . " (${description})"; + } else { $description = $self->projectnumber; if ($self->description && do { my $desc = quotemeta $self->description; $self->projectnumber !~ m/$desc/ }) { @@ -128,6 +136,13 @@ Returns only the project's number. Returns only the project's description. +=item C + +Returns the customer name followed by the project number and project +description in parenthesis (e.g. "Evil Corp (12345 World +domination)"). If the project's description is already part of the +project's number then it will not be appended. + =back =back diff --git a/SL/Presenter/Project.pm b/SL/Presenter/Project.pm index b8d20a0b3..5f3977156 100644 --- a/SL/Presenter/Project.pm +++ b/SL/Presenter/Project.pm @@ -5,7 +5,7 @@ use strict; use parent qw(Exporter); use Exporter qw(import); -our @EXPORT = qw(project); +our @EXPORT = qw(project project_picker); use Carp; @@ -29,6 +29,25 @@ sub project { return $self->escaped_text($text); } +sub project_picker { + my ($self, $name, $value, %params) = @_; + + $value = SL::DB::Manager::Project->find_by(id => $value) if $value && !ref $value; + my $id = delete($params{id}) || $self->name_to_id($name); + my @classes = $params{class} ? ($params{class}) : (); + push @classes, 'project_autocomplete'; + + my $ret = + $self->input_tag($name, (ref $value && $value->can('id') ? $value->id : ''), class => "@classes", type => 'hidden', id => $id) . + join('', map { $params{$_} ? $self->input_tag("", delete $params{$_}, id => "${id}_${_}", type => 'hidden') : '' } qw(customer_id)) . + $self->input_tag("", ref $value ? $value->displayable_name : '', id => "${id}_name", %params); + + $::request->layout->add_javascripts('autocomplete_project.js'); + $::request->presenter->need_reinit_widgets($id); + + $self->html_tag('span', $ret, class => 'project_picker'); +} + 1; __END__ diff --git a/SL/Template/Plugin/L.pm b/SL/Template/Plugin/L.pm index 62a4e8620..48960097a 100644 --- a/SL/Template/Plugin/L.pm +++ b/SL/Template/Plugin/L.pm @@ -69,6 +69,7 @@ sub simple_format { return _call_presenter('simple_format', @_); } sub part_picker { return _call_presenter('part_picker', @_); } sub chart_picker { return _call_presenter('chart_picker', @_); } sub customer_vendor_picker { return _call_presenter('customer_vendor_picker', @_); } +sub project_picker { return _call_presenter('project_picker', @_); } sub _set_id_attribute { my ($attributes, $name, $unique) = @_; diff --git a/css/kivitendo/main.css b/css/kivitendo/main.css index e9b54a136..b14c25fdc 100644 --- a/css/kivitendo/main.css +++ b/css/kivitendo/main.css @@ -394,6 +394,7 @@ label { } .customer-vendor-picker-undefined, .chartpicker-undefined, +.projectpicker-undefined, .partpicker-undefined { color: red; font-style: italic; diff --git a/js/autocomplete_project.js b/js/autocomplete_project.js new file mode 100644 index 000000000..c55450463 --- /dev/null +++ b/js/autocomplete_project.js @@ -0,0 +1,223 @@ +namespace('kivi', function(k){ + k.ProjectPicker = function($real, options) { + // short circuit in case someone double inits us + if ($real.data("project_picker")) + return $real.data("project_picker"); + + var KEY = { + ESCAPE: 27, + ENTER: 13, + TAB: 9, + LEFT: 37, + RIGHT: 39, + PAGE_UP: 33, + PAGE_DOWN: 34, + }; + var CLASSES = { + PICKED: 'projectpicker-picked', + UNDEFINED: 'projectpicker-undefined', + } + var o = $.extend({ + limit: 20, + delay: 50, + }, options); + var STATES = { + PICKED: CLASSES.PICKED, + UNDEFINED: CLASSES.UNDEFINED + } + var real_id = $real.attr('id'); + var $dummy = $('#' + real_id + '_name'); + var $customer_id = $('#' + real_id + '_customer_id'); + var state = STATES.PICKED; + var last_real = $real.val(); + var last_dummy = $dummy.val(); + var timer; + + function ajax_data(term) { + var data = { + 'filter.all:substr:multi::ilike': term, + 'filter.valid': 'valid', + no_paginate: $('#no_paginate').prop('checked') ? 1 : 0, + current: $real.val(), + }; + + if ($customer_id && $customer_id.val()) + data['filter.customer_id'] = $customer_id.val().split(','); + + return data; + } + + function set_item (item) { + if (item.id) { + $real.val(item.id); + // autocomplete ui has name, use the value for ajax items, which contains displayable_name + $dummy.val(item.name ? item.name : item.value); + } else { + $real.val(''); + $dummy.val(''); + } + state = STATES.PICKED; + last_real = $real.val(); + last_dummy = $dummy.val(); + last_unverified_dummy = $dummy.val(); + + $real.trigger('change'); + $real.trigger('set_item:ProjectPicker', item); + + annotate_state(); + } + + function make_defined_state () { + if (state == STATES.PICKED) { + annotate_state(); + return true + } else if (state == STATES.UNDEFINED && $dummy.val() == '') + set_item({}) + else { + last_unverified_dummy = $dummy.val(); + set_item({ id: last_real, name: last_dummy }) + } + annotate_state(); + } + + function annotate_state () { + if (state == STATES.PICKED) + $dummy.removeClass(STATES.UNDEFINED).addClass(STATES.PICKED); + else if (state == STATES.UNDEFINED && $dummy.val() == '') + $dummy.removeClass(STATES.UNDEFINED).addClass(STATES.PICKED); + else { + last_unverified_dummy = $dummy.val(); + $dummy.addClass(STATES.UNDEFINED).removeClass(STATES.PICKED); + } + } + + function update_results () { + $.ajax({ + url: 'controller.pl?action=Project/project_picker_result', + data: $.extend({ + 'real_id': $real.val(), + }, ajax_data(function(){ var val = $('#project_picker_filter').val(); return val === undefined ? '' : val })), + success: function(data){ $('#project_picker_result').html(data) } + }); + }; + + function result_timer (event) { + if (!$('no_paginate').prop('checked')) { + if (event.keyCode == KEY.PAGE_UP) { + $('#project_picker_result a.paginate-prev').click(); + return; + } + if (event.keyCode == KEY.PAGE_DOWN) { + $('#project_picker_result a.paginate-next').click(); + return; + } + } + window.clearTimeout(timer); + timer = window.setTimeout(update_results, 100); + } + + $dummy.autocomplete({ + source: function(req, rsp) { + $.ajax($.extend(o, { + url: 'controller.pl?action=Project/ajax_autocomplete', + dataType: "json", + data: ajax_data(req.term), + success: function (data){ rsp(data) } + })); + }, + select: function(event, ui) { + set_item(ui.item); + }, + }); + /* In case users are impatient and want to skip ahead: + * Capture key events and check if it's a unique hit. + * If it is, go ahead and assume it was selected. If it wasn't don't do + * anything so that autocompletion kicks in. For don't prevent + * propagation. It would be nice to catch it, but javascript is too stupid + * to fire a tab event later on, so we'd have to reimplement the "find + * next active element in tabindex order and focus it". + */ + /* note: + * event.which does not contain tab events in keypressed in firefox but will report 0 + * chrome does not fire keypressed at all on tab or escape + */ + $dummy.keydown(function(event){ + if (event.which == KEY.ENTER || event.which == KEY.TAB) { + // if string is empty assume they want to delete + if ($dummy.val() == '') { + set_item({}); + return true; + } else if (state == STATES.PICKED) { + return true; + } + if (event.which == KEY.TAB) event.preventDefault(); + $.ajax({ + url: 'controller.pl?action=Project/ajax_autocomplete', + dataType: "json", + data: $.extend( ajax_data($dummy.val()), { prefer_exact: 1 } ), + success: function (data) { + if (data.length == 1) { + set_item(data[0]); + if (event.which == KEY.ENTER) + $('#update_button').click(); + } else { + } + annotate_state(); + } + }); + if (event.which == KEY.ENTER) + return false; + } else { + state = STATES.UNDEFINED; + } + }); + + $dummy.blur(function(){ + window.clearTimeout(timer); + timer = window.setTimeout(annotate_state, 100); + }); + + // now add a picker div after the original input + var pcont = $('').addClass('position-absolute'); + var picker = $('
'); + $dummy.after(pcont); + pcont.append(picker); + + var pp = { + real: function() { return $real }, + dummy: function() { return $dummy }, + type: function() { return $type }, + customer_id: function() { return $customer_id }, + update_results: update_results, + result_timer: result_timer, + set_item: set_item, + reset: make_defined_state, + is_defined_state: function() { return state == STATES.PICKED }, + init_results: function () { + $('div.project_picker_project').each(function(){ + $(this).click(function(){ + set_item({ + id: $(this).children('input.project_picker_id').val(), + name: $(this).children('input.project_picker_description').val(), + }); + $dummy.focus(); + return true; + }); + }); + $('#project_selection').keydown(function(e){ + if (e.which == KEY.ESCAPE) { + $dummy.focus(); + } + }); + } + } + $real.data('project_picker', pp); + return pp; + } +}); + +$(function(){ + $('input.project_autocomplete').each(function(i,real){ + kivi.ProjectPicker($(real)); + }) +}); diff --git a/templates/webpages/project/test_page.html b/templates/webpages/project/test_page.html new file mode 100644 index 000000000..65f88c451 --- /dev/null +++ b/templates/webpages/project/test_page.html @@ -0,0 +1,6 @@ +[% USE L %] + +

Projekt-Picker-Testpage

+ +
+[% L.project_picker('project_id', '', style='width: 600px') %] text
-- 2.20.1