[Rt-commit] rt 04/04: Initial ticket search filter support

sunnavy sunnavy at bestpractical.com
Thu Jul 29 21:40:11 UTC 2021


This is an automated email from the git hooks/post-receive script.

sunnavy pushed a commit to branch 5.0/search-filter
in repository rt.

commit e3cf698871ed8820f69e980f1d8f843e7d60f77e
Author: sunnavy <sunnavy at bestpractical.com>
AuthorDate: Fri Jun 25 06:59:08 2021 +0800

    Initial ticket search filter support
    
    Currently the following fields are supported: Subject, Status, Queue,
    Owner, Creator and LastUpdatedBy.
---
 lib/RT/Interface/Web/MenuBuilder.pm          |   2 +-
 share/html/Elements/CollectionAsTable/Header |  31 +++-
 share/html/Elements/SearchFilter             | 229 +++++++++++++++++++++++++++
 share/html/Search/Results.html               |   7 +-
 share/static/css/elevator-light/misc.css     |  13 ++
 share/static/js/util.js                      |  68 ++++++++
 6 files changed, 341 insertions(+), 9 deletions(-)

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 3b3b67c554..2adcd08353 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -617,7 +617,7 @@ sub BuildMainNav {
                 map {
                     my $p = $_;
                     $p => $HTML::Mason::Commands::DECODED_ARGS->{$p} || $current_search->{$p}
-                } qw(Query Format OrderBy Order Page Class ObjectType ResultPage ExtraQueryParams),
+                } qw(BaseQuery Query Format OrderBy Order Page Class ObjectType ResultPage ExtraQueryParams),
             ),
         );
 
diff --git a/share/html/Elements/CollectionAsTable/Header b/share/html/Elements/CollectionAsTable/Header
index 0fe80642fb..7f7660e234 100644
--- a/share/html/Elements/CollectionAsTable/Header
+++ b/share/html/Elements/CollectionAsTable/Header
@@ -59,6 +59,7 @@ $GenericQueryArgs => undef
 $maxitems     => undef
 
 $AllowSorting  => undef
+$AllowFiltering => undef
 $BaseURL       => undef
 @PassArguments => qw(Query Format Rows Page Order OrderBy)
 </%ARGS>
@@ -161,16 +162,18 @@ foreach my $col ( @Format ) {
         $loc_title = loc($m->comp('/Elements/ScrubHTML', Content => $title));
     }
 
