[Rt-commit] rt branch, 4.6/txn-query-builder, created. rt-4.4.4-97-g9fdae856c

? sunnavy sunnavy at bestpractical.com
Wed Jul 17 13:55:58 EDT 2019


The branch, 4.6/txn-query-builder has been created
        at  9fdae856cd54f9644931608d5b5cb3c1877298d6 (commit)

- Log -----------------------------------------------------------------
commit 985fb76b498528eff8ca83d99fb90f19572fc788
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 67d402ab8..d0a46a0cc 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -2248,6 +2248,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 0d86db633..cb592bbfe 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4549,6 +4549,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 806d23888..15154a93d 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -170,6 +170,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'),
@@ -443,15 +447,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};
 
@@ -465,7 +481,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'}
@@ -473,6 +489,8 @@ sub BuildMainNav {
                 : $current_search->{'RowsPerPage'}
             ),
         );
+        $fallback_query_args{Class} ||= $class;
+        $fallback_query_args{ObjectType} ||= 'RT::Ticket';
 
         if ($query_string) {
             $args = '?' . $query_string;
@@ -502,10 +520,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;
         }
 
@@ -514,54 +535,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 1ab187794..2da719c9c 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 6c602b39d..78bcb2147 100644
--- a/lib/RT/Transactions.pm
+++ b/lib/RT/Transactions.pm
@@ -138,6 +138,821 @@ 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
+    TicketUpdatedBy       => ['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
+    TicketUpdated         => ['TICKETFIELD'],                  #loc_left_pair
+    TicketPriority        => ['TICKETFIELD'],                  #loc_left_pair
+    TicketInitialPriority => ['TICKETFIELD'],                  #loc_left_pair
+    TicketFinalPriority   => ['TICKETFIELD'],                  #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,
+    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!!;
+
+    unless ( defined $self->{_sql_ticketalias} ) {
+        $self->{_sql_ticketalias} = $self->Join(
+            TYPE   => 'LEFT',
+            FIELD1 => 'ObjectId',
+            TABLE2 => 'Tickets',
+            FIELD2 => 'id',
+        );
+        $self->Limit(
+            FIELD           => 'ObjectType',
+            VALUE           => 'RT::Ticket',
+            ENTRYAGGREGATOR => 'AND',
+        );
+    }
+
+    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;
+    }
+
+    $self->Limit(
+        %rest,
+        ALIAS         => $self->{_sql_ticketalias},
+        FIELD         => $field,
+        OPERATOR      => $op,
+        VALUE         => $value,
+        CASESENSITIVE => 0,
+    );
+}
+
+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->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;
+
+    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;
+
+    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);
+    }
+
+    # set SB's dirty flag
+    $self->{'must_redo_search'} = 1;
+
+    return (1, $self->loc("Valid Query"));
+}
+
+=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 275ea76b6..5bd8009f9 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 28efbfc49..d66c72251 100644
--- a/share/html/Elements/RT__Transaction/ColumnMap
+++ b/share/html/Elements/RT__Transaction/ColumnMap
@@ -109,6 +109,14 @@ 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' ) },
+    },
 };
 
 
diff --git a/share/html/Elements/SelectDateType b/share/html/Elements/SelectDateType
index c94780c7b..1879dd7ea 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Elements/SelectDateType
@@ -46,15 +46,12 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <select name="<%$Name%>">
-<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 7f519e6fe..51237de14 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 8e415f636..965f4c6e1 100644
--- a/share/html/Elements/ShowSearch
+++ b/share/html/Elements/ShowSearch
@@ -52,7 +52,7 @@
     titleright => $customize ? loc('Edit') : '',
     titleright_href => $customize,
     hideable => $hideable &>
-<& $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;
@@ -61,6 +61,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);
@@ -76,7 +77,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
@@ -133,14 +138,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 790d026b1..574ed5fa7 100644
--- a/share/html/Elements/ShowTransaction
+++ b/share/html/Elements/ShowTransaction
@@ -51,6 +51,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 b887b81e9..580fc73f0 100644
--- a/share/html/Search/Build.html
+++ b/share/html/Search/Build.html
@@ -73,12 +73,14 @@
 <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 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'&>
@@ -112,15 +114,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 ) {
 
@@ -130,23 +148,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{$_};
@@ -167,7 +190,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;
 };
@@ -282,7 +305,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'},
@@ -308,7 +331,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;
@@ -319,4 +346,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 9dde53813..5abf6b1a3 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 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 b35a81d31..77b24e2b1 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,114 @@ $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
+        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 d49facc4a..1afb13c58 100644
--- a/share/html/Search/Elements/ConditionRow
+++ b/share/html/Search/Elements/ConditionRow
@@ -84,7 +84,7 @@ $handle_block = sub {
         my $res = '';
         $res .= qq{<select id="$name" name="$name">};
         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 e7de62bd0..015afdf66 100644
--- a/share/html/Search/Elements/EditSearches
+++ b/share/html/Search/Elements/EditSearches
@@ -102,7 +102,7 @@ my $is_dirty = sub {
     my %arg = (
         Query       => {},
         SavedSearch => {},
-        SearchFields => [qw(Query Format OrderBy Order RowsPerPage)],
+        SearchFields => [qw(Query Format OrderBy Order RowsPerPage ObjectType)],
         @_
     );
 
@@ -128,7 +128,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   => ()
@@ -140,7 +141,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>
 
@@ -148,6 +151,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;
 
@@ -224,7 +228,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 7b70b923e..1c58b783d 100644
--- a/share/html/Search/Elements/EditSort
+++ b/share/html/Search/Elements/EditSort
@@ -98,8 +98,8 @@ selected="selected"
 </table>
 
 <%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) {
@@ -108,24 +108,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 );
 
@@ -145,4 +148,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 08f7c949a..2c3ab60dc 100644
--- a/share/html/Search/Elements/PickBasics
+++ b/share/html/Search/Elements/PickBasics
@@ -50,192 +50,352 @@
 % }
 <%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 => 'text', Size => 5 },
         },
