[Rt-commit] rt branch, 4.6/txn-query-builder-with-new-themes, created. rt-4.4.4-548-ga896b4d1b2

? sunnavy sunnavy at bestpractical.com
Tue Dec 10 07:00:30 EST 2019


The branch, 4.6/txn-query-builder-with-new-themes has been created
        at  a896b4d1b268199f2bdc1920164d410425449dcb (commit)

- Log -----------------------------------------------------------------
commit 17fde7e07de2f509421192bc841de2f56ae911b9
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Jun 28 04:26:07 2019 +0800

    Initial ticket transaction query builder
    
    Currently, the following fields are supported:
    
    * transaction core fields
    * transaction custom fields
    * ticket core fields

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 35b73f5b25..b76df54f9b 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -2332,6 +2332,68 @@ Set($PreferDropzone, 1);
 
 =back
 
+=head2 Transaction search
+
+=over 4
+
+=item C<%TransactionDefaultSearchResultFormat>
+
+C<%TransactionDefaultSearchResultFormat> is the default format for RT
+transaction search results for various object types. Keys are object types
+like C<RT::Ticket>, values are the format string.
+
+=cut
+
+Set(%TransactionDefaultSearchResultFormat,
+    'RT::Ticket' => qq{
+        '<B><A HREF="__WebPath__/Transaction/Display.html?id=__id__">__id__</a></B>/TITLE:ID',
+        '<B><A HREF="__WebPath__/Ticket/Display.html?id=__ObjectId__">__ObjectId__</a></B>/TITLE:Ticket',
+        '__Description__',
+        '<small>__OldValue__</small>',
+        '<small>__NewValue__</small>',
+        '<small>__Content__</small>',
+        '<small>__CreatedRelative__</small>',
+   },
+);
+
+=item C<%TranactionDefaultSearchResultOrderBy>
+
+What Transactions column should we order by for RT Transaction search
+results for various object types.  Keys are object types like C<RT::Ticket>,
+values are the column names.
+
+Defaults to I<id>.
+
+=cut
+
+Set( %TransactionDefaultSearchResultOrderBy, 'RT::Ticket' => 'id' );
+
+=item C<%TransactionDefaultSearchResultOrder>
+
+When ordering RT Transaction search results by
+C<%TransactionDefaultSearchResultOrderBy>, should the sort be ascending
+(ASC) or descending (DESC).  Keys are object types like C<RT::Ticket>,
+values are either "ASC" or "DESC".
+
+Defaults to I<ASC>.
+
+=cut
+
+Set( %TransactionDefaultSearchResultOrder, 'RT::Ticket' => 'ASC' );
+
+=item C<%TransactionShowSearchResultCount>
+
+Display search result count on transaction lists.  Keys are object types
+like C<RT::Ticket>, values are either 1 or 0.
+
+Defaults to 1 (show them).
+
+=cut
+
+Set( %TransactionShowSearchResultCount, 'RT::Ticket' => 1 );
+
+=back
+
 =head2 Transaction display
 
 =over 4
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 4623aa91a7..94876843bc 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4557,6 +4557,34 @@ sub GetCustomFieldInputNamePrefix {
     RT::Interface::Web::GetCustomFieldInputNamePrefix(@_);
 }
 
