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

? sunnavy sunnavy at bestpractical.com
Fri Jul 5 15:46:00 EDT 2019


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

- Log -----------------------------------------------------------------
commit 640e19a80bfd33df247c481bd9b515d533426fc4
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
    
    The code is generally copied from ticket search builder with a few
    adjustments.

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 67d402ab8..9166a8e68 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:#',
+        '<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..01361f407 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("No ticket specified", Code => HTTP::Status::HTTP_BAD_REQUEST);
+    }
+
+    my $Transaction = RT::Transaction->new( $session{'CurrentUser'} );
+    $Transaction->Load($id);
+    unless ( $Transaction->id ) {
+        Abort("Could not load ticket $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..56e18de0e 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 => '/Transaction/Search/Build.html?ObjectType=RT::Ticket' );
+    my $txns_tickets = $txns->child( new => title => loc('Tickets'), path => "/Transaction/Search/Build.html?ObjectType=RT::Ticket" );
+    $txns_tickets->child( new => title => loc('New Search'), path => "/Transaction/Search/Build.html?ObjectType=RT::Ticket&NewQuery=1" );
+
     my $reports = $top->child( reports =>
         title       => loc('Reports'),
         description => loc('Reports summarizing ticket resolution and status'),
@@ -565,6 +569,79 @@ sub BuildMainNav {
         }
     }
 
+    if ( $request_path =~ m{^/Transaction/Search/} ) {
+        my $search = $top->child('search')->child('transactions');
+        my $current_search = $HTML::Mason::Commands::session{"CurrentSearchHash"} || {};
+        my $search_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchLoad'} || $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchId'} || $current_search->{'SearchId'} || '';
+
+        $has_query = 1 if ( $HTML::Mason::Commands::DECODED_ARGS->{'Query'} or $current_search->{'Query'} );
+
+        my %query_args;
+        my %fallback_query_args = (
+            SavedSearchId => ( $search_id eq 'new' ) ? undef : $search_id,
+            (
+                Class => 'RT::Transactions',
+                map {
+                    my $p = $_;
+                    $p => $HTML::Mason::Commands::DECODED_ARGS->{$p} || $current_search->{$p}
+                } qw(Query Format OrderBy Order Page ObjectType)
+            ),
+            RowsPerPage => (
+                defined $HTML::Mason::Commands::DECODED_ARGS->{'RowsPerPage'}
+                ? $HTML::Mason::Commands::DECODED_ARGS->{'RowsPerPage'}
+                : $current_search->{'RowsPerPage'}
+            ),
+        );
+
+        if ($query_string) {
+            $args = '?' . $query_string;
+        }
+        else {
+            my %final_query_args = ();
+            # key => callback to avoid unnecessary work
+
+            for my $param (keys %fallback_query_args) {
+                $final_query_args{$param} = defined($query_args->{$param})
+                                          ? $query_args->{$param}
+                                          : $fallback_query_args{$param};
+            }
+
+            for my $field (qw(Order OrderBy)) {
+                if ( ref( $final_query_args{$field} ) eq 'ARRAY' ) {
+                    $final_query_args{$field} = join( "|", @{ $final_query_args{$field} } );
+                } elsif (not defined $final_query_args{$field}) {
+                    delete $final_query_args{$field};
+                }
+                else {
+                    $final_query_args{$field} ||= '';
+                }
+            }
+
+            $args = '?' . QueryString(%final_query_args);
+        }
+
+        my $current_search_menu;
+        if ( $request_path =~ m{^/Transaction/[^/]+\.html} ) {
+            $current_search_menu = $search->child( current_search => title => loc('Current Search') );
+            $current_search_menu->path("/Transaction/Search/Results.html$args") if $has_query;
+        } else {
+            $current_search_menu = $page;
+        }
+
+        $current_search_menu->child( edit_search =>
+            title => loc('Edit Search'), path => "/Transaction/Search/Build.html" . ( ($has_query) ? $args : '' ) );
+        $current_search_menu->child( advanced =>
+            title => loc('Advanced'),    path => "/Transaction/Search/Edit.html$args" );
+        if ($has_query) {
+            $current_search_menu->child( results => title => loc('Show Results'), path => "/Transaction/Search/Results.html$args" );
+        }
+
+        if ( $has_query ) {
+            my $more = $current_search_menu->child( more => title => loc('Feeds') );
+            $more->child( spreadsheet => title => loc('Spreadsheet'), path => "/Transaction/Search/Results.tsv$args" );
+        }
+    }
+
     if ( $request_path =~ m{^/Article/} ) {
         if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
             my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
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..48cdefea7 100644
--- a/lib/RT/Transactions.pm
+++ b/lib/RT/Transactions.pm
@@ -138,6 +138,728 @@ 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
+
+    Created       => [ 'DATE' => 'Created', ],    #loc_left_pair
+    Type          => ['STRING'],
+    Field         => ['STRING'],
+    OldValue      => ['STRING'],
+    NewValue      => ['STRING'],
+    ReferenceType => ['STRING'],
+    OldReference  => ['STRING'],
+    NewReference  => ['STRING'],
+    Data          => ['STRING'],
+
+    CustomFieldValue => [ 'CUSTOMFIELD' => 'Transaction' ], #loc_left_pair
+    CustomField      => [ 'CUSTOMFIELD' => 'Transaction' ], #loc_left_pair
+    CF               => [ 'CUSTOMFIELD' => 'Transaction' ], #loc_left_pair
+
+    Content          => [ 'ATTACHCONTENT', ], #loc_left_pair
+    ContentType      => [ 'ATTACHFIELD', ], #loc_left_pair
+    Filename         => [ 'ATTACHFIELD', ], #loc_left_pair
+    Subject          => [ 'ATTACHFIELD', ], #loc_left_pair
+
+    TicketId            => [ 'TICKETFIELD', ],    #loc_left_pair
+    TicketSubject       => [ 'TICKETFIELD', ],    #loc_left_pair
+    TicketQueue         => [ 'TICKETFIELD', ],
+    TicketStatus        => [ 'TICKETFIELD', ],
+    TicketOwner         => [ 'TICKETFIELD', ],
+    TicketCreator       => [ 'TICKETFIELD', ],
+    TicketLastUpdatedBy => [ 'TICKETFIELD', ],
+    TicketUpdatedBy     => [ 'TICKETFIELD', ],
+    TicketCreated       => [ 'TICKETFIELD', ],
+    TicketStarted       => [ 'TICKETFIELD', ],
+    TicketResolved      => [ 'TICKETFIELD', ],
+    TicketTold          => [ 'TICKETFIELD', ],
+    TicketLastUpdated   => [ 'TICKETFIELD', ],
+    TicketStarts        => [ 'TICKETFIELD', ],
+    TicketDue           => [ 'TICKETFIELD', ],
+    TicketUpdated       => [ 'TICKETFIELD', ],
+);
+
+# 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,
+);
+
+sub FIELDS     { return \%FIELD_METADATA }
+
+our @SORTFIELDS = qw(id ObjectId Created);
+
+=head2 SortFields
+
+Returns the list of fields that lists of tickets 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.  (For example,
+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, LastTold..)
+
+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.  (Subject,Type)
+
+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 _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_attachalias} ) {
+        $self->{_sql_attachalias} = $self->Join(
+            TYPE   => 'LEFT', # not all txns have an attachment
+            FIELD1 => 'id',
+            TABLE2 => 'Attachments',
+            FIELD2 => 'TransactionId',
+        );
+    }
+
+    $self->Limit(
+        %rest,
+        ALIAS         => $self->{_sql_attachalias},
+        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_attachalias} ) {
+        $self->{_sql_attachalias} = $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_attachalias'},
+                FIELD1 => 'id',
+                TABLE2 => $config->{'Table'},
+                FIELD2 => 'id',
+            );
+        } else {
+            $alias = $self->{'_sql_attachalias'};
+        }
+
+        #XXX: handle negative searches
+        my $index = $config->{'Column'};
+        if ( $db_type eq 'Oracle' ) {
+            my $dbh = $RT::Handle->dbh;
+            my $alias = $self->{_sql_attachalias};
+            $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_attachalias},
+                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_attachalias},
+            FIELD           => $field,
+            OPERATOR        => $op,
+            VALUE           => $value,
+            CASESENSITIVE   => 0,
+        );
+    }
+    if ( RT->Config->Get('DontSearchFileAttachments') ) {
+        $self->Limit(
+            ENTRYAGGREGATOR => 'AND',
+            ALIAS           => $self->{_sql_attachalias},
+            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..c141b18e6 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);
 }
 
