[Rt-commit] rt branch, 5.0-trunk, updated. rt-5.0.1-536-gd321afd294

? sunnavy sunnavy at bestpractical.com
Mon Jun 21 16:50:39 EDT 2021


The branch, 5.0-trunk has been updated
       via  d321afd294bd87c45f3ace1870732a3a25be9011 (commit)
       via  e5d0991b91872e3bb397e88d6cb3c0875ba05520 (commit)
       via  60506500f64af67af3fc37390878d00607f086a7 (commit)
       via  0ed6611b629718d0699b10980a7b8531cd67bcfa (commit)
       via  6e2141330b11c8d42b08a7a6b7df65298caad781 (commit)
       via  f9acb355b30e8bd9242a3e6344c6ad2aba0edfdd (commit)
       via  f88c6f144398263ea28fa8d0e2bbbcb52fb82bef (commit)
       via  353c6f078377bb6bc7f601eeb84d8feb5bbd3343 (commit)
       via  c5b647ec478e6b8f344a8a42b12a392cdea2f30c (commit)
       via  b66555ac8bc705f81895af012628fb32aecc65d4 (commit)
       via  205b517966f220dad1073f6e4309ec0fe01ce95b (commit)
       via  34fb69cbc5c8732e092b412703a564b484708917 (commit)
       via  a4fbc7e4056b43c35a48fc2d8385f82bb0d76f66 (commit)
       via  f510647574a7a937f9e845537319e7f83bcf15c0 (commit)
       via  611f706f256353c4178814174a54e5c17c6c9b74 (commit)
       via  8fe46a8d40808cb8df8a47e2b055d63798b56937 (commit)
       via  2acd9c4b5306b1fcc104dd0f65dbfc3a4a83854c (commit)
       via  d638a53cc96926199c23b230130bee7a1bc93b35 (commit)
       via  40f8a7de3d6c6534b97e4cb43659b02d519f15d9 (commit)
       via  0c7a60e26880c248c8ea5ae49d3949ca7018e9e6 (commit)
       via  759a60cf8cfa6e6c9278b38488294616ab4e6b76 (commit)
       via  0a86eaa526a3f74dc6cefc2621888ad9aefb7eb9 (commit)
       via  46afb50b290d80892d3e619dabdfc4da2a7ca768 (commit)
       via  0fc5300b727fe515e3f63f8caa1a08c134d66357 (commit)
       via  72579cd5475c7c0f34acded7902f554684735abf (commit)
       via  f720701ee3aba5a9377b11fee2848524e4a1a3c4 (commit)
       via  542dfc95ff81c1a34c25e8a405cd2d7ed21d7a65 (commit)
       via  1792f97b5e623607d63fc34f6f82d405fbe6cc30 (commit)
       via  5ab6360868212697ee02ccb38191abc8939a2389 (commit)
       via  602d451fdffd1c5a2e44992e2c3df229dab2fa6e (commit)
       via  14af0211a5d60a66c6b6962a4c921d3e99ce26b2 (commit)
       via  19aa86b44020e048ad7c24b28e681dbbdf2a900e (commit)
       via  5d7039ff0ddfa6af780c362e6389d2d708b2060a (commit)
       via  fd29f7e1cf500c9b2b757b012c06f6aa34287b13 (commit)
       via  8b5c8d1b28dc06b5259fb7d07b6da0df8efd6731 (commit)
       via  7abb7900e4f547c6bea1d2399c6ad07fad9b94d7 (commit)
       via  d9c9d676486698056ef0e862d08fe1f1b938b4d6 (commit)
       via  c37090a5b3233324213ca7c29a7250e4bf2513d9 (commit)
       via  0d8c8b5306edf657edc9c17ff3e0a7c079985f14 (commit)
       via  f420227f0d1099056f64cdd9c64823cf104fc39b (commit)
       via  43acfb645b556e1c3d2d57bc5d7c610517664be4 (commit)
       via  c9752bde56e64d6d2ded4e8f9e6a649f76c079a8 (commit)
       via  05950dd46639761dde9fb4b1cd79b5815210e5ab (commit)
       via  a53753b385995789a880cc14ef28938185672739 (commit)
       via  3effbaf7493cce28baf8c3df15b81581e53a7589 (commit)
       via  64cd63caf19e2063e3c3fcd1d7119e6e7c91bc76 (commit)
       via  ea3094cef7ec7639802bbad9f1da9eb1aff54da0 (commit)
       via  2b197e8d42c7b45ec470c88b5dc8db15493472e2 (commit)
       via  6a0c9a23f7216104249cb32492cfe8d0d4a087de (commit)
       via  135410c8e5ecb5fe2eb00d5c60da8d5aa44c4710 (commit)
       via  e6f26b46100b539827f2c5e43ad0b427de413750 (commit)
       via  a8956daba22b7e09b8823e0479928fcdd0bfe79c (commit)
       via  b419da95308f1c09e0739b7800431b96a160d4ec (commit)
       via  f2dc4ffd95b9400296200ba672df292e9e39253d (commit)
       via  771ee67205471a8d68717684e5e87a16913271c8 (commit)
       via  75d348fa6b4fe9eb0545334acdaebc079dfa71a6 (commit)
       via  e21fdf5346e44547c8100262846a81f0bbaa2608 (commit)
       via  2da669e38137c87646afd938293ea2a9e42e6ed6 (commit)
       via  0adc04189f71102052b19653f057722fead0ace2 (commit)
       via  ed6f82c59d37fe970bfb42b5005ad55d9fa331f2 (commit)
       via  7a74a0e07b81001c7710cdc2ad93264f3646d763 (commit)
       via  f790e5d499723552cb42568bed56b0c0fc8239de (commit)
       via  670d88dc7ebb8a0461c4055ebc9e20d62d04307c (commit)
       via  146ed9f4d57d28b19effb95d2c361f08c2a97382 (commit)
       via  fc72b6521bba8287546ce3fa287a6e3e5fb4745e (commit)
       via  dc1a5204c687d3d00d1f76598862311a4b49a103 (commit)
       via  74280dc783422058a3b2c11c1b464d89f9deed2e (commit)
       via  1cad1ce87520dffaab889966870ee9b6a9da523c (commit)
       via  10c640ef34bf49171934cceab4edee91072c99af (commit)
       via  528277f60977be25aa66d44a3d91bdd8619999e1 (commit)
      from  129495bae0f90a74b7c9c31f0440eac9a2d8a252 (commit)

Summary of changes:
 docs/UPGRADING-4.4                           |  12 ++
 docs/charts.pod                              |  21 ++
 docs/images/user-time-worked-report.png      | Bin 0 -> 210562 bytes
 docs/query_builder.pod                       |  65 +++++++
 docs/reporting/user_time_worked.pod          |  14 ++
 etc/initialdata                              |   5 +
 etc/upgrade/4.4.5/content                    |  34 ++++
 etc/upgrade/5.0.2/content                    |  28 +++
 lib/RT/Asset.pm                              |  19 +-
 lib/RT/Group.pm                              |   2 +-
 lib/RT/Interface/Web.pm                      |   7 +-
 lib/RT/Interface/Web/QueryBuilder/Tree.pm    |  45 ++++-
 lib/RT/Record/Role/Roles.pm                  |  58 +++++-
 lib/RT/Report/Tickets.pm                     | 224 ++++++++++++++++++----
 lib/RT/Report/Tickets/Entry.pm               |   8 +
 lib/RT/SQL.pm                                |  33 +++-
 lib/RT/SearchBuilder.pm                      |  44 ++++-
 lib/RT/SearchBuilder/Role/Roles.pm           |  78 ++++++--
 lib/RT/Ticket.pm                             |  25 ++-
 lib/RT/Tickets.pm                            | 170 ++++++++++++++--
 sbin/rt-validator.in                         |   3 +-
 share/html/Admin/Users/index.html            |  30 ++-
 share/html/Elements/ColumnMap                | 147 +++++++++++++-
 share/html/Elements/ValidateCustomFields     |  19 ++
 share/html/Helpers/Autocomplete/Groups       |   1 +
 share/html/Helpers/Autocomplete/Principals   |  22 +++
 share/html/Helpers/Autocomplete/Users        |   2 +-
 share/html/Reports/TimeWorkedReport.html     | 277 +++++++++++++++++++++++++++
 share/html/Search/Build.html                 |   1 +
 share/html/Search/Elements/BuildFormatString |  24 ++-
 share/html/Search/Elements/EditSort          |  30 +--
 share/html/Search/Elements/NewListActions    |   1 -
 share/static/css/elevator-light/misc.css     |   8 +
 t/api/ticket.t                               |  20 ++
 t/api/tickets.t                              | 143 +++++++++++++-
 t/charts/group-by-cr.t                       |  75 ++++++++
 t/mail/crypt-gnupg.t                         |  10 +-
 t/ticket/search_by_watcher_group.t           |  17 +-
 t/ticket/sort-by-user.t                      |  54 +++++-
 t/web/cf_access.t                            |   2 +-
 t/web/charting.t                             |   8 +-
 t/web/crypt-gnupg.t                          |   5 +-
 t/web/query_builder.t                        | 145 +++++++++++++-
 43 files changed, 1788 insertions(+), 148 deletions(-)
 create mode 100644 docs/images/user-time-worked-report.png
 create mode 100644 docs/reporting/user_time_worked.pod
 create mode 100644 etc/upgrade/5.0.2/content
 create mode 100644 share/html/Reports/TimeWorkedReport.html
 create mode 100644 t/charts/group-by-cr.t

- Log -----------------------------------------------------------------
commit d321afd294bd87c45f3ace1870732a3a25be9011
Merge: 129495bae0 e5d0991b91
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Jun 22 02:20:11 2021 +0800

    Merge branch '4.4-trunk' into 5.0-trunk