+=head2  LoadTransaction id
+
+Takes a transaction id as its only variable. if it's handed an array, it takes
+the first value.
+
+Returns an RT::Transaction object as the current user.
+
+=cut
+
+sub LoadTransaction {
+    my $id = shift;
+
+    if ( ref($id) eq "ARRAY" ) {
+        $id = $id->[0];
+    }
+
+    unless ($id) {
+        Abort( loc('No transaction specified'), Code => HTTP::Status::HTTP_BAD_REQUEST );
+    }
+
+    my $Transaction = RT::Transaction->new( $session{'CurrentUser'} );
+    $Transaction->Load($id);
+    unless ( $Transaction->id ) {
+        Abort( loc( 'Could not load transaction #[_1]', $id ), Code => HTTP::Status::HTTP_NOT_FOUND );
+    }
+    return $Transaction;
+}
+
 package RT::Interface::Web;
 RT::Base->_ImportOverlays();
 
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 0e1d7b9c36..afce228867 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -181,6 +181,10 @@ sub BuildMainNav {
     $search->child( assets => title => loc("Assets"), path => "/Asset/Search/" )
         if $current_user->HasRight( Right => 'ShowAssetsMenu', Object => RT->System );
 
+    my $txns = $search->child( transactions => title => loc('Transactions'), path => '/Search/Build.html?Class=RT::Transactions&ObjectType=RT::Ticket' );
+    my $txns_tickets = $txns->child( tickets => title => loc('Tickets'), path => "/Search/Build.html?Class=RT::Transactions&ObjectType=RT::Ticket" );
+    $txns_tickets->child( new => title => loc('New Search'), path => "/Search/Build.html?Class=RT::Transactions&ObjectType=RT::Ticket&NewQuery=1" );
+
     my $reports = $top->child( reports =>
         title       => loc('Reports'),
         description => loc('Reports summarizing ticket resolution and status'),
@@ -455,15 +459,27 @@ sub BuildMainNav {
     my $has_query = '';
     if (
         (
-               $request_path =~ m{^/(?:Ticket|Search)/}
+               $request_path =~ m{^/(?:Ticket|Transaction|Search)/}
             && $request_path !~ m{^/Search/Simple\.html}
         )
         || (   $request_path =~ m{^/Search/Simple\.html}
             && $HTML::Mason::Commands::DECODED_ARGS->{'q'} )
       )
     {
-        my $search = $top->child('search')->child('tickets');
-        my $current_search = $HTML::Mason::Commands::session{"CurrentSearchHash"} || {};
+        my $class = $HTML::Mason::Commands::DECODED_ARGS->{Class}
+            || ( $request_path =~ m{^/Transaction/} ? 'RT::Transactions' : 'RT::Tickets' );
+
+        my ( $search, $hash_name );
+        if ( $class eq 'RT::Tickets' ) {
+            $search = $top->child('search')->child('tickets');
+            $hash_name = 'CurrentSearchHash';
+        }
+        else {
+            $search = $txns_tickets;
+            $hash_name = join '-', 'CurrentSearchHash', $class, $HTML::Mason::Commands::DECODED_ARGS->{ObjectType} || 'RT::Ticket';
+        }
+
+        my $current_search = $HTML::Mason::Commands::session{$hash_name} || {};
         my $search_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchLoad'} || $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchId'} || $current_search->{'SearchId'} || '';
         my $chart_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedChartSearchId'} || $current_search->{SavedChartSearchId};
 
@@ -477,7 +493,7 @@ sub BuildMainNav {
                 map {
                     my $p = $_;
                     $p => $HTML::Mason::Commands::DECODED_ARGS->{$p} || $current_search->{$p}
-                } qw(Query Format OrderBy Order Page)
+                } qw(Query Format OrderBy Order Page Class ObjectType)
             ),
             RowsPerPage => (
                 defined $HTML::Mason::Commands::DECODED_ARGS->{'RowsPerPage'}
@@ -485,6 +501,8 @@ sub BuildMainNav {
                 : $current_search->{'RowsPerPage'}
             ),
         );
+        $fallback_query_args{Class} ||= $class;
+        $fallback_query_args{ObjectType} ||= 'RT::Ticket';
 
         if ($query_string) {
             $args = '?' . $query_string;
@@ -514,10 +532,13 @@ sub BuildMainNav {
         }
 
         my $current_search_menu;
-        if ( $request_path =~ m{^/Ticket} ) {
+        if (   $class eq 'RT::Tickets' && $request_path =~ m{^/Ticket}
+            || $class eq 'RT::Transactions' && $request_path =~ m{^/Transaction} )
+        {
             $current_search_menu = $search->child( current_search => title => loc('Current Search') );
             $current_search_menu->path("/Search/Results.html$args") if $has_query;
-        } else {
+        }
+        else {
             $current_search_menu = $page;
         }
 
@@ -526,54 +547,60 @@ sub BuildMainNav {
         $current_search_menu->child( advanced =>
             title => loc('Advanced'),    path => "/Search/Edit.html$args" );
         $current_search_menu->child( custom_date_ranges =>
-            title => loc('Custom Date Ranges'), path => "/Search/CustomDateRanges.html" );
+            title => loc('Custom Date Ranges'), path => "/Search/CustomDateRanges.html" ) if $class eq 'RT::Tickets';
         if ($has_query) {
             $current_search_menu->child( results => title => loc('Show Results'), path => "/Search/Results.html$args" );
         }
 
         if ( $has_query ) {
-            $current_search_menu->child( bulk  => title => loc('Bulk Update'), path => "/Search/Bulk.html$args" );
-            $current_search_menu->child( chart => title => loc('Chart'),       path => "/Search/Chart.html$args" );
+            if ( $class eq 'RT::Tickets' ) {
+                $current_search_menu->child( bulk  => title => loc('Bulk Update'), path => "/Search/Bulk.html$args" );
+                $current_search_menu->child( chart => title => loc('Chart'),       path => "/Search/Chart.html$args" );
+            }
 
             my $more = $current_search_menu->child( more => title => loc('Feeds') );
 
             $more->child( spreadsheet => title => loc('Spreadsheet'), path => "/Search/Results.tsv$args" );
 
-            my %rss_data = map {
-                $_ => $query_args->{$_} || $fallback_query_args{$_} || '' }
-                    qw(Query Order OrderBy);
-            my $RSSQueryString = "?"
-                . QueryString( Query   => $rss_data{Query},
-                                   Order   => $rss_data{Order},
-                                   OrderBy => $rss_data{OrderBy}
-                                 );
-            my $RSSPath = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes( $_, 'u' ),
-                $current_user->UserObj->Name,
-                $current_user
-                ->UserObj->GenerateAuthString(   $rss_data{Query}
-                                               . $rss_data{Order}
-                                               . $rss_data{OrderBy} );
-
-            $more->child( rss => title => loc('RSS'), path => "/NoAuth/rss/$RSSPath/$RSSQueryString");
-            my $ical_path = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes($_, 'u'),
-                $current_user->UserObj->Name,
-                $current_user->UserObj->GenerateAuthString( $rss_data{Query} ),
-                $rss_data{Query};
-            $more->child( ical => title => loc('iCal'), path => '/NoAuth/iCal/'.$ical_path);
-
-            if ($request_path =~ m{^/Search/Results.html}
-                &&                        #XXX TODO better abstraction
-                $current_user->HasRight( Right => 'SuperUser', Object => RT->System )) {
-                my $shred_args = QueryString(
-                    Search          => 1,
-                    Plugin          => 'Tickets',
-                    'Tickets:query' => $rss_data{'Query'},
-                    'Tickets:limit' => $query_args->{'Rows'},
-                );
+            if ( $class eq 'RT::Tickets' ) {
+                my %rss_data
+                    = map { $_ => $query_args->{$_} || $fallback_query_args{$_} || '' } qw(Query Order OrderBy);
+                my $RSSQueryString = "?"
+                    . QueryString(
+                    Query   => $rss_data{Query},
+                    Order   => $rss_data{Order},
+                    OrderBy => $rss_data{OrderBy}
+                    );
+                my $RSSPath = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes( $_, 'u' ),
+                    $current_user->UserObj->Name,
+                    $current_user->UserObj->GenerateAuthString(
+                    $rss_data{Query} . $rss_data{Order} . $rss_data{OrderBy} );
+
+                $more->child( rss => title => loc('RSS'), path => "/NoAuth/rss/$RSSPath/$RSSQueryString" );
+                my $ical_path = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes( $_, 'u' ),
+                    $current_user->UserObj->Name,
+                    $current_user->UserObj->GenerateAuthString( $rss_data{Query} ),
+                    $rss_data{Query};
+                $more->child( ical => title => loc('iCal'), path => '/NoAuth/iCal/' . $ical_path );
+
+                if ($request_path =~ m{^/Search/Results.html}
+                    &&    #XXX TODO better abstraction
+                    $current_user->HasRight( Right => 'SuperUser', Object => RT->System )
+                   )
+                {
+                    my $shred_args = QueryString(
+                        Search          => 1,
+                        Plugin          => 'Tickets',
+                        'Tickets:query' => $rss_data{'Query'},
+                        'Tickets:limit' => $query_args->{'Rows'},
+                    );
 
-                $more->child( shredder => title => loc('Shredder'), path => '/Admin/Tools/Shredder/?' . $shred_args);
+                    $more->child(
+                        shredder => title => loc('Shredder'),
+                        path     => '/Admin/Tools/Shredder/?' . $shred_args
+                    );
+                }
             }
-
         }
     }
 
diff --git a/lib/RT/Interface/Web/QueryBuilder/Tree.pm b/lib/RT/Interface/Web/QueryBuilder/Tree.pm
index 1ab187794f..2da719c9c2 100644
--- a/lib/RT/Interface/Web/QueryBuilder/Tree.pm
+++ b/lib/RT/Interface/Web/QueryBuilder/Tree.pm
@@ -110,7 +110,7 @@ sub GetReferencedQueues {
             return unless $node->isLeaf;
 
             my $clause = $node->getNodeValue();
-            return unless $clause->{Key} eq 'Queue';
+            return unless $clause->{Key} =~ /^(?:Ticket)?Queue$/;
             return unless $clause->{Op} eq '=';
 
             $queues->{ $clause->{Value} } = 1;
@@ -251,13 +251,14 @@ sub ParseSQL {
     my %args = (
         Query => '',
         CurrentUser => '', #XXX: Hack
+        Class => 'RT::Tickets',
         @_
     );
     my $string = $args{'Query'};
 
     my @results;
 
-    my %field = %{ RT::Tickets->new( $args{'CurrentUser'} )->FIELDS };
+    my %field = %{ $args{Class}->new( $args{'CurrentUser'} )->FIELDS };
     my %lcfield = map { ( lc($_) => $_ ) } keys %field;
 
     my $node =  $self;
diff --git a/lib/RT/Transactions.pm b/lib/RT/Transactions.pm
index 6c602b39db..cd59c1ed11 100644
--- a/lib/RT/Transactions.pm
+++ b/lib/RT/Transactions.pm
@@ -72,23 +72,32 @@ use warnings;
 use base 'RT::SearchBuilder';
 
 use RT::Transaction;
+use 5.010;
 
 sub Table { 'Transactions'}
 
 # {{{ sub _Init  
 sub _Init   {
-  my $self = shift;
-  
-  $self->{'table'} = "Transactions";
-  $self->{'primary_key'} = "id";
-  
-  # By default, order by the date of the transaction, rather than ID.
-  $self->OrderByCols( { FIELD => 'Created',
-                        ORDER => 'ASC' },
-                      { FIELD => 'id',
-                        ORDER => 'ASC' } );
+    my $self = shift;
+
+    $self->{'table'} = "Transactions";
+    $self->{'primary_key'} = "id";
+
+    # By default, order by the date of the transaction, rather than ID.
+    $self->OrderByCols( { FIELD => 'Created',
+                          ORDER => 'ASC' },
+                        { FIELD => 'id',
+                          ORDER => 'ASC' } );
 
-  return ( $self->SUPER::_Init(@_));
+    $self->SUPER::_Init(@_);
+    $self->_InitSQL();
+}
+
+sub _InitSQL {
+    my $self = shift;
+    # Private Member Variables (which should get cleaned)
+    $self->{'_sql_query'}         = '';
+    $self->{'_sql_looking_at'}    = {};
 }
 
 =head2 LimitToTicket TICKETID 
@@ -138,6 +147,973 @@ sub AddRecord {
     return $self->SUPER::AddRecord($record);
 }
 
+our %FIELD_METADATA = (
+    id         => ['INT'],                 #loc_left_pair
+    ObjectId   => ['ID'],                  #loc_left_pair
+    ObjectType => ['STRING'],              #loc_left_pair
+    Creator    => [ 'ENUM' => 'User' ],    #loc_left_pair
+    TimeTaken  => ['INT'],                 #loc_left_pair
+
+    Type          => ['STRING'],                 #loc_left_pair
+    Field         => ['STRING'],                 #loc_left_pair
+    OldValue      => ['STRING'],                 #loc_left_pair
+    NewValue      => ['STRING'],                 #loc_left_pair
+    ReferenceType => ['STRING'],                 #loc_left_pair
+    OldReference  => ['STRING'],                 #loc_left_pair
+    NewReference  => ['STRING'],                 #loc_left_pair
+    Data          => ['STRING'],                 #loc_left_pair
+    Created       => [ 'DATE' => 'Created' ],    #loc_left_pair
+
+    Content     => ['ATTACHCONTENT'],            #loc_left_pair
+    ContentType => ['ATTACHFIELD'],              #loc_left_pair
+    Filename    => ['ATTACHFIELD'],              #loc_left_pair
+    Subject     => ['ATTACHFIELD'],              #loc_left_pair
+
+    CustomFieldValue => [ 'CUSTOMFIELD' => 'Transaction' ],    #loc_left_pair
+    CustomField      => [ 'CUSTOMFIELD' => 'Transaction' ],    #loc_left_pair
+    CF               => [ 'CUSTOMFIELD' => 'Transaction' ],    #loc_left_pair
+
+    TicketId              => ['TICKETFIELD'],                  #loc_left_pair
+    TicketSubject         => ['TICKETFIELD'],                  #loc_left_pair
+    TicketQueue           => ['TICKETFIELD'],                  #loc_left_pair
+    TicketStatus          => ['TICKETFIELD'],                  #loc_left_pair
+    TicketOwner           => ['TICKETFIELD'],                  #loc_left_pair
+    TicketCreator         => ['TICKETFIELD'],                  #loc_left_pair
+    TicketLastUpdatedBy   => ['TICKETFIELD'],                  #loc_left_pair
+    TicketCreated         => ['TICKETFIELD'],                  #loc_left_pair
+    TicketStarted         => ['TICKETFIELD'],                  #loc_left_pair
+    TicketResolved        => ['TICKETFIELD'],                  #loc_left_pair
+    TicketTold            => ['TICKETFIELD'],                  #loc_left_pair
+    TicketLastUpdated     => ['TICKETFIELD'],                  #loc_left_pair
+    TicketStarts          => ['TICKETFIELD'],                  #loc_left_pair
+    TicketDue             => ['TICKETFIELD'],                  #loc_left_pair
+    TicketPriority        => ['TICKETFIELD'],                  #loc_left_pair
+    TicketInitialPriority => ['TICKETFIELD'],                  #loc_left_pair
+    TicketFinalPriority   => ['TICKETFIELD'],                  #loc_left_pair
+    TicketType            => ['TICKETFIELD'],                  #loc_left_pair
+    TicketQueueLifecycle  => ['TICKETQUEUEFIELD'],             #loc_left_pair
+
+    CustomFieldName => ['CUSTOMFIELDNAME'],                    #loc_left_pair
+    CFName          => ['CUSTOMFIELDNAME'],                    #loc_left_pair
+
+    OldCFValue => ['OBJECTCUSTOMFIELDVALUE'],                  #loc_left_pair
+    NewCFValue => ['OBJECTCUSTOMFIELDVALUE'],                  #loc_left_pair
+);
+
+# Lower Case version of FIELDS, for case insensitivity
+our %LOWER_CASE_FIELDS = map { ( lc($_) => $_ ) } (keys %FIELD_METADATA);
+
+our %dispatch = (
+    INT                    => \&_IntLimit,
+    ID                     => \&_IdLimit,
+    ENUM                   => \&_EnumLimit,
+    DATE                   => \&_DateLimit,
+    STRING                 => \&_StringLimit,
+    CUSTOMFIELD            => \&_CustomFieldLimit,
+    ATTACHFIELD            => \&_AttachLimit,
+    ATTACHCONTENT          => \&_AttachContentLimit,
+    TICKETFIELD            => \&_TicketLimit,
+    TICKETQUEUEFIELD       => \&_TicketQueueLimit,
+    OBJECTCUSTOMFIELDVALUE => \&_ObjectCustomFieldValueLimit,
+    CUSTOMFIELDNAME        => \&_CustomFieldNameLimit,
+);
+
+sub FIELDS     { return \%FIELD_METADATA }
+
+our @SORTFIELDS = qw(id ObjectId Created);
+
+=head2 SortFields
+
+Returns the list of fields that lists of transactions can easily be sorted by
+
+=cut
+
+sub SortFields {
+    my $self = shift;
+    return (@SORTFIELDS);
+}
+
+=head1 Limit Helper Routines
+
+These routines are the targets of a dispatch table depending on the
+type of field.  They all share the same signature:
+
+  my ($self,$field,$op,$value, at rest) = @_;
+
+The values in @rest should be suitable for passing directly to
+DBIx::SearchBuilder::Limit.
+
+Essentially they are an expanded/broken out (and much simplified)
+version of what ProcessRestrictions used to do.  They're also much
+more clearly delineated by the TYPE of field being processed.
+
+=head2 _IdLimit
+
+Handle ID field.
+
+=cut
+
+sub _IdLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    if ( $value eq '__Bookmarked__' ) {
+        return $sb->_BookmarkLimit( $field, $op, $value, @rest );
+    } else {
+        return $sb->_IntLimit( $field, $op, $value, @rest );
+    }
+}
+
+=head2 _EnumLimit
+
+Handle Fields which are limited to certain values, and potentially
+need to be looked up from another class.
+
+This subroutine actually handles two different kinds of fields.  For
+some the user is responsible for limiting the values.  (i.e. ObjectType).
+
+For others, the value specified by the user will be looked by via
+specified class.
+
+Meta Data:
+  name of class to lookup in (Optional)
+
+=cut
+
+sub _EnumLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    # SQL::Statement changes != to <>.  (Can we remove this now?)
+    $op = "!=" if $op eq "<>";
+
+    die "Invalid Operation: $op for $field"
+        unless $op eq "="
+        or $op     eq "!=";
+
+    my $meta = $FIELD_METADATA{$field};
+    if ( defined $meta->[1] && defined $value && $value !~ /^\d+$/ ) {
+        my $class = "RT::" . $meta->[1];
+        my $o     = $class->new( $sb->CurrentUser );
+        $o->Load($value);
+        $value = $o->Id || 0;
+    }
+    $sb->Limit(
+        FIELD    => $field,
+        VALUE    => $value,
+        OPERATOR => $op,
+        @rest,
+    );
+}
+
+=head2 _IntLimit
+
+Handle fields where the values are limited to integers.  (id)
+
+Meta Data:
+  None
+
+=cut
+
+sub _IntLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    my $is_a_like = $op =~ /MATCHES|ENDSWITH|STARTSWITH|LIKE/i;
+
+    # We want to support <id LIKE '1%'>, but we need to explicitly typecast
+    # on Postgres
+
+    if ( $is_a_like && RT->Config->Get('DatabaseType') eq 'Pg' ) {
+        return $sb->Limit(
+            FUNCTION => "CAST(main.$field AS TEXT)",
+            OPERATOR => $op,
+            VALUE    => $value,
+            @rest,
+        );
+    }
+
+    $sb->Limit(
+        FIELD    => $field,
+        VALUE    => $value,
+        OPERATOR => $op,
+        @rest,
+    );
+}
+
+=head2 _DateLimit
+
+Handle date fields.  (Created)
+
+Meta Data:
+  1: type of link.  (Probably not necessary.)
+
+=cut
+
+sub _DateLimit {
+    my ( $sb, $field, $op, $value, %rest ) = @_;
+
+    die "Invalid Date Op: $op"
+        unless $op =~ /^(=|>|<|>=|<=|IS(\s+NOT)?)$/i;
+
+    my $meta = $FIELD_METADATA{$field};
+    die "Incorrect Meta Data for $field"
+        unless ( defined $meta->[1] );
+
+    if ( $op =~ /^(IS(\s+NOT)?)$/i) {
+        return $sb->Limit(
+            FUNCTION => $sb->NotSetDateToNullFunction,
+            FIELD    => $meta->[1],
+            OPERATOR => $op,
+            VALUE    => "NULL",
+            %rest,
+        );
+    }
+
+    my $date = RT::Date->new( $sb->CurrentUser );
+    $date->Set( Format => 'unknown', Value => $value );
+
+    if ( $op eq "=" ) {
+
+        # if we're specifying =, that means we want everything on a
+        # particular single day.  in the database, we need to check for >
+        # and < the edges of that day.
+
+        $date->SetToMidnight( Timezone => 'server' );
+        my $daystart = $date->ISO;
+        $date->AddDay;
+        my $dayend = $date->ISO;
+
+        $sb->_OpenParen;
+
+        $sb->Limit(
+            FIELD    => $meta->[1],
+            OPERATOR => ">=",
+            VALUE    => $daystart,
+            %rest,
+        );
+
+        $sb->Limit(
+            FIELD    => $meta->[1],
+            OPERATOR => "<",
+            VALUE    => $dayend,
+            %rest,
+            ENTRYAGGREGATOR => 'AND',
+        );
+
+        $sb->_CloseParen;
+
+    }
+    else {
+        $sb->Limit(
+            FUNCTION => $sb->NotSetDateToNullFunction,
+            FIELD    => $meta->[1],
+            OPERATOR => $op,
+            VALUE    => $date->ISO,
+            %rest,
+        );
+    }
+}
+
+=head2 _StringLimit
+
+Handle simple fields which are just strings.  (Type, Field, OldValue, NewValue, ReferenceType)
+
+Meta Data:
+  None
+
+=cut
+
+sub _StringLimit {
+    my ( $sb, $field, $op, $value, @rest ) = @_;
+
+    # FIXME:
+    # Valid Operators:
+    #  =, !=, LIKE, NOT LIKE
+    if ( RT->Config->Get('DatabaseType') eq 'Oracle'
+        && (!defined $value || !length $value)
+        && lc($op) ne 'is' && lc($op) ne 'is not'
+    ) {
+        if ($op eq '!=' || $op =~ /^NOT\s/i) {
+            $op = 'IS NOT';
+        } else {
+            $op = 'IS';
+        }
+        $value = 'NULL';
+    }
+
+    $sb->Limit(
+        FIELD         => $field,
+        OPERATOR      => $op,
+        VALUE         => $value,
+        CASESENSITIVE => 0,
+        @rest,
+    );
+}
+
+=head2 _ObjectCustomFieldValueLimit
+
+Handle object custom field values.  (OldReference, NewReference)
+
+Meta Data:
+  None
+
+=cut
+
+sub _ObjectCustomFieldValueLimit {
+    my ( $self, $field, $op, $value, @rest ) = @_;
+
+    my $alias_name = $field =~ /new/i ? 'newocfv' : 'oldocfv';
+    $self->{_sql_aliases}{$alias_name} ||= $self->Join(
+        TYPE   => 'LEFT',
+        FIELD1 => $field =~ /new/i ? 'NewReference' : 'OldReference',
+        TABLE2 => 'ObjectCustomFieldValues',
+        FIELD2 => 'id',
+    );
+
+    my $value_is_long = ( length( Encode::encode( "UTF-8", $value ) ) > 255 ) ? 1 : 0;
+
+    $self->Limit(
+        @rest,
+        ALIAS         => $self->{_sql_aliases}{$alias_name},
+        FIELD         => $value_is_long ? 'LargeContent' : 'Content',
+        OPERATOR      => $op,
+        VALUE         => $value,
+        CASESENSITIVE => 0,
+        @rest,
+    );
+}
+
+=head2 _CustomFieldNameLimit
+
+Handle custom field name field.  (Field)
+
+Meta Data:
+  None
+
+=cut
+
+sub _CustomFieldNameLimit {
+    my ( $self, $_field, $op, $value, %rest ) = @_;
+
+    $self->Limit(
+        FIELD         => 'Type',
+        OPERATOR      => '=',
+        VALUE         => 'CustomField',
+        CASESENSITIVE => 0,
+        ENTRYAGGREGATOR => 'AND',
+    );
+
+    if ( $value =~ /\D/ ) {
+        my $cfs = RT::CustomFields->new( RT->SystemUser );
+        $cfs->Limit(
+            FIELD         => 'Name',
+            VALUE         => $value,
+            CASESENSITIVE => 0,
+        );
+        $value = [ map { $_->id } @{ $cfs->ItemsArrayRef } ];
+
+        $self->Limit(
+            FIELD         => 'Field',
+            OPERATOR      => $op eq '!=' ? 'NOT IN' : 'IN',
+            VALUE         => $value,
+            CASESENSITIVE => 0,
+            ENTRYAGGREGATOR => 'AND',
+            %rest,
+        );
+    }
+    else {
+        $self->Limit(
+            FIELD         => 'Field',
+            OPERATOR      => $op,
+            VALUE         => $value,
+            CASESENSITIVE => 0,
+            ENTRYAGGREGATOR => 'AND',
+            %rest,
+        );
+    }
+}
+
+=head2 _CustomFieldDecipher
+
+Try and turn a CF descriptor into (cfid, cfname) object pair.
+
+Takes an optional second parameter of the CF LookupType, defaults to Ticket CFs.
+
+=cut
+
+sub _CustomFieldDecipher {
+    my ($self, $string, $lookuptype) = @_;
+    $lookuptype ||= $self->_SingularClass->CustomFieldLookupType;
+
+    my ($object, $field, $column) = ($string =~ /^(?:(.+?)\.)?\{(.+)\}(?:\.(Content|LargeContent))?$/);
+    $field ||= ($string =~ /^\{(.*?)\}$/)[0] || $string;
+
+    my ($cf, $applied_to);
+
+    if ( $object ) {
+        my $record_class = RT::CustomField->RecordClassFromLookupType($lookuptype);
+        $applied_to = $record_class->new( $self->CurrentUser );
+        $applied_to->Load( $object );
+
+        if ( $applied_to->id ) {
+            RT->Logger->debug("Limiting to CFs identified by '$field' applied to $record_class #@{[$applied_to->id]} (loaded via '$object')");
+        }
+        else {
+            RT->Logger->warning("$record_class '$object' doesn't exist, parsed from '$string'");
+            $object = 0;
+            undef $applied_to;
+        }
+    }
+
+    if ( $field =~ /\D/ ) {
+        $object ||= '';
+        my $cfs = RT::CustomFields->new( $self->CurrentUser );
+        $cfs->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
+        $cfs->LimitToLookupType($lookuptype);
+
+        if ($applied_to) {
+            $cfs->SetContextObject($applied_to);
+            $cfs->LimitToObjectId($applied_to->id);
+        }
+
+        # if there is more then one field the current user can
+        # see with the same name then we shouldn't return cf object
+        # as we don't know which one to use
+        $cf = $cfs->First;
+        if ( $cf ) {
+            $cf = undef if $cfs->Next;
+        }
+    }
+    else {
+        $cf = RT::CustomField->new( $self->CurrentUser );
+        $cf->Load( $field );
+        $cf->SetContextObject($applied_to)
+            if $cf->id and $applied_to;
+    }
+
+    return ($object, $field, $cf, $column);
+}
+
+=head2 _CustomFieldLimit
+
+Limit based on CustomFields
+
+Meta Data:
+  none
+
+=cut
+
+sub _CustomFieldLimit {
+    my ( $self, $_field, $op, $value, %rest ) = @_;
+
+    my $meta  = $FIELD_METADATA{ $_field };
+    my $class = $meta->[1] || 'Transaction';
+    my $type  = "RT::$class"->CustomFieldLookupType;
+
+    my $field = $rest{'SUBKEY'} || die "No field specified";
+
+    # For our sanity, we can only limit on one object at a time
+
+    my ($object, $cfid, $cf, $column);
+    ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $field, $type );
+
+
+    $self->_LimitCustomField(
+        %rest,
+        LOOKUPTYPE  => $type,
+        CUSTOMFIELD => $cf || $field,
+        KEY      => $cf ? $cf->id : "$type-$object.$field",
+        OPERATOR => $op,
+        VALUE    => $value,
+        COLUMN   => $column,
+        SUBCLAUSE => "txnsql",
+    );
+}
+
+=head2 _AttachLimit
+
+Limit based on the ContentType or the Filename of an attachment.
+
+=cut
+
+sub _AttachLimit {
+    my ( $self, $field, $op, $value, %rest ) = @_;
+
+    unless ( defined $self->{_sql_aliases}{attach} ) {
+        $self->{_sql_aliases}{attach} = $self->Join(
+            TYPE   => 'LEFT', # not all txns have an attachment
+            FIELD1 => 'id',
+            TABLE2 => 'Attachments',
+            FIELD2 => 'TransactionId',
+        );
+    }
+
+    $self->Limit(
+        %rest,
+        ALIAS         => $self->{_sql_aliases}{attach},
+        FIELD         => $field,
+        OPERATOR      => $op,
+        VALUE         => $value,
+        CASESENSITIVE => 0,
+    );
+}
+
+=head2 _AttachContentLimit
+
+Limit based on the Content of a transaction.
+
+=cut
+
+sub _AttachContentLimit {
+
+    my ( $self, $field, $op, $value, %rest ) = @_;
+    $field = 'Content' if $field =~ /\W/;
+
+    my $config = RT->Config->Get('FullTextSearch') || {};
+    unless ( $config->{'Enable'} ) {
+        $self->Limit( %rest, FIELD => 'id', VALUE => 0 );
+        return;
+    }
+
+    unless ( defined $self->{_sql_aliases}{attach} ) {
+        $self->{_sql_aliases}{attach} = $self->Join(
+            TYPE   => 'LEFT', # not all txns have an attachment
+            FIELD1 => 'id',
+            TABLE2 => 'Attachments',
+            FIELD2 => 'TransactionId',
+        );
+    }
+
+    $self->_OpenParen;
+    if ( $config->{'Indexed'} ) {
+        my $db_type = RT->Config->Get('DatabaseType');
+
+        my $alias;
+        if ( $config->{'Table'} and $config->{'Table'} ne "Attachments") {
+            $alias = $self->{'_sql_aliases'}{'full_text'} ||= $self->Join(
+                TYPE   => 'LEFT',
+                ALIAS1 => $self->{_sql_aliases}{attach},
+                FIELD1 => 'id',
+                TABLE2 => $config->{'Table'},
+                FIELD2 => 'id',
+            );
+        } else {
+            $alias = $self->{_sql_aliases}{attach};
+        }
+
+        #XXX: handle negative searches
+        my $index = $config->{'Column'};
+        if ( $db_type eq 'Oracle' ) {
+            my $dbh = $RT::Handle->dbh;
+            my $alias = $self->{_sql_aliases}{attach};
+            $self->Limit(
+                %rest,
+                FUNCTION      => "CONTAINS( $alias.$field, ".$dbh->quote($value) .")",
+                OPERATOR      => '>',
+                VALUE         => 0,
+                QUOTEVALUE    => 0,
+                CASESENSITIVE => 1,
+            );
+            # this is required to trick DBIx::SB's LEFT JOINS optimizer
+            # into deciding that join is redundant as it is
+            $self->Limit(
+                ENTRYAGGREGATOR => 'AND',
+                ALIAS           => $self->{_sql_aliases}{attach},
+                FIELD           => 'Content',
+                OPERATOR        => 'IS NOT',
+                VALUE           => 'NULL',
+            );
+        }
+        elsif ( $db_type eq 'Pg' ) {
+            my $dbh = $RT::Handle->dbh;
+            $self->Limit(
+                %rest,
+                ALIAS       => $alias,
+                FIELD       => $index,
+                OPERATOR    => '@@',
+                VALUE       => 'plainto_tsquery('. $dbh->quote($value) .')',
+                QUOTEVALUE  => 0,
+            );
+        }
+        elsif ( $db_type eq 'mysql' and not $config->{Sphinx}) {
+            my $dbh = $RT::Handle->dbh;
+            $self->Limit(
+                %rest,
+                FUNCTION    => "MATCH($alias.Content)",
+                OPERATOR    => 'AGAINST',
+                VALUE       => "(". $dbh->quote($value) ." IN BOOLEAN MODE)",
+                QUOTEVALUE  => 0,
+            );
+            # As with Oracle, above, this forces the LEFT JOINs into
+            # JOINS, which allows the FULLTEXT index to be used.
+            # Orthogonally, the IS NOT NULL clause also helps the
+            # optimizer decide to use the index.
+            $self->Limit(
+                ENTRYAGGREGATOR => 'AND',
+                ALIAS           => $alias,
+                FIELD           => "Content",
+                OPERATOR        => 'IS NOT',
+                VALUE           => 'NULL',
+                QUOTEVALUE      => 0,
+            );
+        }
+        elsif ( $db_type eq 'mysql' ) {
+            # This is a special character.  Note that \ does not escape
+            # itself (in Sphinx 2.1.0, at least), so 'foo\;bar' becoming
+            # 'foo\\;bar' is not a vulnerability, and is still parsed as
+            # "foo, \, ;, then bar".  Happily, the default mode is
+            # "all", meaning that boolean operators are not special.
+            $value =~ s/;/\\;/g;
+
+            my $max = $config->{'MaxMatches'};
+            $self->Limit(
+                %rest,
+                ALIAS       => $alias,
+                FIELD       => 'query',
+                OPERATOR    => '=',
+                VALUE       => "$value;limit=$max;maxmatches=$max",
+            );
+        }
+    } else {
+        # This is the main difference from ticket content search.
+        # For transaction searches, it probably worths keeping emails.
+        # $self->Limit(
+        #     %rest,
+        #     FIELD    => 'Type',
+        #     OPERATOR => 'NOT IN',
+        #     VALUE    => ['EmailRecord', 'CommentEmailRecord'],
+        # );
+
+        $self->Limit(
+            ENTRYAGGREGATOR => 'AND',
+            ALIAS           => $self->{_sql_aliases}{attach},
+            FIELD           => $field,
+            OPERATOR        => $op,
+            VALUE           => $value,
+            CASESENSITIVE   => 0,
+        );
+    }
+    if ( RT->Config->Get('DontSearchFileAttachments') ) {
+        $self->Limit(
+            ENTRYAGGREGATOR => 'AND',
+            ALIAS           => $self->{_sql_aliases}{attach},
+            FIELD           => 'Filename',
+            OPERATOR        => 'IS',
+            VALUE           => 'NULL',
+        );
+    }
+    $self->_CloseParen;
+}
+
+sub _TicketLimit {
+    my ( $self, $field, $op, $value, %rest ) = @_;
+    $field =~ s!^Ticket!!;
+
+    if ( $field eq 'Queue' && $value =~ /\D/ ) {
+        my $queue = RT::Queue->new($self->CurrentUser);
+        $queue->Load($value);
+        $value = $queue->id if $queue->id;
+    }
+
+    if ( $field =~ /^(?:Owner|Creator)$/ && $value =~ /\D/ ) {
+        my $user = RT::User->new( $self->CurrentUser );
+        $user->Load($value);
+        $value = $user->id if $user->id;
+    }
+
+    if ( $field eq 'Status' && $value =~ /^(?:__(?:in)?active__)$/i ) {
+        my $user = RT::User->new( $self->CurrentUser );
+        $user->Load($value);
+        $value = $user->id if $user->id;
+    }
+
+    $self->{_sql_looking_at}{ lc "ticket$field" } = 1;
+    $self->Limit(
+        %rest,
+        ALIAS         => $self->_JoinTickets,
+        FIELD         => $field,
+        OPERATOR      => $op,
+        VALUE         => $value,
+        CASESENSITIVE => 0,
+    );
+}
+
+sub _TicketQueueLimit {
+    my ( $self, $field, $op, $value, %rest ) = @_;
+    $field =~ s!^TicketQueue!!;
+
+    my $queue = $self->{_sql_aliases}{ticket_queues} ||= $_[0]->Join(
+        ALIAS1 => $self->_JoinTickets,
+        FIELD1 => 'Queue',
+        TABLE2 => 'Queues',
+        FIELD2 => 'id',
+    );
+
+    $self->Limit(
+        ALIAS    => $queue,
+        FIELD    => $field,
+        OPERATOR => $op,
+        VALUE    => $value,
+        %rest,
+    );
+}
+
+sub PrepForSerialization {
+    my $self = shift;
+    delete $self->{'items'};
+    delete $self->{'items_array'};
+    $self->RedoSearch();
+}
+
+sub _OpenParen {
+    $_[0]->SUPER::_OpenParen( $_[1] || 'txnsql' );
+}
+sub _CloseParen {
+    $_[0]->SUPER::_CloseParen( $_[1] || 'txnsql' );
+}
+
+sub Limit {
+    my $self = shift;
+    my %args = @_;
+    $self->{'must_redo_search'} = 1;
+    delete $self->{'raw_rows'};
+    delete $self->{'count_all'};
+
+    $args{SUBCLAUSE} ||= "txnsql"
+        if $self->{parsing_txnsql} and not $args{LEFTJOIN};
+
+    $self->{_sql_looking_at}{ lc $args{FIELD} }{ $args{VALUE} } = 1
+        if $args{FIELD} and ( not $args{ALIAS} or $args{ALIAS} eq "main" );
+    $self->SUPER::Limit(%args);
+}
+
+=head2 FromSQL
+
+Convert a RT-SQL string into a set of SearchBuilder restrictions.
+
+Returns (1, 'Status message') on success and (0, 'Error Message') on
+failure.
+
+=cut
+
+sub _parser {
+    my ($self,$string) = @_;
+
+    require RT::Interface::Web::QueryBuilder::Tree;
+    my $tree = RT::Interface::Web::QueryBuilder::Tree->new;
+    my @results = $tree->ParseSQL(
+        Query => $string,
+        CurrentUser => $self->CurrentUser,
+        Class => ref $self || $self,
+    );
+    die join "; ", map { ref $_ eq 'ARRAY' ? $_->[ 0 ] : $_ } @results if @results;
+
+
+    # To handle __Active__ and __InActive__ statuses, copied from
+    # RT::Tickets::_parser with field name updates, i.e.
+    #   Lifecycle => TicketQueueLifecycle
+    #   Status => TicketStatus
+
+    state ( $active_status_node, $inactive_status_node );
+    my $escape_quotes = sub {
+        my $text = shift;
+        $text =~ s{(['\\])}{\\$1}g;
+        return $text;
+    };
+
+    $tree->traverse(
+        sub {
+            my $node = shift;
+            return unless $node->isLeaf and $node->getNodeValue;
+            my ($key, $subkey, $meta, $op, $value, $bundle)
+                = @{$node->getNodeValue}{qw/Key Subkey Meta Op Value Bundle/};
+            return unless $key eq "TicketStatus" && $value =~ /^(?:__(?:in)?active__)$/i;
+
+            my $parent = $node->getParent;
+            my $index = $node->getIndex;
+
+            if ( ( lc $value eq '__inactive__' && $op eq '=' ) || ( lc $value eq '__active__' && $op eq '!=' ) ) {
+                unless ( $inactive_status_node ) {
+                    my %lifecycle =
+                      map { $_ => $RT::Lifecycle::LIFECYCLES{ $_ }{ inactive } }
+                      grep { @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ inactive } || [] } }
+                      grep { $RT::Lifecycle::LIFECYCLES_CACHE{ $_ }{ type } eq 'ticket' }
+                      keys %RT::Lifecycle::LIFECYCLES;
+                    return unless %lifecycle;
+
+                    my $sql;
+                    if ( keys %lifecycle == 1 ) {
+                        $sql = join ' OR ', map { qq{ TicketStatus = '$_' } } map { $escape_quotes->($_) } map { @$_ } values %lifecycle;
+                    }
+                    else {
+                        my @inactive_sql;
+                        for my $name ( keys %lifecycle ) {
+                            my $escaped_name = $escape_quotes->($name);
+                            my $inactive_sql =
+                                qq{TicketQueueLifecycle = '$escaped_name'}
+                              . ' AND ('
+                              . join( ' OR ', map { qq{ TicketStatus = '$_' } } map { $escape_quotes->($_) } @{ $lifecycle{ $name } } ) . ')';
+                            push @inactive_sql, qq{($inactive_sql)};
+                        }
+                        $sql = join ' OR ', @inactive_sql;
+                    }
+                    $inactive_status_node = RT::Interface::Web::QueryBuilder::Tree->new;
+                    $inactive_status_node->ParseSQL(
+                        Class       => ref $self,
+                        Query       => $sql,
+                        CurrentUser => $self->CurrentUser,
+                    );
+                }
+                $parent->removeChild( $node );
+                $parent->insertChild( $index, $inactive_status_node );
+            }
+            else {
+                unless ( $active_status_node ) {
+                    my %lifecycle =
+                      map {
+                        $_ => [
+                            @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ initial } || [] },
+                            @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ active }  || [] },
+                          ]
+                      }
+                      grep {
+                             @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ initial } || [] }
+                          || @{ $RT::Lifecycle::LIFECYCLES{ $_ }{ active }  || [] }
+                      }
+                      grep { $RT::Lifecycle::LIFECYCLES_CACHE{ $_ }{ type } eq 'ticket' }
+                      keys %RT::Lifecycle::LIFECYCLES;
+                    return unless %lifecycle;
+
+                    my $sql;
+                    if ( keys %lifecycle == 1 ) {
+                        $sql = join ' OR ', map { qq{ TicketStatus = '$_' } } map { $escape_quotes->($_) } map { @$_ } values %lifecycle;
+                    }
+                    else {
+                        my @active_sql;
+                        for my $name ( keys %lifecycle ) {
+                            my $escaped_name = $escape_quotes->($name);
+                            my $active_sql =
+                                qq{TicketQueueLifecycle = '$escaped_name'}
+                              . ' AND ('
+                              . join( ' OR ', map { qq{ TicketStatus = '$_' } } map { $escape_quotes->($_) } @{ $lifecycle{ $name } } ) . ')';
+                            push @active_sql, qq{($active_sql)};
+                        }
+                        $sql = join ' OR ', @active_sql;
+                    }
+                    $active_status_node = RT::Interface::Web::QueryBuilder::Tree->new;
+                    $active_status_node->ParseSQL(
+                        Class       => ref $self,
+                        Query       => $sql,
+                        CurrentUser => $self->CurrentUser,
+                    );
+                }
+                $parent->removeChild( $node );
+                $parent->insertChild( $index, $active_status_node );
+            }
+        }
+    );
+
+    my $ea = '';
+    $tree->traverse(
+        sub {
+            my $node = shift;
+            $ea = $node->getParent->getNodeValue if $node->getIndex > 0;
+            return $self->_OpenParen unless $node->isLeaf;
+
+            my ($key, $subkey, $meta, $op, $value, $bundle)
+                = @{$node->getNodeValue}{qw/Key Subkey Meta Op Value Bundle/};
+
+            # normalize key and get class (type)
+            my $class = $meta->[0];
+
+            # replace __CurrentUser__ with id
+            $value = $self->CurrentUser->id if $value eq '__CurrentUser__';
+
+            # replace __CurrentUserName__ with the username
+            $value = $self->CurrentUser->Name if $value eq '__CurrentUserName__';
+
+            my $sub = $dispatch{ $class }
+                or die "No dispatch method for class '$class'";
+
+            # A reference to @res may be pushed onto $sub_tree{$key} from
+            # above, and we fill it here.
+            $sub->( $self, $key, $op, $value,
+                    ENTRYAGGREGATOR => $ea,
+                    SUBKEY          => $subkey,
+                    BUNDLE          => $bundle,
+                  );
+        },
+        sub {
+            my $node = shift;
+            return $self->_CloseParen unless $node->isLeaf;
+        }
+    );
+}
+
+sub FromSQL {
+    my ($self,$query) = @_;
+
+    $self->CleanSlate;
+    $self->_InitSQL;
+
+    return (1, $self->loc("No Query")) unless $query;
+
+    $self->{_sql_query} = $query;
+    eval {
+        local $self->{parsing_txnsql} = 1;
+        $self->_parser( $query );
+    };
+    if ( $@ ) {
+        my $error = "$@";
+        $RT::Logger->error("Couldn't parse query: $error");
+        return (0, $error);
+    }
+
+    if ( !$self->{_sql_looking_at}{objecttype} ) {
+        $self->Limit( FIELD => 'ObjectType', VALUE => 'RT::Ticket' );
+        $self->{_sql_looking_at}{objecttype}{'RT::Ticket'} = 1;
+    }
+
+    if ( $self->{_sql_looking_at}{objecttype}{'RT::Ticket'} ) {
+        if ( !$self->{_sql_looking_at}{tickettype} ) {
+            $self->Limit(
+                ALIAS         => $self->_JoinTickets,
+                FIELD         => 'Type',
+                OPERATOR      => '=',
+                VALUE         => 'ticket',
+                CASESENSITIVE => 0,
+            );
+        }
+    }
+
+    # set SB's dirty flag
+    $self->{'must_redo_search'} = 1;
+
+    return (1, $self->loc("Valid Query"));
+}
+
+sub _JoinTickets {
+    my $self = shift;
+    unless ( defined $self->{_sql_aliases}{tickets} ) {
+        $self->{_sql_aliases}{tickets} = $self->Join(
+            TYPE   => 'LEFT',
+            FIELD1 => 'ObjectId',
+            TABLE2 => 'Tickets',
+            FIELD2 => 'id',
+        );
+    }
+    return $self->{_sql_aliases}{tickets};
+}
+
+=head2 Query
+
+Returns the last string passed to L</FromSQL>.
+
+=cut
+
+sub Query {
+    my $self = shift;
+    return $self->{_sql_query};
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/share/html/Elements/CollectionList b/share/html/Elements/CollectionList
index f21785e2d1..4bc3cde865 100644
--- a/share/html/Elements/CollectionList
+++ b/share/html/Elements/CollectionList
@@ -46,8 +46,11 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%INIT>
-if (!$Collection && $Class eq 'RT::Tickets') {
-    $Collection = RT::Tickets->new( $session{'CurrentUser'} );
+if (!$Collection) {
+    $Collection = $Class->new( $session{'CurrentUser'} );
+    if ( $Class eq 'RT::Transactions' ) {
+        $Query = join ' AND ', "ObjectType = '$ObjectType'", $Query ? "($Query)" : ();
+    }
     $Collection->FromSQL($Query);
 }
 
@@ -201,7 +204,7 @@ $Rows          => undef
 $Page          => 1
 $Title         => loc('Ticket Search')
 $BaseURL       => RT->Config->Get('WebPath') . $m->request_comp->path .'?'
- at PassArguments => qw( Query Format Rows Page Order OrderBy)
+ at PassArguments => qw( Query Format Rows Page Order OrderBy Class ObjectType )
 
 $AllowSorting   => 0  # Make headers in table links that will resort results
 $PreferOrderBy  => 0  # Prefer the passed-in @OrderBy to the collection default
@@ -210,4 +213,5 @@ $ShowHeader     => 1
 $ShowEmpty      => 0
 $Query => 0
 $HasResults     => undef
+$ObjectType     => 'RT::Ticket'
 </%ARGS>
diff --git a/share/html/Elements/RT__Transaction/ColumnMap b/share/html/Elements/RT__Transaction/ColumnMap
index 28efbfc490..70fcfc6580 100644
--- a/share/html/Elements/RT__Transaction/ColumnMap
+++ b/share/html/Elements/RT__Transaction/ColumnMap
@@ -50,6 +50,11 @@ $Name
 $Attr => undef
 </%ARGS>
 <%ONCE>
+my $get_ticket_value = sub {
+    my $sub = pop;
+    return sub { return $_[0]->ObjectType eq 'RT::Ticket' ? $sub->(@_) : '' };
+};
+
 my $COLUMN_MAP = {
     ObjectType => {
         title     => 'Object Type', # loc
@@ -109,6 +114,78 @@ my $COLUMN_MAP = {
         title       => 'Content', # loc
         value       => sub { return $_[0]->Content },
     },
+    PlainContent => {
+        title       => 'Content', # loc
+        value       => sub { return $_[0]->Content( Type => 'text/plain' ) },
+    },
+    HTMLContent => {
+        title       => 'Content', # loc
+        value       => sub { return \$_[0]->Content( Type => 'text/html' ) },
+    },
+    TicketId => {
+        title     => 'Ticket ID', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->id } ),
+    },
+    TicketSubject => {
+        title     => 'Ticket Subject', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->Subject } ),
+    },
+    TicketQueue => {
+        title     => 'Ticket Queue', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->QueueObj->Name } ),
+    },
+    TicketOwner => {
+        title     => 'Ticket Owner', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->OwnerObj->Name } ),
+    },
+    TicketCreator => {
+        title     => 'Ticket Creator', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->CreatorObj->Name } ),
+    },
+    TicketLastUpdatedBy => {
+        title     => 'Ticket LastUpdatedBy', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->LastUpdatedByObj->Name } ),
+    },
+    TicketCreated => {
+        title     => 'Ticket Created', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->CreatedObj->AsString } ),
+    },
+    TicketStarted => {
+        title     => 'Ticket Started', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->StartedObj->AsString } ),
+    },
+    TicketResolved => {
+        title     => 'Ticket Resolved', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->ResolvedObj->AsString } ),
+    },
+    TicketTold => {
+        title     => 'Ticket Told', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->ToldObj->AsString } ),
+    },
+    TicketLastUpdated => {
+        title     => 'Ticket LastUpdated', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->LastUpdatedObj->AsString } ),
+    },
+    TicketStarts => {
+        title     => 'Ticket Starts', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->StartsObj->AsString } ),
+    },
+    TicketDue => {
+        title     => 'Ticket Due', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->DueObj->AsString } ),
+    },
+    TicketPriority => {
+        title     => 'Ticket Priority', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->Priority } ),
+    },
+    TicketInitialPriority => {
+        title     => 'Ticket InitialPriority', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->InitialPriority } ),
+    },
+    TicketFinalPriority => {
+        title     => 'Ticket FinalPriority', # loc
+        value     => $get_ticket_value->( @_, sub { $_[0]->Object->FinalPriority } ),
+    },
 };
 
 