-        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 => 'text', Size => 5 },
+                {
+                    Type => 'component',
+                    Path => '/Elements/SelectTimeUnits',
+                },
+            ],
         },
-    },
-    {
-        Name => 'Status',
-        Field => loc('Status'),
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectBoolean',
-            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        {
+            Name => 'Type',
+            Field => loc('Type'),
+            Op => {
+                Type => 'component',
+                Path => '/Elements/SelectMatch',
+            },
+            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 => 'text', Size => 5 },
-            {
-                Type => 'component',
-                Path => '/Elements/SelectTimeUnits',
-            },
-        ],
-    },
-    {
-        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 => 'text', Size => 5 },
+                {
+                    Type => 'component',
+                    Path => '/Elements/SelectTimeUnits',
+                },
             ],
         },
-        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 );
 
@@ -245,10 +405,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 = "";
@@ -265,7 +430,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();
@@ -285,4 +454,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 d0820f18a..278fa2cff 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickCriteria
@@ -51,12 +51,22 @@
 
 
 % $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" );
+% }
 
 <tr class="separator"><td colspan="3"><hr /></td></tr>
 <tr>
@@ -73,4 +83,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 08f7c949a..ae37935b7 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', Size => 5 }
     },
     {
-        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',
-        Field => {
-            Type => 'component',
-            Path => 'SelectPersonType',
-            Arguments => { Default => 'Requestor' },
-        },
-        Op => {
-            Type => 'component',
-            Path => '/Elements/SelectMatch',
-        },
-        Value => { Type => 'text', Size => 20 }
-    },
-    {
-        Name => 'WatcherGroup',
+        Name => 'TicketDate',
         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 => {
@@ -207,13 +166,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 => {
@@ -225,64 +184,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 d0820f18a..ec2e4966d 100644
--- a/share/html/Search/Elements/PickCriteria
+++ b/share/html/Search/Elements/PickTransactionCFs
@@ -45,32 +45,34 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<&| /Widgets/TitleBox, title => loc('Add Criteria')&>
-
-<table width="100%" cellspacing="0" cellpadding="0" border="0">
-
-
-% $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" );
-
-<tr class="separator"><td colspan="3"><hr /></td></tr>
-<tr>
-<td class="label"><&|/l&>Aggregator</&></td>
-<td class="operator" colspan="2"><& SelectAndOr, Name => "AndOr" &></td>
-
-</tr>
-
-</table>
-
-</&>
-
 <%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 101ed648d..6ea51e0f0 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%>">
 <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 1d3c81513..d9938b526 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 => 'SearchResultsRefreshInterval', Default => $session{$interval_name} || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'}) &>
 <input type="submit" class="button" value="<&|/l&>Change</&>" />
 </form>
 </div>
@@ -104,9 +105,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.
@@ -122,39 +130,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',
@@ -166,16 +209,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;
@@ -194,22 +236,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
@@ -221,4 +265,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 a28c04eae..934411755 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 80%
copy from share/html/Elements/SelectDateType
copy to share/html/SelfService/Transaction/Display.html
index c94780c7b..26a7379e6 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%>">
-<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 70%
copy from share/html/Search/Results.tsv
copy to share/html/Transaction/Display.html
index a28c04eae..5cfd32502 100644
--- a/share/html/Search/Results.tsv
+++ b/share/html/Transaction/Display.html
@@ -45,30 +45,26 @@
 %# 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,
+&>
 <%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>

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

    Add support to clamp long search result columns
    
    This is initially for transaction's Content column, which could be very
    long and we need to clamp 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 1254d838b..489290696 100644
--- a/share/html/Elements/JavascriptConfig
+++ b/share/html/Elements/JavascriptConfig
@@ -73,6 +73,7 @@ my $Catalog = {
     loading => "Loading...", #loc
     try_again => "Try again", #loc
     history_scroll_error => "Could not load ticket history. Reason:", #loc
+    unclamp => "Show all", #loc
 };
 $_ = loc($_) for values %$Catalog;
 
diff --git a/share/static/css/base/collection.css b/share/static/css/base/collection.css
index a06199977..daa5319dc 100644
--- a/share/static/css/base/collection.css
+++ b/share/static/css/base/collection.css
@@ -24,3 +24,12 @@ table.collection td:first-child, table.collection th:first-child {
 .results-count::before {
     content: '\a0\a0\a0\a0';
 }
+
+.collection-as-table div.clamp {
+    overflow-y: hidden;
+}
+
+.collection-as-table a.unclamp {
+    font-size: smaller;
+    font-weight: normal;
+}
diff --git a/share/static/js/util.js b/share/static/js/util.js
index d5bf84562..1351559f0 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -275,6 +275,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="unclamp button">' + loc_key('unclamp') + '</a>');
+            }
+        }
+    });
+    jQuery('a.unclamp').click(function() {
+        jQuery(this).siblings('div.clamp').css('height', 'auto');
+        jQuery(this).hide();
+        return false;
+    });
 });
 
 function textToHTML(value) {

commit aac59d102e477f0fa6eda2a4f53d37c48d23df94
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 000000000..d9066951c
--- /dev/null
+++ b/t/transaction/search.t
@@ -0,0 +1,124 @@
+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 );
+}
+
+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 9fdae856cd54f9644931608d5b5cb3c1877298d6
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 000000000..7824a6eba
--- /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;

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


More information about the rt-commit mailing list