diff --cc docs/images/user-time-worked-report.png
index 0000000000,5c47708a23..2ae34deca6
mode 000000,100644..100644
Binary files differ
diff --cc docs/query_builder.pod
index 7e2be2ba36,6a40047eab..bed320905f
--- a/docs/query_builder.pod
+++ b/docs/query_builder.pod
@@@ -238,48 -253,57 +254,97 @@@ you can search for 
  
      'CF.{Transport Type}' IS NULL
  
 -=cut
 +=head1 Transaction Query Builder
 +
 +Similar to the Ticket Query Builder, the Transaction Query Builder provides an
 +interface to search for individual transactions. Transactions are all of the
 +changes made to a ticket through its life. Each of the entries displayed in the
 +ticket history at the bottom of the ticket display page is a transaction.
 +
 +In some cases, RT users looking for a particular reply on a ticket will
 +search in their email client rather than in RT because they will remenber
 +getting the email with the information they need. On a busy ticket, it
 +can be a challenge to find the reply from Jane some time this week. The
 +Transaction Query Builder now makes that sort of search easy.
 +
 +=head2 Basic Transaction Searches
 +
 +In the example above, suppose you remember getting a reply from Jane in email
 +on a ticket and you know it was in the last week. But it's been a busy week
 +and Jane is on a bunch of active tickets, so you're not sure where to start.
 +With the Transaction Query Builder, you can easily create a search to show all
 +replies from Jane.
 +
 +First find Creator, select "is", and type Jane's username. The "Creator" of a
 +transaction is always the person who made the change. For a reply, by email or
 +in RT itself, the person who replied will be the Creator of the transaction.
 +
 +Next, for Created select "after" and type "1 week ago". RT will then automatically
 +figure out the date 7 days ago and show you only results in the last 7 days.
 +
 +Finally for Type select "is" and select "Correspond". Correspond is the name RT
 +users internally for all replies on a ticket.
 +
 +Run the search and you'll see all replies from Jane on any tickets over the
 +last week. Note that you'll see all transactions you have rights to see, even
 +if you aren't a watcher and possibly didn't get an email originally.
 +
 +=head2 Including Ticket Information
 +
 +When searching for transactions, you can also add criteria about the types of
 +tickets the transactions should be on. In our example, we probably only want
 +to see active tickets, so in the bottom Ticket Fields section you can select
 +Status "is" and "Active". This will then filter out inactive statuses.
  
+ =head1 Advanced
+ 
+ In addition to the graphical query builder, RT also has an Advanced page
+ where you can write and modify queries and formats directly. For example,
+ you can type the following query directly in the Query box:
+ 
+     Status = 'resolved' AND Resolved > '2019-04-01' AND Queue = 'RT'
+ 
+ and then click "Apply" to let RT validate it. RT will display any syntax errors
+ if you make any mistakes so you can fix them.
+ 
+ =head2 Valid Search Values
+ 
+ In the above example, search values like C<'resolved'>, C<'2019-04-01'>,
+ and C<'RT'> are all literal search terms: a status, a date, and a string
+ representing the name of a queue. These are all static values that will then
+ return matching tickets. However, sometimes you want to compare 2 columns
+ in RT tables without knowing exact values. This is also supported, for
+ example:
+ 
+ =over
+ 
+ =item Search tickets where LastUpdated is after (later than) Resolved
+ 
+     LastUpdated > Resolved
+ 
+ This finds tickets that have been commented on or otherwise updated after
+ they were resolved. Note that C<Resolved> is not quoted, indicating that it's
+ the relative date value from a ticket instead of the literal string "Resolved".
+ 
+ =item Search tickets where the Requestor is also the Owner
+ 
+     Requestor.id = Owner
+ 
+ =item Search tickets where custom fields Foo and Bar have the same value
+ 
+     CF.Foo = CF.Bar
+ 
+ This is equal to:
+ 
+     CF.{Foo} = CF.{Bar}
+     CF.Foo = CF.Bar.Content
+ 
+ To compare LargeContent instead:
+ 
+     CF.IP = CF.IPRange.LargeContent
+ 
+ =back
+ 
  =head1 Learn More
  
  To use the query builder to build and save reports, see