-    if ( $AllowSorting and $col->{'attribute'}
-        and my $attr = $m->comp(
+    my $attribute;
+    if ( $col->{'attribute'} ) {
+        $attribute = $m->comp(
             "/Elements/ColumnMap",
             Class => $column_map_class,
             Name  => $col->{'attribute'},
             Attr  => 'attribute'
-        )
-      )
-    {
-        $attr = ProcessColumnMapValue( $attr, Arguments => [ $col->{'attribute'} ], Escape => 0 );
+        );
+    }
+
+    if ( $AllowSorting and $attribute ) {
+        my $attr = ProcessColumnMapValue( $attribute, Arguments => [ $col->{'attribute'} ], Escape => 0 );
 
 
         my @new_order_by = @OrderBy;
@@ -210,6 +213,22 @@ foreach my $col ( @Format ) {
     else {
         $m->out( $loc_title );
     }
+
+    if ( $AllowFiltering && $Class eq 'RT::Tickets' ) {
+        my $attr = ProcessColumnMapValue( $attribute, Arguments => [ $col->{'attribute'} ], Escape => 1 );
+        if ( ( $attr || '' ) =~ /^(Subject|Status|Queue|Owner|Creator|LastUpdatedBy)\b/ ) {
+            my $field = $1;
+            my $base_query = $ARGS{BaseQuery} || $ARGS{Query};
+            my $query      = $ARGS{Query};
+            RT::Interface::Web::EscapeHTML( \$base_query );
+            RT::Interface::Web::EscapeHTML( \$query );
+            my $tooltip = loc( 'Filter on [_1]', loc($field) );
+            $m->out(
+                qq{ <a href="javascript:void(0)" class="search-filter" data-attribute="$field" data-query="$query" data-base-query="$base_query" class="btn btn-primary button"><span class="fas fa-filter" data-toggle="tooltip" data-placement="bottom" data-original-title="$tooltip"></span></a>}
+            );
+            $m->out( $m->scomp( '/Elements/SearchFilter', Attribute => $field, %ARGS ) );
+        }
+    }
     $m->out('</th>');
 }
 </%PERL>
diff --git a/share/html/Elements/SearchFilter b/share/html/Elements/SearchFilter
new file mode 100644
index 0000000000..40300a8257
--- /dev/null
+++ b/share/html/Elements/SearchFilter
@@ -0,0 +1,229 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<div class="modal search-results-filter">
+  <div class="modal-dialog" role="document">
+    <form name="search-results-filter">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title"><&|/l&>Filter Results</&></h5>
+          <a href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
+            <span aria-hidden="true">×</span>
+          </a>
+        </div>
+        <div class="modal-body">
+%       if ( $Attribute eq 'Subject' ) {
+          <div class="form-row">
+            <div class="label col-3">
+                <&|/l&>Subject</&>:
+            </div>
+            <div class="value col-9">
+                <input class="form-control" name="Subject" value="<% $filter{Subject} // '' %>" />
+            </div>
+          </div>
+%       } elsif ( $Attribute eq 'Status' ) {
+          <div class="form-row">
+            <div class="label col-3">
+                <&|/l&>Status</&>:
+            </div>
+            <div class="value col-9">
+              <ul class="list-group list-group-compact">
+%           for my $status ( sort { lc $a cmp lc $b } keys %status  ) {
+                <li class="list-group-item">
+                  <div class="custom-control custom-checkbox">
+                    <input type="checkbox" id="Status-<% $status %>" name="Status" class="custom-control-input" value="<% $status %>" <% $filter{Status}{$status} ? 'checked="checked"' : '' |n %> />
+                    <label class="custom-control-label" for="Status-<% $status %>"><% $status %></label>
+                  </div>
+                </li>
+%           }
+              </ul>
+            </div>
+          </div>
+%       } elsif ( $Attribute eq 'Queue' ) {
+          <div class="form-row">
+            <div class="label col-3">
+                <&|/l&>Queue</&>:
+            </div>
+            <div class="value col-9">
+              <ul class="list-group list-group-compact">
+%             for my $queue ( sort { lc $a->Name cmp lc $b->Name } @queues ) {
+                <li class="list-group-item">
+                  <div class="custom-control custom-checkbox">
+                    <input type="checkbox" id="Queue-<% $queue->Id %>" name="Queue" class="custom-control-input" value="<% $queue->Id %>" <% $filter{Queue}{$queue->Id} ? 'checked="checked"' : '' |n %> />
+                    <label class="custom-control-label" for="Queue-<% $queue->Id %>"><% $queue->Name %></label>
+                  </div>
+                </li>
+%             }
+              </ul>
+            </div>
+          </div>
+%       } elsif ( $Attribute eq 'Owner' ) {
+          <div class="form-row">
+            <div class="label col-3">
+              <&|/l&>Owner</&>:
+            </div>
+            <div class="value col-9">
+              <& /Elements/SelectOwner, Name => 'Owner', Default => $filter{Owner} &>
+            </div>
+          </div>
+
+%       } elsif ( $Attribute =~ /^(?:Creator|LastUpdatedBy)$/ ) {
+          <div class="form-row">
+            <div class="label col-3">
+              <% loc($Attribute) %>:
+            </div>
+            <div class="value col-9">
+              <input class="form-control" data-autocomplete="Users" name="<% $Attribute %>" value="<% $filter{$Attribute} %>" data-autocomplete-return="Name" />
+            </div>
+          </div>
+%       }
+
+        </div>
+        <div class="modal-footer">
+          <div class="form-row justify-content-end">
+            <div class="col-auto">
+              <input type="button" class="button btn btn-primary" data-dismiss="modal" name="Apply" value="<% loc('Cancel') %>" />
+            </div>
+            <div class="col-auto">
+              <input type="button" class="button btn btn-primary" onclick="filterSearchResults()" name="Apply" value="<% loc('Apply') %>" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </form>
+  </div>
+</div>
+<%INIT>
+return unless $ARGS{Query};
+
+my $tickets = RT::Tickets->new($session{CurrentUser});
+my ( $ok ) = $tickets->FromSQL($ARGS{Query});
+return unless $ok && ( $ARGS{ BaseQuery } || $tickets->Count );
+
+my @queues;
+
+{
+    my $tree = RT::Interface::Web::QueryBuilder::Tree->new;
+    $tree->ParseSQL( Query => $ARGS{BaseQuery} || $ARGS{Query}, CurrentUser => $session{'CurrentUser'} );
+    my $referenced_queues = $tree->GetReferencedQueues;
+    for my $name_or_id ( keys %$referenced_queues ) {
+        my $queue = RT::Queue->new($session{CurrentUser});
+        $queue->Load($name_or_id);
+        if ( $queue->id ) {
+            push @queues, $queue;
+        }
+    }
+}
+
+my %status;
+my @lifecycles;
+
+if ( @queues ) {
+    my %lifecycle;
+    for my $queue ( @queues ) {
+        next if $lifecycle{$queue->Lifecycle}++;
+        push @lifecycles, $queue->LifecycleObj;
+    }
+}
+else {
+    @lifecycles = map { RT::Lifecycle->Load(Type => 'ticket', Name => $_) } RT::Lifecycle->List('ticket');
+}
+
+for my $lifecycle ( @lifecycles ) {
+    $status{$_} = 1 for $lifecycle->Valid;
+}
+delete $status{deleted};
+
+if ( !@queues ) {
+    my $queues = RT::Queues->new( $session{CurrentUser} );
+    $queues->UnLimit;
+
+    while ( my $queue = $queues->Next ) {
+        push @queues, $queue;
+        last if @queues == 10; # TODO make a config for it
+    }
+}
+
+my %filter;
+
+if ( $ARGS{BaseQuery} && $ARGS{BaseQuery} ne $ARGS{Query} ) {
+    my $query = $ARGS{Query};
+    $query =~ s!^\s*\(?\s*\Q$ARGS{BaseQuery}\E\s*\)? AND !!;
+    my $tree = RT::Interface::Web::QueryBuilder::Tree->new;
+    $tree->ParseSQL( Query => $query, CurrentUser => $session{'CurrentUser'} );
+    $tree->traverse(
+        sub {
+            my $node = shift;
+
+            return if $node->isRoot;
+            return unless $node->isLeaf;
+
+            my $clause = $node->getNodeValue();
+            if ( $clause->{Key} =~ /Queue/ ) {
+                my $queue = RT::Queue->new($session{CurrentUser});
+                $queue->Load($clause->{Value});
+                if ( $queue->id ) {
+                    $filter{ $clause->{Key} }{ $queue->id } = 1;
+                }
+            }
+            elsif ( $clause->{Key} =~ /Status|SLA/ ) {
+                $filter{ $clause->{Key} }{ $clause->{Value} } = 1;
+            }
+            else {
+                my $key = $clause->{Key};
+                my $value = $clause->{Value};
+                $value =~ s!\\([\\"])!$1!g;
+                $filter{$key} = $value;
+            }
+        }
+    );
+}
+</%INIT>
+
+<%ARGS>
+$Attribute => ''
+</%ARGS>
diff --git a/share/html/Search/Results.html b/share/html/Search/Results.html
index 27737ec2b4..496748ad26 100644
--- a/share/html/Search/Results.html
+++ b/share/html/Search/Results.html
@@ -64,6 +64,7 @@
     Query => $Query,
     TotalFound => $count,
     AllowSorting => 1,
+    AllowFiltering => 1,
     OrderBy => $OrderBy,
     Order => $Order,
     Rows => $Rows,
@@ -75,13 +76,14 @@
     SavedSearchId => $ARGS{'SavedSearchId'},
     SavedChartSearchId => $ARGS{'SavedChartSearchId'},
     ObjectType => $ObjectType,
+    BaseQuery => $BaseQuery || $Query,
     @ExtraQueryParams ? ( map { $_ => $ARGS{$_} } grep { defined $ARGS{$_} } 'ExtraQueryParams', @ExtraQueryParams ) : (),
-    PassArguments => [qw(Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId Class ObjectType ExtraQueryParams), @ExtraQueryParams],
+    PassArguments => [qw(BaseQuery Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId Class ObjectType ExtraQueryParams), @ExtraQueryParams],
 &>
 % }
 % $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
 
-% my %hiddens = (Query => $Query, Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
+% my %hiddens = (BaseQuery => $BaseQuery || $Query, Query => $Query, Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
 <div align="right" class="refresh">
 <form method="get" action="<%RT->Config->Get('WebPath')%>/Search/Results.html">
 % foreach my $key (keys(%hiddens)) {
@@ -293,6 +295,7 @@ $session{$session_name}->PrepForSerialization();
 </%CLEANUP>
 <%ARGS>
 $Query => undef
+$BaseQuery => undef
 $Format => undef 
 $HideResults => 0
 $Rows => undef
diff --git a/share/static/css/elevator-light/misc.css b/share/static/css/elevator-light/misc.css
index 2a05277000..f35c24740a 100644
--- a/share/static/css/elevator-light/misc.css
+++ b/share/static/css/elevator-light/misc.css
@@ -164,3 +164,16 @@ span.pagenum {
 ul.ui-autocomplete {
     z-index: 9999;
 }
+
+.modal.search-results-filter {
+    position: absolute;
+    min-width: 300px;
+    width: auto;
+    height: auto;
+    max-height: 80%;
+    background-color: inherit;
+}
+
+.modal.search-results-filter .modal.dialog {
+    margin: 0;
+}
diff --git a/share/static/js/util.js b/share/static/js/util.js
index 0553d1aa75..f099628f14 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -828,8 +828,76 @@ jQuery(function() {
             });
         });
     }
+
+    jQuery(".search-filter").click(function(ev){
+        ev.preventDefault();
+        var modal = jQuery(this).closest('th').find('.modal.search-results-filter');
+        modal.css('top', jQuery(this).offset().top);
+        var left = jQuery(this).offset().left;
+        // 10 is extra space to move modal a bit away from edge
+        if ( left + modal.width() + 10 > jQuery('body').width() ) {
+            left = jQuery('body').width() - modal.width() - 10;
+        }
+        modal.css('left', left);
+        modal.modal('show');
+    });
 });
 
+function filterSearchResults () {
+    var clauses = [];
+
+    var queue_clauses = [];
+    jQuery('.search-results-filter input[name=Queue]:checked').each( function() {
+        queue_clauses.push( 'Queue = ' + '"' + jQuery(this).val() + '"' );
+    });
+
+    if ( queue_clauses.length ) {
+        clauses.push( '( ' + queue_clauses.join( ' OR ' ) + ' )' );
+    }
+
+    var status_clauses = [];
+    jQuery('.search-results-filter input[name=Status]:checked').each( function() {
+        status_clauses.push('Status = ' + '"' + jQuery(this).val() + '"' );
+    });
+
+    if ( status_clauses.length ) {
+        clauses.push( '( ' + status_clauses.join( ' OR ' ) + ' )' );
+    }
+
+    var subject = jQuery('.search-results-filter input[name=Subject]').val();
+    if ( subject && subject.match(/\S/) ) {
+        clauses.push( '( Subject LIKE "' + subject.replace(/(["\\])/g, "\\$1") + '" )' );
+    }
+
+    [ 'Owner', 'Creator', 'LastUpdatedBy' ].forEach( function(role) {
+        var value = jQuery('.search-results-filter :input[name=' + role + ']').val();
+        if ( value && value.match(/\S/) ) {
+            var subs = [];
+            clauses.push( role + ' = "' + value + '"' );
+        }
+    });
+
+    var refresh_form = jQuery('div.refresh form');
+    var base_query = refresh_form.find('input[name=BaseQuery]').val();
+
+    var query;
+    if ( clauses.length ) {
+        if ( base_query.match(/^\s*\(.+\)\s*$/) ) {
+            query = base_query + " AND " + clauses.join( ' AND ' );
+        }
+        else {
+            query = '( ' + base_query + " ) AND " + clauses.join( ' AND ' );
+        }
+    }
+    else {
+        query = base_query;
+    }
+
+    refresh_form.find('input[name=Query]').val(query);
+    refresh_form.submit();
+    return false;
+};
+
 /* inline edit */
 jQuery(function () {
     var inlineEditEnabled = true;

-- 
To stop receiving notification emails like this one, please contact
sysadmin at bestpractical.com.


More information about the rt-commit mailing list