@@ -210,4 +213,5 @@ $ShowHeader     => 1
 $ShowEmpty      => 0
 $Query => 0
 $HasResults     => undef
+$ObjectType     => 'RT::Ticket'
 </%ARGS>
diff --git a/share/html/Elements/SelectDateType b/share/html/Elements/SelectDateType
index c94780c7b..be38726bc 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Elements/SelectDateType
@@ -46,15 +46,16 @@
 %#
 %# 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>
+<option value="<% $Prefix %>Created"><&|/l&>Created</&></option>
+<option value="<% $Prefix %>Started"><&|/l&>Started</&></option>
+<option value="<% $Prefix %>Resolved"><&|/l&>Resolved</&></option>
+<option value="<% $Prefix %>Told"><&|/l&>Last Contacted</&></option>
+<option value="<% $Prefix %>LastUpdated"><&|/l&>Last Updated</&></option>
+<option value="<% $Prefix %>Starts"><&|/l&>Starts</&></option>
+<option value="<% $Prefix %>Due"><&|/l&>Due</&></option>
+<option value="<% $Prefix %>Updated"><&|/l&>Updated</&></option>
 </select>
 <%ARGS>
 $Name => 'DateType'
+$Prefix => ''
 </%ARGS>
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/SelectDateType b/share/html/Transaction/Display.html
similarity index 71%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Display.html
index c94780c7b..133bc151f 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Display.html
@@ -45,16 +45,23 @@
 %# 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>
+<& /Elements/Header, Title => loc('Transaction #[_1]', $id) &>
+<& /Elements/Tabs &>
+<& /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>
-$Name => 'DateType'
+$id => undef
 </%ARGS>
+
+<%INIT>
+Abort('No transaction specified') unless $id;
+my $txn = LoadTransaction($id);
+Abort( "No permission to view transaction", Code => HTTP::Status::HTTP_FORBIDDEN ) unless $txn->CurrentUserCanSee;
+
+</%INIT>
diff --git a/share/html/Transaction/Search/Build.html b/share/html/Transaction/Search/Build.html
new file mode 100644
index 000000000..3d896e7ad
--- /dev/null
+++ b/share/html/Transaction/Search/Build.html
@@ -0,0 +1,320 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+%#
+%# Data flow here:
+%#   The page receives a Query from the previous page, and maybe arguments
+%#   corresponding to actions.  (If it doesn't get a Query argument, it pulls
+%#   one out of the session hash.  Also, it could be getting just a raw query from
+%#   Build/Edit.html (Advanced).)
+%#
+%#   After doing some stuff with default arguments and saved searches, the ParseQuery
+%#   function (which is similar to, but not the same as, _parser in lib/RT/Tickets.pm)
+%#   converts the Query into a RT::Interface::Web::QueryBuilder::Tree.  This mason file
+%#   then adds stuff to or modifies the tree based on the actions that had been requested
+%#   by clicking buttons.  It then calls GetQueryAndOptionList on the tree to generate
+%#   the SQL query (which is saved as a hidden input) and the option list for the Clauses
+%#   box in the top right corner.
+%#
+%#   Worthwhile refactoring: the tree manipulation code for the actions could use some cleaning
+%#   up.  The node-adding code is different in the "add" actions from in ParseQuery, which leads
+%#   to things like ParseQuery correctly not quoting numbers in numerical fields, while the "add"
+%#   action does quote it (this breaks SQLite).
+%#
+<& /Elements/Header, Title => $title &>
+<& /Elements/Tabs, %TabArgs &>
+
+<form method="post" action="Build.html" name="BuildQuery" id="BuildQuery">
+<input type="hidden" class="hidden" name="SavedSearchId" value="<% $saved_search{'Id'} %>" />
+<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'} %>" />
+
+
+
+
+<div id="pick-criteria">
+    <& Elements/PickCriteria, query => $query{'Query'}, queues => $queues &>
+</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'&>
+
+
+<div id="editquery">
+<& /Search/Elements/EditQuery,
+    %ARGS,
+    actions => \@actions,
+    optionlist => $optionlist,
+    Description => $saved_search{'Description'},
+    &>
+</div>
+<div id="editsearches">
+    <& Elements/EditSearches, %saved_search, CurrentSearch => \%query &>
+</div>
+
+<span id="display-options">
+<& Elements/DisplayOptions,
+    %ARGS, %query,
+    AvailableColumns => $AvailableColumns,
+    CurrentFormat    => $CurrentFormat,
+&>
+</span>
+<& /Elements/Submit, Label => loc('Update format and Search'), Name => 'DoSearch', id=>"formatbuttons"&>
+</form>
+
+<%INIT>
+use RT::Interface::Web::QueryBuilder;
+use RT::Interface::Web::QueryBuilder::Tree;
+
+my $title = loc("Query Builder");
+
+my %query;
+for( qw(Query Format OrderBy Order RowsPerPage ObjectType) ) {
+    $query{$_} = $ARGS{$_};
+}
+
+my %saved_search;
+my @actions = $m->comp( 'Elements/EditSearches:Init', %ARGS, Query => \%query, SavedSearch => \%saved_search);
+
+if ( $NewQuery ) {
+
+    # Wipe all data-carrying variables clear if we want a new
+    # search, or we're deleting an old one..
+    %query = ();
+    %saved_search = ( Id => 'new' );
+
+    # ..then wipe the session out..
+    delete $session{'CurrentTransactionSearchHash'};
+
+    # ..and the search results.
+    $session{'txns'}->CleanSlate if defined $session{'txns'};
+}
+
+{ # Attempt to load what we can from the session and preferences, set defaults
+
+    my $current = $session{'CurrentTransactionSearchHash'};
+    my $prefs = $session{'CurrentUser'}->UserObj->Preferences("SearchDisplay") || {};
+    my $default = { Query => '',
+                    Format => '',
+                    OrderBy => RT->Config->Get('TransactionDefaultSearchResultOrderBy')->{$ObjectType},
+                    Order => RT->Config->Get('TransactionDefaultSearchResultOrder')->{$ObjectType},
+                    ObjectType => 'RT::Ticket',
+                    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{$_};
+    }
+
+    for( qw(Order OrderBy) ) {
+        if (ref $query{$_} eq "ARRAY") {
+            $query{$_} = join( '|', @{ $query{$_} } );
+        }
+    }
+    if ( $query{'Format'} ) {
+        # Clean unwanted junk from the format
+        $query{'Format'} = $m->comp( '/Elements/ScrubHTML', Content => $query{'Format'} );
+    }
+}
+
+my $ParseQuery = sub {
+    my ($string, $results) = @_;
+
+    my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
+    @$results = $tree->ParseSQL( Query => $string, CurrentUser => $session{'CurrentUser'}, Class => 'RT::Transactions' );
+
+    return $tree;
+};
+
+my @parse_results;
+my $tree = $ParseQuery->( $query{'Query'}, \@parse_results );
+
+# if parsing went poorly, send them to the edit page to fix it
+if ( @parse_results ) {
+    push @actions, @parse_results;
+    return $m->comp(
+        "Edit.html",
+        Query => $query{'Query'},
+        Format => $query{'Format'},
+        SavedSearchId => $saved_search{'Id'},
+        actions => \@actions,
+    );
+}
+
+my @options = $tree->GetDisplayedNodes;
+my @current_values = grep defined, @options[@clauses];
+my @new_values = ();
+
+my $cf_field_names =
+    join "|",
+     map quotemeta,
+    grep { $RT::Tickets::FIELD_METADATA{$_}->[0] eq 'CUSTOMFIELD' }
+    sort keys %RT::Tickets::FIELD_METADATA;
+
+# Try to find if we're adding a clause
+foreach my $arg ( keys %ARGS ) {
+    next unless $arg =~ m/^ValueOf(\w+|($cf_field_names).\{.*?\}|CustomRole.\{.*?\})$/
+                && ( ref $ARGS{$arg} eq "ARRAY"
+                     ? grep $_ ne '', @{ $ARGS{$arg} }
+                     : $ARGS{$arg} ne '' );
+
+    # We're adding a $1 clause
+    my $field = $1;
+
+    my ($op, $value);
+
+    #figure out if it's a grouping
+    my $keyword = $ARGS{ $field . "Field" } || $field;
+
+    my ( @ops, @values );
+    if ( ref $ARGS{ 'ValueOf' . $field } eq "ARRAY" ) {
+        # we have many keys/values to iterate over, because there is
+        # more than one CF with the same name.
+        @ops    = @{ $ARGS{ $field . 'Op' } };
+        @values = @{ $ARGS{ 'ValueOf' . $field } };
+    }
+    else {
+        @ops    = ( $ARGS{ $field . 'Op' } );
+        @values = ( $ARGS{ 'ValueOf' . $field } );
+    }
+    $RT::Logger->error("Bad Parameters passed into Query Builder")
+        unless @ops == @values;
+
+    for ( my $i = 0; $i < @ops; $i++ ) {
+        my ( $op, $value ) = ( $ops[$i], $values[$i] );
+        next if !defined $value || $value eq '';
+
+        my $clause = {
+            Key   => $keyword,
+            Op    => $op,
+            Value => $value,
+        };
+
+        push @new_values, RT::Interface::Web::QueryBuilder::Tree->new($clause);
+    }
+}
+
+
+push @actions, $m->comp('/Search/Elements/EditQuery:Process',
+    %ARGS,
+    Tree     => $tree,
+    Selected => \@current_values,
+    New      => \@new_values,
+);
+
+# Rebuild $Query based on the additions / movements
+
+my $optionlist_arrayref;
+($query{'Query'}, $optionlist_arrayref) = $tree->GetQueryAndOptionList(\@current_values);
+
+my $optionlist = join "\n", map { qq(<option value="$_->{INDEX}" $_->{SELECTED}>)
+                                  . (" " x (5 * $_->{DEPTH}))
+                                  . $m->interp->apply_escapes($_->{TEXT}, 'h') . qq(</option>) } @$optionlist_arrayref;
+
+
+my $queues = $tree->GetReferencedQueues;
+
+# Deal with format changes
+my ( $AvailableColumns, $CurrentFormat );
+( $query{'Format'}, $AvailableColumns, $CurrentFormat ) = $m->comp(
+    'Elements/BuildFormatString',
+    %ARGS,
+    queues => $queues,
+    Format => $query{'Format'},
+);
+
+
+# if we're asked to save the current search, save it
+push @actions, $m->comp( 'Elements/EditSearches:Save', %ARGS, Query => \%query, SavedSearch => \%saved_search);
+
+# Populate the "query" context with saved search data
+
+if ($ARGS{SavedSearchSave}) {
+    $query{'SavedSearchId'} = $saved_search{'Id'};
+}
+
+# Push the updates into the session so we don't lose 'em
+
+$session{'CurrentTransactionSearchHash'} = {
+    %query,
+    SearchId    => $saved_search{'Id'},
+    Object      => $saved_search{'Object'},
+    Description => $saved_search{'Description'},
+};
+
+
+# Show the results, if we were asked.
+
+if ( $ARGS{'DoSearch'} ) {
+    my $redir_query_string = $m->comp(
+        '/Elements/QueryString',
+        %query,
+        SavedSearchId => $saved_search{'Id'},
+    );
+    RT::Interface::Web::Redirect(RT->Config->Get('WebURL') . 'Transaction/Search/Results.html?' . $redir_query_string);
+    $m->abort;
+}
+
+
+# Build a querystring for the tabs
+
+my %TabArgs = ();
+if ($NewQuery) {
+    $TabArgs{QueryString} = 'NewQuery=1';
+}
+elsif ( $query{'Query'} ) {
+    $TabArgs{QueryArgs} = \%query;
+}
+
+</%INIT>
+
+<%ARGS>
+$NewQuery => 0
+ at clauses => ()
+$ObjectType => 'RT::Ticket'
+</%ARGS>
diff --git a/share/html/Elements/SelectDateType b/share/html/Transaction/Search/Edit.html
similarity index 62%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Edit.html
index c94780c7b..540097ef8 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Edit.html
@@ -45,16 +45,43 @@
 %# 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>