diff --cc etc/initialdata
index 1ebe6d7fba,454f76336b..61522db700
--- a/etc/initialdata
+++ b/etc/initialdata
@@@ -944,27 -936,6 +944,32 @@@ Hour:         { $SubscriptionObj->SubVa
                ],
          },
      },
 +# initial reports
 +    { Name => 'ReportsInMenu',
 +      Description => 'Content of the Reports menu', #loc
 +      Content     => [
 +          {
 +              id          => 'resolvedbyowner',
 +              title       => 'Resolved by owner', # loc
 +              path        => '/Reports/ResolvedByOwner.html',
 +          },
 +          {
 +              id          => 'resolvedindaterange',
 +              title       => 'Resolved in date range', # loc
 +              path        => '/Reports/ResolvedByDates.html',
 +          },
++          {
++              id          => 'user_time',
++              title       => 'User time worked',
++              path        => '/Reports/TimeWorkedReport.html', # loc
++          },
 +          {
 +              id          => 'createdindaterange',
 +              title       => 'Created in a date range', # loc
 +              path        => '/Reports/CreatedByDates.html',
 +          },
 +      ],
 +    },
  );
  
  @Classes = (
diff --cc etc/upgrade/5.0.2/content
index 0000000000,0000000000..ad6f07edda
new file mode 100644
--- /dev/null
+++ b/etc/upgrade/5.0.2/content
@@@ -1,0 -1,0 +1,28 @@@
++use strict;
++use warnings;
++
++our @Initial = (
++
++    # add default reports
++    sub {
++        my $reports_in_menu = 'ReportsInMenu';
++        my $attr            = RT::Attribute->new( RT->SystemUser );
++        $attr->LoadByNameAndObject( Object => RT->System, Name => $reports_in_menu );
++
++        # Update menu if it's not touched by anyone else
++        if ( $attr->Id && $attr->Created eq $attr->LastUpdated ) {
++            RT->Logger->debug("Adding time worked report in menu");
++            my $content = $attr->Content or return;
++            splice @$content, 2, 0,
++                {   id    => 'user_time',
++                    title => 'User time worked',
++                    path  => '/Reports/TimeWorkedReport.html',
++                };
++
++            my ( $ret, $msg ) = $attr->SetContent($content);
++            if ( !$ret ) {
++                RT->Logger->error("Couldn't update ReportsInMenu: $msg");
++            }
++        }
++    }
++);
diff --cc lib/RT/Interface/Web.pm
index b0e88f1b42,41d937ed9a..d9d6bd7a6c
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@@ -4717,373 -4639,110 +4717,378 @@@ Returns an RT::Transaction object as th
  
  =cut
  
 -our @SCRUBBER_ALLOWED_TAGS = qw(
 -    A B U P BR I HR BR SMALL EM FONT SPAN STRONG SUB SUP S DEL STRIKE H1 H2 H3 H4 H5
 -    H6 DIV UL OL LI DL DT DD PRE BLOCKQUOTE BDO
 -);
 +sub LoadTransaction {
 +    my $id = shift;
  
 -our %SCRUBBER_ALLOWED_ATTRIBUTES = (
 -    # Match http, https, ftp, mailto and relative urls
 -    # XXX: we also scrub format strings with this module then allow simple config options
 -    href   => qr{^(?:https?:|ftp:|mailto:|/|__Web(?:Path|HomePath|BaseURL|URL)__)}i,
 -    face   => 1,
 -    size   => 1,
 -    color  => 1,
 -    target => 1,
 -    style  => qr{
 -        ^(?:\s*
 -            (?:(?:background-)?color: \s*
 -                    (?:rgb\(\s* \d+, \s* \d+, \s* \d+ \s*\) |   # rgb(d,d,d)
 -                       \#[a-f0-9]{3,6}                      |   # #fff or #ffffff
 -                       [\w\-]+                                  # green, light-blue, etc.
 -                       )                            |
 -               text-align: \s* \w+                  |
 -               font-size: \s* [\w.\-]+              |
 -               font-family: \s* [\w\s"',.\-]+       |
 -               font-weight: \s* [\w\-]+             |
 -
 -               border-style: \s* \w+                |
 -               border-color: \s* [#\w]+             |
 -               border-width: \s* [\s\w]+            |
 -               padding: \s* [\s\w]+                 |
 -               margin: \s* [\s\w]+                  |
 -
 -               # MS Office styles, which are probably fine.  If we don't, then any
 -               # associated styles in the same attribute get stripped.
 -               mso-[\w\-]+?: \s* [\w\s"',.\-]+
 -            )\s* ;? \s*)
 -         +$ # one or more of these allowed properties from here 'till sunset
 -    }ix,
 -    dir    => qr/^(rtl|ltr)$/i,
 -    lang   => qr/^\w+(-\w+)?$/,
 -
 -    # timeworked per user attributes
 -    'data-ticket-id'    => 1,
 -    'data-ticket-class' => 1,
 -);
 +    if ( ref($id) eq "ARRAY" ) {
 +        $id = $id->[0];
 +    }
  
 -our %SCRUBBER_RULES = ();
 +    unless ($id) {
 +        Abort( loc('No transaction specified'), Code => HTTP::Status::HTTP_BAD_REQUEST );
 +    }
  
 -# If we're displaying images, let embedded ones through
 -if (RT->Config->Get('ShowTransactionImages') or RT->Config->Get('ShowRemoteImages')) {
 -    $SCRUBBER_RULES{'img'} = {
 -        '*' => 0,
 -        alt => 1,
 -    };
 +    my $Transaction = RT::Transaction->new( $session{'CurrentUser'} );
 +    $Transaction->Load($id);
 +    unless ( $Transaction->id ) {
 +        Abort( loc( 'Could not load transaction #[_1]', $id ), Code => HTTP::Status::HTTP_NOT_FOUND );
 +    }
 +    return $Transaction;
 +}
 +
 +=head2 GetDefaultQueue
 +
 +Processes global and user-level configuration options to find the default
 +queue for the current user.
 +
 +Accepts no arguments, returns the ID of the default queue, if found, or undef.
 +
 +=cut
  
 -    my @src;
 -    push @src, qr/^cid:/i
 -        if RT->Config->Get('ShowTransactionImages');
 +sub GetDefaultQueue {
 +    my $queue;
  
 -    push @src, $SCRUBBER_ALLOWED_ATTRIBUTES{'href'}
 -        if RT->Config->Get('ShowRemoteImages');
 +    # RememberDefaultQueue tracks the last queue used by this user, if set.
 +    if ( $session{'DefaultQueue'} && RT->Config->Get( "RememberDefaultQueue", $session{'CurrentUser'} ) ) {
 +        $queue = $session{'DefaultQueue'};
 +    }
 +    else {
 +        $queue = RT->Config->Get( "DefaultQueue", $session{'CurrentUser'} );
 +    }
  
 -    $SCRUBBER_RULES{'img'}->{'src'} = join "|", @src;
 +    return $queue;
  }
  
 -sub _NewScrubber {
 -    require HTML::Scrubber;
 -    my $scrubber = HTML::Scrubber->new();
 +=head2 UpdateDashboard
  
 -    if (HTML::Gumbo->require) {
 -        no warnings 'redefine';
 -        my $orig = \&HTML::Scrubber::scrub;
 -        *HTML::Scrubber::scrub = sub {
 -            my $self = shift;
 +Update global and user-level dashboard preferences.
  
 -            eval { $_[0] = HTML::Gumbo->new->parse( $_[0] ); chomp $_[0] };
 -            warn "HTML::Gumbo pre-parse failed: $@" if $@;
 -            return $orig->($self, @_);
 -        };
 -        push @SCRUBBER_ALLOWED_TAGS, qw/TABLE THEAD TBODY TFOOT TR TD TH/;
 -        $SCRUBBER_ALLOWED_ATTRIBUTES{$_} = 1 for
 -            qw/colspan rowspan align valign cellspacing cellpadding border width height/;
 +For arguments, takes submitted args from the page and a hashref of available
 +items.
 +
 +Gets additional information for submitted items from the hashref of
 +available items, since the args can't contain all information about the
 +item.
 +
 +=cut
 +
 +sub UpdateDashboard {
 +    my $args            = shift;
 +    my $available_items = shift;
 +
 +    my $id = $args->{dashboard_id};
 +
 +    my $data = {
 +        "dashboard_id" => $id,
 +        "panes"        => {
 +            "body"    => [],
 +            "sidebar" => []
 +        }
 +    };
 +
 +    foreach my $arg (qw{ body sidebar }) {
 +        my $pane   = $arg;
 +        my $values = $args->{$pane};
 +
 +        next unless $values;
 +
 +        # force value to an arrayref so we can handle both single and multiple members of each pane.
 +        $values = [$values] unless ref $values;
 +
 +        foreach my $value ( @{$values} ) {
 +            $value =~ m/^(\w+)-(.+)$/i;
 +            my $type = $1;
 +            my $name = $2;
 +            push @{ $data->{panes}->{$pane} }, { type => $type, name => $name };
 +        }
      }
  
 -    $scrubber->default(
 -        0,
 +    my ( $ok, $msg );
 +    if ( $id eq 'MyRT' ) {
 +        my $user = $session{CurrentUser};
 +
 +        if ( my $user_id = $args->{user_id} ) {
 +            my $UserObj = RT::User->new( $session{'CurrentUser'} );
 +            ( $ok, $msg ) = $UserObj->Load($user_id);
 +            return ( $ok, $msg ) unless $ok;
 +
 +            return ( $ok, $msg ) = $UserObj->SetPreferences( 'HomepageSettings', $data->{panes} );
 +        } elsif ( $args->{is_global} ) {
 +            my $sys = RT::System->new( $session{'CurrentUser'} );
 +            my ($default_portlets) = $sys->Attributes->Named('HomepageSettings');
 +            return ( $ok, $msg ) = $default_portlets->SetContent( $data->{panes} );
 +        } else {
 +            return ( $ok, $msg ) = $user->SetPreferences( 'HomepageSettings', $data->{panes} );
 +        }
 +    } else {
 +        my $class = $args->{self_service_dashboard} ? 'RT::Dashboard::SelfService' : 'RT::Dashboard';
 +        my $Dashboard = $class->new( $session{'CurrentUser'} );
 +        ( $ok, $msg ) = $Dashboard->LoadById($id);
 +
 +        # report error at the bottom
 +        return ( $ok, $msg ) unless $ok && $Dashboard->Id;
 +
 +        my $content;
 +        for my $pane_name ( keys %{ $data->{panes} } ) {
 +            my @pane;
 +
 +            for my $item ( @{ $data->{panes}{$pane_name} } ) {
 +                my %saved;
 +                $saved{pane}         = $pane_name;
 +                $saved{portlet_type} = $item->{type};
 +
 +                $saved{description} = $available_items->{ $item->{type} }{ $item->{name} }{label};
 +
 +                if ( $item->{type} eq 'component' ) {
 +                    $saved{component} = $item->{name};
 +
 +                    # Absolute paths stay absolute, relative paths go into
 +                    # /Elements. This way, extensions that add portlets work.
 +                    my $path = $item->{name};
 +                    $path = "/Elements/$path" if substr( $path, 0, 1 ) ne '/';
 +
 +                    $saved{path} = $path;
 +                } elsif ( $item->{type} eq 'saved' ) {
 +                    $saved{portlet_type} = 'search';
 +
 +                    $item->{searchType} = $available_items->{ $item->{type} }{ $item->{name} }{search_type}
 +                                          if exists $available_items->{ $item->{type} }{ $item->{name} }{search_type};
 +
 +                    my $type = $item->{searchType};
 +                    $type = 'Saved Search' if !$type || $type eq 'Ticket';
 +                    $saved{description} = loc($type) . ': ' . $saved{description};
 +
 +                    $item->{searchId} = $available_items->{ $item->{type} }{ $item->{name} }{search_id}
 +                                        if exists $available_items->{ $item->{type} }{ $item->{name} }{search_id};
 +
 +                    my ( $obj_type, $obj_id, undef, $search_id ) = split '-', $item->{name};
 +                    $saved{privacy} = "$obj_type-$obj_id";
 +                    $saved{id}      = $search_id;
 +                } elsif ( $item->{type} eq 'dashboard' ) {
 +                    my ( undef, $dashboard_id, $obj_type, $obj_id ) = split '-', $item->{name};
 +                    $saved{privacy}     = "$obj_type-$obj_id";
 +                    $saved{id}          = $dashboard_id;
 +                    $saved{description} = loc('Dashboard') . ': ' . $saved{description};
 +                }
 +
 +                push @pane, \%saved;
 +            }
 +
 +            $content->{$pane_name} = \@pane;
 +        }
 +
 +        return ( $ok, $msg ) = $Dashboard->Update( Panes => $content );
 +    }
 +}
 +
 +=head2 ListOfReports
 +
 +Returns the list of reports registered with RT.
 +
 +=cut
 +
 +sub ListOfReports {
 +
 +    # TODO: Make this a dynamic list generated by loading files in the Reports
 +    # directory
 +
 +    my $list_of_reports = [
          {
 -            %SCRUBBER_ALLOWED_ATTRIBUTES,
 -            '*' => 0, # require attributes be explicitly allowed
 +            id          => 'resolvedbyowner',
 +            title       => 'Resolved by owner', # loc
 +            path        => '/Reports/ResolvedByOwner.html',
          },
 +        {
 +            id          => 'resolvedindaterange',
 +            title       => 'Resolved in date range', # loc
 +            path        => '/Reports/ResolvedByDates.html',
 +        },
++        {
++            id          => 'user_time',
++            title       => 'User time worked',
++            path        => '/Reports/TimeWorkedReport.html',
++        },
 +        {
 +            id          => 'createdindaterange',
 +            title       => 'Created in a date range', # loc
 +            path        => '/Reports/CreatedByDates.html',
 +        },
 +    ];
 +
 +    return $list_of_reports;
 +}
 +
 +=head2 ProcessCustomDateRanges ARGSRef => ARGSREF, UserPreference => 0|1
 +
 +For system database configuration, it adds corresponding arguments to the
 +passed ARGSRef, and the following code on EditConfig.html page will do the
 +real update job.
 +
 +For user preference, it updates attributes accordingly.
 +
 +Returns an array of results messages.
 +
 +=cut
 +
 +sub ProcessCustomDateRanges {
 +    my %args = (
 +        ARGSRef        => undef,
 +        UserPreference => 0,
 +        @_
      );
 -    $scrubber->deny(qw[*]);
 -    $scrubber->allow(@SCRUBBER_ALLOWED_TAGS);
 -    $scrubber->rules(%SCRUBBER_RULES);
 +    my $args_ref = $args{ARGSRef};
 +
 +    my ( $config, $content );
 +    if ( $args{UserPreference} ) {
 +        $config = { 'RT::Ticket' => { RT::Ticket->CustomDateRanges( ExcludeUser => $session{CurrentUser}->Id ) } };
 +        $content = $session{CurrentUser}->Preferences('CustomDateRanges');
  
 -    # Scrubbing comments is vital since IE conditional comments can contain
 -    # arbitrary HTML and we'd pass it right on through.
 -    $scrubber->comment(0);
 +        # SetPreferences also checks rights, we short-circuit to avoid
 +        # returning misleading messages.
  
 -    return $scrubber;
 +        return ( 0, loc("No permission to set preferences") )
 +            unless $session{CurrentUser}->CurrentUserCanModify('Preferences');
 +    }
 +    else {
 +        $config = RT->Config->Get('CustomDateRanges');
 +        my $db_config = RT::Configuration->new( $session{CurrentUser} );
 +        $db_config->LoadByCols( Name => 'CustomDateRangesUI', Disabled => 0 );
 +        $content = $db_config->_DeserializeContent( $db_config->Content ) if $db_config->id;
 +    }
 +
 +    my @results;
 +    my %label = (
 +        from          => 'From',                   # loc
 +        to            => 'To',                     # loc
 +        from_fallback => 'From Value if Unset',    # loc
 +        to_fallback   => 'To Value if Unset',      # loc
 +    );
 +
 +    my $need_save;
 +    if ($content) {
 +        my @current_names = sort keys %{ $content->{'RT::Ticket'} };
 +        for my $id ( 0 .. $#current_names ) {
 +            my $current_name = $current_names[$id];
 +            my $spec         = $content->{'RT::Ticket'}{$current_name};
 +            my $name         = $args_ref->{"$id-name"};
 +
 +            if ( $args_ref->{"$id-Delete"} ) {
 +                delete $content->{'RT::Ticket'}{$current_name};
 +                push @results, loc( 'Deleted [_1]', $current_name );
 +                $need_save ||= 1;
 +                next;
 +            }
 +
 +            if ( $config && $config->{'RT::Ticket'}{$name} ) {
 +                push @results, loc( "[_1] already exists", $name );
 +                next;
 +            }
 +
 +            my $updated;
 +            for my $field (qw/from from_fallback to to_fallback/) {
 +                next if ( $spec->{$field} // '' ) eq $args_ref->{"$id-$field"};
 +                if ((   $args_ref->{"$id-$field"}
 +                        && RT::Ticket->_ParseCustomDateRangeSpec( $name, join ' - ', 'now', $args_ref->{"$id-$field"} )
 +                    )
 +                    || ( !$args_ref->{"$id-$field"} && $field =~ /fallback/ )
 +                   )
 +                {
 +                    $spec->{$field} = $args_ref->{"$id-$field"};
 +                    $updated ||= 1;
 +                }
 +                else {
 +                    push @results, loc( 'Invalid [_1] for [_2]', loc( $label{$field} ), $name );
 +                    next;
 +                }
 +            }
 +
 +            if ( $spec->{business_time} != $args_ref->{"$id-business_time"} ) {
 +                $spec->{business_time} = $args_ref->{"$id-business_time"};
 +                $updated ||= 1;
 +            }
 +
 +            $content->{'RT::Ticket'}{$name} = $spec;
 +            if ( $name ne $current_name ) {
 +                delete $content->{'RT::Ticket'}{$current_name};
 +                $updated ||= 1;
 +            }
 +
 +            if ($updated) {
 +                push @results, loc( 'Updated [_1]', $name );
 +                $need_save ||= 1;
 +            }
 +        }
 +    }
 +
 +    if ( $args_ref->{name} ) {
 +        for my $field (qw/from from_fallback to to_fallback business_time/) {
 +            $args_ref->{$field} = [ $args_ref->{$field} ] unless ref $args_ref->{$field};
 +        }
 +
 +        my $i = 0;
 +        for my $name ( @{ $args_ref->{name} } ) {
 +            if ($name) {
 +                if ( $config && $config->{'RT::Ticket'}{$name} || $content && $content->{'RT::Ticket'}{$name} ) {
 +                    push @results, loc( "[_1] already exists", $name );
 +                    $i++;
 +                    next;
 +                }
 +            }
 +            else {
 +                $i++;
 +                next;
 +            }
 +
 +            my $spec = { business_time => $args_ref->{business_time}[$i] };
 +            for my $field (qw/from from_fallback to to_fallback/) {
 +                if ((   $args_ref->{$field}[$i]
 +                        && RT::Ticket->_ParseCustomDateRangeSpec( $name, join ' - ', 'now', $args_ref->{$field}[$i] )
 +                    )
 +                    || ( !$args_ref->{$field}[$i] && $field =~ /fallback/ )
 +                   )
 +                {
 +                    $spec->{$field} = $args_ref->{$field}[$i];
 +                }
 +                else {
 +                    push @results, loc( 'Invalid [_1] for [_2]', loc($field), $name );
 +                    $i++;
 +                    next;
 +                }
 +            }
 +
 +            $content->{'RT::Ticket'}{$name} = $spec;
 +            push @results, loc( 'Created [_1]', $name );
 +            $need_save ||= 1;
 +            $i++;
 +        }
 +    }
 +
 +    if ($need_save) {
 +        if ( $args{UserPreference} ) {
 +            my ( $ret, $msg );
 +            if ( keys %{$content->{'RT::Ticket'}} ) {
 +                ( $ret, $msg ) = $session{CurrentUser}->SetPreferences( 'CustomDateRanges', $content );
 +            }
 +            else {
 +                ( $ret, $msg ) = $session{CurrentUser}->DeletePreferences( 'CustomDateRanges' );
 +            }
 +
 +            unless ($ret) {
 +                RT->Logger->error($msg);
 +                push @results, $msg;
 +            }
 +        }
 +        else {
 +            $args_ref->{'CustomDateRangesUI-Current'} = ''; # EditConfig.html needs this to update CustomDateRangesUI
 +            $args_ref->{CustomDateRangesUI} = $content;
 +        }
 +    }
 +    return @results;
  }
  
 -=head2 JSON
 +=head2 ProcessAuthToken ARGSRef => ARGSREF
  
 -Redispatches to L<RT::Interface::Web/EncodeJSON>
 +Returns an array of results messages.
  
  =cut
  
diff --cc lib/RT/Report/Tickets.pm
index fae3273d3e,6802958b63..abed846426
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@@ -234,21 -320,8 +336,24 @@@ our %GROUPINGS_META = 
      },
      Enum => {
          Localize => 1,
+         Distinct => 1,
      },
 +    Duration => {
 +        SubFields => [ qw/Default Hour Day Week Month Year/ ],
 +        Localize => 1,
 +        Short    => 0,
 +        Show     => 1,
 +        Sort     => 'duration',
++        Distinct => 1,
 +    },
 +    DurationInBusinessHours => {
 +        SubFields => [ qw/Default Hour/ ],
 +        Localize => 1,
 +        Short    => 0,
 +        Show     => 1,
 +        Sort     => 'duration',
++        Distinct => 1,
 +    },
  );
  
  # loc'able strings below generated with (s/loq/loc/):
@@@ -1492,99 -1264,14 +1640,107 @@@ sub FormatTable 
      return thead => \@head, tbody => \@body, tfoot => \@footer;
  }
  
 +sub _CalculateTime {
 +    my $self = shift;
 +    my ( $type, $value, $current ) = @_;
 +
 +    return $current unless defined $value;
 +
 +    if ( $type eq 'SUM' ) {
 +        $current += $value;
 +    }
 +    elsif ( $type eq 'AVG' ) {
 +        $current ||= {};
 +        $current->{total} += $value;
 +        $current->{count}++;
 +        $current->{calculate} ||= sub {
 +            my $item = shift;
 +            return sprintf '%.0f', $item->{total} / $item->{count};
 +        };
 +    }
 +    elsif ( $type eq 'MAX' ) {
 +        $current = $value unless $current && $current > $value;
 +    }
 +    elsif ( $type eq 'MIN' ) {
 +        $current = $value unless $current && $current < $value;
 +    }
 +    else {
 +        RT->Logger->error("Unsupported type $type");
 +    }
 +    return $current;
 +}
 +
 +sub new {
 +    my $self = shift;
 +    $self->_SetupCustomDateRanges;
 +    return $self->SUPER::new(@_);
 +}
 +
 +
 +sub _SetupCustomDateRanges {
 +    my $self = shift;
 +    my %names;
 +
 +    # Remove old custom date range groupings
 +    for my $field ( grep {ref} @STATISTICS ) {
 +        if ( $field->[1] && $field->[1] eq 'CustomDateRangeAll' ) {
 +            $names{ $field->[2] } = 1;
 +        }
 +    }
 +
 +    my ( @new_groupings, @new_statistics );
 +    while (@GROUPINGS) {
 +        my $name = shift @GROUPINGS;
 +        my $type = shift @GROUPINGS;
 +        if ( !$names{$name} ) {
 +            push @new_groupings, $name, $type;
 +        }
 +    }
 +
 +    while (@STATISTICS) {
 +        my $key    = shift @STATISTICS;
 +        my $info   = shift @STATISTICS;
 +        my ($name) = $key =~ /^(?:ALL|SUM|AVG|MIN|MAX)\((.+)\)$/;
 +        unless ( $name && $names{$name} ) {
 +            push @new_statistics, $key, $info;
 +        }
 +    }
 +
 +    # Add new ones
 +    my %ranges = RT::Ticket->CustomDateRanges;
 +    for my $name ( sort keys %ranges ) {
 +        my %extra_info;
 +        my $spec = $ranges{$name};
 +        if ( ref $spec && $spec->{business_time} ) {
 +            $extra_info{business_time} = 1;
 +        }
 +
 +        push @new_groupings, $name => $extra_info{business_time} ? 'DurationInBusinessHours' : 'Duration';
 +        push @new_statistics,
 +            (
 +            "ALL($name)" => [ "Summary of $name", 'CustomDateRangeAll', $name, \%extra_info ],
 +            "SUM($name)" => [ "Total $name",   'CustomDateRange', 'SUM', $name, \%extra_info ],
 +            "AVG($name)" => [ "Average $name", 'CustomDateRange', 'AVG', $name, \%extra_info ],
 +            "MIN($name)" => [ "Minimum $name", 'CustomDateRange', 'MIN', $name, \%extra_info ],
 +            "MAX($name)" => [ "Maximum $name", 'CustomDateRange', 'MAX', $name, \%extra_info ],
 +            );
 +    }
 +
 +    @GROUPINGS  = @new_groupings;
 +    @STATISTICS = @new_statistics;
 +    %GROUPINGS  = %STATISTICS = ();
 +
 +    return 1;
 +}
 +
+ sub _GroupingType {
+     my $self = shift;
+     my $key  = shift or return;
+     # keys for custom roles are like "CustomRole.{1}"
+     $key = 'CustomRole' if $key =~ /^CustomRole/;
+     return $GROUPINGS{$key};
+ }
+ 
  RT::Base->_ImportOverlays();
  
  1;
diff --cc share/html/Admin/Users/index.html
index 201bb11e47,01940611ab..e64846174c
--- a/share/html/Admin/Users/index.html
+++ b/share/html/Admin/Users/index.html
@@@ -97,66 -87,38 +97,75 @@@ jQuery(function()
  %   }
            <option value="<% $group_value %>" <% $group_selected |n %>><% loc($group_value) %></option>
  % }
 -        </select>
 +      </select>
 +    </div>
 +  </div>
 +  <div class="form-row">
 +    <div class="label col-3 text-left">
 +      <&|/l&>Find all users whose</&>
 +    </div>
 +      <& /Elements/SelectUsers, %ARGS, Fields => \@fields &>
 +  </div>
 +
 +  <div class="form-row">
 +    <div class="label col-3 text-left">
 +      <&|/l&>And all users whose</&>
 +    </div>
 +    <& /Elements/SelectUsers, %ARGS, Fields => \@fields,
 +        SelectFieldName => 'UserField2',
 +        SelectOpName    => 'UserOp2',
 +        InputStringName => 'UserString2',
 +        UserField       => $UserField2,
 +        UserOp          => $UserOp2,
 +        UserString      => $UserString2,
 +    &>
 +  </div>
 +
 +  <div class="form-row">
 +    <div class="label col-3 text-left">
 +      <&|/l&>And all users whose</&>
 +    </div>
 +    <& /Elements/SelectUsers, %ARGS, Fields => \@fields,
 +        SelectFieldName => 'UserField3',
 +        SelectOpName    => 'UserOp3',
 +        InputStringName => 'UserString3',
 +        UserField       => $UserField3,
 +        UserOp          => $UserOp3,
 +        UserString      => $UserString3,
 +    &>
 +  </div>
 +
 +  <div class="form-row">
-     <div class="col-12">
++    <div class="label col-3 text-left pt-1">
++      <&|/l&>Include all</&>
++    </div>
++    <div class="col-auto mt-1">
++      <div class="custom-control custom-checkbox">
++        <input type="checkbox" class="custom-control-input checkbox" id="FindEnabledUsers" name="FindEnabledUsers" value="1" <% $FindEnabledUsers? 'checked="checked"': '' %> />
++        <label class="custom-control-label" for="FindEnabledUsers"><&|/l&>Enabled users</&></label>
++      </div>
++    </div>
++    <div class="col-auto mt-1">
 +      <div class="custom-control custom-checkbox">
 +        <input type="checkbox" class="custom-control-input checkbox" id="FindDisabledUsers" name="FindDisabledUsers" value="1" <% $FindDisabledUsers? 'checked="checked"': '' %> />
-         <label class="custom-control-label" for="FindDisabledUsers"><&|/l&>Include disabled users in search.</&></label>
++        <label class="custom-control-label" for="FindDisabledUsers"><&|/l&>Disabled users</&></label>
        </div>
 -    </tr>
 +    </div>
 +  </div>
  
 -    <tr><td><&|/l&>Find all users whose</&> <& /Elements/SelectUsers, %ARGS, Fields => \@fields &></td></tr>
 -    <tr><td><&|/l&>And all users whose</&> <& /Elements/SelectUsers, %ARGS, Fields => \@fields,
 -                SelectFieldName => 'UserField2',
 -                SelectOpName    => 'UserOp2',
 -                InputStringName => 'UserString2',
 -                UserField       => $UserField2,
 -                UserOp          => $UserOp2,
 -                UserString      => $UserString2,
 -            &></td></tr>
 -    <tr><td><&|/l&>And all users whose</&> <& /Elements/SelectUsers, %ARGS, Fields => \@fields,
 -                SelectFieldName => 'UserField3',
 -                SelectOpName    => 'UserOp3',
 -                InputStringName => 'UserString3',
 -                UserField       => $UserField3,
 -                UserOp          => $UserOp3,
 -                UserString      => $UserString3,
 -            &></td></tr>
 -</table>
 -<p>Include all <input type="checkbox" class="checkbox" id="FindEnabledUsers" name="FindEnabledUsers" value="1" <% $FindEnabledUsers ? 'checked="checked"' : '' %> /><label for="FindEnabledUsers"><&|/l&>Enabled users</&></label>
 -<input type="checkbox" class="checkbox" id="FindDisabledUsers" name="FindDisabledUsers" value="1" <% $FindDisabledUsers ? 'checked="checked"' : '' %> /><label for="FindDisabledUsers"><&|/l&>Disabled users</&></label>
 -</p>
  % $m->callback( %ARGS, UsersObj => $users, CallbackName => 'InUsersAdminForm' );
 -<br />
 -<div align="right"><input type="submit" class="button" value="<&|/l&>Go!</&>" name="Go" /></div>
 -</form>
  
 +  <div class="form-row">
 +    <div class="col-12 text-right">
 +      <input type="submit" class="button btn btn-primary" value="<&|/l&>Go!</&>" name="Go" />
 +    </div>
 +  </div>
 +  </form>
 +</&>
 +</div>
 +<br />
  % unless ( $users->Count ) {
 -<em><&|/l&>No users matching search criteria found.</&></em>
 +<p class="font-weight-bold"><&|/l&>No users matching search criteria found.</&></p>
  % } else {
  <p><&|/l&>Select a user</&>:</p>
  
diff --cc share/html/Elements/ColumnMap
index f854c23220,50082ef37d..39c98dcdaf
--- a/share/html/Elements/ColumnMap
+++ b/share/html/Elements/ColumnMap
@@@ -54,6 -54,104 +54,104 @@@ $Attr  => unde
  
  use Scalar::Util;
  
+ my $role_value = sub {
+     my $role   = shift;
+     my $object = shift;
+ 
+     # $[0] is the index number of current row
+     my $field  = $_[1] || '';
+ 
+     my ( $role_type, $attr, $cf_name );
+ 
+ 
+     if ( $role eq 'CustomRole' ) {
+         my $role_name;
+         if ( $field =~ /^\{(.+)\}\.CustomField\.\{(.+)\}/ ) {
+ 
+             # {test}.CustomField.{foo}
+             $role_name = $1;
+             $cf_name   = $2;
+         }
+         elsif ( $field =~ /^\{(.+)\}(?:\.(\w+))?$/ ) {
+ 
+             # {test}.Name or {test}
+             $role_name = $1;
+             $attr      = $2;
+         }
+ 
+         # Cache the role object on a per-request basis, to avoid
+         # having to load it for every row
+         my $key = "RT::CustomRole-" . $role_name;
+ 
 -        $role_type = $m->notes($key);
 -        if ( !$role_type ) {
 -            my $role_obj = RT::CustomRole->new( $object->CurrentUser );
++        my $role_obj = $m->notes($key);
++        if ( !$role_obj ) {
++            $role_obj = RT::CustomRole->new( $object->CurrentUser );
+             $role_obj->Load($role_name);
+ 
+             RT->Logger->notice("Unable to load custom role $role_name")
+                 unless $role_obj->Id;
+ 
 -            $role_type = $role_obj->GroupType;
 -            $m->notes( $key, $role_type );
++            $m->notes( $key, $role_obj );
+         }
++        $role_type = $role_obj->GroupType;
+     }
+     else {
+         if ( $field =~ /^CustomField\.\{(.+)\}/ ) {
+             $cf_name = $1;
+         }
+         elsif ( $field =~ /^(\w+)$/ ) {
+             $attr = $1;
+         }
+         $role_type = $role;
+     }
+ 
+     return if !$role_type;
+ 
+     my $role_group = $object->RoleGroup($role_type);
+     if ( $cf_name || $attr ) {
+         # TODO Show direct members only?
+         my $users = $role_group->UserMembersObj;
+         my @values;
+ 
+         while ( my $user = $users->Next ) {
+             if ($cf_name) {
+                 my $key = join( "-", "CF", $user->CustomFieldLookupType, $cf_name );
+                 my $cf = $m->notes($key);
+                 if ( !$cf ) {
+                     $cf = $user->LoadCustomFieldByIdentifier($cf_name);
+                     RT->Logger->debug( "Unable to load $cf_name for " . $user->CustomFieldLookupType )
+                         unless $cf->Id;
+                     $m->notes( $key, $cf );
+                 }
+ 
+                 my $ocfvs = $cf->ValuesForObject($user)->ItemsArrayRef;
+                 my $comp
+                     = $m->comp_exists( "/Elements/ShowCustomField" . $cf->Type )
+                     ? "/Elements/ShowCustomField" . $cf->Type
+                     : undef;
+ 
+                 push @values, map { $comp ? \( $m->scomp( $comp, Object => $_ ) ) : $_->Content } @$ocfvs;
+ 
+             }
+             elsif ( $user->_Accessible( $attr, 'read' ) ) {
+                 push @values, $user->$attr || ();
+             }
+         }
+         return @values if @values <= 1;
+ 
+         if ($cf_name) {
+             @values = map { \"<li>", $_, \"</li>" } @values;
+             @values = ( \"<ul class='cf-values'>", @values, \"</ul>" );
+         }
+         else {
+             return join ', ', @values;
+         }
+     }
+     else {
+         return \( $m->scomp( "/Elements/ShowPrincipal", Object => $role_group ) );
+     }
+ };
+ 
  my ($COLUMN_MAP, $WCOLUMN_MAP);
  $WCOLUMN_MAP = $COLUMN_MAP = {
      id => {
@@@ -153,44 -241,21 +251,63 @@@
      },
      CustomRole => {
          attribute => sub { return shift @_ },
-         title     => sub { return pop @_ },
-         value     => sub {
-             my $self = $WCOLUMN_MAP->{CustomRole};
-             my $role   = $self->{load}->(@_);
-             return unless $role->Id;
-             return \($m->scomp("/Elements/ShowPrincipal", Object => $_[0]->RoleGroup($role->GroupType) ) );
+         title     => sub {
+             my $field = pop @_;
+             if (   $field =~ /^\{(.+)\}\.CustomField\.\{(.+)\}/
+                 || $field =~ /^\{(.+)\}\.(.+)/ )
+             {
+                 return "$1.$2";
+             }
+             elsif ( $field =~ /^\{(.+)\}$/ ) {
+                 return $1;
+             }
+             else {
+                 return $field;
+             }
          },
 +        load      => sub {
-             my $role_name = pop;
++            my $field = $_[2];
++            my $role_name;
++            if ( $field =~ /^\{(.+)\}\.CustomField\.\{(.+)\}/ ) {
++
++                # {test}.CustomField.{foo}
++                $role_name = $1;
++            }
++            elsif ( $field =~ /^\{(.+)\}(?:\.(\w+))?$/ ) {
++
++                # {test}.Name or {test}
++                $role_name = $1;
++            }
 +
 +            # Cache the role object on a per-request basis, to avoid
 +            # having to load it for every row
 +            my $key = "RT::CustomRole-" . $role_name;
 +
 +            my $role_obj = $m->notes($key);
 +            if (!$role_obj) {
 +                $role_obj = RT::CustomRole->new($_[0]->CurrentUser);
 +                $role_obj->Load($role_name);
 +
 +                RT->Logger->notice("Unable to load custom role $role_name")
 +                    unless $role_obj->Id;
 +
 +                $m->notes($key, $role_obj);
 +            }
 +
 +            return $role_obj;
 +        },
 +        edit => sub {
 +            my $self = $WCOLUMN_MAP->{CustomRole};
 +            my $role   = $self->{load}->(@_);
 +            return unless $role->Id;
 +            if ($role->SingleValue) {
 +                return \($m->scomp("/Elements/SingleUserRoleInput", role => $role, Ticket => $_[0]));
 +            }
 +            else {
 +                return undef;
 +            }
 +        },
+         value => sub { return $role_value->('CustomRole', @_) },
      },
  
      CheckBox => {
@@@ -283,11 -339,20 +400,21 @@@ if ($RecordClass->DOES("RT::Record::Rol
          for my $role ($RecordClass->Roles(UserDefined => 0)) {
              my $attrs = $RecordClass->Role($role);
              $ROLE_MAP->{$RecordClass}{$role} = {
-                 title => $role,
-                 attribute => $attrs->{Column} || "$role.EmailAddress",
-                 value => sub { return \($m->scomp("/Elements/ShowPrincipal", Object => $_[0]->RoleGroup($role) ) ) },
+                 attribute => sub { return shift @_ },
+                 title => sub {
+                     my $field = pop @_;
+                     if (   $field =~ /^CustomField\.\{(.+)\}/
+                         || $field =~ /^(?!$role)(.+)/ )
+                     {
+                         return "$role.$1";
+                     }
+                     else {
+                         return $role;
+                     }
+                 },
+                 value => sub { return $role_value->($role, @_, @_ == 2 ? '' : () ) },
              };
 +
              $ROLE_MAP->{$RecordClass}{$role . "s"} = $ROLE_MAP->{$RecordClass}{$role}
                  unless $attrs->{Single};
          }
diff --cc share/html/Reports/TimeWorkedReport.html
index 0000000000,4be0a85a40..c19b124c8e
mode 000000,100644..100644
--- a/share/html/Reports/TimeWorkedReport.html
+++ b/share/html/Reports/TimeWorkedReport.html
@@@ -1,0 -1,278 +1,277 @@@
+ %# BEGIN BPS TAGGED BLOCK {{{
+ %#
+ %# COPYRIGHT:
+ %#
+ %# This software is Copyright (c) 1996-2018 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 => loc("User Time Worked") &>
+ <& /Elements/Tabs &>
+ <& /Elements/ListActions, actions => \@results &>
+ 
 -<div class="user-timeworked-form-content">
 -    <form method="POST" action="TimeWorkedReport.html">
 -      <table>
 -        <tr>
 -          <td align="right">
 -            <label><&|/l&>User</&>:</label>
 -          </td>
 -          <td align="right">
 -            <input style="width: 17em" class="user-time-worked-input" name="User"
 -              data-autocomplete="Users"
 -              data-autocomplete-return="Name"
 -              placeholder="<% loc("Find a user...") %>" Value = <% $User %>
 -            >
 -          </td>
 -        </tr>
 -        <tr>
 -          <td align="right">
 -            <label><&|/l&>Start Date</&>:</label>
 -          </td>
 -          <td align="right">
 -            <& /Elements/SelectDate, ShowTime => 1, Name => 'StartDate', Default => $StartDate &>
 -          </td>
 -        </tr>
 -        <tr>
 -          <td align="right">
 -            <label><&|/l&>End Date</&>:</label>
 -          </td>
 -          <td align="">
 -            <& /Elements/SelectDate, ShowTime => 1, Name => 'EndDate', Default => $EndDate  &>
 -          </td>
 -        </tr>
 -        <tr>
 -          <td align="right">
 -            <label><&|/l&>Sort By</&>:</label>
 -          </td>
 -          <td>
 -            <select name="SortBy">
 -              <option value="Date" <% $SortBy eq 'Date' ?  'selected="selected"' : '' |n%>><&|/l&>By Date</&></option>
 -              <option value="User" <% $SortBy eq 'User' ? 'selected="selected"' : '' |n%>><&|/l&>By User</&></option>
 -              <option value="Ticket" <% $SortBy eq 'Ticket' ? 'selected="selected"' : '' |n%>><&|/l&>By Ticket</&></option>
 -              <option value="Queue" <% $SortBy eq 'Queue' ? 'selected="selected"' : '' |n%>><&|/l&>By Queue</&></option>
 -            </select>
 -          </td>
 -        </tr>
 -        <tr>
 -          <td align="right">
 -            <label><&|/l&>Queue</&>:</label>
 -          </td>
 -          <td>
 -            <& /Elements/SelectQueue, Name => 'Queue', Id => 'queue', Default => $Queue &>
 -          </td>
 -        </tr>
 -        <tr>
 -          <td></td>
 -          <td align="right"><button type="submit"><&|/l&>See Time</&></button></td>
 -        </tr>
 -      </table>
 -    </form>
++<div class="container">
++<&| /Widgets/TitleBox, hideable => 0, class => 'user-timeworked-form-content', content_class => 'mx-auto width-md' &>
++  <form method="POST" action="TimeWorkedReport.html" class="mx-auto">
++    <div class="form-row">
++      <div class="label col-3">
++        <&|/l&>User</&>:
++      </div>
++      <div class="value col-9">
++        <input class="form-control user-time-worked-input" name="User" data-autocomplete="Users" data-autocomplete-return="Name" placeholder="<%
++loc("Find a user...") %>" value="<% $User %>" />
++      </div>
++    </div>
++    <div class="form-row">
++      <div class="label col-3">
++        <&|/l&>Start Date</&>:
++      </div>
++      <div class="value col-9">
++        <& /Elements/SelectDate, ShowTime => 1, Name => 'StartDate', Default => $StartDate &>
++      </div>
++    </div>
++    <div class="form-row">
++      <div class="label col-3">
++        <&|/l&>End Date</&>:
++      </div>
++      <div class="value col-9">
++        <& /Elements/SelectDate, ShowTime => 1, Name => 'EndDate', Default => $EndDate &>
++      </div>
++    </div>
++    <div class="form-row">
++      <div class="label col-3">
++        <&|/l&>Sort By</&>:
++      </div>
++      <div class="value col-9">
++        <select name="SortBy" class="selectpicker">
++          <option value="Date" <% $SortBy eq 'Date' ?  'selected="selected"' : '' |n%>><&|/l&>By Date</&></option>
++          <option value="User" <% $SortBy eq 'User' ? 'selected="selected"' : '' |n%>><&|/l&>By User</&></option>
++          <option value="Ticket" <% $SortBy eq 'Ticket' ? 'selected="selected"' : '' |n%>><&|/l&>By Ticket</&></option>
++          <option value="Queue" <% $SortBy eq 'Queue' ? 'selected="selected"' : '' |n%>><&|/l&>By Queue</&></option>
++        </select>
++      </div>
++    </div>
++    <div class="form-row">
++      <div class="label col-3">
++        <&|/l&>Queue</&>:
++      </div>
++      <div class="value col-9">
++        <& /Elements/SelectQueue, Name => 'Queue', Id => 'queue', Default => $Queue &>
++      </div>
++    </div>
++    <div class="form-row">
++      <div class="col-12">
++        <& /Elements/Submit, Label => loc('See Time') &>
++      </div>
++    </div>
++  </form>
++</&>
+ </div>
 -
 -% if ( $data ) {
++% if ( @delimeters ) {
+ <div class="user-time-content">
+ %   foreach my $delimeter (@delimeters) {
+     <h3><% $data->{$delimeter}[0]{Head} %></h3>
 -    <table class="ticket-list collection-as-table">
++    <table class="ticket-list collection-as-table table">
+       <tr class="collection-as-table">
+         <th class="collection-as-table"><&|/l&>Id</&></th>
+         <th class="collection-as-table"><&|/l&>Subject</&></th>
+         <th class="collection-as-table"><&|/l&>Queue</&></th>
+         <th class="collection-as-table"><&|/l&>Status</&></th>
+         <th class="collection-as-table"><&|/l&>Owner</&></th>
+         <th class="collection-as-table"><&|/l&>Time Worked</&></th>
+         <th class="collection-as-table"><&|/l&>Worked By</&></th>
+       </tr>
+ % my $line_type = "oddline";
+ % my ($total_time_mins, $total_time_hours);
+ %       foreach my $time (@{$data->{$delimeter} }) {
+       <tr class="<% $line_type %>">
+         <td class="collection-as-table">
+           <a href="<% RT->Config->Get('WebBaseURL')."/Ticket/Display.html?id=$time->{Id}" %>"><% $time->{Id} %></a>
+         </td>
+         <td class="collection-as-table">
+           <a href="<% RT->Config->Get('WebBaseURL')."/Ticket/Display.html?id=$time->{Id}" %>"><% $time->{Subject} %></a>
+         </td>
+         <td class="collection-as-table"><% $time->{Queue} %></td>
+         <td class="collection-as-table"><% $time->{Status} %></td>
+         <td class="collection-as-table">
+ %       if ( $time->{OwnerId} != RT->Nobody->Id ) {
+           <a href="<% RT->Config->Get('WebBaseURL')."/User/Summary.html?id=$time->{OwnerId}" %>"><% $time->{Owner} %></a>
+ %       } else {
+           <% $time->{Owner} %>
+ %       }
+         </td>
+         <td class="collection-as-table"><% $time->{Time} %></td>
+         <td class="collection-as-table">
+           <a href="<% RT->Config->Get('WebBaseURL')."/User/Summary.html?id=$time->{WorkerId}" %>"><% $time->{Worker} %></a>
+         </td>
+       </tr>
+ %       $line_type = $line_type eq "oddline" ? "evenline" : "oddline";
+ %       $total_time_mins += $time->{TimeMin};
+ %       $total_time_hours += $time->{TimeHours};
+ %       }
+     </table>
+     <label><&|/l, $total_time_hours, $total_time_mins &>Total: [_1] hours ([_2] minutes)</&></label>
+ %   }
+ </div>
+ % }
+ % elsif ( $StartDate && $EndDate ) {
++<p class="description mt-3 mt-1 ml-3">
+   <&|/l&>No tickets found.</&>
++</p>
+ % }
+ 
+ <%INIT>
+ my @results;
+ 
+ my $data;
+ my @delimeters;
+ 
+ # if we are just getting here and the form values are empty, we are done
+ if ( $StartDate && $EndDate ) {
+ 
+     #### DATES ####
+     my $start_date = RT::Date->new( $session{'CurrentUser'} );
+     my $end_date   = RT::Date->new( $session{'CurrentUser'} );
+ 
+     # If we have a value for start date, parse it into an RT::Date object
+     if ($StartDate) {
+         $start_date->Set( Format => 'unknown', Value => $StartDate, Timezone => 'User' );
+ 
+         # And then get it back as an ISO string for display purposes, in the form field and
+         # report header
+         $StartDate = $start_date->AsString( Format => 'ISO', Timezone => 'User' );
+     }
+ 
+     # Same treatment for end date
+     if ($EndDate) {
+         $end_date->Set( Format => 'unknown', Value => $EndDate );
+         $EndDate = $end_date->AsString( Format => 'ISO', Timezone => 'User' );
+     }
+ 
+     # Get a new transactions object to hold transaction search results for this ticket
+     my $trans = RT::Transactions->new( $session{'CurrentUser'} );
+ 
+     my $txns = RT::Transactions->new($session{CurrentUser});
+     $txns->Limit( FIELD => 'ObjectType', VALUE => 'RT::Ticket' );
+     if ( $User ) {
+         my $user = RT::User->new( $session{'CurrentUser'} );
+         my ($ret, $msg) = $user->Load( $User );
+         if ( $ret && $user->Id ) {
+             $txns->Limit( FIELD => 'Creator', VALUE => $user->id )
+         }
+         else {
+             push @results, loc("Could not load user, report is not limited to user: [_1]", $User);
+         }
+     }
+     $txns->Limit( FIELD => 'TimeTaken', VALUE => 0, OPERATOR => '!=' );
+     $txns->Limit( FIELD => 'Created', VALUE => $start_date->ISO(Timezone => 'user'), OPERATOR => '>=' );
+     $txns->Limit( FIELD => 'Created', VALUE => $end_date->ISO(Timezone => 'user'), OPERATOR => '<', ENTRYAGGREGATOR => 'AND');
+ 
+     my $total_time_worked = 0;
+ 
+     while ( my $txn = $txns->Next ) {
+         my $ticket = $txn->TicketObj;
+ 
+         my $worker = RT::User->new($session{'CurrentUser'});
+         my ($ret, $msg) = $worker->Load( $txn->Creator );
+         push @results, $msg unless $ret;
+ 
+         if ( $Queue && $ticket->QueueObj ) {
+             next unless $Queue eq $ticket->QueueObj->Id;
+         }
+ 
+         $total_time_worked = $total_time_worked + $txn->TimeTaken;
+ 
+         my $time_hours = sprintf '%.2f', $txn->TimeTaken / 60;
+ 
+         my ( $head, $delimeter );
+         if ( $SortBy eq 'User' ) {
+             $head = $delimeter = loc( 'User: [_1]', $worker->Name );
+         }
+         elsif ( $SortBy eq 'Ticket' ) {
+             $head      = $ticket->Id . ': ' . $ticket->Subject;
+             $delimeter = $ticket->Id;
+         }
+         elsif ( $SortBy eq 'Queue' ) {
+             $head = $delimeter = loc( 'Queue: [_1]', $ticket->QueueObj->Name );
+         }
+         else {
+             $head      = $txn->CreatedObj->RFC2822( Time => 0, Timezone => 'user' );
+             $delimeter = $txn->CreatedObj->iCal( Time => 0 );
+         }
+ 
+         push @{ $data->{$delimeter} },
+             {   Head      => $head,
+                 Id        => $ticket->Id,
+                 Subject   => $ticket->Subject,
+                 Queue     => $ticket->QueueObj->Name,
+                 Status    => $ticket->Status,
+                 OwnerId   => $ticket->OwnerObj->Id,
+                 Owner     => $ticket->OwnerObj->Name,
+                 Time      => $time_hours > 1
+                 ? loc( '[_1] hours ([_2] minutes)', $time_hours, $txn->TimeTaken )
+                 : loc( '[_1] minutes', $txn->TimeTaken ),
+                 TimeMin   => $txn->TimeTaken,
+                 TimeHours => $time_hours,
+                 Worker    => $worker->Name,
+                 WorkerId  => $worker->Id,
+             };
+     }
+ 
+     @delimeters = keys %$data;
+     if ( $SortBy =~ /Ticket|Date/ ) {
+         @delimeters = sort { $a <=> $b } keys %$data;
+     }
+     else {
+         @delimeters = sort { lc $a cmp lc $b } keys %$data;
+     }
+ }
+ 
+ </%INIT>
+ 
+ <%ARGS>
+ $StartDate    => ''
+ $EndDate      => ''
+ $User         => ''
+ $SortBy       => 'Date'
+ $Queue        => ''
+ </%ARGS>
diff --cc share/html/Search/Elements/BuildFormatString
index 2f63777988,d5e8718960..58776688cd
--- a/share/html/Search/Elements/BuildFormatString
+++ b/share/html/Search/Elements/BuildFormatString
@@@ -73,151 -69,88 +73,171 @@@ $ObjectType => $Class eq 'RT::Transacti
  # it -- and it grows per request.
  
  # All the things we can display in the format string by default
 -my @fields = qw(
 -    id QueueName Subject
 -    Status ExtendedStatus UpdateStatus
 -    Type
 -
 -    OwnerName Requestors Cc AdminCc CreatedBy LastUpdatedBy
 -
 -    Priority InitialPriority FinalPriority
 -
 -    TimeWorked TimeLeft TimeEstimated
 -
 -    Starts      StartsRelative
 -    Started     StartedRelative
 -    Created     CreatedRelative
 -    LastUpdated LastUpdatedRelative
 -    Told        ToldRelative
 -    Due         DueRelative
 -    Resolved    ResolvedRelative
 -
 -    SLA
 -
 -    RefersTo    ReferredToBy
 -    DependsOn   DependedOnBy
 -    MemberOf    Members
 -    Parents     Children
 -
 -    Bookmark    Timer
 -
 -    NEWLINE
 -    NBSP
 -); # loc_qw
 -
 -# Total time worked is an optional ColumnMap enabled for rolling up child
 -# TimeWorked
 -push @fields, 'TotalTimeWorked' if (RT->Config->Get('DisplayTotalTimeWorked'));
 -
 -my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
 -foreach my $id (keys %queues) {
 -    # Gotta load up the $queue object, since queues get stored by name now.
 -    my $queue = RT::Queue->new($session{'CurrentUser'});
 -    $queue->Load($id);
 -    next unless $queue->Id;
 -    $CustomFields->LimitToQueue($queue->Id);
 -    $CustomFields->SetContextObject( $queue ) if keys %queues == 1;
 -}
 -$CustomFields->LimitToGlobal;
 +my @fields;
 +if ( $Class eq 'RT::Transactions' ) {
 +    $Format ||= RT->Config->Get('TransactionDefaultSearchResultFormat')->{$ObjectType};
  
 -while ( my $CustomField = $CustomFields->Next ) {
 -    push @fields, "CustomField.{" . $CustomField->Name . "}";
 -}
 +    @fields = qw( id ObjectId ObjectType ObjectName Type Field TimeTaken
 +        OldValue NewValue ReferenceType OldReference NewReference
 +        Created CreatedRelative CreatedBy Description Content PlainContent HTMLContent
 +        TicketId TicketSubject TicketQueue TicketStatus TicketOwner TicketCreator
 +        TicketLastUpdatedBy TicketCreated TicketStarted TicketResolved
 +        TicketTold TicketLastUpdated TicketDue
 +        TicketPriority TicketInitialPriority TicketFinalPriority
 +        NEWLINE NBSP );    # loc_qw
  
 -my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
 -foreach my $id (keys %queues) {
 -    # Gotta load up the $queue object, since queues get stored by name now.
 -    my $queue = RT::Queue->new($session{'CurrentUser'});
 -    $queue->Load($id);
 -    next unless $queue->Id;
 -    $CustomRoles->LimitToObjectId($queue->Id);
 -}
 +    my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'} );
 +    foreach my $id ( keys %queues ) {
  
 -my @user_fields = qw/id Name EmailAddress Organization RealName City Country/;
 -my $user_cfs    = RT::CustomFields->new( $session{CurrentUser} );
 -$user_cfs->Limit( FIELD => 'LookupType', VALUE => RT::User->CustomFieldLookupType );
 -while ( my $user_cf = $user_cfs->Next ) {
 -    push @user_fields, join '.', 'CustomField', '{' . $user_cf->Name . '}';
 +        # 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 . "}";
 +        push @fields, "CustomFieldView.{" . $CustomField->Name . "}";
 +    }
  }
 +elsif ( $Class eq 'RT::Assets' ) {
 +    $Format ||= RT->Config->Get('AssetDefaultSearchResultFormat');
 +    @fields = qw(
 +        id Name Description Status
 +        CreatedBy LastUpdatedBy
  
 -for my $watcher (qw/AdminCc Cc Owner Requestor/) {
 -    for my $user_field (@user_fields) {
 -        my $field = join '.', $watcher, $user_field;
 -        push @fields, $field;
 +        Created     CreatedRelative
 +        LastUpdated LastUpdatedRelative
 +
 +        RefersTo    ReferredToBy
 +        DependsOn   DependedOnBy
 +        MemberOf    Members
 +        Parents     Children
 +
 +        Owner HeldBy Contacts
 +
 +        NEWLINE
 +        NBSP
 +    ); # loc_qw
 +
 +    my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'});
 +    foreach my $id (keys %catalogs) {
 +        # Gotta load up the $catalog object, since catalogs get stored by name now.
 +        my $catalog = RT::Catalog->new($session{'CurrentUser'});
 +        $catalog->Load($id);
 +        next unless $catalog->Id;
 +        $CustomFields->LimitToCatalog($catalog->Id);
 +        $CustomFields->SetContextObject( $catalog ) if keys %catalogs == 1;
 +    }
 +    $CustomFields->LimitToCatalog(0);
 +    while ( my $CustomField = $CustomFields->Next ) {
 +        push @fields, "CustomField.{" . $CustomField->Name . "}";
 +        push @fields, "CustomFieldView.{" . $CustomField->Name . "}";
      }
 +
  }
 +else {
 +    $Format ||= RT->Config->Get('DefaultSearchResultFormat');
  
 -# Add all available CustomRoles to the list of sortable columns.
 -while ( my $role = $CustomRoles->Next ) {
 -    push @fields, 'CustomRole.{' . $role->Name . '}';
 +    @fields = qw(
 +        id QueueName Subject
 +        Status ExtendedStatus UpdateStatus
 +        Type
 +
 +        OwnerName OwnerNameEdit Requestors Cc AdminCc CreatedBy LastUpdatedBy
 +
 +        Priority InitialPriority FinalPriority
 +
 +        TimeWorked TimeLeft TimeEstimated
 +
 +        Starts      StartsRelative
 +        Started     StartedRelative
 +        Created     CreatedRelative
 +        LastUpdated LastUpdatedRelative
 +        Told        ToldRelative
 +        Due         DueRelative
 +        Resolved    ResolvedRelative
 +
 +        SLA
 +
 +        RefersTo    ReferredToBy
 +        DependsOn   DependedOnBy
 +        MemberOf    Members
 +        Parents     Children
 +
 +        Bookmark    Timer
 +        UnreadMessages
 +
 +        NEWLINE
 +        NBSP
 +        );    # loc_qw
 +
 +    # Total time worked is an optional ColumnMap enabled for rolling up child
 +    # TimeWorked
 +    push @fields, 'TotalTimeWorked' if ( RT->Config->Get('DisplayTotalTimeWorked') );
 +
 +    my $CustomFields = RT::CustomFields->new( $session{'CurrentUser'} );
 +    foreach my $id ( keys %queues ) {
 +
 +        # Gotta load up the $queue object, since queues get stored by name now.
 +        my $queue = RT::Queue->new( $session{'CurrentUser'} );
 +        $queue->Load($id);
 +        next unless $queue->Id;
 +        $CustomFields->LimitToQueue( $queue->Id );
 +        $CustomFields->SetContextObject($queue) if keys %queues == 1;
 +    }
 +    $CustomFields->LimitToGlobal;
  
 -    # Add all available CustomRoles to the list of sortable columns.
 -    for my $user_field (@user_fields) {
 -        push @fields, join '.', 'CustomRole.{' . $role->Name . '}', $user_field;
 +    while ( my $CustomField = $CustomFields->Next ) {
 +        push @fields, "CustomField.{" . $CustomField->Name . "}";
 +        push @fields, "CustomFieldView.{" . $CustomField->Name . "}";
      }
 +
 +    my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'} );
 +    foreach my $id ( keys %queues ) {
 +
 +        # Gotta load up the $queue object, since queues get stored by name now.
 +        my $queue = RT::Queue->new( $session{'CurrentUser'} );
 +        $queue->Load($id);
 +        next unless $queue->Id;
 +        $CustomRoles->LimitToObjectId( $queue->Id );
 +    }
-     while ( my $Role = $CustomRoles->Next ) {
-         push @fields, "CustomRole.{" . $Role->Name . "}";
++
++    my @user_fields = qw/id Name EmailAddress Organization RealName City Country/;
++    my $user_cfs    = RT::CustomFields->new( $session{CurrentUser} );
++    $user_cfs->Limit( FIELD => 'LookupType', VALUE => RT::User->CustomFieldLookupType );
++    while ( my $user_cf = $user_cfs->Next ) {
++        push @user_fields, join '.', 'CustomField', '{' . $user_cf->Name . '}';
++    }
++
++    for my $watcher (qw/AdminCc Cc Owner Requestor/) {
++        for my $user_field (@user_fields) {
++            my $field = join '.', $watcher, $user_field;
++            push @fields, $field;
++        }
++    }
++
++    while ( my $role = $CustomRoles->Next ) {
++        push @fields, 'CustomRole.{' . $role->Name . '}';
++
++        # Add all available CustomRoles to the list of sortable columns.
++        for my $user_field (@user_fields) {
++            push @fields, join '.', 'CustomRole.{' . $role->Name . '}', $user_field;
++        }
 +    }
 +
 +    my %ranges = RT::Ticket->CustomDateRanges;
 +    push @fields, sort keys %ranges;
 +
  }
  
  $m->callback( Fields => \@fields, ARGSRef => \%ARGS );
diff --cc share/html/Search/Elements/EditSort
index 27c8bcf3b5,9d9a15aaaf..c9e5911dea
--- a/share/html/Search/Elements/EditSort
+++ b/share/html/Search/Elements/EditSort
@@@ -109,34 -108,30 +109,42 @@@ for my $field (keys %FieldDescriptions
      $fields{$field} = $field;
  }
  
- if ( $Class eq 'RT::Tickets' ) {
-     $fields{'Owner'} = 'Owner';
-     $fields{ $_ . '.EmailAddress' } = $_ . '.EmailAddress' for qw(Requestor Cc AdminCc);
- }
- elsif ( $Class eq 'RT::Assets' ) {
++if ( $Class eq 'RT::Assets' ) {
 +    $fields{'Owner'} = 'Owner';
 +    $fields{'HeldBy'} = 'HeldBy';
 +    $fields{'Contact'} = 'Contact';
 +}
 +
  # Add all available CustomFields to the list of sortable columns.
 -my @cfs = grep /^CustomField/, @{$ARGS{AvailableColumns}};
 +my @cfs = grep /^CustomField(?!View)/, @{$ARGS{AvailableColumns}};
  $fields{$_} = $_ for @cfs;
  
- # Add all available CustomRoles to the list of sortable columns.
 -# Add all available core roles to the list of sortable columns.
 -my @roles = grep /^(?:Owner|Requestor|Cc|AdminCc)\./, @{$ARGS{AvailableColumns}};
 -$fields{$_} = $_ for @roles;
 +if ( $Class eq 'RT::Tickets' ) {
-     my @roles = grep /^CustomRole/, @{$ARGS{AvailableColumns}};
-     for my $role (@roles) {
-         my ($label) = $role =~ /^CustomRole.\{(.*)\}$/;
-         my $value = $role;
-         $fields{$label . '.EmailAddress' } = $value . '.EmailAddress';
+ 
 -# Add all available CustomRoles to the list of sortable columns.
 -my @custom_roles = grep /^CustomRole\./, @{$ARGS{AvailableColumns}};
 -for my $role ( @custom_roles ) {
 -    my $label = $role;
 -    # In case custom role contains "{}" in name.
 -    if ( $label =~ /\.CustomField/ ) {
 -        $label =~ s!^CustomRole\.\{(.*)\}(?=\.CustomField\.)!$1!;
 -    }
 -    else {
 -        $label =~ s!^CustomRole\.\{(.*)\}!$1!;
++    # Add all available core roles to the list of sortable columns.
++    my @roles = grep /^(?:Owner|Requestor|Cc|AdminCc)\./, @{ $ARGS{AvailableColumns} };
++    $fields{$_} = $_ for @roles;
++
++    # Add all available CustomRoles to the list of sortable columns.
++    my @custom_roles = grep /^CustomRole\./, @{ $ARGS{AvailableColumns} };
++    for my $role (@custom_roles) {
++        my $label = $role;
++
++        # In case custom role contains "{}" in name.
++        if ( $label =~ /\.CustomField/ ) {
++            $label =~ s!^CustomRole\.\{(.*)\}(?=\.CustomField\.)!$1!;
++        }
++        else {
++            $label =~ s!^CustomRole\.\{(.*)\}!$1!;
++        }
++        $fields{$label} = $role;
      }
 -    $fields{$label} = $role;
  }
  
 -# Add PAW sort
 -$fields{'Custom.Ownership'} = 'Custom.Ownership';
 +if ( $Class =~ /^RT::(?:Tickets|Assets)$/ ) {
 +    # Add PAW sort
 +    $fields{'Custom.Ownership'} = 'Custom.Ownership';
 +}
  
  $m->callback(CallbackName => 'MassageSortFields', Fields => \%fields );
  
diff --cc share/static/css/elevator-light/misc.css
index fedc52ad71,0000000000..58f82460bd
mode 100644,000000..100644
--- a/share/static/css/elevator-light/misc.css
+++ b/share/static/css/elevator-light/misc.css
@@@ -1,154 -1,0 +1,162 @@@
 +.hide, .hidden { display: none !important; }
 +
 +.clear { clear: both; }
 +
 +* html .clearfix {
 +    height: 1%; /* IE5-6 */
 +}
 +.clearfix {
 +    display: inline-block; /* IE7xhtml*/
 +}
 +html[xmlns] .clearfix { /* O */
 +    display: block;
 +}
 +.clearfix:after { /* FF, O, etc. */
 +    content: ".";
 +    display: block;
 +    height: 0;
 +    clear: both;
 +    visibility: hidden;
 +}
 +
 +hr.clear {
 +    visibility: hidden;
 +    height: 0;
 +    margin: 0;
 +    padding: 0;
 +    border: none;
 +    font-size: 1px;
 +}
 +
 +.query-stacktrace-toggle {
 +    float: right;
 +}
 +
 +/* jQuery UI overrides */
 +.ui-widget {
 +    font-family: arial,helvetica,sans-serif !important;
 +}
 +
 +textarea.messagebox, #cke_Content, #cke_UpdateContent {
 +  -moz-box-sizing: border-box;
 +  box-sizing: border-box;
 +}
 +
 +.selection-box {
 +    min-width: 300px;
 +}
 +
 +.datepicker {
 +    max-width: 17em;
 +    min-width: 10em;
 +}
 +
 +.selectowner {
 +    max-width: 15.8em;
 +    min-width: 10em;
 +}
 +
 +.dashboard-subscription tr.frequency .value input {
 +    margin-bottom: 0.75em;
 +}
 +
 +/* infinite history error message */
 +
 +.error-load-history {
 +    background-color: #b32;
 +    padding: 10px;
 +    border-radius: 5px;
 +    color: white;
 +}
 +
 +.error-load-history a {
 +    text-decoration: underline !important;
 +    color: white !important;
 +}
 +
 +/* */
 +
 +.comment {
 +    padding-left: 0.5em;
 +    color: #999;
 +}
 +
 +#comp-Ticket-ShowEmailRecord #header {
 +    top: 0em;
 +}
 +
 +#comp-Ticket-ShowEmailRecord #body {
 +    margin-left: 1em;
 +    margin-top: 1em;
 +    overflow: auto;
 +}
 +
 +.modal {
 +  background: rgb(0, 0, 0, .70); 
 +}
 +
 +/* manipulate the svg image for selected bookmarks */
 +svg.bookmark-selected path {
 +    stroke: black;
 +    stroke-width: 50;
 +    fill: #46B346;
 +}
 +
 +/* borders for cog icons */
 +svg.icon-bordered {
 +    width: 1em !important;
 +    height: 1em !important;
 +    border: solid 0.05em #eee;
 +    border-radius: 0.1em;
 +    padding: 0.2em 0.25em 0.15em;
 +}
 +
 +/* styling for helper text svg images */
 +svg.icon-helper {
 +    padding-left: 0.2em;
 +    color: #666;
 +}
 +
 +/* row colouring */
 +.oddline {
 +    background-color: rgba(242, 242, 242);
 +}
 +
 +.cke_toolgroup a.cke_button {
 +    padding-left: 3px;
 +    padding-right: 3px;
 +}
 +
 +.cke_toolbar .cke_combo_button,
 +.cke_toolbar .cke_toolgroup {
 +    margin-right: 5px;
 +}
 +
 +legend {
 +  font-size: 1rem;
 +}
 +
 +/* transaction display page */
 +h1#transaction-extra-info {
 +    font-size: 1.4rem;
 +    padding-top: 0.4rem;
 +}
 +
 +/* Prevent page links from running off the side of the page for Firefox */
 +span.pagenum {
 +    display: inline-block;
 +}
 +
 +/* Do not change background color on click of dropdown items like "Show
 + * full headers" in ticket history */
 +.btn-group.dropdown .dropdown-item:active {
 +    background-color: transparent;
 +}
++
++/* Remove the default 2 px top/bottom added by Firefox to give more room
++   for the saved search form on the query builder */
++
++#editquery select option {
++    padding-top: 0;
++    padding-bottom: 0;
++}
diff --cc t/web/query_builder.t
index 89e89f5707,94a167041a..fa4dd33866
--- a/t/web/query_builder.t
+++ b/t/web/query_builder.t
@@@ -370,6 -394,86 +394,88 @@@ diag "make sure the list of columns ava
              is ($scraped_orderby, '[none] '.$orderby);
      }
  
+     my @formats = qw(
+         id
+         QueueName
+         Subject
+         Status
+         ExtendedStatus
+         UpdateStatus
+         Type
+         OwnerName
++        OwnerNameEdit
+         Requestors
+         Cc
+         AdminCc
+         CreatedBy
+         LastUpdatedBy
+         Priority
+         InitialPriority
+         FinalPriority
+         TimeWorked
+         TimeLeft
+         TimeEstimated
+         Starts
+         StartsRelative
+         Started
+         StartedRelative
+         Created
+         CreatedRelative
+         LastUpdated
+         LastUpdatedRelative
+         Told
+         ToldRelative
+         Due
+         DueRelative
+         Resolved
+         ResolvedRelative
+         SLA
+         RefersTo
+         ReferredToBy
+         DependsOn
+         DependedOnBy
+         MemberOf
+         Members
+         Parents
+         Children
+         Bookmark
+         Timer
++        UnreadMessages
+         NEWLINE
+         NBSP
+         AdminCc.id
+         AdminCc.Name
+         AdminCc.EmailAddress
+         AdminCc.Organization
+         AdminCc.RealName
+         AdminCc.City
+         AdminCc.Country
+         Cc.id
+         Cc.Name
+         Cc.EmailAddress
+         Cc.Organization
+         Cc.RealName
+         Cc.City
+         Cc.Country
+         Owner.id
+         Owner.Name
+         Owner.EmailAddress
+         Owner.Organization
+         Owner.RealName
+         Owner.City
+         Owner.Country
+         Requestor.id
+         Requestor.Name
+         Requestor.EmailAddress
+         Requestor.Organization
+         Requestor.RealName
+         Requestor.City
+         Requestor.Country
+         );
+     my $formats = join(' ', @formats);
+     my $scraped_formats = $agent->scrape_text_by_attr('name', 'SelectDisplayColumns');
+     is( $scraped_formats, $formats, 'Default format' );
+ 
      my $cf = RT::Test->load_or_create_custom_field(
          Name  => 'Location',
          Queue => 'General',
@@@ -393,6 -515,23 +517,23 @@@
          is ($scraped_orderby, '[none] '.$orderby);
      }
  
+     # Formats are not sorted, we need to insert new items accordingly
+     my @new_formats;
+     for my $format ( @formats ) {
+         push @new_formats, $format;
+         if ( $format eq 'NBSP' ) {
 -            push @new_formats, 'CustomField.{Location}';
++            push @new_formats, 'CustomField.{Location}', 'CustomFieldView.{Location}';
+         }
+         elsif ( $format =~ /(\w+)\.Country/ ) {
+             push @new_formats, "$1.CustomField.{Employee ID}";
+         }
+     }
+     push @new_formats, 'CustomRole.{Engineer}',
+         map {"CustomRole.{Engineer}.$_"} qw/id Name EmailAddress Organization RealName City Country/, 'CustomField.{Employee ID}';
+     $formats = join(' ', @new_formats);
+     $scraped_formats = $agent->scrape_text_by_attr('name', 'SelectDisplayColumns');
+     is( $scraped_formats, $formats, 'Format with custom fields and custom roles' );
+ 
      $cf->SetDisabled(1);
  }
  

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


More information about the rt-commit mailing list