diff --git a/share/html/Elements/SelectDateType b/share/html/Elements/SelectDateType
index 60e2979af2..3c0614a1a7 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Elements/SelectDateType
@@ -46,15 +46,12 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <select name="<%$Name%>" class="form-control selectpicker">
-<option value="Created"><&|/l&>Created</&></option>
-<option value="Started"><&|/l&>Started</&></option>
-<option value="Resolved"><&|/l&>Resolved</&></option>
-<option value="Told"><&|/l&>Last Contacted</&></option>
-<option value="LastUpdated"><&|/l&>Last Updated</&></option>
-<option value="Starts"><&|/l&>Starts</&></option>
-<option value="Due"><&|/l&>Due</&></option>
-<option value="Updated"><&|/l&>Updated</&></option>
+% for my $option ( @Options ) {
+<option value="<% $Prefix . $option %>"><% loc($option) %></option>
+% }
 </select>
 <%ARGS>
 $Name => 'DateType'
+$Prefix => ''
+ at Options => qw(Created Started Resolved Told LastUpdated Starts Due Updated) # loc_qw
 </%ARGS>
diff --git a/share/html/Elements/ShowHistoryPage b/share/html/Elements/ShowHistoryPage
index 7f519e6fe0..51237de147 100644
--- a/share/html/Elements/ShowHistoryPage
+++ b/share/html/Elements/ShowHistoryPage
@@ -70,7 +70,6 @@ for my $attachment (@{$Attachments->ItemsArrayRef()}) {
 
 {
     my %tmp = (
-        DisplayPath     => 'Display.html',
         AttachmentPath  => 'Attachment',
         UpdatePath      => 'Update.html',
         ForwardPath     => 'Forward.html',
diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch
index 89d6814dbf..12d976fdcb 100644
--- a/share/html/Elements/ShowSearch
+++ b/share/html/Elements/ShowSearch
@@ -55,7 +55,7 @@
     titleright_href => $customize,
     hideable => $hideable,
     class => 'fullwidth' &>
-<& $query_display_component, hideable => $hideable, %$ProcessedSearchArg, ShowNavigation => 0, Class => 'RT::Tickets', HasResults => $HasResults, PreferOrderBy => 1 &>
+<& $query_display_component, hideable => $hideable, %$ProcessedSearchArg, ShowNavigation => 0, Class => $class, HasResults => $HasResults, PreferOrderBy => 1 &>
 </&>
 <%init>
 my $search;
@@ -64,6 +64,7 @@ my $SearchArg;
 my $customize;
 my $query_display_component = '/Elements/CollectionList';
 my $query_link_url = RT->Config->Get('WebPath').'/Search/Results.html';
+my $class = 'RT::Tickets';
 
 if ($SavedSearch) {
     my ( $container_object, $search_id ) = _parse_saved_search($SavedSearch);
@@ -79,7 +80,11 @@ if ($SavedSearch) {
     }
     $SearchArg->{'SavedSearchId'} ||= $SavedSearch;
     $SearchArg->{'SearchType'} ||= 'Ticket';
-    if ( $SearchArg->{SearchType} ne 'Ticket' ) {
+    if ( $SearchArg->{SearchType} eq 'Transaction' ) {
+        $class = 'RT::Transactions';
+        $query_link_url = RT->Config->Get('WebPath') .  "/Transaction/Search/Results.html";
+    }
+    elsif ( $SearchArg->{SearchType} ne 'Ticket' ) {
 
         # XXX: dispatch to different handler here
         $query_display_component
@@ -136,14 +141,30 @@ my $QueryString = '?' . $m->comp( '/Elements/QueryString', %$SearchArg );
 
 my $title_raw;
 if ($ShowCount) {
-    my $tickets = RT::Tickets->new( $session{'CurrentUser'} );
-    $tickets->FromSQL($ProcessedSearchArg->{Query});
-    my $count = $tickets->CountAll();
+    my $collection = $class->new( $session{'CurrentUser'} );
+    my $query;
+    if ( $class eq 'RT::Transactions' ) {
+        $query = join ' AND ', "ObjectType = '$ProcessedSearchArg->{ObjectType}'",
+            $ProcessedSearchArg->{Query} ? "($ProcessedSearchArg->{Query})" : ();
+    }
+    else {
+        $query = $ProcessedSearchArg->{Query};
+    }
 
-    $title_raw = '<span class="results-count">' . loc('(Found [quant,_1,ticket,tickets])', $count) . '</span>';
+    $collection->FromSQL($query);
+    my $count = $collection->CountAll();
+
+    my $title;
+    if ( $class eq 'RT::Transactions' ) {
+        $title = loc('(Found [quant,_1,transaction,transactions])', $count);
+    }
+    else {
+        $title = loc('(Found [quant,_1,ticket,tickets])', $count);
+    }
+    $title_raw = '<span class="results-count">' . $title . '</span>';
 
     # don't repeat the search in CollectionList
-    $ProcessedSearchArg->{Collection} = $tickets;
+    $ProcessedSearchArg->{Collection} = $collection;
     $ProcessedSearchArg->{TotalFound} = $count;
 }
 </%init>
diff --git a/share/html/Elements/ShowTransaction b/share/html/Elements/ShowTransaction
index 8dfef5e48a..a2c785ece6 100644
--- a/share/html/Elements/ShowTransaction
+++ b/share/html/Elements/ShowTransaction
@@ -54,6 +54,10 @@
       <a name="txn-<% $Transaction->id %>" \
 % if ( $DisplayPath ) {
       href="<% $DisplayPath %>?id=<% $Object->id %>#txn-<% $Transaction->id %>" \
+% } elsif ( $HTML::Mason::Commands::r->path_info =~ m{^/SelfService/} ) {
+      href="<% RT->Config->Get('WebPath') %>/SelfService/Transaction/Display.html?id=<% $Transaction->id %>" \
+% } else {
+      href="<% RT->Config->Get('WebPath') %>/Transaction/Display.html?id=<% $Transaction->id %>" \
 % }
       >#</a>
     </span>
diff --git a/share/html/Search/Build.html b/share/html/Search/Build.html
index 6c672bcb4e..24bb59d7b0 100644
--- a/share/html/Search/Build.html
+++ b/share/html/Search/Build.html
@@ -73,13 +73,15 @@
 <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $ARGS{'SavedChartSearchId'} %>" />
 <input type="hidden" class="hidden" name="Query" value="<% $query{'Query'} %>" />
 <input type="hidden" class="hidden" name="Format" value="<% $query{'Format'} %>" />
+<input type="hidden" class="hidden" name="ObjectType" value="<% $query{'ObjectType'} %>" />
+<input type="hidden" class="hidden" name="Class" value="<% $Class %>" />
 
 
 
 <div class="row">
   <div class="col-xl-7">
     <div id="pick-criteria">
-      <& Elements/PickCriteria, query => $query{'Query'}, queues => $queues &>
+      <& Elements/PickCriteria, query => $query{'Query'}, queues => $queues, %ARGS &>
     </div>
     <& /Elements/Submit,  Label => loc('Add these terms'), SubmitId => 'AddClause', Name => 'AddClause'&>
     <& /Elements/Submit, Label => loc('Add these terms and Search'), SubmitId => 'DoSearch', Name => 'DoSearch'&>
@@ -115,15 +117,31 @@ use RT::Interface::Web::QueryBuilder::Tree;
 
 $ARGS{SavedChartSearchId} ||= 'new';
 
-my $title = loc("Query Builder");
+my $title;
+if ( $Class eq 'RT::Transactions' ) {
+    $title = loc('Transaction Query Builder');
+}
+else {
+    $title = loc("Query Builder")
+}
 
 my %query;
-for( qw(Query Format OrderBy Order RowsPerPage) ) {
-    $query{$_} = $ARGS{$_};
+for( qw(Query Format OrderBy Order RowsPerPage Class ObjectType) ) {
+    $query{$_} = $ARGS{$_} if defined $ARGS{$_};
 }
 
 my %saved_search;
-my @actions = $m->comp( 'Elements/EditSearches:Init', %ARGS, Query => \%query, SavedSearch => \%saved_search);
+my @actions = $m->comp( 'Elements/EditSearches:Init', %ARGS, Query => \%query, SavedSearch => \%saved_search );
+
+my $hash_name;
+if ( $Class eq 'RT::Tickets' ) {
+    $hash_name = 'CurrentSearchHash';
+}
+else {
+    $hash_name = join '-', 'CurrentSearchHash', $Class, $ObjectType;
+}
+
+my $session_name = $Class eq 'RT::Tickets' ? 'tickets' : join '-', 'collection', $Class, $ObjectType;
 
 if ( $NewQuery ) {
 
@@ -133,23 +151,28 @@ if ( $NewQuery ) {
     %saved_search = ( Id => 'new' );
 
     # ..then wipe the session out..
-    delete $session{'CurrentSearchHash'};
+    delete $session{$hash_name};
 
     # ..and the search results.
-    $session{'tickets'}->CleanSlate if defined $session{'tickets'};
+    $session{$session_name}->CleanSlate if defined $session{$session_name};
 }
 
 { # Attempt to load what we can from the session and preferences, set defaults
 
-    my $current = $session{'CurrentSearchHash'};
+    my $current = $session{$hash_name};
     my $prefs = $session{'CurrentUser'}->UserObj->Preferences("SearchDisplay") || {};
-    my $default = { Query => '',
-                    Format => '',
-                    OrderBy => RT->Config->Get('DefaultSearchResultOrderBy'),
-                    Order => RT->Config->Get('DefaultSearchResultOrder'),
-                    RowsPerPage => 50 };
-
-    for( qw(Query Format OrderBy Order RowsPerPage) ) {
+    my $default = {
+        Query   => '',
+        Format  => '',
+        OrderBy => $Class eq 'RT::Tickets' ? RT->Config->Get('DefaultSearchResultOrderBy')
+        : RT->Config->Get('TransactionDefaultSearchResultOrderBy')->{$ObjectType},
+        Order => $Class eq 'RT::Tickets' ? RT->Config->Get('DefaultSearchResultOrder')
+        : RT->Config->Get('TransactionDefaultSearchResultOrder')->{$ObjectType},
+        ObjectType  => $ObjectType,
+        RowsPerPage => 50
+    };
+
+    for( qw(Query Format OrderBy Order RowsPerPage ObjectType) ) {
         $query{$_} = $current->{$_} unless defined $query{$_};
         $query{$_} = $prefs->{$_} unless defined $query{$_};
         $query{$_} = $default->{$_} unless defined $query{$_};
@@ -170,7 +193,7 @@ my $ParseQuery = sub {
     my ($string, $results) = @_;
 
     my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
-    @$results = $tree->ParseSQL( Query => $string, CurrentUser => $session{'CurrentUser'} );
+    @$results = $tree->ParseSQL( Query => $string, CurrentUser => $session{'CurrentUser'}, Class => $Class );
 
     return $tree;
 };
@@ -285,7 +308,7 @@ if ($ARGS{SavedSearchSave}) {
 
 # Push the updates into the session so we don't lose 'em
 
-$session{'CurrentSearchHash'} = {
+$session{$hash_name} = {
     %query,
     SearchId    => $saved_search{'Id'},
     Object      => $saved_search{'Object'},
@@ -311,7 +334,11 @@ if ( $ARGS{'DoSearch'} ) {
 
 my %TabArgs = ();
 if ($NewQuery) {
-    $TabArgs{QueryString} = 'NewQuery=1';
+    $TabArgs{QueryString} = $m->comp(
+        '/Elements/QueryString',
+        NewQuery => 1,
+        $Class ne 'RT::Tickets' ? ( Class => $Class, ObjectType => $ObjectType ) : ()
+    );
 }
 elsif ( $query{'Query'} ) {
     $TabArgs{QueryArgs} = \%query;
@@ -322,4 +349,6 @@ elsif ( $query{'Query'} ) {
 <%ARGS>
 $NewQuery => 0
 @clauses => ()
+$Class => 'RT::Tickets'
+$ObjectType => 'RT::Ticket'
 </%ARGS>
diff --git a/share/html/Search/Edit.html b/share/html/Search/Edit.html
index ea3e5b9a78..563dd7cf2c 100644
--- a/share/html/Search/Edit.html
+++ b/share/html/Search/Edit.html
@@ -53,6 +53,9 @@
 <form method="post" action="Build.html" id="BuildQueryAdvanced" name="BuildQueryAdvanced">
 <input type="hidden" class="hidden" name="SavedSearchId" value="<% $SavedSearchId %>" />
 <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $SavedChartSearchId %>" />
+<input type="hidden" class="hidden" name="Class" value="<% $Class %>" />
+<input type="hidden" class="hidden" name="ObjectType" value="<% $ObjectType %>" />
+
 <&|/Widgets/TitleBox, title => loc('Query'), &>
 <textarea class="form-control" name="Query" rows="8" cols="72"><% $Query %></textarea>
 </&>
@@ -63,7 +66,14 @@
 </form>
 
 <%INIT>
-my $title = loc("Edit Query");
+my $title;
+if ( $Class eq 'RT::Transactions' ) {
+    $title = loc('Edit Transaction Query');
+}
+else {
+    $title = loc("Edit Query")
+}
+
 $Format = $m->comp('/Elements/ScrubHTML', Content => $Format);
 my $QueryString = $m->comp('/Elements/QueryString',
                            Query   => $Query,
@@ -84,6 +94,8 @@ $Format        => ''
 $Rows          => '50'
 $OrderBy       => 'id'
 $Order         => 'ASC'
+$Class         => 'RT::Tickets'
+$ObjectType    => 'RT::Ticket'
 
 @actions       => ()
 </%ARGS>
diff --git a/share/html/Search/Elements/BuildFormatString b/share/html/Search/Elements/BuildFormatString
index b35a81d313..de60f98a9a 100644
--- a/share/html/Search/Elements/BuildFormatString
+++ b/share/html/Search/Elements/BuildFormatString
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%ARGS>
-$Format => RT->Config->Get('DefaultSearchResultFormat')
+$Format => undef
 
 %queues => ()
 
@@ -62,6 +62,9 @@ $ColDown => undef
 
 $SelectDisplayColumns => undef
 $CurrentDisplayColumns => undef
+
+$Class => 'RT::Tickets'
+$ObjectType => 'RT::Ticket'
 </%ARGS>
 <%init>
 # This can't be in a <once> block, because otherwise we return the
@@ -69,77 +72,118 @@ $CurrentDisplayColumns => undef
 # it -- and it grows per request.
 
 # All the things we can display in the format string by default
-my @fields = qw(
-    id QueueName Subject
-    Status ExtendedStatus UpdateStatus
-    Type
-
-    OwnerName Requestors Cc AdminCc CreatedBy LastUpdatedBy
-
-    Priority InitialPriority FinalPriority
-
-    TimeWorked TimeLeft TimeEstimated
-
-    Starts      StartsRelative
-    Started     StartedRelative
-    Created     CreatedRelative
-    LastUpdated LastUpdatedRelative
-    Told        ToldRelative
-    Due         DueRelative
-    Resolved    ResolvedRelative
-
-    SLA
-
-    RefersTo    ReferredToBy
-    DependsOn   DependedOnBy
-    MemberOf    Members
-    Parents     Children
-
-    Bookmark    Timer
-
-    NEWLINE
-    NBSP
-); # loc_qw
-
-# Total time worked is an optional ColumnMap enabled for rolling up child
-# TimeWorked
-push @fields, 'TotalTimeWorked' if (RT->Config->Get('DisplayTotalTimeWorked'));
-
-my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
-foreach my $id (keys %queues) {
-    # Gotta load up the $queue object, since queues get stored by name now.
-    my $queue = RT::Queue->new($session{'CurrentUser'});
-    $queue->Load($id);
-    next unless $queue->Id;
-    $CustomFields->LimitToQueue($queue->Id);
-    $CustomFields->SetContextObject( $queue ) if keys %queues == 1;
-}
-$CustomFields->LimitToGlobal;
+my @fields;
+if ( $Class eq 'RT::Transactions' ) {
+    $Format ||= RT->Config->Get('TransactionDefaultSearchResultFormat')->{$ObjectType};
 
-while ( my $CustomField = $CustomFields->Next ) {
-    push @fields, "CustomField.{" . $CustomField->Name . "}";
-}
+    @fields = qw( id ObjectId ObjectType ObjectName Type Field TimeTaken
+        OldValue NewValue ReferenceType OldReference NewReference
+        Created CreatedRelative CreatedBy Description Content PlainContent HTMLContent
+        TicketId TicketSubject TicketQueue TicketStatus TicketOwner TicketCreator
+        TicketLastUpdatedBy TicketCreated TicketStarted TicketResolved
+        TicketTold TicketLastUpdated TicketDue
+        TicketPriority TicketInitialPriority TicketFinalPriority
+        NEWLINE NBSP );    # loc_qw
 
-my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
-foreach my $id (keys %queues) {
-    # Gotta load up the $queue object, since queues get stored by name now.
-    my $queue = RT::Queue->new($session{'CurrentUser'});
-    $queue->Load($id);
-    next unless $queue->Id;
-    $CustomRoles->LimitToObjectId($queue->Id);
-}
-while ( my $Role = $CustomRoles->Next ) {
-    push @fields, "CustomRole.{" . $Role->Name . "}";
+    my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'} );
+    foreach my $id ( keys %queues ) {
+
+        # Gotta load up the $queue object, since queues get stored by name now.
+        my $queue = RT::Queue->new( $session{'CurrentUser'} );
+        $queue->Load($id);
+        next unless $queue->Id;
+        $CustomFields->LimitToQueue( $queue->Id );
+        $CustomFields->SetContextObject($queue) if keys %queues == 1;
+    }
+    $CustomFields->Limit(
+        ALIAS           => $CustomFields->_OCFAlias,
+        ENTRYAGGREGATOR => 'OR',
+        FIELD           => 'ObjectId',
+        VALUE           => 0,
+    );
+    $CustomFields->LimitToLookupType('RT::Queue-RT::Ticket-RT::Transaction');
+
+    while ( my $CustomField = $CustomFields->Next ) {
+        push @fields, "CustomField.{" . $CustomField->Name . "}";
+    }
 }
+else {
+    $Format ||= RT->Config->Get('DefaultSearchResultFormat');
+
+    @fields = qw(
+        id QueueName Subject
+        Status ExtendedStatus UpdateStatus
+        Type
+
+        OwnerName Requestors Cc AdminCc CreatedBy LastUpdatedBy
+
+        Priority InitialPriority FinalPriority
+
+        TimeWorked TimeLeft TimeEstimated
+
+        Starts      StartsRelative
+        Started     StartedRelative
+        Created     CreatedRelative
+        LastUpdated LastUpdatedRelative
+        Told        ToldRelative
+        Due         DueRelative
+        Resolved    ResolvedRelative
+
+        SLA
+
+        RefersTo    ReferredToBy
+        DependsOn   DependedOnBy
+        MemberOf    Members
+        Parents     Children
 
-my %ranges = RT::Ticket->CustomDateRanges;
-push @fields, sort keys %ranges;
+        Bookmark    Timer
+
+        NEWLINE
+        NBSP
+        );    # loc_qw
+
+    # Total time worked is an optional ColumnMap enabled for rolling up child
+    # TimeWorked
+    push @fields, 'TotalTimeWorked' if ( RT->Config->Get('DisplayTotalTimeWorked') );
+
+    my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'} );
+    foreach my $id ( keys %queues ) {
+
+        # Gotta load up the $queue object, since queues get stored by name now.
+        my $queue = RT::Queue->new( $session{'CurrentUser'} );
+        $queue->Load($id);
+        next unless $queue->Id;
+        $CustomFields->LimitToQueue( $queue->Id );
+        $CustomFields->SetContextObject($queue) if keys %queues == 1;
+    }
+    $CustomFields->LimitToGlobal;
+
+    while ( my $CustomField = $CustomFields->Next ) {
+        push @fields, "CustomField.{" . $CustomField->Name . "}";
+    }
+
+    my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'} );
+    foreach my $id ( keys %queues ) {
+
+        # Gotta load up the $queue object, since queues get stored by name now.
+        my $queue = RT::Queue->new( $session{'CurrentUser'} );
+        $queue->Load($id);
+        next unless $queue->Id;
+        $CustomRoles->LimitToObjectId( $queue->Id );
+    }
+    while ( my $Role = $CustomRoles->Next ) {
+        push @fields, "CustomRole.{" . $Role->Name . "}";
+    }
+
+    my %ranges = RT::Ticket->CustomDateRanges;
+    push @fields, sort keys %ranges;
+
+}
 
 $m->callback( Fields => \@fields, ARGSRef => \%ARGS );
 
 my ( @seen);
 
-$Format ||= RT->Config->Get('DefaultSearchResultFormat');
 my @format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $Format);
 foreach my $field (@format) {
     # "title" is for columns like NEWLINE, which doesn't have "attribute"
diff --git a/share/html/Search/Elements/ConditionRow b/share/html/Search/Elements/ConditionRow
index b7728d7167..7d5e785bab 100644
--- a/share/html/Search/Elements/ConditionRow
+++ b/share/html/Search/Elements/ConditionRow
@@ -91,7 +91,7 @@ $handle_block = sub {
         my $res = '';
         $res .= qq{<select id="$name" name="$name" class="form-control selectpicker">};
         my @options = @{ $box->{'Options'} };
-        while( my $k = shift @options ) {
+        while( defined( my $k = shift @options ) ) {
             my $v = shift @options;
             $res .= qq{<option value="$k">$v</option>};
         }
diff --git a/share/html/Search/Elements/EditSearches b/share/html/Search/Elements/EditSearches
index e009e735d8..6bdb77cf5f 100644
--- a/share/html/Search/Elements/EditSearches
+++ b/share/html/Search/Elements/EditSearches
@@ -111,7 +111,7 @@ my $is_dirty = sub {
     my %arg = (
         Query       => {},
         SavedSearch => {},
-        SearchFields => [qw(Query Format OrderBy Order RowsPerPage)],
+        SearchFields => [qw(Query Format OrderBy Order RowsPerPage ObjectType)],
         @_
     );
 
@@ -137,7 +137,8 @@ my $Dirty = $is_dirty->(
 <%ARGS>
 $Id            => 'new'
 $Object        => undef
-$Type          => 'Ticket'
+$Class         => 'RT::Tickets'
+$Type          => $Class eq 'RT::Transactions' ? 'Transaction' : 'Ticket'
 $Description   => ''
 $CurrentSearch => {}
 @SearchFields   => ()
@@ -149,7 +150,9 @@ $Title         => loc('Saved searches')
 <%ARGS>
 $Query       => {}
 $SavedSearch => {}
- at SearchFields => qw(Query Format OrderBy Order RowsPerPage)
+ at SearchFields => qw(Query Format OrderBy Order RowsPerPage ObjectType)
+$Class        => 'RT::Tickets'
+$Type         => $Class eq 'RT::Transactions' ? 'Transaction' : 'Ticket'
 </%ARGS>
 <%INIT>
 
@@ -157,6 +160,7 @@ $SavedSearch->{'Id'}          = ( $ARGS{Type} && $ARGS{Type} eq 'Chart' ?
 $ARGS{'SavedChartSearchId'} : $ARGS{'SavedSearchId'} ) || 'new';
 $SavedSearch->{'Description'} = $ARGS{'SavedSearchDescription'} || '';
 $SavedSearch->{'Privacy'}     = $ARGS{'SavedSearchOwner'}       || undef;
+$SavedSearch->{'Type'}        = $Type;
 
 my @results;
 
@@ -233,7 +237,7 @@ return @results;
 <%ARGS>
 $Query        => {}
 $SavedSearch  => {}
- at SearchFields => qw(Query Format OrderBy Order RowsPerPage)
+ at SearchFields => qw(Query Format OrderBy Order RowsPerPage ObjectType)
 </%ARGS>
 <%INIT>
 
diff --git a/share/html/Search/Elements/EditSort b/share/html/Search/Elements/EditSort
index 5c5965df40..6e34fae26a 100644
--- a/share/html/Search/Elements/EditSort
+++ b/share/html/Search/Elements/EditSort
@@ -99,8 +99,8 @@ selected="selected"
 </div>
 
 <%INIT>
-my $tickets = RT::Tickets->new($session{'CurrentUser'});
-my %FieldDescriptions = %{$tickets->FIELDS};
+my $collection = $Class->new($session{'CurrentUser'});
+my %FieldDescriptions = %{$collection->FIELDS};
 my %fields;
 
 for my $field (keys %FieldDescriptions) {
@@ -109,24 +109,27 @@ for my $field (keys %FieldDescriptions) {
     $fields{$field} = $field;
 }
 
-$fields{'Owner'} = 'Owner';
-$fields{ $_ . '.EmailAddress' } = $_ . '.EmailAddress'
-    for qw(Requestor Cc AdminCc);
+if ( $Class eq 'RT::Tickets' ) {
+    $fields{'Owner'} = 'Owner';
+    $fields{ $_ . '.EmailAddress' } = $_ . '.EmailAddress' for qw(Requestor Cc AdminCc);
+}
 
 # Add all available CustomFields to the list of sortable columns.
 my @cfs = grep /^CustomField/, @{$ARGS{AvailableColumns}};
 $fields{$_} = $_ for @cfs;
 
 # Add all available CustomRoles to the list of sortable columns.
-my @roles = grep /^CustomRole/, @{$ARGS{AvailableColumns}};
-for my $role (@roles) {
-    my ($label) = $role =~ /^CustomRole.\{(.*)\}$/;
-    my $value = $role;
-    $fields{$label . '.EmailAddress' } = $value . '.EmailAddress';
-}
+if ( $Class eq 'RT::Tickets' ) {
+    my @roles = grep /^CustomRole/, @{$ARGS{AvailableColumns}};
+    for my $role (@roles) {
+        my ($label) = $role =~ /^CustomRole.\{(.*)\}$/;
+        my $value = $role;
+        $fields{$label . '.EmailAddress' } = $value . '.EmailAddress';
+    }
 
-# Add PAW sort
-$fields{'Custom.Ownership'} = 'Custom.Ownership';
+    # Add PAW sort
+    $fields{'Custom.Ownership'} = 'Custom.Ownership';
+}
 
 $m->callback(CallbackName => 'MassageSortFields', Fields => \%fields );
 
@@ -146,4 +149,5 @@ $OrderBy => ''
 $RowsPerPage => undef
 $Format => undef
 $GroupBy => 'id'
+$Class => 'RT::Tickets'
 </%ARGS>
diff --git a/share/html/Search/Elements/PickBasics b/share/html/Search/Elements/PickBasics
index e2a5229d93..124fc7d9b3 100644
--- a/share/html/Search/Elements/PickBasics
+++ b/share/html/Search/Elements/PickBasics
@@ -50,191 +50,355 @@
 % }
 <%INIT>
 
-my @lines = (
-    {
-        Name => 'id',
-        Field => loc('id'),
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectEqualityOperator',
+my @lines;
+
+if ( $Class eq 'RT::Transactions' ) {
+    my $cfs = RT::CustomFields->new( $session{'CurrentUser'} );
+    $cfs->LimitToLookupType( $ObjectType->CustomFieldLookupType );
+    if ( %queues && $ObjectType eq 'RT::Ticket' ) {
+        my @ids = 0;
+        for my $name ( keys %queues ) {
+            my $queue = RT::Queue->new( $session{CurrentUser} );
+            $queue->Load($name);
+            push @ids, $queue->id if $queue->id;
+        }
+        $cfs->LimitToAdded( @ids );
+    }
+
+    @lines = (
+        {
+            Name => 'id',
+            Field => loc('id'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectEqualityOperator',
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-        Value => { Type => 'text', Size => 5 }
-    },
-    {
-        Name => 'Attachment',
-        Field => {
-            Type => 'component',
-            Path => '/Elements/SelectAttachmentField',
+        {
+            Name => 'Attachment',
+            Field => {
+                Type => 'component',
+                Path => '/Elements/SelectAttachmentField',
+            },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => {
+                    True => loc("matches"),
+                    False => loc("doesn't match"),
+                    TrueVal => 'LIKE',
+                    FalseVal => 'NOT LIKE',
+                },
+            },
+            Value => { Type => 'text', Size => 20 },
         },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectBoolean',
-            Arguments => {
-                True => loc("matches"), 
-                False => loc("doesn't match"), 
-                TrueVal => 'LIKE',
-                FalseVal => 'NOT LIKE',
+        {
+            Name => 'Creator',
+            Field => loc('Creator'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => { TrueVal=> '=', FalseVal => '!=' },
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Elements/EmailInput',
+                Arguments => { AutocompleteReturn => 'Name' },
             },
         },
-        Value => { Type => 'text', Size => 20 },
-    },
-    {
-        Name => 'Queue',
-        Field => loc('Queue'),
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectMatch',
-            Arguments => { Default => '=' },
+        {
+            Name => 'Created',
+            Field => loc('Created'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectDateRelation',
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Elements/SelectDate',
+                Arguments => { ShowTime => 0, Default => '' },
+            },
         },
-        Value => {
-            Type => 'component',
-            Path => '/Elements/SelectQueue',
-            Arguments => { NamedValues => 1, },
+        {
+            Name => 'TimeTaken',
+            Field => loc('Time Taken'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectEqualityOperator',
+            },
+            Value => [
+                {
+                    Type => 'component',
+                    Path => '/Elements/EditTimeValue',
+                },
+            ],
         },
-    },
-    {
-        Name => 'Status',
-        Field => loc('Status'),
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectBoolean',
-            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        {
+            Name => 'Type',
+            Field => loc('Type'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => { TrueVal=> '=', FalseVal => '!=' },
+            },
+            Value => {
+                Type    => 'select',
+                Options => [
+                    # The key could be ObjectType-Type-Field or Type-Field or Type.
+                    # We just want Type
+                    '' => '-',
+                    map {  $_ => loc($_) } List::MoreUtils::uniq map { s/RT::.*?-//; s/-.*//; $_ } sort keys %RT::Transaction::_BriefDescriptions,
+                ],
+            },
         },
-        Value => {
-            Type => 'component',
-            Path => '/Ticket/Elements/SelectStatus',
-            Arguments => { SkipDeleted => 1, Queues => \%queues, ShowActiveInactive => 1 },
+        {
+            Name => 'Field',
+            Field => loc('Field'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-    },
-    {
-        Name => 'SLA',
-        Field => loc('SLA'),
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectMatch',
-            Arguments => { Default => '=' },
+        {
+            Name => 'OldValue',
+            Field => loc('Old Value'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-        Value => {
-            Type => 'component',
-            Path => '/Elements/SelectSLA',
-            Arguments => { NamedValues => 1 },
+        {
+            Name => 'NewValue',
+            Field => loc('New Value'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-    },
-    {
-        Name => 'Actor',
-        Field => {
-            Type    => 'select',
-            Options => [
-                Owner => loc('Owner'),
-                Creator => loc('Creator'),
-                LastUpdatedBy => loc('Last updated by'),
-                UpdatedBy => loc('Updated by'),
-            ],
+        {
+            Name => 'CFName',
+            Field => loc('CF Name'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => { TrueVal=> '=', FalseVal => '!=' },
+            },
+            Value => {
+                Type    => 'select',
+                Options => [ '' => '-', map {  $_->Name => $_->Name } @{$cfs->ItemsArrayRef} ],
+            },
         },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectBoolean',
-            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        {
+            Name => 'OldCFValue',
+            Field => loc('Old CF Value'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-        Value => {
-            Type => 'component',
-            Path => '/Elements/SelectOwner',
-            Arguments => { ValueAttribute => 'Name', Queues => \%queues },
+        {
+            Name => 'NewCFValue',
+            Field => loc('New CF Value'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-    },
-    {
-        Name => 'Watcher',
-        Field => {
-            Type => 'component',
-            Path => 'SelectPersonType',
-            Arguments => { Default => 'Requestor' },
+    );
+}
+else {
+    @lines = (
+        {
+            Name => 'id',
+            Field => loc('id'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectEqualityOperator',
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectMatch',
+        {
+            Name => 'Attachment',
+            Field => {
+                Type => 'component',
+                Path => '/Elements/SelectAttachmentField',
+            },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => {
+                    True => loc("matches"),
+                    False => loc("doesn't match"),
+                    TrueVal => 'LIKE',
+                    FalseVal => 'NOT LIKE',
+                },
+            },
+            Value => { Type => 'text', Size => 20 },
         },
-        Value => { Type => 'text', Size => 20 }
-    },
-    {
-        Name => 'WatcherGroup',
-        Field => {
-            Type => 'component',
-            Path => 'SelectPersonType',
-            Arguments => { Default => 'Owner', Suffix => 'Group' },
+        {
+            Name => 'Queue',
+            Field => loc('Queue'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+                Arguments => { Default => '=' },
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Elements/SelectQueue',
+                Arguments => { NamedValues => 1, },
+            },
         },
-        Op => {
-            Type => 'select',
-            Options => [ '=' => loc('is') ],
+        {
+            Name => 'Status',
+            Field => loc('Status'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => { TrueVal=> '=', FalseVal => '!=' },
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Ticket/Elements/SelectStatus',
+                Arguments => { SkipDeleted => 1, Queues => \%queues, ShowActiveInactive => 1 },
+            },
         },
-        Value => { Type => 'text', Size => 20, "data-autocomplete" => "Groups" }
-    },
-    {
-        Name => 'Date',
-        Field => {
-            Type => 'component',
-            Path => '/Elements/SelectDateType',
+        {
+            Name => 'SLA',
+            Field => loc('SLA'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+                Arguments => { Default => '=' },
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Elements/SelectSLA',
+                Arguments => { NamedValues => 1 },
+            },
         },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectDateRelation',
+        {
+            Name => 'Actor',
+            Field => {
+                Type    => 'select',
+                Options => [
+                    Owner => loc('Owner'),
+                    Creator => loc('Creator'),
+                    LastUpdatedBy => loc('Last updated by'),
+                    UpdatedBy => loc('Updated by'),
+                ],
+            },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => { TrueVal=> '=', FalseVal => '!=' },
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Elements/SelectOwner',
+                Arguments => { ValueAttribute => 'Name', Queues => \%queues },
+            },
         },
-        Value => {
-            Type => 'component',
-            Path => '/Elements/SelectDate',
-            Arguments => { ShowTime => 0, Default => '' },
+        {
+            Name => 'Watcher',
+            Field => {
+                Type => 'component',
+                Path => 'SelectPersonType',
+                Arguments => { Default => 'Requestor' },
+            },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+            },
+            Value => { Type => 'text', Size => 20 }
         },
-    },
-    {
-        Name => 'Time',
-        Field => {
-            Type    => 'select',
-            Options => [
-                TimeWorked => loc('Time Worked'),
-                TimeEstimated => loc('Time Estimated'),
-                TimeLeft => loc('Time Left'),
-            ],
+        {
+            Name => 'WatcherGroup',
+            Field => {
+                Type => 'component',
+                Path => 'SelectPersonType',
+                Arguments => { Default => 'Owner', Suffix => 'Group' },
+            },
+            Op => {
+                Type => 'select',
+                Options => [ '=' => loc('is') ],
+            },
+            Value => { Type => 'text', Size => 20, "data-autocomplete" => "Groups" }
         },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectEqualityOperator',
+        {
+            Name => 'Date',
+            Field => {
+                Type => 'component',
+                Path => '/Elements/SelectDateType',
+            },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectDateRelation',
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Elements/SelectDate',
+                Arguments => { ShowTime => 0, Default => '' },
+            },
         },
-        Value => [
-            {
-                Type => 'component',
-                Path => '/Elements/EditTimeValue',
-            },
-        ],
-    },
-    {
-        Name => 'Priority',
-        Field => {
-            Type    => 'select',
-            Options => [
-                Priority => loc('Priority'),
-                InitialPriority => loc('Initial Priority'),
-                FinalPriority => loc('Final Priority'),
+        {
+            Name => 'Time',
+            Field => {
+                Type    => 'select',
+                Options => [
+                    TimeWorked => loc('Time Worked'),
+                    TimeEstimated => loc('Time Estimated'),
+                    TimeLeft => loc('Time Left'),
+                ],
+            },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectEqualityOperator',
+            },
+            Value => [
+                {
+                    Type => 'component',
+                    Path => '/Elements/EditTimeValue',
+                },
             ],
         },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectEqualityOperator',
-        },
-        Value => {
-            Type => 'component',
-            Path => '/Elements/SelectPriority',
+        {
+            Name => 'Priority',
+            Field => {
+                Type    => 'select',
+                Options => [
+                    Priority => loc('Priority'),
+                    InitialPriority => loc('Initial Priority'),
+                    FinalPriority => loc('Final Priority'),
+                ],
+            },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectEqualityOperator',
+            },
+            Value => {
+                Type => 'component',
+                Path => '/Elements/SelectPriority',
+            },
         },
-    },
-    {
-        Name => 'Links',
-        Field => { Type => 'component', Path => 'SelectLinks' },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectBoolean',
-            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        {
+            Name => 'Links',
+            Field => { Type => 'component', Path => 'SelectLinks' },
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectBoolean',
+                Arguments => { TrueVal=> '=', FalseVal => '!=' },
+            },
+            Value => { Type => 'text', Size => 5 }
         },
-        Value => { Type => 'text', Size => 5 }
-    },
-);
+    );
+}
 
 $m->callback( Conditions => \@lines );
 
@@ -244,10 +408,15 @@ $m->callback( Conditions => \@lines );
     jQuery(function() {
 
     // move the actual value to a hidden value, and shadow the others
+% if ( $Class eq 'RT::Tickets' ) {
     var hidden = jQuery('<input>').attr('type','hidden').attr('name','ValueOfQueue');
 
     // change the selector's name, but preserve the values, we'll set value via js
     var selector = jQuery("[name='ValueOfQueue']");
+% } else {
+    var hidden = jQuery('<input>').attr('type','hidden').attr('name','ValueOfTicketQueue');
+    var selector = jQuery("[name='ValueOfTicketQueue']");
+% }
 
     // rename the selector so we don't get an extra term in the query
     selector[0].name = "";
@@ -264,7 +433,11 @@ $m->callback( Conditions => \@lines );
     });
 
     // hook the op field so that we can swap between the two input types
+% if ( $Class eq 'RT::Tickets' ) {
     var op = jQuery("[name='QueueOp']");
+% } else {
+    var op = jQuery("[name='TicketQueueOp']");
+% }
     op.bind('change',function() {
         if (op[0].value == "=" || op[0].value == "!=" ) {
             text.hide();
@@ -284,4 +457,6 @@ $m->callback( Conditions => \@lines );
 </script>
 <%ARGS>
 %queues => ()
+$Class => 'RT::Tickets'
+$ObjectType => 'RT::Ticket'
 </%ARGS>
diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickCriteria
index 4b411a694d..ceb758025a 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickCriteria
@@ -48,12 +48,22 @@
 <&| /Widgets/TitleBox, title => loc('Add Criteria')&>
 
 % $m->callback( %ARGS, CallbackName => "BeforeBasics" );
-<& PickBasics, queues => \%queues &>
-<& PickCustomRoles, queues => \%queues &>
-<& PickTicketCFs, queues => \%queues &>
-<& PickObjectCFs, Class => 'Transaction', queues => \%queues &>
-<& PickObjectCFs, Class => 'Queue', queues => \%queues &>
-% $m->callback( %ARGS, CallbackName => "AfterCFs" );
+
+% if ( $Class eq 'RT::Transactions' ) {
+    <& PickBasics, queues => \%queues, %ARGS &>
+    <& PickTransactionCFs, queues => \%queues, %ARGS &>
+%   $m->callback( %ARGS, CallbackName => "AfterCFs" );
+%   if ( $ObjectType eq 'RT::Ticket' ) {
+    <& PickTickets, queues => \%queues, %ARGS &>
+%   }
+% } else {
+    <& PickBasics, queues => \%queues &>
+    <& PickCustomRoles, queues => \%queues &>
+    <& PickTicketCFs, queues => \%queues &>
+    <& PickObjectCFs, Class => 'Transaction', queues => \%queues &>
+    <& PickObjectCFs, Class => 'Queue', queues => \%queues &>
+%   $m->callback( %ARGS, CallbackName => "AfterCFs" );
+% }
 
 <div class="form-row">
   <div class="col-md-3 label"><&|/l&>Aggregator</&></div>
@@ -66,4 +76,6 @@
 $addquery => 0
 $query => undef
 %queues => ()
+$Class => 'RT::Tickets'
+$ObjectType => 'RT::Ticket'
 </%ARGS>
diff --git a/share/html/Search/Elements/PickBasics b/share/html/Search/Elements/PickTickets
similarity index 54%
copy from share/html/Search/Elements/PickBasics
copy to share/html/Search/Elements/PickTickets
index e2a5229d93..2ecdaddf04 100644
--- a/share/html/Search/Elements/PickBasics
+++ b/share/html/Search/Elements/PickTickets
@@ -45,14 +45,21 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
+
+<tr class="separator">
+  <td colspan="3">
+    <hr><em><% loc("Ticket Fields") %></em>
+  </td>
+</tr>
+
 % foreach( @lines ) {
-<& ConditionRow, Condition => $_ &>
+<& /Search/Elements/ConditionRow, Condition => $_ &>
 % }
 <%INIT>
 
 my @lines = (
     {
-        Name => 'id',
+        Name => 'TicketId',
         Field => loc('id'),
         Op => {
             Type => 'component',
@@ -61,25 +68,16 @@ my @lines = (
         Value => { Type => 'text', Size => 5 }
     },
     {
-        Name => 'Attachment',
-        Field => {
-            Type => 'component',
-            Path => '/Elements/SelectAttachmentField',
-        },
+        Name => 'TicketSubject',
+        Field => loc('Subject'),
         Op => {
             Type => 'component',
-            Path => '/Elements/SelectBoolean',
-            Arguments => {
-                True => loc("matches"), 
-                False => loc("doesn't match"), 
-                TrueVal => 'LIKE',
-                FalseVal => 'NOT LIKE',
-            },
+            Path => '/Elements/SelectMatch',
         },
-        Value => { Type => 'text', Size => 20 },
+        Value => { Type => 'text' }
     },
     {
-        Name => 'Queue',
+        Name => 'TicketQueue',
         Field => loc('Queue'),
         Op => {
             Type => 'component',
@@ -93,7 +91,7 @@ my @lines = (
         },
     },
     {
-        Name => 'Status',
+        Name => 'TicketStatus',
         Field => loc('Status'),
         Op => {
             Type => 'component',
@@ -107,28 +105,14 @@ my @lines = (
         },
     },
     {
-        Name => 'SLA',
-        Field => loc('SLA'),
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectMatch',
-            Arguments => { Default => '=' },
-        },
-        Value => {
-            Type => 'component',
-            Path => '/Elements/SelectSLA',
-            Arguments => { NamedValues => 1 },
-        },
-    },
-    {
-        Name => 'Actor',
+        Name => 'TicketActor',
         Field => {
             Type    => 'select',
             Options => [
-                Owner => loc('Owner'),
-                Creator => loc('Creator'),
-                LastUpdatedBy => loc('Last updated by'),
-                UpdatedBy => loc('Updated by'),
+                TicketOwner => loc('Owner'),
+                TicketCreator => loc('Creator'),
+                TicketLastUpdatedBy => loc('Last updated by'),
+                TicketUpdatedBy => loc('Updated by'),
             ],
         },
         Op => {
@@ -143,36 +127,11 @@ my @lines = (
         },
     },
     {
-        Name => 'Watcher',
+        Name => 'TicketDate',
         Field => {
-            Type => 'component',
-            Path => 'SelectPersonType',
-            Arguments => { Default => 'Requestor' },
-        },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectMatch',
-        },
-        Value => { Type => 'text', Size => 20 }
-    },
-    {
-        Name => 'WatcherGroup',
-        Field => {
-            Type => 'component',
-            Path => 'SelectPersonType',
-            Arguments => { Default => 'Owner', Suffix => 'Group' },
-        },
-        Op => {
-            Type => 'select',
-            Options => [ '=' => loc('is') ],
-        },
-        Value => { Type => 'text', Size => 20, "data-autocomplete" => "Groups" }
-    },
-    {
-        Name => 'Date',
-        Field => {
-            Type => 'component',
-            Path => '/Elements/SelectDateType',
+            Type      => 'component',
+            Path      => '/Elements/SelectDateType',
+            Arguments => { Prefix => 'Ticket', Options => [qw/Created Started Resolved Told LastUpdated Starts Due/] },
         },
         Op => {
             Type => 'component',
@@ -185,13 +144,13 @@ my @lines = (
         },
     },
     {
-        Name => 'Time',
+        Name => 'TicketTime',
         Field => {
             Type    => 'select',
             Options => [
-                TimeWorked => loc('Time Worked'),
-                TimeEstimated => loc('Time Estimated'),
-                TimeLeft => loc('Time Left'),
+                TicketTimeWorked => loc('Time Worked'),
+                TicketTimeEstimated => loc('Time Estimated'),
+                TicketTimeLeft => loc('Time Left'),
             ],
         },
         Op => {
@@ -206,13 +165,13 @@ my @lines = (
         ],
     },
     {
-        Name => 'Priority',
+        Name => 'TicketPriority',
         Field => {
             Type    => 'select',
             Options => [
-                Priority => loc('Priority'),
-                InitialPriority => loc('Initial Priority'),
-                FinalPriority => loc('Final Priority'),
+                TicketPriority => loc('Priority'),
+                TicketInitialPriority => loc('Initial Priority'),
+                TicketFinalPriority => loc('Final Priority'),
             ],
         },
         Op => {
@@ -224,64 +183,12 @@ my @lines = (
             Path => '/Elements/SelectPriority',
         },
     },
-    {
-        Name => 'Links',
-        Field => { Type => 'component', Path => 'SelectLinks' },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectBoolean',
-            Arguments => { TrueVal=> '=', FalseVal => '!=' },
-        },
-        Value => { Type => 'text', Size => 5 }
-    },
 );
 
 $m->callback( Conditions => \@lines );
 
 </%INIT>
 
-<script type="text/javascript">
-    jQuery(function() {
-
-    // move the actual value to a hidden value, and shadow the others
-    var hidden = jQuery('<input>').attr('type','hidden').attr('name','ValueOfQueue');
-
-    // change the selector's name, but preserve the values, we'll set value via js
-    var selector = jQuery("[name='ValueOfQueue']");
-
-    // rename the selector so we don't get an extra term in the query
-    selector[0].name = "";
-    selector.bind('change',function() {
-        hidden[0].value = selector[0].value;
-    });
-
-    // create a text input box and hide it for use with matches / doesn't match
-    // NB: if you give text a name it will add an additional term to the query!
-    var text = jQuery('<input>').attr('type','text');
-    text.hide();
-    text.bind('change',function() {
-        hidden[0].value = text[0].value;
-    });
-
-    // hook the op field so that we can swap between the two input types
-    var op = jQuery("[name='QueueOp']");
-    op.bind('change',function() {
-        if (op[0].value == "=" || op[0].value == "!=" ) {
-            text.hide();
-            selector.show();
-            hidden[0].value = selector[0].value;
-        } else {
-            text.show();
-            selector.hide();
-            hidden[0].value = text[0].value;
-        }
-    });
-
-    // add the fields to the DOM
-    selector.before(hidden);
-    selector.after(text);
-    });
-</script>
 <%ARGS>
 %queues => ()
 </%ARGS>
diff --git a/share/html/Search/Elements/PickCriteria b/share/html/Search/Elements/PickTransactionCFs
similarity index 66%
copy from share/html/Search/Elements/PickCriteria
copy to share/html/Search/Elements/PickTransactionCFs
index 4b411a694d..ec2e4966da 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickTransactionCFs
@@ -45,25 +45,34 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<&| /Widgets/TitleBox, title => loc('Add Criteria')&>
-
-% $m->callback( %ARGS, CallbackName => "BeforeBasics" );
-<& PickBasics, queues => \%queues &>
-<& PickCustomRoles, queues => \%queues &>
-<& PickTicketCFs, queues => \%queues &>
-<& PickObjectCFs, Class => 'Transaction', queues => \%queues &>
-<& PickObjectCFs, Class => 'Queue', queues => \%queues &>
-% $m->callback( %ARGS, CallbackName => "AfterCFs" );
-
-<div class="form-row">
-  <div class="col-md-3 label"><&|/l&>Aggregator</&></div>
-  <div class="col-md-9 operator"><& SelectAndOr, Name => "AndOr" &></div>
-</div>
-
-</&>
-
 <%ARGS>
-$addquery => 0
-$query => undef
 %queues => ()
 </%ARGS>
+<%init>
+my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
+foreach my $id (keys %queues) {
+    # Gotta load up the $queue object, since queues get stored by name now.
+    my $queue = RT::Queue->new($session{'CurrentUser'});
+    $queue->Load($id);
+    next unless $queue->Id;
+    $CustomFields->Limit(
+        ALIAS           => $CustomFields->_OCFAlias,
+        ENTRYAGGREGATOR => 'OR',
+        FIELD           => 'ObjectId',
+        VALUE           => $queue->id,
+    ) if defined $queue;
+    $CustomFields->LimitToLookupType('RT::Queue-RT::Ticket-RT::Transaction');
+
+    $CustomFields->SetContextObject( $queue ) if keys %queues == 1;
+}
+$CustomFields->Limit(
+    ALIAS           => $CustomFields->_OCFAlias,
+    ENTRYAGGREGATOR => 'OR',
+    FIELD           => 'ObjectId',
+    VALUE           => 0
+);
+$CustomFields->LimitToLookupType('RT::Queue-RT::Ticket-RT::Transaction');
+
+$CustomFields->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
+</%init>
+<& PickCFs, %ARGS, TransactionSQLField => 'CF', CustomFields => $CustomFields &>
diff --git a/share/html/Search/Elements/SelectSearchesForObjects b/share/html/Search/Elements/SelectSearchesForObjects
index c6f2a8487b..c9758b9a28 100644
--- a/share/html/Search/Elements/SelectSearchesForObjects
+++ b/share/html/Search/Elements/SelectSearchesForObjects
@@ -48,7 +48,9 @@
 <%args>
 @Objects => undef
 $Name => undef
-$SearchType => 'Ticket',
+$Class => 'RT::Tickets'
+$ObjectType => 'RT::Ticket'
+$SearchType => $Class eq 'RT::Transactions' ? 'Transaction' : 'Ticket'
 </%args>
 <select id="<%$Name%>" name="<%$Name%>" class="form-control selectpicker">
 <option value="">-</option>
@@ -62,6 +64,7 @@ $SearchType => 'Ticket',
 %     # Skip it if it is not of search type we want.
 %     next if ($search->SubValue('SearchType')
 %              && $search->SubValue('SearchType') ne $SearchType);
+%     next if ($search->SubValue('SearchType') // '') eq 'RT::Transactions' && ($search->SubValue('ObjectType') // '') ne $ObjectType;
 <option value="<%ref($object)%>-<%$object->id%>-SavedSearch-<%$search->Id%>"><%$search->Description||loc('Unnamed search')%></option>
 % }
 </optgroup>
diff --git a/share/html/Search/Results.html b/share/html/Search/Results.html
index be50ecdea1..5eee049762 100644
--- a/share/html/Search/Results.html
+++ b/share/html/Search/Results.html
@@ -62,7 +62,7 @@
 
 <& /Elements/CollectionList, 
     Query => $Query,
-    TotalFound => $ticketcount,
+    TotalFound => $count,
     AllowSorting => 1,
     OrderBy => $OrderBy,
     Order => $Order,
@@ -70,11 +70,12 @@
     Page => $Page,
     Format => $Format,
     DisplayFormat => $DisplayFormat, # in case we set it in callbacks
-    Class => 'RT::Tickets',
+    Class => $Class,
     BaseURL => $BaseURL,
     SavedSearchId => $ARGS{'SavedSearchId'},
     SavedChartSearchId => $ARGS{'SavedChartSearchId'},
-    PassArguments => [qw(Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId)],
+    ObjectType => $ObjectType,
+    PassArguments => [qw(Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId Class ObjectType)],
 &>
 % }
 % $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
@@ -85,7 +86,7 @@
 % foreach my $key (keys(%hiddens)) {
 <input type="hidden" class="hidden" name="<%$key%>" value="<% defined($hiddens{$key})?$hiddens{$key}:'' %>" />
 % }
-<& /Elements/Refresh, Name => 'TicketsRefreshInterval', Default => $session{'tickets_refresh_interval'}||RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'}) &>
+<& /Elements/Refresh, Name => 'TicketsRefreshInterval', Default => $session{$interval_name}||RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'}) &>
 <div class="form-row">
   <div class="col-md-12">
     <input type="submit" class="button btn btn-primary form-control" value="<&|/l&>Change</&>" />
@@ -108,9 +109,16 @@ my $prefs = $session{'CurrentUser'}->UserObj->Preferences("SearchDisplay") || {}
 
 # These variables are what define a search_hash; this is also
 # where we give sane defaults.
-$Format      ||= $prefs->{'Format'} || RT->Config->Get('DefaultSearchResultFormat');
-$Order       ||= $prefs->{'Order'} || RT->Config->Get('DefaultSearchResultOrder');
-$OrderBy     ||= $prefs->{'OrderBy'} || RT->Config->Get('DefaultSearchResultOrderBy');
+if ( $Class eq 'RT::Transactions' ) {
+    $Format  ||= RT->Config->Get('TransactionDefaultSearchResultFormat')->{$ObjectType};
+    $Order   ||= RT->Config->Get('TransactionDefaultSearchResultOrder')->{$ObjectType};
+    $OrderBy ||= RT->Config->Get('TransactionDefaultSearchResultOrderBy')->{$ObjectType};
+}
+else {
+    $Format  ||= $prefs->{'Format'}  || RT->Config->Get('DefaultSearchResultFormat');
+    $Order   ||= $prefs->{'Order'}   || RT->Config->Get('DefaultSearchResultOrder');
+    $OrderBy ||= $prefs->{'OrderBy'} || RT->Config->Get('DefaultSearchResultOrderBy');
+}
 
 # Some forms pass in "RowsPerPage" rather than "Rows"
 # We call it RowsPerPage everywhere else.
@@ -126,39 +134,74 @@ if ( !defined($Rows) ) {
 }
 $Page = 1 unless $Page && $Page > 0;
 
+my $hash_name;
+if ( $Class eq 'RT::Tickets' ) {
+    $hash_name = 'CurrentSearchHash';
+}
+else {
+    $hash_name = join '-', 'CurrentSearchHash', $Class, $ObjectType;
+}
+
+my $session_name = $Class eq 'RT::Tickets' ? 'tickets' : join '-', 'collection', $Class, $ObjectType;
+
 $session{'i'}++;
-$session{'tickets'} = RT::Tickets->new($session{'CurrentUser'}) ;
-my ($ok, $msg) = $Query ? $session{'tickets'}->FromSQL($Query) : (1, "Vacuously OK");
+$session{$session_name} = $Class->new($session{'CurrentUser'}) ;
+
+my ( $ok, $msg );
+if ( $Query ) {
+    if ( $Class eq 'RT::Transactions' ) {
+        ( $ok, $msg ) = $session{$session_name}->FromSQL( join ' AND ', "ObjectType = '$ObjectType'", "($Query)" );
+    }
+    else {
+        ( $ok, $msg ) = $session{$session_name}->FromSQL($Query);
+    }
+}
+
 # Provide an empty search if parsing failed
-$session{'tickets'}->FromSQL("id < 0") unless ($ok);
+$session{$session_name}->FromSQL("id < 0") unless ($ok);
 
 if ($OrderBy =~ /\|/) {
     # Multiple Sorts
     my @OrderBy = split /\|/,$OrderBy;
     my @Order = split /\|/,$Order;
-    $session{'tickets'}->OrderByCols(
+    $session{$session_name}->OrderByCols(
         map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } } ( 0
         .. $#OrderBy ) );; 
 } else {
-    $session{'tickets'}->OrderBy(FIELD => $OrderBy, ORDER => $Order); 
+    $session{$session_name}->OrderBy(FIELD => $OrderBy, ORDER => $Order); 
 }
-$session{'tickets'}->RowsPerPage( $Rows ) if $Rows;
-$session{'tickets'}->GotoPage( $Page - 1 );
+$session{$session_name}->RowsPerPage( $Rows ) if $Rows;
+$session{$session_name}->GotoPage( $Page - 1 );
 
-$session{'CurrentSearchHash'} = {
+$session{$hash_name} = {
     Format      => $Format,
     Query       => $Query,
-    Page       => $Page,
+    Page        => $Page,
     Order       => $Order,
     OrderBy     => $OrderBy,
-    RowsPerPage => $Rows
+    RowsPerPage => $Rows,
+    ObjectType  => $ObjectType,
 };
 
 
-my ($title, $ticketcount) = (loc("Find tickets"), 0);
-if ( $session{'tickets'}->Query()) {
-    $ticketcount = $session{tickets}->CountAll();
-    $title = loc('Found [quant,_1,ticket,tickets]', $ticketcount);
+my $count = $session{$session_name}->Query() ? $session{$session_name}->CountAll() : 0;
+
+my $title;
+if ( $Class eq 'RT::Transactions' ) {
+    if ( $session{$session_name}->Query() ) {
+        $title = loc( 'Found [quant,_1,transaction,transactions]', $count );
+    }
+    else {
+        $title = loc("Find transactions");
+    }
+}
+else {
+    if ( $session{$session_name}->Query() ) {
+        $title = loc( 'Found [quant,_1,ticket,tickets]', $count );
+    }
+    else {
+        $title = loc("Find tickets");
+    }
 }
 
 my $QueryString = "?".$m->comp('/Elements/QueryString',
@@ -170,16 +213,15 @@ my $QueryString = "?".$m->comp('/Elements/QueryString',
                                Page => $Page);
 my $ShortQueryString = "?".$m->comp('/Elements/QueryString', Query => $Query);
 
-if ($ARGS{'TicketsRefreshInterval'}) {
-    $session{'tickets_refresh_interval'} = $ARGS{'TicketsRefreshInterval'};
+my $interval_name = $Class eq 'RT::Tickets' ? 'tickets_refresh_interval' : "${Class}_${ObjectType}_refresh_interval";
+if ($ARGS{'SearchResultsRefreshInterval'}) {
+    $session{$interval_name} = $ARGS{'SearchResultsRefreshInterval'};
 }
-
-my $refresh = $session{'tickets_refresh_interval'}
-    || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'} );
+my $refresh = $session{$interval_name} || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'} );
 
 # Check $m->request_args, not $DECODED_ARGS, to avoid creating a new CSRF token on each refresh
 if (RT->Config->Get('RestrictReferrer') and $refresh and not $m->request_args->{CSRF_Token}) {
-    my $token = RT::Interface::Web::StoreRequestToken( $session{'CurrentSearchHash'} );
+    my $token = RT::Interface::Web::StoreRequestToken( $session{$hash_name} );
     $m->notes->{RefreshURL} = RT->Config->Get('WebURL')
         . "Search/Results.html?CSRF_Token="
             . $token;
@@ -198,22 +240,24 @@ my $genpage = sub {
     );
 };
 
-if ( RT->Config->Get('SearchResultsAutoRedirect') && $ticketcount == 1 &&
-    $session{tickets}->First ) {
-# $ticketcount is not always precise unless $UseSQLForACLChecks is set to true,
-# check $session{tickets}->First here is to make sure the ticket is there.
+if ( RT->Config->Get('SearchResultsAutoRedirect') && $count == 1 &&
+    $session{$session_name}->First ) {
+# $count is not always precise unless $UseSQLForACLChecks is set to true,
+# check $session{$session_name}->First here is to make sure the ticket is there.
     RT::Interface::Web::Redirect( RT->Config->Get('WebURL')
-            ."Ticket/Display.html?id=". $session{tickets}->First->id );
+            . ( $Class eq 'RT::Transactions' ? 'Transaction' : 'Ticket' )
+            . "/Display.html?id="
+            . $session{$session_name}->First->id );
 }
 
 my $BaseURL = RT->Config->Get('WebPath')."/Search/Results.html?";
 $link_rel{first} = $BaseURL . $genpage->(1)         if $Page > 1;
 $link_rel{prev}  = $BaseURL . $genpage->($Page - 1) if $Page > 1;
-$link_rel{next}  = $BaseURL . $genpage->($Page + 1) if ($Page * $Rows) < $ticketcount;
-$link_rel{last}  = $BaseURL . $genpage->(POSIX::ceil($ticketcount/$Rows)) if $Rows and ($Page * $Rows) < $ticketcount;
+$link_rel{next}  = $BaseURL . $genpage->($Page + 1) if ($Page * $Rows) < $count;
+$link_rel{last}  = $BaseURL . $genpage->(POSIX::ceil($count/$Rows)) if $Rows and ($Page * $Rows) < $count;
 </%INIT>
 <%CLEANUP>
-$session{'tickets'}->PrepForSerialization();
+$session{$session_name}->PrepForSerialization();
 </%CLEANUP>
 <%ARGS>
 $Query => undef
@@ -225,4 +269,6 @@ $OrderBy => undef
 $Order => undef
 $SavedSearchId => undef
 $SavedChartSearchId => undef
+$Class => 'RT::Tickets'
+$ObjectType => 'RT::Ticket'
 </%ARGS>
diff --git a/share/html/Search/Results.tsv b/share/html/Search/Results.tsv
index a28c04eae8..9344117558 100644
--- a/share/html/Search/Results.tsv
+++ b/share/html/Search/Results.tsv
@@ -52,23 +52,25 @@ $OrderBy => 'id'
 $Order => 'ASC'
 $PreserveNewLines => 0
 $UserData => 0
+$Class => 'RT::Tickets'
+$ObjectType => 'RT::Ticket'
 </%ARGS>
 <%INIT>
-my $Tickets = RT::Tickets->new( $session{'CurrentUser'} );
-$Tickets->FromSQL( $Query );
+my $collection = $Class->new( $session{'CurrentUser'} );
+$collection->FromSQL( $Class eq 'RT::Transactions' ? join( ' AND ', "ObjectType = '$ObjectType'", "($Query)" ) : $Query );
 if ( $OrderBy =~ /\|/ ) {
     # Multiple Sorts
     my @OrderBy = split /\|/, $OrderBy;
     my @Order   = split /\|/, $Order;
-    $Tickets->OrderByCols(
+    $collection->OrderByCols(
         map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } }
         ( 0 .. $#OrderBy )
     );
 }
 else {
-    $Tickets->OrderBy( FIELD => $OrderBy, ORDER => $Order );
+    $collection->OrderBy( FIELD => $OrderBy, ORDER => $Order );
 }
 
 my $filename = $UserData ? 'UserTicketData.tsv' : undef;
-$m->comp( "/Elements/TSVExport", Collection => $Tickets, Format => $Format, PreserveNewLines => $PreserveNewLines, Filename => $filename );
+$m->comp( "/Elements/TSVExport", Collection => $collection, Format => $Format, PreserveNewLines => $PreserveNewLines, Filename => $filename );
 </%INIT>
diff --git a/share/html/Elements/SelectDateType b/share/html/SelfService/Transaction/Display.html
similarity index 79%
copy from share/html/Elements/SelectDateType
copy to share/html/SelfService/Transaction/Display.html
index 60e2979af2..26a7379e6f 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/SelfService/Transaction/Display.html
@@ -45,16 +45,5 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<select name="<%$Name%>" class="form-control selectpicker">
-<option value="Created"><&|/l&>Created</&></option>
-<option value="Started"><&|/l&>Started</&></option>
-<option value="Resolved"><&|/l&>Resolved</&></option>
-<option value="Told"><&|/l&>Last Contacted</&></option>
-<option value="LastUpdated"><&|/l&>Last Updated</&></option>
-<option value="Starts"><&|/l&>Starts</&></option>
-<option value="Due"><&|/l&>Due</&></option>
-<option value="Updated"><&|/l&>Updated</&></option>
-</select>
-<%ARGS>
-$Name => 'DateType'
-</%ARGS>
+
+<& /Transaction/Display.html, %ARGS &>
diff --git a/share/html/Search/Results.tsv b/share/html/Transaction/Display.html
similarity index 69%
copy from share/html/Search/Results.tsv
copy to share/html/Transaction/Display.html
index a28c04eae8..36686f6922 100644
--- a/share/html/Search/Results.tsv
+++ b/share/html/Transaction/Display.html
@@ -45,30 +45,32 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc('Transaction #[_1]', $id) &>
+<& /Elements/Tabs &>
+
+<& /Elements/ShowHistoryHeader, Object => $txn, %ARGS &>
+
+<& /Elements/ShowTransaction, Transaction => $txn,
+    DisplayPath     => RT->Config->Get('WebPath') . '/Ticket/Display.html',
+    AttachmentPath  => RT->Config->Get('WebPath') . '/Ticket/Attachment',
+    UpdatePath      => RT->Config->Get('WebPath') . '/Ticket/Update.html',
+    ForwardPath     => RT->Config->Get('WebPath') . '/Ticket/Forward.html',
+    EmailRecordPath => RT->Config->Get('WebPath') . '/Ticket/ShowEmailRecord.html',
+    EncryptionPath  => RT->Config->Get('WebPath') . '/Ticket/Crypt.html',
+    %ARGS,
+&>
+</div>
+
+<& /Widgets/TitleBoxEnd &>
+</div>
+
 <%ARGS>
-$Format => undef
-$Query => ''
-$OrderBy => 'id'
-$Order => 'ASC'
-$PreserveNewLines => 0
-$UserData => 0
+$id => undef
 </%ARGS>
+
 <%INIT>
-my $Tickets = RT::Tickets->new( $session{'CurrentUser'} );
-$Tickets->FromSQL( $Query );
-if ( $OrderBy =~ /\|/ ) {
-    # Multiple Sorts
-    my @OrderBy = split /\|/, $OrderBy;
-    my @Order   = split /\|/, $Order;
-    $Tickets->OrderByCols(
-        map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } }
-        ( 0 .. $#OrderBy )
-    );
-}
-else {
-    $Tickets->OrderBy( FIELD => $OrderBy, ORDER => $Order );
-}
+Abort( loc('No transaction specified') ) unless $id;
+my $txn = LoadTransaction($id);
+Abort( loc('No permission to view transaction'), Code => HTTP::Status::HTTP_FORBIDDEN ) unless $txn->CurrentUserCanSee;
 
-my $filename = $UserData ? 'UserTicketData.tsv' : undef;
-$m->comp( "/Elements/TSVExport", Collection => $Tickets, Format => $Format, PreserveNewLines => $PreserveNewLines, Filename => $filename );
 </%INIT>
diff --git a/share/static/css/elevator-light/history.css b/share/static/css/elevator-light/history.css
index d1902f094a..453e63b8c4 100644
--- a/share/static/css/elevator-light/history.css
+++ b/share/static/css/elevator-light/history.css
@@ -1,11 +1,11 @@
-.transaction {
+.history .transaction {
     border-top: 2px solid #ccc;
     padding-bottom: 0.5em;
     position: relative; /* gives us a container for position: absolute */
     clear: both;
 }
 
-.transaction.odd {
+.history .transaction.odd {
  background-color: #fff;
 }
 
@@ -19,7 +19,7 @@ div.history-container {
 
 }
 
-.transaction div.metadata span.actions {
+.history .transaction div.metadata span.actions {
  float: right;
  padding: 0em;
  background: #ccc;
@@ -39,7 +39,7 @@ div.history-container {
 }
 
 
-.transaction div.metadata  span.type {
+.history .transaction div.metadata  span.type {
  text-align: center;
  float: left;
  width: 1em;
@@ -54,39 +54,39 @@ div.history-container {
   border: none;
 }
 
-.transaction span.type a {
+.history .transaction span.type a {
  color: #fff;
  padding-top: 0.75em;
  display: block;
 }
 
 
-.transaction span.date {
+.history .transaction span.date {
  width: 15em;
 }
 
 
-.transaction span.description {
+.history .transaction span.description {
  margin-left: 1em;
  font-weight: bold;
 }
 
-.transaction .description a:visited {
+.history .transaction .description a:visited {
     color: inherit;
 }
 
-.transaction span.time-taken {
+.history .transaction span.time-taken {
  margin-left: 1em;
 }
 
-.transaction div.content {
+.history .transaction div.content {
  padding-right: 1em;
  padding-bottom: 0.7em;
  margin-left: 1.5em;
 }
 
 
-.transaction .messagebody {
+.history .transaction .messagebody {
  font-size: 1em;
  padding-left: 1em;
  margin-top: 0.5em;

commit 57dec7ad983e8a4577db40581b533abe6843ae02
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Dec 10 04:27:37 2019 +0800

    Resolve the inconsistent $Class param in Search and CollectionAsTable
    
    $Class on search pages is supposed to be "RT::Tickets" or
    "RT::Transactions", but is "RT__Ticket" or "RT__Transaction" in
    CollectionAsTable.
    
    As we support not just tickets but also transactions on search pages, we
    need to pass $Class from CollectionAsTable/Header back to search
    pages to make links like "ID", Subject", etc. work.
    
    Considering CollectionAsTable actually needs ColumnMapClass and it's
    easy to get that from collection class, here we switch to
    "RT::Tickets"/"RT::Transactions" in CollectionAsTable, so we don't need
    to do conversions.

diff --git a/share/html/Elements/CollectionAsTable/Header b/share/html/Elements/CollectionAsTable/Header
index c83e58119b..6eefb75a8f 100644
--- a/share/html/Elements/CollectionAsTable/Header
+++ b/share/html/Elements/CollectionAsTable/Header
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%ARGS>
-$Class        => 'RT__Ticket'
+$Class        => 'RT::Tickets'
 
 @Format       => undef
 $FormatString => undef
@@ -69,6 +69,15 @@ my $generic_query_args = $GenericQueryArgs || {map { $_ => $ARGS{$_} } @PassArgu
 # backward compatibility workaround
 $generic_query_args->{'Format'} = $FormatString if grep $_ eq 'Format', @PassArguments;
 
+my $column_map_class;
+if ( $Class =~ /::/ ) {
+    $column_map_class = $Class->ColumnMapClassName;
+}
+else {
+    # For back compatibility
+    $column_map_class = $Class;
+}
+
 my $item = 0;
 foreach my $col ( @Format ) {
     my $attr = $col->{'attribute'} || $col->{'last_attribute'};
@@ -98,7 +107,7 @@ foreach my $col ( @Format ) {
 
     my $align = $col->{'align'} || do {
         my $tmp_columnmap = $m->comp( '/Elements/ColumnMap',
-            Class => $Class,
+            Class => $column_map_class,
             Name => $attr,
             Attr => 'align',
         );
@@ -112,7 +121,7 @@ foreach my $col ( @Format ) {
     # one we saw in the format
     unless ( defined $col->{'title'} ) {
         my $tmp = $m->comp( '/Elements/ColumnMap',
-            Class => $Class,
+            Class => $column_map_class,
             Name  => $attr,
             Attr  => 'title',
         );
@@ -129,7 +138,7 @@ foreach my $col ( @Format ) {
     if ( $AllowSorting and $col->{'attribute'}
         and my $attr = $m->comp(
             "/Elements/ColumnMap",
-            Class => $Class,
+            Class => $column_map_class,
             Name  => $col->{'attribute'},
             Attr  => 'attribute'
         )
diff --git a/share/html/Elements/CollectionAsTable/Row b/share/html/Elements/CollectionAsTable/Row
index 7332f5176b..81b1425d78 100644
--- a/share/html/Elements/CollectionAsTable/Row
+++ b/share/html/Elements/CollectionAsTable/Row
@@ -53,10 +53,19 @@ $maxitems => undef
 $Depth => undef
 $Warning => undef
 $ColumnMap => {}
-$Class     => 'RT__Ticket'
+$Class     => 'RT::Tickets'
 $Classes => ''
 </%ARGS>
 <%init>
+
+my $column_map_class;
+if ( $Class =~ /::/ ) {
+    $column_map_class = $Class->ColumnMapClassName;
+}
+else {
+    $column_map_class = $Class;
+}
+
 $m->out( '<tbody class="list-item"' . ( $record->can('id') ? ' data-record-id="'.$record->id.'"' : '' ) . '>' );
 
 $m->out(  '<tr class="' . $Classes . ' '
@@ -94,7 +103,7 @@ foreach my $column (@Format) {
 
             $ColumnMap->{$col}{$attr} = $m->comp(
                 "/Elements/ColumnMap",
-                Class => $Class,
+                Class => $column_map_class,
                 Name  => $col,
                 Attr  => $attr,
             );
@@ -120,7 +129,7 @@ foreach my $column (@Format) {
         unless ( exists $ColumnMap->{$col}{'value'} ) {
             $ColumnMap->{$col}{'value'} = $m->comp(
                 "/Elements/ColumnMap",
-                Class => $Class,
+                Class => $column_map_class,
                 Name  => $col,
                 Attr  => 'value'
             );
diff --git a/share/html/Elements/CollectionList b/share/html/Elements/CollectionList
index 4bc3cde865..0563de7794 100644
--- a/share/html/Elements/CollectionList
+++ b/share/html/Elements/CollectionList
@@ -114,10 +114,6 @@ foreach my $col (@Format) {
 }
 
 $Class ||= $Collection->ColumnMapClassName;
-if ($Class =~ /::/) { # older passed in value
-    $Class =~ s/s$//;
-    $Class =~ s/:/_/g;
-}
 
 $m->out('<table cellspacing="0" class="table ' .
             ($Collection->isa('RT::Tickets') ? 'ticket-list' : 'collection') . ' collection-as-table">');

commit a4ad1d535fe8b6e775b1b55ed5691e175eca870d
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Jul 16 00:02:59 2019 +0800

    Add support to clip long search result columns
    
    This is initially for transaction's Content column, which could be very
    long and we need to clip it.
    
    To get real height of the column value, this feature requires the value
    to be wrapped into an HTML tag, like '<small>__Content__</small>'

diff --git a/share/html/Elements/JavascriptConfig b/share/html/Elements/JavascriptConfig
index 4af7326df8..548e0bad6b 100644
--- a/share/html/Elements/JavascriptConfig
+++ b/share/html/Elements/JavascriptConfig
@@ -76,6 +76,7 @@ my $Catalog = {
     contains => "Contains", # loc
     lower_disabled => "disabled", # loc
     history_scroll_error => "Could not load ticket history. Reason:", #loc
+    unclip => "Show all", #loc
 };
 $_ = loc($_) for values %$Catalog;
 
diff --git a/share/static/css/elevator-light/collection.css b/share/static/css/elevator-light/collection.css
index 742a10d01a..d531ae9966 100644
--- a/share/static/css/elevator-light/collection.css
+++ b/share/static/css/elevator-light/collection.css
@@ -16,3 +16,12 @@
 .results-count::before {
     content: '\a0\a0\a0\a0';
 }
+
+.collection-as-table div.clamp {
+    overflow-y: hidden;
+}
+
+.collection-as-table a.unclip {
+    font-size: smaller;
+    font-weight: normal;
+}
diff --git a/share/static/js/util.js b/share/static/js/util.js
index f54c129656..2b3cdbaaab 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -257,6 +257,22 @@ jQuery(function() {
             };
         };
     });
+
+    jQuery('td.collection-as-table').each( function() {
+        if ( jQuery(this).children() ) {
+            var max_height = jQuery(this).css('line-height').replace('px', '') * 5;
+            if ( jQuery(this).children().height() > max_height ) {
+                jQuery(this).children().wrapAll('<div class="clamp">');
+                jQuery(this).children('div.clamp').height('' + max_height + 'px');
+                jQuery(this).append('<a href="#" class="unclip button btn btn-primary">' + loc_key('unclip') + '</a>');
+            }
+        }
+    });
+    jQuery('a.unclip').click(function() {
+        jQuery(this).siblings('div.clamp').css('height', 'auto');
+        jQuery(this).hide();
+        return false;
+    });
 });
 
 function textToHTML(value) {

commit 44c48a98d6aaa3f6844079c2a1bd46b7c07167c8
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Jul 17 22:45:59 2019 +0800

    Add transaction search tests

diff --git a/t/transaction/search.t b/t/transaction/search.t
new file mode 100644
index 0000000000..0c2246cd46
--- /dev/null
+++ b/t/transaction/search.t
@@ -0,0 +1,139 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef, config => 'Set( %FullTextSearch, Enable => 1, Indexed => 0 );';
+
+my ( $bilbo, $frodo )
+    = RT::Test->create_tickets( { Queue => 'General' }, { Subject => 'Bilbo' }, { Subject => 'Frodo' }, );
+
+my $txns = RT::Transactions->new( RT->SystemUser );
+$txns->FromSQL('ObjectType="RT::Ticket" AND TicketSubject = "Frodo" AND Type="Create"');
+is( $txns->Count, 1, 'Found the create txn' );
+my $txn = $txns->Next;
+
+my %field_value = (
+    ObjectType => 'RT::Ticket',
+    ObjectId   => $frodo->id,
+    Type       => 'Create',
+);
+
+for my $field ( keys %field_value ) {
+    is( $txn->$field, $field_value{$field}, $field );
+}
+
+$txns->FromSQL('Type="Create" AND TicketStatus="__Active__"');
+is( $txns->Count, 2, 'Found the 2 create txns of active tickets' );
+
+$txns->FromSQL('Type="Create" AND TicketStatus="__Inactive__"');
+is( $txns->Count, 0, 'Found the 0 create txns of inactive tickets' );
+
+ok( $frodo->SetStatus('resolved'), 'Resolved 1 ticket' );
+$txns->FromSQL('Type="Create" AND TicketStatus="__Active__"');
+is( $txns->Count, 1, 'Found the 1 create txn of active tickets' );
+is( $txns->Next->ObjectId, $bilbo->id, 'Active ticket is bilbo' );
+
+$txns->FromSQL('Type="Create" AND TicketStatus="__Inactive__"');
+is( $txns->Count, 1, 'Found the 1 create txn of inactive tickets' );
+is( $txns->Next->ObjectId, $frodo->id, 'Inactive ticket is frodo' );
+
+my $cf_age = RT::Test->load_or_create_custom_field(
+    Name  => 'Age',
+    Queue => 0,
+    Type  => 'FreeformSingle',
+);
+
+my $cf_height = RT::Test->load_or_create_custom_field(
+    Name  => 'Height',
+    Queue => 0,
+    Type  => 'FreeformSingle',
+);
+
+$bilbo->AddCustomFieldValue( Field => $cf_age, Value => '110' );
+$frodo->AddCustomFieldValue( Field => $cf_age, Value => '32' );
+
+$bilbo->AddCustomFieldValue( Field => $cf_age, Value => '111' );
+$frodo->AddCustomFieldValue( Field => $cf_age, Value => '33' );
+
+$bilbo->AddCustomFieldValue( Field => $cf_height->id, Value => '3 feets' );
+$frodo->AddCustomFieldValue( Field => $cf_height->id, Value => '3 feets' );
+
+$txns->FromSQL('OldCFValue = 110');
+is( $txns->Count, 1, 'Found the txns' );
+$txn = $txns->Next;
+is( $txn->OldValue, 110, 'Old value' );
+is( $txn->NewValue, 111, 'New value' );
+
+$txns->FromSQL('NewCFValue = "3 feets"');
+is( $txns->Count, 2, 'Found the 2 txns' );
+my @txns = @{ $txns->ItemsArrayRef };
+is( $txns[0]->OldValue, undef,     'Old value' );
+is( $txns[0]->NewValue, '3 feets', 'New value' );
+is( $txns[1]->OldValue, undef,     'Old value' );
+is( $txns[1]->NewValue, '3 feets', 'New value' );
+
+$txns->FromSQL('ObjectType = "RT::Ticket" AND CFName = "Age"');
+is( $txns->Count, 4, 'Found the txns' );
+ at txns = @{ $txns->ItemsArrayRef };
+is( $txns[0]->OldValue, undef, 'Old value' );
+is( $txns[0]->NewValue, 110,   'New value' );
+
+is( $txns[1]->OldValue, undef, 'Old value' );
+is( $txns[1]->NewValue, 32,    'New value' );
+
+is( $txns[2]->OldValue, 110, 'Old value' );
+is( $txns[2]->NewValue, 111, 'New value' );
+
+is( $txns[3]->OldValue, 32, 'Old value' );
+is( $txns[3]->NewValue, 33, 'New value' );
+
+my $root = RT::CurrentUser->new( RT->SystemUser );
+$root->Load('root');
+ok( $root->id, 'Load root' );
+
+$txns = RT::Transactions->new($root);
+$txns->FromSQL('Creator = "root"');
+is( $txns->Count, 0, 'No txns created by root' );
+
+my $ticket = RT::Ticket->new($root);
+$ticket->Load( $bilbo->id );
+ok( $ticket->SetStatus('open') );
+
+$txns->FromSQL('Creator = "root"');
+is( $txns->Count, 1, 'Found ticket txn created by root' );
+$txn = $txns->Next;
+
+is( $txn->ObjectId, $bilbo->id, 'ObjectId' );
+is( $txn->Field,    'Status',   'Field' );
+is( $txn->Type,     'Status',   'Type' );
+is( $txn->OldValue, 'new',      'OldValue' );
+is( $txn->NewValue, 'open',     'NewValue' );
+
+$txns->FromSQL('Type = "Correspond"');
+is( $txns->Count, 0, 'No correspond txn' );
+
+my ($correspond_txn_id) = $ticket->Correspond( Content => 'this is correspond text' );
+
+$txns->FromSQL('Type = "Correspond"');
+is( $txns->Count, 1, 'Found a correspond txn' );
+is( $txns->Next->id, $correspond_txn_id, 'Found the correspond txn' );
+
+$txns->FromSQL('Content LIKE "this is comment text"');
+is( $txns->Count, 0, 'No txns with comment text' );
+
+$txns->FromSQL('Content LIKE "this is correspond text"');
+is( $txns->Count, 1, 'Found a correspond txn' );
+is( $txns->Next->id, $correspond_txn_id, 'Found the correspond txn' );
+
+$txns->FromSQL('Created > "tomorrow"');
+is( $txns->Count, 0, 'No txns with future created date' );
+
+$txns->FromSQL('Created >= "yesterday"');
+ok( $txns->Count, 'Found txns with past created date' );
+
+$txns->FromSQL("id = $correspond_txn_id");
+is( $txns->Count, 1, 'Found the txn with id limit' );
+
+$txns->FromSQL("id > 10000");
+is( $txns->Count, 0, 'No txns with big ids yet' );
+
+done_testing;

commit c5715e51003935845f814dec9b393d901a487750
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Jul 17 22:46:13 2019 +0800

    Add transaction query builder tests

diff --git a/t/web/search_txns.t b/t/web/search_txns.t
new file mode 100644
index 0000000000..7824a6eba7
--- /dev/null
+++ b/t/web/search_txns.t
@@ -0,0 +1,100 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+my ( $baseurl, $m ) = RT::Test->started_ok;
+
+my $ticket = RT::Ticket->new( RT->SystemUser );
+$ticket->Create(
+    Subject   => 'Test ticket',
+    Queue     => 'General',
+    Owner     => 'root',
+);
+
+ok( $ticket->SetStatus('open') );
+
+is( $ticket->Transactions->Count, 3, 'Ticket has 3 txns' );
+
+$m->login;
+
+diag "Query builder";
+{
+    $m->follow_link_ok( { text => 'New Search', url_regex => qr/Class=RT::Transaction/ }, 'Query builder' );
+    $m->title_is('Transaction Query Builder');
+
+    $m->form_name('BuildQuery');
+    $m->field( TicketIdOp      => '=' );
+    $m->field( ValueOfTicketId => 1 );
+    $m->click('AddClause');
+
+    $m->follow_link_ok( { id => 'page-results' } );
+    $m->title_is('Found 3 transactions');
+
+    $m->back;
+    $m->form_name('BuildQuery');
+    $m->field( TypeOp      => '=' );
+    $m->field( ValueOfType => 'Create' );
+    $m->click('AddClause');
+
+    $m->follow_link_ok( { id => 'page-results' } );
+    $m->title_is('Found 1 transaction');
+    $m->text_contains( 'Ticket created', 'Got create txn' );
+}
+
+diag "Advanced";
+{
+    $m->follow_link_ok( { text => 'New Search', url_regex => qr/Class=RT::Transaction/ }, 'Query builder' );
+    $m->follow_link_ok( { text => 'Advanced' }, 'Advanced' );
+    $m->title_is('Edit Transaction Query');
+
+    $m->form_name('BuildQueryAdvanced');
+    $m->field( Query => q{OldValue = 'new'} );
+    $m->submit;
+
+    $m->follow_link_ok( { id => 'page-results' } );
+    $m->title_is('Found 1 transaction');
+    $m->text_contains( q{Status changed from 'new' to 'open'}, 'Got status change txn' );
+}
+
+diag "Saved searches";
+{
+    $m->follow_link_ok( { text => 'New Search', url_regex => qr/Class=RT::Transaction/ }, 'Query builder' );
+    $m->form_name('BuildQuery');
+    $m->field( ValueOfTicketId => 10 );
+    $m->submit('AddClause');
+
+    $m->form_name('BuildQuery');
+    $m->field( SavedSearchDescription => 'test txn search' );
+    $m->click('SavedSearchSave');
+    $m->text_contains('Current search: test txn search');
+
+    my $form = $m->form_name('BuildQuery');
+    my $input = $form->find_input( 'SavedSearchLoad' );
+    # an empty search and the real saved search
+    is( scalar $input->possible_values, 2, '2 SavedSearchLoad options' );
+
+    my ($attr_id) = ($input->possible_values)[1] =~ /(\d+)$/;
+    my $attr = RT::Attribute->new(RT->SystemUser);
+    $attr->Load($attr_id);
+    is_deeply(
+        $attr->Content,
+        {
+            'Format' => '\'<b><a href="__WebPath__/Transaction/Display.html?id=__id__">__id__</a></b>/TITLE:ID\',
+\'<b><a href="__WebPath__/Ticket/Display.html?id=__ObjectId__">__ObjectId__</a></b>/TITLE:Ticket\',
+\'__Description__\',
+\'<small>__OldValue__</small>\',
+\'<small>__NewValue__</small>\',
+\'<small>__Content__</small>\',
+\'<small>__CreatedRelative__</small>\'',
+            'OrderBy'     => 'id|||',
+            'SearchType'  => 'Transaction',
+            'RowsPerPage' => '50',
+            'Order'       => 'ASC|ASC|ASC|ASC',
+            'Query'       => 'TicketId < 10',
+            'ObjectType'  => 'RT::Ticket'
+        },
+        'Saved search content'
+    );
+}
+
+done_testing;

commit a896b4d1b268199f2bdc1920164d410425449dcb
Author: Jim Brandt <jbrandt at bestpractical.com>
Date:   Fri Dec 6 16:06:27 2019 -0500

    Add Transactions to query builder docs

diff --git a/docs/query_builder.pod b/docs/query_builder.pod
index 1195ea06e7..79951b7750 100644
--- a/docs/query_builder.pod
+++ b/docs/query_builder.pod
@@ -1,18 +1,17 @@
 =head1 Introduction
 
-The Query Builder is RT's search engine. It lets you find tickets matching
+The Ticket Query Builder is RT's search engine. It lets you find tickets matching
 some (potentially very complex) criteria. There are loads of criteria you can
 specify in order to perform a search. Strategies for narrowing your searches
 to find exactly what you're looking for (and no more) are discussed below.
 
+Newer RT versions also include a Transaction query builder, which allows
+you to search for specific changes or types of changes in tickets.
+
 The Query Builder is the heart of reporting in RT, which is covered in the
 L<Dashboard and Reports|docs/dashboards_reporting.pod> document.
 
-To follow along with the examples, go to
-L<issues.bestpractical.com|http://issues.bestpractical.com> and try the
-searches yourself.
-
-=head1 Example
+=head1 Basic Ticket Searches
 
 Let's look for tickets in the "RT" queue (RT's bugtracker for itself) that have
 been resolved in the last year. (These examples assume it's currently mid June,
@@ -28,9 +27,9 @@ src="images/search-criteria.png">
 =for :man [Search Criteria F<docs/images/search-criteria.png>]
 
 RT also has two meta-statuses, 'active' and 'inactive'. By selecting either of
-these from the status dropdown of the query builder, you will no longer need
-to explicitly list all of the active or inactive statuses or manually enter
-the queries: "Status = '__Active__'" or "Status = '__Inactive__'".
+these from the status dropdown of the query builder, your search will include
+tickets in all active or inactive statuses without adding each individual
+status name.
 
 The sets of active and inactive statuses for a queue are defined by the
 associated lifecycle. Active tickets are those listed for the 'active' and
@@ -39,8 +38,8 @@ For the default RT lifecycle, for example, the active statuses are new, open,
 and stalled, and the inactive statuses are resolved, rejected and deleted. See
 F<docs/customizing/lifecycles.pod> for more information.
 
-Now that I've selected some criteria, I can click either Add These Terms or
-Add These Terms and Search. I'll click the former:
+After you select some criteria, you can click either Add These Terms to start
+to build your query.
 
 =for html <img alt="Added Terms"
 src="images/added-terms.png">
@@ -49,15 +48,13 @@ src="images/added-terms.png">
 
 =for :man [Added Terms F<docs/images/added-terms.png>]
 
-The upper right hand side presents all the logic we've specified. This view is
+The upper right hand side presents all the logic you have specified. This view is
 a nice way proofread your search: Have you captured everything you want? Are
 there things you'd maybe prefer to leave out for now?
 
-It turns out I've changed my mind. I actually don't want to restrict the search
-to just the RT queue. I want to see all the tickets in issues.bestpractical.com
-(which also includes feature requests, RTIR, etc) that have been resolved
-within the past year. To adjust the search, click on 'AND Queue = RT' and press
-Delete:
+You can continue to modify and refine your search, adding or removing criteria.
+For example, to see all queues and not just the RT queue, you click that part
+of the query and click Delete.
 
 =for html <img alt="Delete Term"
 src="images/delete-term.png">
@@ -66,7 +63,7 @@ src="images/delete-term.png">
 
 =for :man [Delete Term F<docs/images/delete-term.png>]
 
-Your search should now look like this:
+The updated search has just the remaining criteria:
 
 =for html <img alt="Deleted Term"
 src="images/deleted-term.png">
@@ -75,10 +72,8 @@ src="images/deleted-term.png">
 
 =for :man [Deleted Term F<docs/images/deleted-term.png>]
 
-Now, finally, to make the search go, you can either press 'Add these terms and
-Search' (provided there's no new content in the Query Builder), or scroll all
-the way down and press 'Update format and Search'. This search should turn up
-a full page of tickets. Here's the top portion of the list:
+To run the search, click either 'Add these terms and Search', 'Update format
+and Search' at the very bottom, or Show Results in the submenu near the top.
 
 =for html <img alt="Search Results"
 src="images/search-results.png">
@@ -87,6 +82,8 @@ src="images/search-results.png">
 
 =for :man [Search Results F<docs/images/search-results.png>]
 
+=head1 Customizing Search Result Fields
+
 This is the default view of your results. However, like nearly everything in RT,
 it's configurable. You can select additional columns to appear in your results,
 eliminate columns you don't find useful, or reorder them. To start, notice that
@@ -143,18 +140,79 @@ The same pieces of information are now spread across the display next to one
 another, which can be harder to read. So when you tell RT to display a lot of
 columns, it's usually worth adding a well-placed NEWLINE.
 
-Let's say, for example, you have a custom field named 'TransportType' that takes
-the values, 'Car', 'Bus' or 'Train'. If you were to search for all tickets that
-do not have 'TransportType' set to 'Car', this would result in a list of tickets
-with 'TransportType' values of 'Bus', 'Train', and '(no value)'. In order to ensure
-that custom fields with no set value are not included in the your search results,
-add the following to your query:
+=head1 Custom Field Searches
+
+Users often add custom fields to tickets to capture additional important information.
+All of these fields can be searched in the Query Builder as well. Global custom fields
+will show up by default when you start a search. To see custom fields that are applied
+to individual queues, first add the queue to your search and you'll then see the
+custom fields appear in the bottom of the Add Criteria section.
+
+For example, you might have a custom field named "Transport Type" with values
+like "Car", "Bus" or "Train". You can easily build a search to show just tickets
+with a Transport Type of Train for some time period by selecting those options
+in the custom field entry.
+
+=head2 Custom Field Searches and Null Values
+
+There is a special case if you want to search for tickets with no value, called
+a "Null" value, for a custom field. If you search for all tickets that
+do not have Transport Type set to "Car", this results in a list of tickets
+with Transport Type values of 'Bus', 'Train', and '(no value)'.
+
+If what you intended was to show all tickets that have a value and that value
+is not "Car", you can clarify your query to get the correct results. To filter
+out the empty values, add the following to your search query:
+
+    AND CF.{'Transport Type'} IS NOT NULL
+
+=head1 Transaction Query Builder
+
+Similar to the Ticket Query Builder, the Transaction Query Builder provides an
+interface to search for individual transactions. Transactions are all of the
+changes made to a ticket through its life. Each of the entries displayed in the
+ticket history at the bottom of the ticket display page is a transaction.
+
+In some cases, RT users looking for a particular reply on a ticket will
+search in their email client rather than in RT because they will remenber
+getting the email with the information they need. On a busy ticket, it
+can be a challenge to find the reply from Jane some time this week. The
+Transaction Query Builder now makes that sort of search easy.
+
+=head2 Basic Transaction Searches
+
+In the example above, suppose you remember getting a reply from Jane in email
+on a ticket and you know it was in the last week. But it's been a busy week
+and Jane is on a bunch of active tickets, so you're not sure where to start.
+With the Transaction Query Builder, you can easily create a search to show all
+replies from Jane.
+
+First find Creator, select "is", and type Jane's username. The "Creator" of a
+transaction is always the person who made the change. For a reply, by email or
+in RT itself, the person who replied will be the Creator of the transaction.
+
+Next, for Created select "after" and type "1 week ago". RT will then automatically
+figure out the date 7 days ago and show you only results in the last 7 days.
+
+Finally for Type select "is" and select "Correspond". Correspond is the name RT
+users internally for all replies on a ticket.
+
+Run the search and you'll see all replies from Jane on any tickets over the
+last week. Note that you'll see all transactions you have rights to see, even
+if you aren't a watcher and possibly didn't get an email originally.
+
+=head2 Including Ticket Information
+
+When searching for transactions, you can also add criteria about the types of
+tickets the transactions should be on. In our example, we probably only want
+to see active tickets, so in the bottom Ticket Fields section you can select
+Status "is" and "Active". This will then filter out inactive statuses.
 
-AND CF.{TransportType} IS NOT NULL
+=head1 Learn More
 
-And there are the basics of the query builder! To implement these basics to
-build reports, please see the Dashboard & Reports document. For definitions of
-piece of RT metadata, please see the Definitions of Ticket Metadata document.
+To use the query builder to to build and save reports, see
+L<Dashboard and Reports|docs/dashboards_reporting.pod>. For definitions of
+RT metadata, see L<Ticket Metadata|docs/ticket_metadata.pod>.
 
 =cut
 

-----------------------------------------------------------------------


More information about the rt-commit mailing list