+<& /Elements/Header, Title => $title&>
+<& /Elements/Tabs &>
+
+<& /Search/Elements/NewListActions, actions => \@actions &>
+
+<form method="post" action="Build.html" id="BuildQueryAdvanced" name="BuildQueryAdvanced">
+<input type="hidden" class="hidden" name="SavedSearchId" value="<% $SavedSearchId %>" />
+<&|/Widgets/TitleBox, title => loc('Query'), &>
+<textarea name="Query" rows="8" cols="72"><% $Query %></textarea>
+</&>
+<&|/Widgets/TitleBox, title => loc('Format'), &>
+<textarea name="Format" rows="8" cols="72"><% $Format %></textarea>
+</&>
+<& /Elements/Submit, Label => loc("Apply"), Reset => 1, Caption => loc("Apply your changes")&>
+</form>
+
+<%INIT>
+my $title = loc("Edit Query");
+$Format = $m->comp('/Elements/ScrubHTML', Content => $Format);
+my $QueryString = $m->comp('/Elements/QueryString',
+                           Query   => $Query,
+                           Format  => $Format,
+                           RowsPerPage    => $Rows,
+                           OrderBy => $OrderBy,
+                           Order   => $Order,
+                          );
+
+</%INIT>
+
+
 <%ARGS>
-$Name => 'DateType'
+$SavedSearchId => 'new'
+$Query         => ''
+$Format        => ''
+$Rows          => '50'
+$OrderBy       => 'id'
+$Order         => 'ASC'
+
+ at actions       => ()
 </%ARGS>
diff --git a/share/html/Transaction/Search/Elements/BuildFormatString b/share/html/Transaction/Search/Elements/BuildFormatString
new file mode 100644
index 000000000..5df516cb4
--- /dev/null
+++ b/share/html/Transaction/Search/Elements/BuildFormatString
@@ -0,0 +1,210 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$ObjectType => 'RT::Ticket'
+$Format => ''
+
+%queues => ()
+
+$Face => undef
+$Size => undef
+$Link => undef
+$Title => undef
+
+$AddCol => undef
+$RemoveCol => undef
+$ColUp => undef
+$ColDown => undef
+
+$SelectDisplayColumns => undef
+$CurrentDisplayColumns => undef
+</%ARGS>
+<%init>
+# This can't be in a <once> block, because otherwise we return the
+# same \@fields every request, and keep tacking more CustomFields onto
+# it -- and it grows per request.
+
+# All the things we can display in the format string by default
+my @fields = qw( id ObjectId ObjectType ObjectName Type Field TimeTaken
+    OldValue NewValue ReferenceType OldReference NewReference
+    Created CreatedRelative CreatedBy Description Content
+    NEWLINE NBSP );    # loc_qw
+
+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 . "}";
+}
+
+$m->callback( Fields => \@fields, ARGSRef => \%ARGS );
+
+my ( @seen);
+
+$Format ||= RT->Config->Get('TransactionDefaultSearchResultFormat')->{$ObjectType};
+my @format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $Format);
+foreach my $field (@format) {
+    # "title" is for columns like NEWLINE, which doesn't have "attribute"
+    $field->{Column} = $field->{attribute} || $field->{title} || '<blank>';
+    push @seen, $field;
+}
+my @format_string;
+
+if ( $RemoveCol ) {
+    # we do this regex match to avoid a non-numeric warning
+    my ($index) = ($CurrentDisplayColumns // '') =~ /^(\d+)/;
+    if ( defined($index) ) {
+        delete $seen[$index];
+        my @temp = @seen;
+        @seen = ();
+        foreach my $element (@temp) {
+            next unless $element;
+            push @seen, $element;
+        }
+    }
+}
+elsif ( $AddCol ) {
+    if ( defined $SelectDisplayColumns ) {
+        my $selected = $SelectDisplayColumns;
+        my @columns;
+        if (ref($selected) eq 'ARRAY') {
+            @columns = @$selected;
+        } else {
+            push @columns, $selected;
+        }
+        foreach my $col (@columns) {
+            my %column = ();
+            $column{Column} = $col;
+
+            if ( $Face eq "Bold" ) {
+                $column{Prefix} .= "<b>";
+                $column{Suffix} .= "</b>";
+            }
+            if ( $Face eq "Italic" ) {
+                $column{Prefix} .= "<i>";
+                $column{Suffix} .= "</i>";
+            }
+            if ($Size) {
+                if ( $Size eq 'Large' ) {
+                    $column{Prefix} .= '<span style="font-size:larger">';
+                    $column{Suffix} .= '</span>';
+                }
+                else {
+                    $column{Prefix} .= "<" . $m->interp->apply_escapes( $Size,  'h' ) . ">";
+                    $column{Suffix} .= "</" . $m->interp->apply_escapes( $Size, 'h' ) . ">";
+                }
+            }
+            if ( $Link eq "Display" ) {
+                $column{Prefix} .= q{<a HREF="__WebPath__/Transaction/Display.html?id=__id__">};
+                $column{Suffix} .= "</a>";
+            }
+
+            if ($Title) {
+                $column{Suffix} .= "/TITLE:" . $m->interp->apply_escapes( $Title, 'h' );
+            }
+            push @seen, \%column;
+        }
+    }
+}
+elsif ( $ColUp ) {
+    my ($index) = ($CurrentDisplayColumns // '') =~ /^(\d+)/;
+    if ( defined $index && ( $index - 1 ) >= 0 ) {
+        my $column = $seen[$index];
+        $seen[$index]       = $seen[ $index - 1 ];
+        $seen[ $index - 1 ] = $column;
+        $CurrentDisplayColumns     = $index - 1;
+    }
+}
+elsif ( $ColDown ) {
+    my ($index) = ($CurrentDisplayColumns // '') =~ /^(\d+)/;
+    if ( defined $index && ( $index + 1 ) < scalar @seen ) {
+        my $column = $seen[$index];
+        $seen[$index]       = $seen[ $index + 1 ];
+        $seen[ $index + 1 ] = $column;
+        $CurrentDisplayColumns     = $index + 1;
+    }
+}
+
+
+foreach my $field (@seen) {
+    next unless $field;
+    my $row = "";
+    if ( $field->{'original_string'} ) {
+        $row = $field->{'original_string'};
+    }
+    else {
+        $row .= $field->{'Prefix'} if defined $field->{'Prefix'};
+        $row .= "__$field->{'Column'}__"
+          unless ( $field->{'Column'} eq "<blank>" );
+        $row .= $field->{'Suffix'} if defined $field->{'Suffix'};
+        $row =~ s!([\\'])!\\$1!g;
+        $row = "'$row'";
+    }
+    push( @format_string, $row );
+}
+
+$Format = join(",\n", @format_string);
+
+
+return($Format, \@fields, \@seen);
+
+</%init>
diff --git a/share/html/Elements/SelectDateType b/share/html/Transaction/Search/Elements/DisplayOptions
similarity index 80%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Elements/DisplayOptions
index c94780c7b..4a3939d0d 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Elements/DisplayOptions
@@ -45,16 +45,9 @@
 %# 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>
+<&| /Widgets/TitleBox, title => loc("Sorting"), id => 'sorting' &>
+<& EditSort, %ARGS &>
+</&>
+<&| /Widgets/TitleBox, title => loc("Display Columns"), id => 'columns' &>
+<& EditFormat, %ARGS &>
+</&>
diff --git a/share/html/Transaction/Search/Elements/EditFormat b/share/html/Transaction/Search/Elements/EditFormat
new file mode 100644
index 000000000..30cfcd4fc
--- /dev/null
+++ b/share/html/Transaction/Search/Elements/EditFormat
@@ -0,0 +1,158 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<table class="edit-columns">
+
+<tr>
+<th><&|/l&>Add Columns</&>:</th>
+<th><&|/l&>Format</&>:</th>
+<th></th>
+<th><&|/l&>Show Columns</&>:</th>
+</tr>
+
+<script type="text/javascript">
+jQuery( function() {
+    jQuery('select[name=SelectDisplayColumns].chosen').chosen({ width: '12em', placeholder_text_multiple: ' ', no_results_text: ' ', search_contains: true });
+    jQuery('[name=AddCol], [name=RemoveCol], [name=ColUp], [name=ColDown]').click( function() {
+        var name = jQuery(this).attr('name');
+        var form = jQuery(this).closest('form');
+        jQuery.ajax({
+            url: '<% RT->Config->Get('WebPath') %>/Helpers/BuildFormatString?' + name + '=1&' + form.serialize(),
+            success: function (data) {
+                if ( data.status == 'success' ) {
+                    form.find('input[name=Format]').val(data.Format);
+                    form.find('select[name=CurrentDisplayColumns]').html(data.CurrentDisplayColumns);
+                    form.find('select[name=SelectDisplayColumns]').val('').trigger("chosen:updated");
+                    form.find('[name=Link],[name=Title],[name=Size],[name=Face]').val('');
+                }
+                else {
+                    alert('<% loc("Failed to update format. Reason:") %>' + ' ' + data.message);
+                }
+            },
+            error: function (xhr, reason) {
+                alert('<% loc("Failed to update format. Reason:") %>' + ' ' + reason);
+            }
+        });
+        return false;
+    });
+});
+</script>
+<tr>
+
+<td valign="top"><select name="SelectDisplayColumns" multiple="multiple" class="chosen">
+% my %seen;
+% foreach my $field ( grep !$seen{lc $_}++, @$AvailableColumns) {
+<option value="<% $field %>" <% $selected{$field} ? 'selected="selected"' : '' |n%>>\
+<% $field =~ /^(?:CustomField|CF)\./ ? $field : loc($field) %></option>
+% }
+</select></td>
+<td>
+<div class="row">
+<span class="label"><&|/l&>Link</&>:</span>
+<span class="value">
+<select name="Link">
+<option value="None">-</option>
+<option value="Display"><&|/l&>Display</&></option>
+</select>
+</span>
+</div>
+<div class="row">
+<span class="label"><&|/l&>Title</&>:</span>
+<span class="value"><input name="Title" size="10" /></span>
+</div>
+<div class="row">
+<span class="label"><&|/l&>Size</&>:</span>
+<span class="value"><select name="Size">
+<option value="">-</option>
+<option value="Small"><&|/l&>Small</&></option>
+<option value="Large"><&|/l&>Large</&></option>
+</select>
+</span>
+</div>
+<div class="row">
+<span class="label"><&|/l&>Style</&>:</span>
+<span class="value"><select name="Face">
+<option value="">-</option>
+<option value="Bold"><&|/l&>Bold</&></option>
+<option value="Italic"><&|/l&>Italic</&></option>
+</select>
+</span>
+</div>
+</td>
+
+<td><input type="submit" class="button" name="AddCol" value=" → " /></td>
+
+<td valign="top">
+<select size="8" name="CurrentDisplayColumns">
+% my $i=0;
+% my $current = $ARGS{CurrentDisplayColumns} || ''; $current =~ s/^\d+>//;
+% foreach my $field ( @$CurrentFormat ) {
+<option value="<% $i++ %>><% $field->{Column} %>" <% $field->{Column} eq $current ? 'selected="selected"' : '' |n%>>\
+<% $field->{Column} =~ /^(?:CustomField|CF)\./ ? $field->{Column} : loc( $field->{Column} ) %></option>
+% }
+</select>
+<br />
+<center>
+<input type="submit" class="button" name="ColUp" value=" ↑ " />
+<input type="submit" class="button" name="ColDown" value=" ↓ " />
+<input type="submit" class="button" name="RemoveCol" value="<%loc('Delete')%>" />
+</center>
+</td>
+
+</tr>
+</table>
+
+<%init>
+my $selected = $ARGS{AddCol} ? [] : $ARGS{SelectDisplayColumns};
+$selected = [ $selected ] unless ref $selected;
+my %selected;
+$selected{$_}++ for grep {defined} @{ $selected };
+</%init>
+<%ARGS>
+$CurrentFormat => undef
+$AvailableColumns => undef
+</%ARGS>
diff --git a/share/html/Transaction/Search/Elements/EditSearches b/share/html/Transaction/Search/Elements/EditSearches
new file mode 100644
index 000000000..d33e2c4da
--- /dev/null
+++ b/share/html/Transaction/Search/Elements/EditSearches
@@ -0,0 +1,329 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<div class="edit-saved-searches">
+<&| /Widgets/TitleBox, title => loc($Title)&>
+
+%# Hide all the save functionality if the user shouldn't see it.
+% if ( $can_modify ) {
+<span class="label"><&|/l&>Privacy</&>:</span>
+<& /Search/Elements/SelectSearchObject, Name => 'SavedSearchOwner', Objects => \@Objects, Object => ( $Object && $Object->id ) ? $Object->Object : '' &>
+<br />
+<span class="label"><&|/l&>Description</&>:</span>
+<input size="25" name="SavedSearchDescription" value="<% $Description || '' %>" />
+
+% if ($Id ne 'new') {
+<nobr>
+% if ( $Dirty ) {
+<input type="submit" class="button" name="SavedSearchRevert" value="<%loc('Revert')%>" />
+% }
+<input type="submit" class="button" name="SavedSearchDelete" value="<%loc('Delete')%>" />
+% if ( $AllowCopy ) {
+<input type="submit" class="button" name="SavedSearchCopy"   value="<%loc('Save as New')%>" />
+% }
+</nobr>
+% }
+% if ( $Object && $Object->Id && $Object->CurrentUserHasRight('update') ) {
+<input type="submit" class="button" id="SavedSearchSave" name="SavedSearchSave"   value="<%loc('Update')%>" />
+% } elsif ( !$Object ) {
+<input type="submit" class="button" id="SavedSearchSave" name="SavedSearchSave"   value="<%loc('Save')%>" />
+%}
+% }
+<br />
+<hr />
+<span class="label"><&|/l&>Load saved search</&>:</span>
+<& SelectSearchesForObjects, Name => 'SavedSearchLoad', Objects => \@Objects, SearchType => $Type &>
+<input type="submit" value="<% loc('Load') %>" id="SavedSearchLoadSubmit" name="SavedSearchLoadSubmit" class="button" />
+
+</&>
+</div>
+<%INIT>
+return unless $session{'CurrentUser'}->HasRight(
+    Right  => 'LoadSavedSearch',
+    Object => $RT::System,
+);
+
+my $can_modify = $session{'CurrentUser'}->HasRight(
+    Right  => 'CreateSavedSearch',
+    Object => $RT::System,
+);
+
+use RT::SavedSearch;
+my @Objects = RT::SavedSearch->new($session{CurrentUser})->_PrivacyObjects;
+push @Objects, RT::System->new( $session{'CurrentUser'} )
+    if $session{'CurrentUser'}->HasRight( Object=> $RT::System,
+                                          Right => 'SuperUser' );
+
+my $is_dirty = sub {
+    my %arg = (
+        Query       => {},
+        SavedSearch => {},
+        SearchFields => [qw(Query Format OrderBy Order RowsPerPage)],
+        @_
+    );
+
+    my $obj  = $arg{'SavedSearch'}->{'Object'};
+    return 0 unless $obj && $obj->id;
+
+    foreach( @{ $arg{'SearchFields'} } ) {
+        return 1 if $obj->SubValue( $_ ) ne $arg{'Query'}->{$_};
+    }
+
+    return 0;
+};
+
+# If we're modifying an old query, check if it's been changed
+my $Dirty = $is_dirty->(
+    Query       => $CurrentSearch,
+    SavedSearch => { Id => $Id, Object => $Object, Description => $Description },
+    SearchFields => \@SearchFields,
+);
+
+</%INIT>
+
+<%ARGS>
+$Id            => 'new'
+$Object        => undef
+$Type          => 'Transaction'
+$Description   => ''
+$CurrentSearch => {}
+ at SearchFields   => ()
+$AllowCopy     => 1
+$Title         => loc('Saved searches')
+</%ARGS>
+
+<%METHOD Init>
+<%ARGS>
+$Query       => {}
+$SavedSearch => {}
+ at SearchFields => qw(Query Format OrderBy Order RowsPerPage ObjectType)
+</%ARGS>
+<%INIT>
+
+$SavedSearch->{'Id'}          = $ARGS{'SavedSearchId'} || 'new';
+$SavedSearch->{'Description'} = $ARGS{'SavedSearchDescription'} || '';
+$SavedSearch->{'Privacy'}     = $ARGS{'SavedSearchOwner'}       || undef;
+$SavedSearch->{'Type'}        = $ARGS{'Type'} || 'Transaction';
+
+my @results;
+
+if ( $ARGS{'SavedSearchRevert'} ) {
+    $ARGS{'SavedSearchLoad'} = $SavedSearch->{'Id'};
+}
+
+if ( $ARGS{'SavedSearchLoad'} ) {
+    my ($container, $id ) = _parse_saved_search ($ARGS{'SavedSearchLoad'});
+    if ( $container ) {
+        my $search = RT::Attribute->new( $session{'CurrentUser'} );
+        $search->Load( $id );
+        $SavedSearch->{'Id'}          = $ARGS{'SavedSearchLoad'};
+        $SavedSearch->{'Object'}      = $search;
+        $SavedSearch->{'Description'} = $search->Description;
+        $Query->{$_} = $search->SubValue($_) foreach @SearchFields;
+
+        if ( $ARGS{'SavedSearchRevert'} ) {
+            push @results, loc('Loaded original "[_1]" saved search', $SavedSearch->{'Description'} );
+        } else {
+            push @results, loc('Loaded saved search "[_1]"', $SavedSearch->{'Description'} );
+        }
+    }
+    else {
+        push @results, loc( 'Can not load saved search "[_1]"',
+                $ARGS{'SavedSearchLoad'} );
+        return @results;
+    }
+}
+elsif ( $ARGS{'SavedSearchDelete'} ) {
+    # We set $SearchId to 'new' above already, so peek into the %ARGS
+    my ($container, $id) = _parse_saved_search( $SavedSearch->{'Id'} );
+    if ( $container && $container->id ) {
+        # We have the object the entry is an attribute on; delete the entry...
+        my ($val, $msg) = $container->Attributes->DeleteEntry( Name => 'SavedSearch', id => $id );
+        unless ( $val ) {
+            push @results, $msg;
+            return @results;
+        }
+    }
+    $SavedSearch->{'Id'}          = 'new';
+    $SavedSearch->{'Object'}      = undef;
+    $SavedSearch->{'Description'} = undef;
+    push @results, loc("Deleted saved search");
+}
+elsif ( $ARGS{'SavedSearchCopy'} ) {
+    my ($container, $id ) = _parse_saved_search( $ARGS{'SavedSearchId'} );
+    $SavedSearch->{'Object'} = RT::Attribute->new( $session{'CurrentUser'} );
+    $SavedSearch->{'Object'}->Load( $id );
+    if ( $ARGS{'SavedSearchDescription'} && $ARGS{'SavedSearchDescription'} ne $SavedSearch->{'Object'}->Description ) {
+        $SavedSearch->{'Description'} = $ARGS{'SavedSearchDescription'};
+    } else {
+        $SavedSearch->{'Description'} = loc( "[_1] copy", $SavedSearch->{'Object'}->Description );
+    }
+    $SavedSearch->{'Id'}          = 'new';
+    $SavedSearch->{'Object'}      = undef;
+}
+
+if ( $SavedSearch->{'Id'} && $SavedSearch->{'Id'} ne 'new'
+     && !$SavedSearch->{'Object'} )
+{
+    my ($container, $id ) = _parse_saved_search( $ARGS{'SavedSearchId'} );
+    $SavedSearch->{'Object'} = RT::Attribute->new( $session{'CurrentUser'} );
+    $SavedSearch->{'Object'}->Load( $id );
+    $SavedSearch->{'Description'} ||= $SavedSearch->{'Object'}->Description;
+}
+
+return @results;
+
+</%INIT>
+</%METHOD>
+
+<%METHOD Save>
+<%ARGS>
+$Query        => {}
+$SavedSearch  => {}
+ at SearchFields => qw(Query Format OrderBy Order RowsPerPage ObjectType)
+</%ARGS>
+<%INIT>
+
+return unless $ARGS{'SavedSearchSave'} || $ARGS{'SavedSearchCopy'};
+
+my @results;
+my $obj  = $SavedSearch->{'Object'};
+my $id   = $SavedSearch->{'Id'};
+my $desc = $SavedSearch->{'Description'};
+my $privacy = $SavedSearch->{'Privacy'};
+
+my %params = map { $_ => $Query->{$_} } @SearchFields;
+my ($new_obj_type, $new_obj_id) = split(/\-/, ($privacy || ''));
+
+if ( $obj && $obj->id ) {
+    # permission check
+    if ($obj->Object->isa('RT::System')) {
+        unless ($session{'CurrentUser'}->HasRight( Object=> $RT::System, Right => 'SuperUser')) {
+            push @results, loc("No permission to save system-wide searches");
+            return @results;
+        }
+    }
+
+    $obj->SetSubValues( %params );
+    $obj->SetDescription( $desc );
+
+    my $obj_type = ref($obj->Object);
+    # We need to get current obj_id now, because when we change obj_type to
+    # RT::System, $obj->Object->Id returns 1, not the old one :(
+    my $obj_id = $obj->Object->Id;
+
+    if ( $new_obj_type && $new_obj_id ) {
+        my ($val, $msg);
+
+        # we need to check right before we change any of ObjectType and ObjectId,
+        # or it will fail the 2nd change if we use SetObjectType and
+        # SetObjectId sequentially
+
+        if ( $obj->CurrentUserHasRight('update') ) {
+            if ( $new_obj_type ne $obj_type ) {
+                ( $val, $msg ) = $obj->__Set(
+                    Field => 'ObjectType',
+                    Value => $new_obj_type,
+                );
+                push @results, loc( 'Unable to set privacy object: [_1]', $msg )
+                  unless ($val);
+            }
+            if ( $new_obj_id != $obj_id ) {
+                ( $val, $msg ) = $obj->__Set(
+                    Field => 'ObjectId',
+                    Value => $new_obj_id,
+                );
+                push @results, loc( 'Unable to set privacy id: [_1]', $msg )
+                  unless ($val);
+            }
+        }
+        else {
+            # two loc are just for convenience so we don't need to
+            # write an extra i18n translation item
+            push @results,
+              loc( 'Unable to set privacy object or id: [_1]',
+                loc('Permission Denied') )
+        }
+    } else {
+        push @results, loc('Unable to determine object type or id');
+    }
+    push @results, loc('Updated saved search "[_1]"', $desc);
+}
+elsif ( $id eq 'new' and defined $desc and length $desc ) {
+    my $saved_search = RT::SavedSearch->new( $session{'CurrentUser'} );
+    my ($status, $msg) = $saved_search->Save(
+        Privacy      => $privacy,
+        Name         => $desc,
+        Type         => $SavedSearch->{'Type'},
+        SearchParams => \%params,
+    );
+
+    if ( $status ) {
+        $SavedSearch->{'Object'} = RT::Attribute->new( $session{'CurrentUser'} );
+        $SavedSearch->{'Object'}->Load( $saved_search->Id );
+        # Build new SearchId
+        $SavedSearch->{'Id'} =
+                ref( $session{'CurrentUser'}->UserObj ) . '-'
+                    . $session{'CurrentUser'}->UserObj->Id
+                    . '-SavedSearch-'
+                    . $SavedSearch->{'Object'}->Id;
+    }
+    else {
+        push @results, loc("Can't find a saved search to work with").': '.loc($msg);
+    }
+}
+elsif ( $id eq 'new' ) {
+    push @results, loc("Can't save a search without a Description");
+}
+else {
+    push @results, loc("Can't save this search");
+}
+
+return @results;
+
+</%INIT>
+</%METHOD>
diff --git a/share/html/Elements/SelectDateType b/share/html/Transaction/Search/Elements/EditSort
similarity index 52%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Elements/EditSort
index c94780c7b..252d71804 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Elements/EditSort
@@ -45,16 +45,88 @@
 %# 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>
+<table valign="top">
+
+% for my $o (0..3) {
+% $Order[$o] ||= ''; $OrderBy[$o] ||= '';
+<tr>
+<td class="label">
+% if ($o == 0) {
+<&|/l&>Order by</&>:
+% }
+</td>
+<td class="value">
+<select name="OrderBy">
+% if ($o > 0) {
+<option value=""><&|/l&>~[none~]</&></option>
+% }
+% # %fields maps display name to SQL column/function
+% foreach my $field (sort keys %fields) {
+%    next unless $field;
+%    my $fieldval = $fields{$field};
+<option value="<%$fieldval%>"
+% if (defined $OrderBy[$o] and $fieldval eq $OrderBy[$o]) {
+selected="selected"
+% }
+><% $field =~ /^(?:CustomField|CF)\./ ? $field : loc($field) %></option>
+% }
 </select>
+<select name="Order">
+<option value="ASC"
+% unless ( ($Order[$o]||'') eq "DESC" ) {
+selected="selected"
+% }
+><&|/l&>Asc</&></option>
+<option value="DESC"
+% if ( ($Order[$o]||'') eq "DESC" ) {
+selected="selected"
+% }
+><&|/l&>Desc</&></option>
+</select>
+</td>
+</tr>
+% }
+<tr>
+<td class="label">
+<&|/l&>Rows per page</&>:
+</td><td class="value">
+<& /Elements/SelectResultsPerPage,
+    Name => "RowsPerPage",
+    Default => $RowsPerPage &>
+</td>
+</tr>
+</table>
+
+<%INIT>
+my $txns = RT::Transactions->new($session{'CurrentUser'});
+my %FieldDescriptions = %{$txns->FIELDS};
+my %fields;
+
+for my $field (keys %FieldDescriptions) {
+    next unless $FieldDescriptions{$field}->[0] =~ /^(?:ENUM|QUEUE|INT|DATE|STRING|ID)$/;
+    $fields{$field} = $field;
+}
+
+# Add all available CustomFields to the list of sortable columns.
+my @cfs = grep /^CustomField/, @{$ARGS{AvailableColumns}};
+$fields{$_} = $_ for @cfs;
+
+$m->callback(CallbackName => 'MassageSortFields', Fields => \%fields );
+
+my @Order = split /\|/, $Order;
+my @OrderBy = split /\|/, $OrderBy;
+if ($Order =~ /\|/) {
+    @Order = split /\|/, $Order;
+} else {
+    @Order = ( $Order );
+}
+
+</%INIT>
+
 <%ARGS>
-$Name => 'DateType'
+$Order => ''
+$OrderBy => ''
+$RowsPerPage => undef
+$Format => undef
+$GroupBy => 'id'
 </%ARGS>
diff --git a/share/html/Transaction/Search/Elements/PickBasics b/share/html/Transaction/Search/Elements/PickBasics
new file mode 100644
index 000000000..9f7534966
--- /dev/null
+++ b/share/html/Transaction/Search/Elements/PickBasics
@@ -0,0 +1,199 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+% foreach( @lines ) {
+<& /Search/Elements/ConditionRow, Condition => $_ &>
+% }
+<%INIT>
+
+my @lines = (
+    {
+        Name => 'id',
+        Field => loc('id'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectEqualityOperator',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        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 },
+    },
+    {
+        Name => 'Creator',
+        Field => loc('Creator'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectBoolean',
+            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        },
+        Value => { Type => 'text', Size => 5 },
+    },
+    {
+        Name => 'Created',
+        Field => loc('Created'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectDateRelation',
+        },
+        Value => {
+            Type => 'component',
+            Path => '/Elements/SelectDate',
+            Arguments => { ShowTime => 0, Default => '' },
+        },
+    },
+    {
+        Name => 'TimeTaken',
+        Field => loc('Time Taken'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectEqualityOperator',
+        },
+        Value => [
+            { Type => 'text', Size => 5 },
+            {
+                Type => 'component',
+                Path => '/Elements/SelectTimeUnits',
+            },
+        ],
+    },
+    {
+        Name => 'Field',
+        Field => loc('Field'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'Type',
+        Field => loc('Type'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'OldValue',
+        Field => loc('Old Value'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'NewValue',
+        Field => loc('New Value'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'Reference Type',
+        Field => loc('Reference Type'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'OldReference',
+        Field => loc('Old Reference'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'NewReference',
+        Field => loc('New Reference'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'Data',
+        Field => loc('Data'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+);
+
+$m->callback( Conditions => \@lines );
+
+</%INIT>
+
+<%ARGS>
+%queues => ()
+</%ARGS>
diff --git a/share/html/Elements/SelectDateType b/share/html/Transaction/Search/Elements/PickCFs
similarity index 58%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Elements/PickCFs
index c94780c7b..15d5d6c23 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Elements/PickCFs
@@ -45,16 +45,64 @@
 %# 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>
+% foreach( @lines ) {
+<& /Search/Elements/ConditionRow, Condition => $_ &>
+% }
+<%INIT>
+$m->callback(
+    CallbackName => 'MassageCustomFields',
+    CustomFields => $CustomFields,
+);
+
+
+my @lines;
+while ( my $CustomField = $CustomFields->Next ) {
+    my %line;
+    $line{'Name'} = "$TransactionSQLField.{" . $CustomField->Name . "}";
+    $line{'Field'} = $CustomField->Name;
+
+    # Op
+    if ($CustomField->Type =~ /^Date(Time)?$/ ) {
+        $line{'Op'} = {
+            Type => 'component',
+            Path => '/Elements/SelectDateRelation',
+            Arguments => {},
+        };
+    }
+    elsif ($CustomField->Type =~ /^IPAddress(Range)?$/ ) {
+        $line{'Op'} = {
+            Type => 'component',
+            Path => '/Elements/SelectIPRelation',
+            Arguments => {},
+        };
+    } else {
+        $line{'Op'} = {
+            Type => 'component',
+            Path => '/Elements/SelectCustomFieldOperator',
+            Arguments => { True => loc("is"),
+                           False => loc("isn't"),
+                           TrueVal=> '=',
+                           FalseVal => '!=',
+                         },
+        };
+    }
+
+    # Value
+    $line{'Value'} = {
+        Type => 'component',
+        Path => '/Elements/SelectCustomFieldValue',
+        Arguments => { CustomField => $CustomField },
+    };
+
+    push @lines, \%line;
+}
+
+$m->callback( Conditions => \@lines, Queues => \%queues );
+
+</%INIT>
+
 <%ARGS>
-$Name => 'DateType'
+%queues => ()
+$CustomFields
+$TransactionSQLField => 'CF'
 </%ARGS>
diff --git a/share/html/Elements/SelectDateType b/share/html/Transaction/Search/Elements/PickCriteria
similarity index 78%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Elements/PickCriteria
index c94780c7b..3eb0deca1 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Elements/PickCriteria
@@ -45,16 +45,29 @@
 %# 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>
+<&| /Widgets/TitleBox, title => loc('Add Criteria')&>
+
+<table width="100%" cellspacing="0" cellpadding="0" border="0">
+
+
+% $m->callback( %ARGS, CallbackName => "BeforeBasics" );
+<& PickBasics, queues => \%queues &>
+<& PickTransactionCFs, queues => \%queues &>
+<& PickTickets, queues => \%queues &>
+
+<tr class="separator"><td colspan="3"><hr /></td></tr>
+<tr>
+<td class="label"><&|/l&>Aggregator</&></td>
+<td class="operator" colspan="2"><& /Search/Elements/SelectAndOr, Name => "AndOr" &></td>
+
+</tr>
+
+</table>
+
+</&>
+
 <%ARGS>
-$Name => 'DateType'
+$addquery => 0
+$query => undef
+%queues => ()
 </%ARGS>
diff --git a/share/html/Transaction/Search/Elements/PickTickets b/share/html/Transaction/Search/Elements/PickTickets
new file mode 100644
index 000000000..30ff0ab44
--- /dev/null
+++ b/share/html/Transaction/Search/Elements/PickTickets
@@ -0,0 +1,177 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+
+<tr class="separator">
+  <td colspan="3">
+    <hr><em><% loc("Ticket Fields") %></em>
+  </td>
+</tr>
+
+% foreach( @lines ) {
+<& /Search/Elements/ConditionRow, Condition => $_ &>
+% }
+<%INIT>
+
+my @lines = (
+    {
+        Name => 'TicketId',
+        Field => loc('id'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectEqualityOperator',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'TicketSubject',
+        Field => loc('Subject'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+        },
+        Value => { Type => 'text', Size => 5 }
+    },
+    {
+        Name => 'TicketQueue',
+        Field => loc('Queue'),
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectMatch',
+            Arguments => { Default => '=' },
+        },
+        Value => {
+            Type => 'component',
+            Path => '/Elements/SelectQueue',
+            Arguments => { NamedValues => 1, },
+        },
+    },
+    {
+        Name => 'TicketStatus',
+        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 },
+        },
+    },
+    {
+        Name => 'TicketActor',
+        Field => {
+            Type    => 'select',
+            Options => [
+                TicketOwner => loc('Owner'),
+                TicketCreator => loc('Creator'),
+                TicketLastUpdatedBy => loc('Last updated by'),
+                TicketUpdatedBy => loc('Updated by'),
+            ],
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectBoolean',
+            Arguments => { TrueVal=> '=', FalseVal => '!=' },
+        },
+        Value => {
+            Type => 'component',
+            Path => '/Elements/SelectOwner',
+            Arguments => { ValueAttribute => 'Name', Queues => \%queues },
+        },
+    },
+    {
+        Name => 'TicketDate',
+        Field => {
+            Type => 'component',
+            Path => '/Elements/SelectDateType',
+            Arguments => { Prefix => 'Ticket' },
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectDateRelation',
+        },
+        Value => {
+            Type => 'component',
+            Path => '/Elements/SelectDate',
+            Arguments => { ShowTime => 0, Default => '' },
+        },
+    },
+    {
+        Name => 'TicketTime',
+        Field => {
+            Type    => 'select',
+            Options => [
+                TicketTimeWorked => loc('Time Worked'),
+                TicketTimeEstimated => loc('Time Estimated'),
+                TicketTimeLeft => loc('Time Left'),
+            ],
+        },
+        Op => {
+            Type => 'component',
+            Path => '/Elements/SelectEqualityOperator',
+        },
+        Value => [
+            { Type => 'text', Size => 5 },
+            {
+                Type => 'component',
+                Path => '/Elements/SelectTimeUnits',
+            },
+        ],
+    },
+
+);
+
+$m->callback( Conditions => \@lines );
+
+</%INIT>
+
+<%ARGS>
+%queues => ()
+</%ARGS>
diff --git a/share/html/Elements/SelectDateType b/share/html/Transaction/Search/Elements/PickTransactionCFs
similarity index 65%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Elements/PickTransactionCFs
index c94780c7b..ec2e4966d 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Elements/PickTransactionCFs
@@ -45,16 +45,34 @@
 %# 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'
+%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/Elements/SelectDateType b/share/html/Transaction/Search/Elements/SelectSearchesForObjects
similarity index 68%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Elements/SelectSearchesForObjects
index c94780c7b..6c78e3f67 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Elements/SelectSearchesForObjects
@@ -45,16 +45,27 @@
 %# 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>
+<%args>
+ at Objects => undef
+$Name => undef
+$SearchType => 'Transaction',
+$ObjectType => 'RT::Ticket',
+</%args>
+<select id="<%$Name%>" name="<%$Name%>">
+<option value="">-</option>
+% foreach my $object (@Objects) {
+% my @searches = $object->Attributes->Named('SavedSearch');
+% if ( @searches ) {
+% @searches = sort { lcfirst($a->Description) cmp lcfirst($b->Description) } @searches;
+% $m->callback( CallbackName => 'ManageSavedSearches', ARGSRef => \%ARGS, SearchesRef => \@searches );
+<optgroup label="<& /Search/Elements/SearchPrivacy, Object => $object &>">
+% foreach my $search (@searches) {
+%     # Skip it if it is not of search type we want.
+%     next if ($search->SubValue('SearchType') // '') ne $SearchType;
+%     next if ($search->SubValue('ObjectType') // '') ne $ObjectType;
+<option value="<%ref($object)%>-<%$object->id%>-SavedSearch-<%$search->Id%>"><%$search->Description||loc('Unnamed search')%></option>
+% }
+</optgroup>
+% }
+% }
 </select>
-<%ARGS>
-$Name => 'DateType'
-</%ARGS>
diff --git a/share/html/Transaction/Search/Results.html b/share/html/Transaction/Search/Results.html
new file mode 100644
index 000000000..08c10e745
--- /dev/null
+++ b/share/html/Transaction/Search/Results.html
@@ -0,0 +1,223 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => $title,
+    Refresh => $refresh,
+    LinkRel => \%link_rel &>
+<& /Elements/Tabs &>
+
+% my $DisplayFormat;
+% $m->callback( ARGSRef => \%ARGS, Format => \$Format, DisplayFormat => \$DisplayFormat, CallbackName => 'BeforeResults' );
+
+% unless ($ok) {
+%    $msg =~ s{ at .*? line .*}{}s;
+<&| /Widgets/TitleBox, title => loc("Error"), class => "error-titlebox" &>
+<&|/l_unsafe, "<i>".$m->interp->apply_escapes($msg, "h")."</i>" &>There was an error parsing your search query: [_1].  Your RT admin can find more information in the error logs.</&>
+</&>
+% } else {
+
+<& /Elements/CollectionList,
+    Query => $Query,
+    TotalFound => $txncount,
+    AllowSorting => 1,
+    OrderBy => $OrderBy,
+    Order => $Order,
+    Rows => $Rows,
+    Page => $Page,
+    Format => $Format,
+    DisplayFormat => $DisplayFormat, # in case we set it in callbacks
+    Class => 'RT::Transactions',
+    BaseURL => $BaseURL,
+    SavedSearchId => $ARGS{'SavedSearchId'},
+    ObjectType => $ObjectType,
+    PassArguments => [qw(Query Format Rows Page Order OrderBy SavedSearchId ObjectType)],
+&>
+% }
+% $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
+
+% my %hiddens = (Query => $Query, Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, );
+<div align="right" class="refresh">
+<form method="get" action="<%RT->Config->Get('WebPath')%>/Search/Results.html">
+% 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'}) &>
+<input type="submit" class="button" value="<&|/l&>Change</&>" />
+</form>
+</div>
+
+%# Keyboard shortcuts info
+<div class="clear"></div>
+<div class="keyboard-shortcuts footer">
+    <p><&|/l_unsafe, '<span class="keyboard-shortcuts-key">?</span>' &>Press [_1] to view keyboard shortcuts.</&></p>
+</div>
+
+<%INIT>
+$m->callback( ARGSRef => \%ARGS, CallbackName => 'Initial' );
+
+# Read from user preferences
+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('TransactionDefaultSearchResultFormat')->{$ObjectType};
+$Order       ||= $prefs->{'Order'} || RT->Config->Get('TransactionDefaultSearchResultOrder')->{$ObjectType};
+$OrderBy     ||= $prefs->{'OrderBy'} || RT->Config->Get('TransactionDefaultSearchResultOrderBy')->{$ObjectType};
+
+# Some forms pass in "RowsPerPage" rather than "Rows"
+# We call it RowsPerPage everywhere else.
+
+if ( !defined($Rows) ) {
+    if (defined $ARGS{'RowsPerPage'} ) {
+        $Rows = $ARGS{'RowsPerPage'};
+    } elsif ( defined $prefs->{'RowsPerPage'} ) {
+        $Rows = $prefs->{'RowsPerPage'};
+    } else {
+        $Rows = 50;
+    }
+}
+$Page = 1 unless $Page && $Page > 0;
+
+$session{'i'}++;
+$session{'txns'} = RT::Transactions->new($session{'CurrentUser'}) ;
+my ( $ok, $msg )
+    = $Query
+    ? $session{'txns'}->FromSQL( join ' AND ', "ObjectType = '$ObjectType'", "($Query)" )
+    : ( 1, "Vacuously OK" );
+# Provide an empty search if parsing failed
+$session{'txns'}->FromSQL("id < 0") unless ($ok);
+
+if ($OrderBy =~ /\|/) {
+    # Multiple Sorts
+    my @OrderBy = split /\|/,$OrderBy;
+    my @Order = split /\|/,$Order;
+    $session{'txns'}->OrderByCols( map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } } ( 0 .. $#OrderBy ) );
+} else {
+    $session{'txns'}->OrderBy(FIELD => $OrderBy, ORDER => $Order);
+}
+$session{'txns'}->RowsPerPage( $Rows ) if $Rows;
+$session{'txns'}->GotoPage( $Page - 1 );
+
+$session{'CurrentTransactionSearchHash'} = {
+    Format      => $Format,
+    Query       => $Query,
+    Page       => $Page,
+    Order       => $Order,
+    OrderBy     => $OrderBy,
+    RowsPerPage => $Rows
+};
+
+
+my ($title, $txncount) = (loc("Find transactions"), 0);
+if ( $session{'txns'}->Query()) {
+    $txncount = $session{txns}->CountAll();
+    $title = loc('Found [quant,_1,transaction,transactions]', $txncount);
+}
+
+my $QueryString = "?".$m->comp('/Elements/QueryString',
+                               Query => $Query,
+                               Format => $Format,
+                               Rows => $Rows,
+                               OrderBy => $OrderBy,
+                               Order => $Order,
+                               Page => $Page);
+my $ShortQueryString = "?".$m->comp('/Elements/QueryString', Query => $Query);
+
+if ($ARGS{'TransactionsRefreshInterval'}) {
+    $session{'transactions_refresh_interval'} = $ARGS{'TransactionsRefreshInterval'};
+}
+
+my $refresh = $session{'transactions_refresh_interval'}
+    || 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{'CurrentTransactionSearchHash'} );
+    $m->notes->{RefreshURL} = RT->Config->Get('WebURL')
+        . "Search/Results.html?CSRF_Token="
+            . $token;
+}
+
+my %link_rel;
+my $genpage = sub {
+    return $m->comp(
+        '/Elements/QueryString',
+        Query   => $Query,
+        Format  => $Format,
+        Rows    => $Rows,
+        OrderBy => $OrderBy,
+        Order   => $Order,
+        Page    => shift(@_),
+    );
+};
+
+if ( RT->Config->Get('SearchResultsAutoRedirect') && $txncount == 1 &&
+    $session{txns}->First ) {
+    RT::Interface::Web::Redirect( RT->Config->Get('WebURL')
+            ."Transaction/Display.html?id=". $session{txns}->First->id );
+}
+
+my $BaseURL = RT->Config->Get('WebPath')."/Transaction/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) < $txncount;
+$link_rel{last}  = $BaseURL . $genpage->(POSIX::ceil($txncount/$Rows)) if $Rows and ($Page * $Rows) < $txncount;
+</%INIT>
+<%CLEANUP>
+$session{'txns'}->PrepForSerialization();
+</%CLEANUP>
+<%ARGS>
+$Query => undef
+$Format => undef
+$HideResults => 0
+$Rows => undef
+$Page => 1
+$OrderBy => undef
+$Order => undef
+$SavedSearchId => undef
+$ObjectType => 'RT::Ticket'
+</%ARGS>
diff --git a/share/html/Elements/SelectDateType b/share/html/Transaction/Search/Results.tsv
similarity index 72%
copy from share/html/Elements/SelectDateType
copy to share/html/Transaction/Search/Results.tsv
index c94780c7b..873efb07c 100644
--- a/share/html/Elements/SelectDateType
+++ b/share/html/Transaction/Search/Results.tsv
@@ -45,16 +45,30 @@
 %# 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'
+$Format => undef
+$Query => ''
+$OrderBy => 'id'
+$Order => 'ASC'
+$PreserveNewLines => 0
+$UserData => 0
+$ObjectType => 'RT::Ticket'
 </%ARGS>
+<%INIT>
+my $Transactions = RT::Transactions->new( $session{'CurrentUser'} );
+$Transactions->FromSQL( join ' AND ', "ObjectType = '$ObjectType'", $Query ? "($Query)" : () );
+if ( $OrderBy =~ /\|/ ) {
+    # Multiple Sorts
+    my @OrderBy = split /\|/, $OrderBy;
+    my @Order   = split /\|/, $Order;
+    $Transactions->OrderByCols(
+        map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } }
+        ( 0 .. $#OrderBy )
+    );
+}
+else {
+    $Transactions->OrderBy( FIELD => $OrderBy, ORDER => $Order );
+}
+
+$m->comp( "/Elements/TSVExport", Collection => $Transactions, Format => $Format, PreserveNewLines => $PreserveNewLines );
+</%INIT>
diff --git a/share/static/css/rudder/ticket-search.css b/share/static/css/rudder/ticket-search.css
index 8f022ae20..c07bf7e28 100644
--- a/share/static/css/rudder/ticket-search.css
+++ b/share/static/css/rudder/ticket-search.css
@@ -1,4 +1,5 @@
-#comp-Search-Build #body {
+#comp-Search-Build #body,
+#comp-Transaction-Search-Build #body {
     position: relative
 }
 

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


More information about the rt-commit mailing list