[Rt-commit] rt branch, 5.0-trunk, updated. rt-5.0.0alpha1-126-g32cbce87df

? sunnavy sunnavy at bestpractical.com
Mon Apr 27 17:18:36 EDT 2020


The branch, 5.0-trunk has been updated
       via  32cbce87df4222e82a68e4b3fcd6c36d9f066978 (commit)
       via  252a0bca0379895a1f18ba3410e2cd5df8fa9dc7 (commit)
       via  f86c871ffc59322579265e31e2a9957c1f8efc71 (commit)
       via  1bcdd32b1af1885ed9c5016a97e8ff067f99086d (commit)
       via  e542ff268e2d750e4deca622c8c4d4f4ccd039a2 (commit)
       via  48b5f464e613f720e1851d0ca8eb2d2614db48b9 (commit)
      from  243b18e82670f6e2b7499b43c1aca4890d4ce23a (commit)

Summary of changes:
 lib/RT/Interface/Web.pm                            | 127 +++++++++
 share/html/Admin/Global/MyRT.html                  | 167 ++++++++----
 share/html/Admin/Users/MyRT.html                   | 170 ++++++++----
 share/html/Dashboards/Elements/DashboardsForObject |  30 ++-
 share/html/Dashboards/Queries.html                 | 296 +++++++++------------
 .../Form/Code => Elements/ShowSelectSearch}        |  38 ++-
 share/html/Prefs/MyRT.html                         | 128 ++++++---
 .../Elements/EditLinks => Widgets/SearchSelection} | 109 ++++----
 share/static/css/elevator-light/forms.css          | 114 ++++++++
 share/static/js/forms.js                           | 161 ++++++++++-
 t/mail/dashboard-chart-with-utf8.t                 |  30 ++-
 t/mail/dashboard-empty.t                           |  33 ++-
 t/mail/dashboards.t                                |  22 +-
 t/web/custom_frontpage.t                           |  97 ++++---
 t/web/dashboards-basics.t                          |  90 ++++---
 t/web/dashboards-deleted-saved-search.t            |  47 ++--
 t/web/dashboards-search-cache.t                    |  59 ++--
 17 files changed, 1193 insertions(+), 525 deletions(-)
 copy share/html/{Widgets/Form/Code => Elements/ShowSelectSearch} (70%)
 copy share/html/{Admin/Elements/EditLinks => Widgets/SearchSelection} (53%)

- Log -----------------------------------------------------------------
commit 48b5f464e613f720e1851d0ca8eb2d2614db48b9
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Wed Jan 22 11:12:07 2020 -0600

    Add RT-Extension-DashboardSelectionUI to core

diff --git a/share/html/Admin/Global/MyRT.html b/share/html/Admin/Global/MyRT.html
index f582af6e56..2ec3a597cd 100644
--- a/share/html/Admin/Global/MyRT.html
+++ b/share/html/Admin/Global/MyRT.html
@@ -45,66 +45,124 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<& /Admin/Elements/Header, Title => loc("RT at a glance") &>
+<& /Elements/Header, Title => $title &>
 <& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
 
-<& /Elements/ListActions, actions => \@actions &>
-<br />
-% for my $pane (@panes) {
-<&|/Widgets/TitleBox, title => loc('RT at a glance').': '.loc($pane->{Name}), bodyclass => "" &>
-<& /Widgets/SelectionBox:show, self => $pane &></&>
-<br />
-% }
-<%init>
-my @actions;
-
-my @items = map { [ "component-$_", loc($_) ] } sort @{ RT->Config->Get('HomepageComponents') };
-my $sys = RT::System->new( $session{'CurrentUser'} );
-# XXX: put this in savedsearches_to_portlet_items
-for ( $m->comp( "/Search/Elements/SearchesForObject",
-                Object => $sys )) {
-    my ( $desc, $loc_desc, $search ) = @$_;
-    my $SearchType = $search->Content->{'SearchType'} || 'Ticket';
-    if ( $SearchType eq 'Ticket' ) {
-        push @items, [ "system-$desc", $loc_desc ];
-    } else {
-        my $oid = ref($sys) . '-' . $sys->Id . '-SavedSearch-' . $search->Id;
-        my $type = ( $SearchType eq 'Ticket' )
-          ? 'Saved Search'    # loc
-          : $SearchType;
-        push @items, [ "saved-$oid", loc($type) . ": $loc_desc" ];
+<form method="post" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT' data-is_global=True>
+  <& /Widgets/SearchSelection,
+    pane_name => \%pane_name,
+    sections  => \@sections,
+    selected  => \%selected,
+    filters   => \@filters,
+  &>
+  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+</form>
+
+<%INIT>
+my @results;
+my $title = loc("Customize").' '.loc("Global RT at a glance");
+my $user = $session{'CurrentUser'}->UserObj;
+
+my $portlets;
+my ($defaults) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
+$portlets = $defaults ? $defaults->Content : {};
+
+my @sections;
+my %item_for;
+
+my @components = map { type => "component", name => $_, label => loc($_) }, @{RT->Config->Get('HomepageComponents')};
+
+$item_for{ $_->{type} }{ $_->{name} } = $_ for @components;
+
+push @sections, {
+    id    => 'components',
+    label => loc("Components"),
+    items => \@components,
+};
+
+my $sys = RT::System->new($session{'CurrentUser'});
+my @objs = ($sys);
+
+push @objs, RT::SavedSearch->new( $session{CurrentUser} )->ObjectsForLoading
+    if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
+                                          Object => $RT::System );
+
+for my $object (@objs) {
+    my @items;
+    my $object_id = ref($object) . '-' . $object->Id;
+    $object_id = 'system' if $object eq $sys;
+
+    for ($m->comp("/Search/Elements/SearchesForObject", Object => $object)) {
+        my ($desc, $loc_desc, $search) = @$_;
+
+        my $SearchType = 'Ticket';
+        if ((ref($search->Content)||'') eq 'HASH') {
+            $SearchType = $search->Content->{'SearchType'}
+                if $search->Content->{'SearchType'};
+        }
+        else {
+            $RT::Logger->debug("Search ".$search->id." ($desc) appears to have no Content");
+        }
+
+        my $item;
+        if ($object eq $sys && $SearchType eq 'Ticket') {
+            $item = { type => 'system', name => $desc, label => $loc_desc };
+        }
+        else {
+            my $oid = $object_id.'-SavedSearch-'.$search->Id;
+            $item = { type => 'saved', name => $oid, search_type => $SearchType, label => $loc_desc };
+        }
+
+        $item_for{ $item->{type} }{ $item->{name} } = $item;
+        push @items, $item;
     }
+
+    my $label = $object eq $sys           ? loc('System')
+              : $object->isa('RT::Group') ? $object->Label
+                                          : $object->Name;
+
+    push @sections, {
+        id    => $object_id,
+        label => $label,
+        items => [ sort { lc($a->{label}) cmp lc($b->{label}) } @items ],
+    };
 }
 
-my ($default_portlets) = $sys->Attributes->Named('HomepageSettings');
-
-my $has_right = $session{'CurrentUser'}->HasRight( Object=> $RT::System, Right => 'SuperUser');
-
-my @panes = $m->comp(
-    '/Admin/Elements/ConfigureMyRT',
-    panes  => [
-        'body', #loc
-        'sidebar', #loc
-    ],
-    Action => 'MyRT.html',
-    items => \@items,
-    ReadOnly => !$has_right,
-    current_portlets => $default_portlets->Content,
-    OnSave => sub {
-        my ( $conf, $pane ) = @_;
-        if (!$has_right) {
-            push @actions, loc( 'Permission Denied' );
+my %selected;
+for my $pane (keys %$portlets) {
+    my @items;
+
+    for my $saved (@{ $portlets->{$pane} }) {
+        my $item = $item_for{ $saved->{type} }{ $saved->{name} };
+        if ($item) {
+            push @items, $item;
         }
         else {
-            $default_portlets->SetContent( $conf );
-            push @actions, loc( 'Global portlet [_1] saved.', $pane );
+            push @results, loc('Unable to find [_1] [_2]', $saved->{type}, $saved->{name});
         }
     }
-);
 
-$m->comp( '/Widgets/SelectionBox:process', %ARGS, self => $_ )
-    for @panes;
+    $selected{$pane} = \@items;
+}
 
+my %pane_name = (
+    'body'    => loc('Body'),
+    'sidebar' => loc('Sidebar'),
+);
 
-</%init>
+my @filters = (
+    [ 'component' => loc('Components') ],
+    [ 'ticket'    => loc('Tickets') ],
+    [ 'chart'     => loc('Charts') ],
+);
+
+$m->callback(
+    CallbackName => 'Default',
+    pane_name    => \%pane_name,
+    sections     => \@sections,
+    selected     => \%selected,
+    filters      => \@filters,
+);
 
+</%INIT>
diff --git a/share/html/Admin/Users/MyRT.html b/share/html/Admin/Users/MyRT.html
index 99d069cecf..fefda20b23 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -47,12 +47,17 @@
 %# END BPS TAGGED BLOCK }}}
 <& /Admin/Elements/Header, Title => $title  &>
 <& /Elements/Tabs &>
-
 <& /Elements/ListActions, actions => \@actions &>
 
-<form method="post" action="MyRT.html">
-<input type="hidden" name="id" value="<% $id %>" />
-<input type="hidden" name="Reset" value="1" />
+<form method="post" action="<% RT->Config->Get('WebPath')  %>/Helpers/UpdateDashboard" data-dashboard_id = "MyRT" data-user_id = '<% $id %>'>
+  <& /Widgets/SearchSelection,
+                    pane_name => \%pane_name,
+                    sections  => \@sections,
+                    selected  => \%selected,
+                    filters   => \@filters,
+    &>
+<input type="hidden" name="id" value="<% $id %>"/>
+<input type"submit" class="button" name="UpdateSearches" value= "<% loc('Save') %>"  >
 <div class="form-row">
   <div class="col-md-12">
     <input type="submit" class="button form-control btn btn-primary" value="<%loc('Reset to default')%>">
@@ -60,72 +65,110 @@
 </div>
 </form>
 
-<br />
-
-% for my $pane (@panes) {
-<&|/Widgets/TitleBox, title => loc('RT at a glance').': '.loc($pane->{Name}), bodyclass => "" &>
-<& /Widgets/SelectionBox:show, self => $pane &></&>
-<br />
-% }
-
 <%init>
 my @actions;
 my $UserObj = RT::User->new($session{'CurrentUser'});
 $UserObj->Load($id) || Abort("Couldn't load user '" . ($id || '') . "'");
+my $user = RT::User->new($session{'CurrentUser'});
 my $title = loc("RT at a glance for the user [_1]", $UserObj->Name);
 
-if ($ARGS{Reset}) {
-    my ($ok, $msg) = $UserObj->SetPreferences('HomepageSettings', {});
-    push @actions, $ok ? loc('Preferences saved for user [_1].', $UserObj->Name) : $msg;
+my $portlets = $UserObj->Preferences('HomepageSettings');
+unless ($portlets) {
+   my ($defaults) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
+   $portlets = $defaults ? $defaults->Content : {};
 }
 
-my ($default_portlets) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
-my $portlets  = $UserObj->Preferences('HomepageSettings', $default_portlets ? $default_portlets->Content  : {});
-
-my %allowed_components = map {$_ => 1} @{ RT->Config->Get('HomepageComponents') };
+my @sections;
+my %item_for;
 
-my @items;
-push @items, map {["component-$_", loc($_)]} sort keys %allowed_components;
+my @components = map { type => "component", name => $_, label => loc($_) }, @{RT->Config->Get('HomepageComponents')};
+$item_for{ $_->{type} }{ $_->{name} } = $_ for @components;
 
-my $sys = RT::System->new( RT::CurrentUser->new($UserObj) );
+push @sections, {
+      id    => 'components',
+      label => loc("Components"),
+      items => \@components,
+    };
+my $sys = RT::System->new($session{'CurrentUser'});
+$sys->Load($id) || Abort("Couldn't load user '" . ($id || '') . "'");
 my @objs = ($sys);
+push @objs, RT::SavedSearch->new ($session{'CurrentUser'})->ObjectsForLoading
+    if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
+                                          Object => $RT::System );
+for my $object (@objs) {
+     my @items;
+     my $object_id = ref($object) . '-' . $object->Id;
+     $object_id = 'system' if $object eq $sys;
 
-push @objs, RT::SavedSearch->new( RT::CurrentUser->new( $UserObj ) )->ObjectsForLoading;
+     for ($m->comp("/Search/Elements/SearchesForObject", Object => $object)) {
+         my ($desc, $loc_desc, $search) = @$_;
 
-for my $object (@objs) {
-    for ($m->comp("/Search/Elements/SearchesForObject", Object => $object)) {
-        my ($desc, $loc_desc, $search) = @$_;
-        my $SearchType = $search->Content->{'SearchType'} || 'Ticket';
-        if ($object eq $sys && $SearchType eq 'Ticket') {
-            push @items, ["system-$desc", $loc_desc];
+         my $SearchType = 'Ticket';
+         if ((ref($search->Content)||'') eq 'HASH') {
+             $SearchType = $search->Content->{'SearchType'}
+                 if $search->Content->{'SearchType'};
+         }
+         else {
+           $RT::Logger->debug("Search ".$search->id." ($desc) appears to have  no Content");
+           }
+
+         my $item;
+         if ($object eq $sys && $SearchType eq 'Ticket') {
+             $item = { type => 'system', name => $desc, label => $loc_desc };
+         }
+         else {
+             my $oid = $object_id.'-SavedSearch-'.$search->Id;
+             $item = { type => 'saved', name => $oid, search_type => $SearchType, label => $loc_desc };
+           }
+           $item_for{ $item->{type} }{ $item->{name} } = $item;
+           push @items, $item;
+     }
+
+     my $label = $object eq $sys           ? loc('System')
+               : $object->isa('RT::Group') ? $object->Label
+                                           : $object->Name;
+     push @sections, {
+         id    => $object_id,
+         label => $label,
+         items => [ sort { lc($a->{label}) cmp lc($b->{label}) } @items ],
+     };
+ }
+
+my %selected;
+for my $pane (keys %$portlets) {
+    my @items;
+
+    for my $saved (@{ $portlets->{$pane} }) {
+        my $item = $item_for{ $saved->{type} }{ $saved->{name} };
+        if ($item) {
+            push @items, $item;
         }
         else {
-            my $oid = ref($object).'-'.$object->Id.'-SavedSearch-'.$search->Id;
-            my $type = ($SearchType eq 'Ticket')
-                ? 'Saved Search' # loc
-                : $SearchType;
-            push @items, ["saved-$oid", loc($type).": $loc_desc"];
+            push @actions, loc('Unable to find [_1] [_2]', $saved->{type}, $saved->{name});
         }
     }
-}
-
-my @panes = $m->comp(
-    '/Admin/Elements/ConfigureMyRT',
-    panes  => ['body', 'sidebar'],
-    Action => "MyRT.html?id=$id",
-    items => \@items,
-    current_portlets => $portlets,
-    OnSave => sub {
-        my ( $conf, $pane ) = @_;
-        my ($ok, $msg) = $UserObj->SetPreferences( 'HomepageSettings', $conf );
-        push @actions, $ok ? loc('Preferences [_1] for user [_2].', $pane, $UserObj->Name) : $msg;
+    $selected{$pane} = \@items;
     }
+
+my %pane_name = (
+  'body'    => loc('Body'),
+  'sidebar' => loc('Sidebar'),
 );
 
-$m->comp( '/Widgets/SelectionBox:process', %ARGS, self => $_ )
-    for @panes;
+my @filters = (
+  [ 'component' => loc('Components') ],
+  [ 'ticket'    => loc('Tickets') ],
+  [ 'chart'     => loc('Charts') ],
+);
+$m->callback(
+    CallbackName => 'Default',
+    pane_name    => \%pane_name,
+    sections     => \@sections,
+    selected     => \%selected,
+    filters      => \@filters,
+);
 
-</%init>
+</%INIT>
 <%ARGS>
-$id => undef
+  $id => undef
 </%ARGS>
diff --git a/share/html/Dashboards/Elements/DashboardsForObject b/share/html/Dashboards/Elements/DashboardsForObject
index ddc5649ed6..a4e855a805 100644
--- a/share/html/Dashboards/Elements/DashboardsForObject
+++ b/share/html/Dashboards/Elements/DashboardsForObject
@@ -47,12 +47,16 @@
 %# END BPS TAGGED BLOCK }}}
 <%args>
 $Object => undef
+$User => $session{'CurrentUser'}
+$Flat => 0
 </%args>
 <%init>
 # Returns a hash of dashboards associated on $Object
+# or, if $Flat, a simple list
 
 use RT::Dashboard;
 my %dashboards;
+my @flat_dashboards;
 my $privacy = RT::Dashboard->_build_privacy($Object);
 
 while (my $attr = $Object->Attributes->Next) {
@@ -65,21 +69,23 @@ while (my $attr = $Object->Attributes->Next) {
             next;
         }
 
-        if ($Object->isa('RT::System')) {
-            push @{ $dashboards{system} }, $dashboard;
-        }
-        elsif ($Object->isa('RT::User')) {
-            push @{ $dashboards{personal} }, $dashboard;
+        if ($Flat) {
+            push @flat_dashboards, $dashboard;
         }
-        elsif ($Object->isa('RT::Group')) {
-            push @{ $dashboards{group}{$Object->Name} }, $dashboard;
+        else {
+            if ($Object->isa('RT::System')) {
+                push @{ $dashboards{system} }, $dashboard;
+            }
+            elsif ($Object->isa('RT::User')) {
+                push @{ $dashboards{personal} }, $dashboard;
+            }
+            elsif ($Object->isa('RT::Group')) {
+                push @{ $dashboards{group}{$Object->Name} }, $dashboard;
+            }
         }
     }
 }
-return \%dashboards;
-</%init>
 
-<%args>
-$User => $session{'CurrentUser'}
-</%args>
+return $Flat ? @flat_dashboards : \%dashboards;
+</%init>
 
diff --git a/share/html/Dashboards/Queries.html b/share/html/Dashboards/Queries.html
index 785ea588d3..dfbb8522c7 100644
--- a/share/html/Dashboards/Queries.html
+++ b/share/html/Dashboards/Queries.html
@@ -47,28 +47,18 @@
 %# END BPS TAGGED BLOCK }}}
 <& /Elements/Header, Title => $title &>
 <& /Elements/Tabs &>
-
 <& /Elements/ListActions, actions => \@results &>
 
-<& Elements/Deleted, searches => \@deleted, Dashboard => $Dashboard &>
-
-<& Elements/HiddenSearches, searches => \@hidden_searches, Dashboard => $Dashboard &>
-
-% for my $pane (@panes) {
-<form action="Queries.html" name="Dashboard-<%$pane->{Name}%>" method="post" enctype="multipart/form-data">
-<input type="hidden" class="hidden" name="id" value="<%$Dashboard->Id%>" />
-<input type="hidden" class="hidden" name="Privacy" value="<%$Dashboard->Privacy%>" />
-
-<&| /Widgets/TitleBox, title => $pane->{DisplayName} &>
-% my ( $pane_name ) = $pane->{Name} =~ /Searches-(.+)/;
-    <& /Widgets/SelectionBox:show, self => $pane, grep( {
-            $_->{pane} eq $pane_name} @deleted ) ? ( ShowUpdate => 1 ) : () &>
-</&>
+<form method="post" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='<%$Dashboard->Id%>'>
+  <& /Widgets/SearchSelection,
+    pane_name => \%pane_name,
+    sections  => \@sections,
+    selected  => \%selected,
+    filters   => \@filters,
+  &>
+  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
 </form>
-% }
-
 <%INIT>
-
 my @results;
 
 use RT::Dashboard;
@@ -80,197 +70,148 @@ unless ($ok) {
 }
 my $title = loc("Modify the content of dashboard [_1]", $Dashboard->Name);
 
-my %desc_of;
-my @items;
-my %selected;
-my %still_exists;
-
-# add portlets (homepage componenets)
-my @components = @{ RT->Config->Get('HomepageComponents') };
+my @sections;
+my %item_for;
 
-for my $desc (@components) {
-    my $name = "component-$desc";
-    push @items, [$name, loc($desc)];
-    $desc_of{$name} = loc($desc);
-    $still_exists{$name} = 1;
-}
+my @components = map { type => "component", name => $_, label => loc($_) }, @{RT->Config->Get('HomepageComponents')};
 
-# add dashboards
-my @dashboards = $m->comp("/Dashboards/Elements/ListOfDashboards");
-for my $dashboard (@dashboards) {
-    # Users *can* set up mutually recursive dashboards, but don't make it THIS
-    # easy for them to shoot themselves in the foot.
-    next if $dashboard->Id == $Dashboard->Id;
+$item_for{ $_->{type} }{ $_->{name} } = $_ for @components;
 
-    my $name = 'dashboard-' . $dashboard->Id . '-' . $dashboard->Privacy;
-    my $type = loc('Dashboard'); # loc
-    my $desc = "$type: " . $dashboard->Name;
-    push @items, [$name, $desc];
-    $desc_of{$name} = $desc;
-    $still_exists{$name} = 1;
-}
+push @sections, {
+    id    => 'components',
+    label => loc("Components"),
+    items => \@components,
+};
 
-# add saved searches
-my @objs = RT::System->new($session{'CurrentUser'});
+my $sys = RT::System->new($session{'CurrentUser'});
+my @objs = ($sys);
 
 push @objs, RT::SavedSearch->new( $session{CurrentUser} )->ObjectsForLoading
     if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
                                           Object => $RT::System );
 
 for my $object (@objs) {
+    my @items;
+    my $object_id = ref($object) . '-' . $object->Id;
+    $object_id = 'system' if $object eq $sys;
+
+    # saved searches and charts
     for ($m->comp("/Search/Elements/SearchesForObject", Object => $object)) {
         my ($desc, $loc_desc, $search) = @$_;
-        my $SearchType = $search->Content->{'SearchType'} || 'Ticket';
-        my $type = ( $SearchType eq 'Ticket' )
-          ? 'Saved Search'    # loc
-          : $SearchType;
-        $desc = loc($type) . ": $loc_desc";
-        my $privacy = $Dashboard->_build_privacy($object);
-        my $name = 'search-' . $search->Id . '-' . $privacy;
-        push @items, [$name, $desc];
-        $desc_of{$name} = $desc;
-        $still_exists{$name} = 1;
-    }
-}
 
-# Get the list of portlets already in use
-my @deleted;
-do {
-    my $panes = $Dashboard->Panes;
-    for my $pane (keys %$panes) {
-        for my $portlet (@{ $panes->{$pane} }) {
-            my $name;
-            my $type = $portlet->{portlet_type};
+        my $SearchType = 'Ticket';
+        if ((ref($search->Content)||'') eq 'HASH') {
+            $SearchType = $search->Content->{'SearchType'}
+                if $search->Content->{'SearchType'};
+        }
+        else {
+            $RT::Logger->debug("Search ".$search->id." ($desc) appears to have no Content");
+        }
 
-            if ($type eq 'search' || $type eq 'dashboard') {
-                $name = join '-', $type, $portlet->{id}, $portlet->{privacy};
-            }
-            elsif ($type eq 'component') {
-                $name = join '-', 'component', $portlet->{component};
-            }
+        my $item;
+        if ($object eq $sys && $SearchType eq 'Ticket') {
+            $item = { type => 'system', name => $desc, label => $loc_desc, search_id => $search->id };
 
-            if (!$still_exists{$name}) {
-                push @deleted, {
-                    pane => $pane,
-                    name => $name,
-                    description => $portlet->{description},
-                };
-                next;
-            }
+            # make system searches more easily accessible since dashboards
+            # historically used a human-readable description rather than
+            # something we can comfortably use as a hash key
+            $item_for{ $item->{type} }{ $search->id } = $item;
+        }
+        else {
+            my $oid = $object_id.'-SavedSearch-'.$search->Id;
+            $item = { type => 'saved', name => $oid, search_type => $SearchType, label => $loc_desc };
+
+            my $setting = RT::SavedSearch->new($session{CurrentUser});
+            $setting->Load($object_id, $search->Id);
 
-            push @{ $selected{$pane} }, $name;
-            $desc_of{$name} = $portlet->{description};
+            $item->{possibly_hidden} = !$setting->IsVisibleTo($Dashboard->Privacy);
         }
+
+        $item_for{ $item->{type} }{ $item->{name} } = $item;
+        push @items, $item;
     }
-};
 
-$m->callback(
-    CallbackName => 'PopulatePossibilities',
-    Dashboard    => $Dashboard,
-    items        => \@items,
-    desc_of      => \%desc_of,
-    still_exists => \%still_exists,
-    selected     => \%selected,
-);
+    for my $dashboard ($m->comp("/Dashboards/Elements/DashboardsForObject", Object => $object, Flat => 1)) {
+        # Users *can* set up mutually recursive dashboards, but don't make it
+        # THIS easy for them to shoot themselves in the foot.
+        next if $dashboard->Id == $Dashboard->id;
 
-# Create selectionbox widgets for those portlets
+        my $name = 'dashboard-' . $dashboard->Id . '-' . $dashboard->Privacy;
 
-my %pane_name = (
-    'body'    => loc('Body'),
-    'sidebar' => loc('Sidebar'),
-);
+        my $item = { type => 'dashboard', name => $name, label => $dashboard->Name };
+        $item->{possibly_hidden} = !$dashboard->IsVisibleTo($Dashboard->Privacy);
 
-$m->callback(
-    CallbackName => 'Panes',
-    Dashboard    => $Dashboard,
-    panes        => \%pane_name,
-);
+        $item_for{ $item->{type} }{ $item->{name} } = $item;
+        push @items, $item;
+    }
 
-my @panes;
-for my $pane (sort keys %pane_name) {
-    my $sel = $m->comp(
-        '/Widgets/SelectionBox:new',
-        Action      => 'Queries.html',
-        Name        => "Searches-$pane",
-        DisplayName => $pane_name{$pane},
-        Available   => \@items,
-        Selected    => $selected{$pane},
-        AutoSave    => 1,
-        OnSubmit    => sub {
-            my $self = shift;
+    my $label = $object eq $sys           ? loc('System')
+              : $object->isa('RT::Group') ? $object->Label
+                                          : $object->Name;
 
-            $m->callback(
-                CallbackName => 'Submit',
-                Dashboard    => $Dashboard,
-                Selected     => $self->{Current},
-                pane         => $pane,
-            );
+    push @sections, {
+        id    => $object_id,
+        label => $label,
+        items => [ sort { lc($a->{label}) cmp lc($b->{label}) } @items ],
+    };
+}
 
-            my @portlets;
-            for (@{ $self->{Current} }) {
-                my $item = $_;
-                my $desc = $desc_of{$item};
-                my $portlet_type = $1 if $item =~ s/^(\w+)-//;
+my %pane_name = (
+    'body'    => loc('Body'),
+    'sidebar' => loc('Sidebar'),
+);
 
-                if ($portlet_type eq 'search' || $portlet_type eq 'dashboard') {
-                    my ($id, $privacy) = split '-', $item, 2;
-                    push @portlets, {
-                        portlet_type => $portlet_type,
-                        privacy      => $privacy,
-                        id           => $id,
-                        description  => $desc,
-                        pane         => $pane,
-                    };
+my %selected;
+do {
+    my $panes = $Dashboard->Panes;
+    for my $pane (keys %$panes) {
+        my @items;
+        for my $saved (@{ $panes->{$pane} }) {
+            my $item;
+            if ($saved->{portlet_type} eq 'component') {
+                $item = $item_for{ $saved->{portlet_type} }{ $saved->{component} };
+            }
+            elsif ($saved->{portlet_type} eq 'search') {
+                if ($saved->{privacy} =~ /^RT::System-/) {
+                    $item = $item_for{system}{$saved->{id}};
                 }
-                elsif ($portlet_type eq 'component') {
-                    # Absolute paths stay absolute, relative paths go into
-                    # /Elements. This way, extensions that add portlets work.
-                    my $path = $item;
-                    $path = "/Elements/$path" if substr($path, 0, 1) ne '/';
-
-                    push @portlets, {
-                        portlet_type => $portlet_type,
-                        component    => $item,
-                        path         => $path,
-                        description  => $item,
-                        pane         => $pane,
-                    };
+                else {
+                    my $name = join '-', $saved->{privacy}, 'SavedSearch', $saved->{id};
+                    $item = $item_for{saved}{$name};
                 }
             }
+            else {
+                my $type = $saved->{portlet_type};
+                my $name  = join '-', $type, $saved->{id}, $saved->{privacy};
+                $item = $item_for{$type}{$name};
+            }
 
-            # we want to keep all the other panes the same
-            my $panes = $Dashboard->Panes;
-            $panes->{$pane} = \@portlets;
-
-            # remove "deleted" warnings about this pane
-            @deleted = grep { $_->{pane} ne $pane } @deleted;
-
-            $m->callback(
-                CallbackName => 'BeforeUpdate',
-                Dashboard    => $Dashboard,
-                panes        => $panes,
-            );
-
-            my ($ok, $msg) = $Dashboard->Update(Panes => $panes);
-
-            if ($ok) {
-                push @results, loc("Dashboard updated");
+            if ($item) {
+                push @items, $item;
             }
             else {
-                push @results, loc("Dashboard could not be updated: [_1]", $msg);
+                push @results, loc('Unable to find [_1] [_2]', $saved->{portlet_type}, $saved->{description});
             }
-        },
-    );
-
-    push @panes, $sel;
-}
+        }
+        $selected{$pane} = \@items;
+    }
+};
 
-$m->comp('/Widgets/SelectionBox:process', %ARGS, self => $_ )
-    for @panes;
+my @filters = (
+    [ 'component' => loc('Components') ],
+    [ 'dashboard' => loc('Dashboards') ],
+    [ 'ticket'    => loc('Tickets') ],
+    [ 'chart'     => loc('Charts') ],
+);
 
-my @hidden_searches = $Dashboard->PossibleHiddenSearches;
+$m->callback(
+    CallbackName => 'Default',
+    pane_name    => \%pane_name,
+    sections     => \@sections,
+    selected     => \%selected,
+    filters      => \@filters,
+);
 </%INIT>
 <%ARGS>
 $id => '' unless defined $id
 </%ARGS>
-
diff --git a/share/html/Dashboards/Elements/DashboardsForObject b/share/html/Elements/ShowSelectSearch
similarity index 65%
copy from share/html/Dashboards/Elements/DashboardsForObject
copy to share/html/Elements/ShowSelectSearch
index ddc5649ed6..6b2ea4da98 100644
--- a/share/html/Dashboards/Elements/DashboardsForObject
+++ b/share/html/Elements/ShowSelectSearch
@@ -2,7 +2,7 @@
 %#
 %# COPYRIGHT:
 %#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
 %#                                          <sales at bestpractical.com>
 %#
 %# (Except where explicitly superseded by other copyright notices)
@@ -45,41 +45,27 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%args>
-$Object => undef
-</%args>
-<%init>
-# Returns a hash of dashboards associated on $Object
+<%ARGS>
+$type
+$name
+$label
+$search_id => undef
+$search_type => undef
+$possibly_hidden => 0
+</%ARGS>
+<li data-type="<% $type %>" data-name="<% $name %>" data-search-type="<% $search_type || ''  %>" data-search-id="<% $search_id || '' %>" data-description="<% $label %>"><a href="#" class="remove"><img src="<% RT->Config->Get('WebPath') %>/static/images/close.png" alt="<% loc('Remove') %>" title="<% loc('Remove') %>" /></a> <% $prefix ? $prefix . ': ' : '' %>
+<% $label %>
+% if ($possibly_hidden) {
+<span class="warning"><&|/l&>Warning: may not be visible to all viewers</&></span>
+% }
+</li>
+<%INIT>
+my $prefix;
 
-use RT::Dashboard;
-my %dashboards;
-my $privacy = RT::Dashboard->_build_privacy($Object);
-
-while (my $attr = $Object->Attributes->Next) {
-    if ($attr->Name =~ /^Dashboard\b/) {
-        my $dashboard = RT::Dashboard->new($User);
-        my ($ok, $msg) = $dashboard->Load($privacy, $attr->id);
-
-        if (!$ok) {
-            $RT::Logger->debug("Unable to load dashboard $ok (privacy $privacy): $msg");
-            next;
-        }
-
-        if ($Object->isa('RT::System')) {
-            push @{ $dashboards{system} }, $dashboard;
-        }
-        elsif ($Object->isa('RT::User')) {
-            push @{ $dashboards{personal} }, $dashboard;
-        }
-        elsif ($Object->isa('RT::Group')) {
-            push @{ $dashboards{group}{$Object->Name} }, $dashboard;
-        }
-    }
+if ($type eq 'saved' || $type eq 'search') {
+    $prefix = loc(ucfirst($search_type || 'ticket'));
 }
-return \%dashboards;
-</%init>
-
-<%args>
-$User => $session{'CurrentUser'}
-</%args>
-
+elsif ($type eq 'dashboard') {
+    $prefix = loc('Dashboard');
+}
+</%INIT>
diff --git a/share/html/Helpers/UpdateDashboard b/share/html/Helpers/UpdateDashboard
new file mode 100644
index 0000000000..fb686ac61a
--- /dev/null
+++ b/share/html/Helpers/UpdateDashboard
@@ -0,0 +1,137 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 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>
+$content
+</%ARGS>
+<%INIT>
+my $args = JSON::from_json($content);
+my $id = $args->{dashboard_id};
+my $user_id = $args->{user_id};
+my $is_global = $args->{is_global};
+
+my ($ok, $msg);
+if ($id eq 'MyRT') {
+        my $user = $session{CurrentUser};
+        if($user_id){
+            $user->Load($user_id);
+            ($ok, $msg) = $user->SetPreferences('HomepageSettings', $args->{panes});
+      }
+      elsif($is_global){
+        my $sys = RT::System->new($session{'CurrentUser'});
+        my ($default_portlets) = $sys->Attributes->Named('HomepageSettings');
+        ($ok, $msg) = $default_portlets->SetContent( $args->{panes} );
+      }
+      else{
+          ($ok, $msg) = $user->SetPreferences('HomepageSettings', $args->{panes});
+        }
+}
+else {
+    use RT::Dashboard;
+    my $Dashboard = RT::Dashboard->new($session{'CurrentUser'});
+    ($ok, $msg) = $Dashboard->LoadById($id);
+
+    # report error at the bottom
+    goto DONE unless $ok && $Dashboard->Id;
+
+    my $content;
+    for my $pane_name (keys %{ $args->{panes} }) {
+        my @pane;
+
+        for my $item (@{ $args->{panes}{$pane_name} }) {
+            my %saved;
+            $saved{pane} = $pane_name;
+            $saved{portlet_type} = $item->{type};
+            $saved{description} = $item->{description};
+
+            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 'system' || $item->{type} eq 'saved') {
+                $saved{portlet_type} = 'search';
+                my $type = $item->{searchType};
+                $type = 'Saved Search' if !$type || $type eq 'Ticket';
+                $saved{description} = loc($type) . ': ' . $saved{description};
+
+                if ($item->{type} eq 'system') {
+                    $saved{privacy} = 'RT::System-1';
+                    $saved{id} = $item->{searchId};
+                }
+                else {
+                    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;
+    }
+
+    ($ok, $msg) = $Dashboard->Update(Panes => $content);
+}
+
+DONE:
+$r->content_type('application/json; charset=utf-8');
+$m->print(JSON({ ok => $ok, msg => $msg}));
+$m->abort;
+</%INIT>
diff --git a/share/html/Prefs/MyRT.html b/share/html/Prefs/MyRT.html
index 1bc841f80b..15c6bccb45 100644
--- a/share/html/Prefs/MyRT.html
+++ b/share/html/Prefs/MyRT.html
@@ -46,20 +46,18 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <& /Elements/Header, Title => $title &>
-<& /Elements/Tabs
-&>
+<& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<br />
-
-% for my $pane ( @panes ) {
-<&|/Widgets/TitleBox,
-    title => loc('RT at a glance') .': '. loc( $pane->{Name} ),
-    bodyclass => ""
-&>
-<& /Widgets/SelectionBox:show, self => $pane &>
-</&>
-% }
+<form method="post" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT'>
+  <& /Widgets/SearchSelection,
+    pane_name => \%pane_name,
+    sections  => \@sections,
+    selected  => \%selected,
+    filters   => \@filters,
+  &>
+  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+</form>
 
 <&|/Widgets/TitleBox, title => loc('Options'), bodyclass => "" &>
 <form method="post" action="MyRT.html">
@@ -116,8 +114,18 @@ unless ($portlets) {
     $portlets = $defaults ? $defaults->Content : {};
 }
 
-my %seen;
-my @items = map ["component-$_", loc($_)], grep !$seen{$_}++, @{RT->Config->Get('HomepageComponents')};
+my @sections;
+my %item_for;
+
+my @components = map { type => "component", name => $_, label => loc($_) }, @{RT->Config->Get('HomepageComponents')};
+
+$item_for{ $_->{type} }{ $_->{name} } = $_ for @components;
+
+push @sections, {
+    id    => 'components',
+    label => loc("Components"),
+    items => \@components,
+};
 
 my $sys = RT::System->new($session{'CurrentUser'});
 my @objs = ($sys);
@@ -126,8 +134,11 @@ push @objs, RT::SavedSearch->new( $session{CurrentUser} )->ObjectsForLoading
     if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
                                           Object => $RT::System );
 
-my @sys_searches;
 for my $object (@objs) {
+    my @items;
+    my $object_id = ref($object) . '-' . $object->Id;
+    $object_id = 'system' if $object eq $sys;
+
     for ($m->comp("/Search/Elements/SearchesForObject", Object => $object)) {
         my ($desc, $loc_desc, $search) = @$_;
 
@@ -140,34 +151,64 @@ for my $object (@objs) {
             $RT::Logger->debug("Search ".$search->id." ($desc) appears to have no Content");
         }
 
+        my $item;
         if ($object eq $sys && $SearchType eq 'Ticket') {
-            push @items, ["system-$desc", $loc_desc];
-            push @sys_searches, [$desc, $search];
+            $item = { type => 'system', name => $desc, label => $loc_desc };
         }
         else {
-            my $oid = ref($object).'-'.$object->Id.'-SavedSearch-'.$search->Id;
-            my $type = ($SearchType eq 'Ticket')
-                ? 'Saved Search' # loc
-                : $SearchType;
-            push @items, ["saved-$oid", loc($type).": $loc_desc"];
+            my $oid = $object_id.'-SavedSearch-'.$search->Id;
+            $item = { type => 'saved', name => $oid, search_type => $SearchType, label => $loc_desc };
         }
+
+        $item_for{ $item->{type} }{ $item->{name} } = $item;
+        push @items, $item;
     }
+
+    my $label = $object eq $sys           ? loc('System')
+              : $object->isa('RT::Group') ? $object->Label
+                                          : $object->Name;
+
+    push @sections, {
+        id    => $object_id,
+        label => $label,
+        items => [ sort { lc($a->{label}) cmp lc($b->{label}) } @items ],
+    };
 }
 
-my @panes = $m->comp(
-    '/Admin/Elements/ConfigureMyRT',
-    panes  => ['body', 'sidebar'],
-    Action => 'MyRT.html',
-    items => \@items,
-    current_portlets => $portlets,
-    OnSave => sub {
-        my ( $conf, $pane ) = @_;
-        my ($ok, $msg) = $user->SetPreferences( 'HomepageSettings', $conf );
-        push @results, $ok ? loc('Preferences saved for [_1].', $pane) : $msg;
+my %selected;
+for my $pane (keys %$portlets) {
+    my @items;
+
+    for my $saved (@{ $portlets->{$pane} }) {
+        my $item = $item_for{ $saved->{type} }{ $saved->{name} };
+        if ($item) {
+            push @items, $item;
+        }
+        else {
+            push @results, loc('Unable to find [_1] [_2]', $saved->{type}, $saved->{name});
+        }
     }
+
+    $selected{$pane} = \@items;
+}
+
+my %pane_name = (
+    'body'    => loc('Body'),
+    'sidebar' => loc('Sidebar'),
 );
 
-$m->comp( '/Widgets/SelectionBox:process', %ARGS, self => $_ )
-    for @panes;
+my @filters = (
+    [ 'component' => loc('Components') ],
+    [ 'ticket'    => loc('Tickets') ],
+    [ 'chart'     => loc('Charts') ],
+);
+
+$m->callback(
+    CallbackName => 'Default',
+    pane_name    => \%pane_name,
+    sections     => \@sections,
+    selected     => \%selected,
+    filters      => \@filters,
+);
 
 </%INIT>
diff --git a/share/html/Dashboards/Elements/DashboardsForObject b/share/html/Widgets/SearchSelection
similarity index 55%
copy from share/html/Dashboards/Elements/DashboardsForObject
copy to share/html/Widgets/SearchSelection
index ddc5649ed6..7416e4a4bc 100644
--- a/share/html/Dashboards/Elements/DashboardsForObject
+++ b/share/html/Widgets/SearchSelection
@@ -2,7 +2,7 @@
 %#
 %# COPYRIGHT:
 %#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
 %#                                          <sales at bestpractical.com>
 %#
 %# (Except where explicitly superseded by other copyright notices)
@@ -45,41 +45,67 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%args>
-$Object => undef
-</%args>
-<%init>
-# Returns a hash of dashboards associated on $Object
+<div class="selectionbox-js">
+  <div class="destinations">
+%   for my $pane (sort keys %pane_name) {
+      <div class="destination" data-pane="<% $pane %>">
+        <h2><% $pane_name{$pane} %></h2>
+        <div class="contents">
+          <ul>
+%           for my $item (@{ $selected{$pane} }) {
+              <& /Elements/ShowSelectSearch, %$item &>
+%           }
+          </ul>
+        </div>
+      </div>
+%   }
+  </div>
+  <div class="source">
+    <h2><&|/l&>Saved Searches</&></h2>
 
-use RT::Dashboard;
-my %dashboards;
-my $privacy = RT::Dashboard->_build_privacy($Object);
+    <div class="filters">
+      <input type="search" name="search" placeholder="<&|/l&>Search…</&>" autocomplete="off">
+      <select name="filter">
+          <option value=""><&|/l&>All Types</&></option>
+%         for (@filters) {
+%           my ($value, $label) = @$_;
+            <option value="<% $value %>"><% $label %></option>
+% }
+      </select>
+    </div>
 
-while (my $attr = $Object->Attributes->Next) {
-    if ($attr->Name =~ /^Dashboard\b/) {
-        my $dashboard = RT::Dashboard->new($User);
-        my ($ok, $msg) = $dashboard->Load($privacy, $attr->id);
+    <div class="contents">
+%     for my $section (@sections) {
+%       my $label = $section->{label};
+%       my $items = $section->{items};
 
-        if (!$ok) {
-            $RT::Logger->debug("Unable to load dashboard $ok (privacy $privacy): $msg");
-            next;
-        }
+        <div class="section">
+          <h3><% $label | n %></h3>
+          <ul>
+%           for my $item (sort {$a->{'label'} cmp $b->{'label'}} @$items) {
+              <& /Elements/ShowSelectSearch, %$item &>
+%           }
+          </ul>
+        </div>
+%     }
+    </div>
+  </div>
+</div>
 
-        if ($Object->isa('RT::System')) {
-            push @{ $dashboards{system} }, $dashboard;
-        }
-        elsif ($Object->isa('RT::User')) {
-            push @{ $dashboards{personal} }, $dashboard;
-        }
-        elsif ($Object->isa('RT::Group')) {
-            push @{ $dashboards{group}{$Object->Name} }, $dashboard;
-        }
-    }
-}
-return \%dashboards;
-</%init>
-
-<%args>
-$User => $session{'CurrentUser'}
-</%args>
+<div class="clear"></div>
 
+<%INIT>
+use utf8;
+$m->callback(
+    CallbackName => 'Default',
+    sections     => \@sections,
+    selected     => \%selected,
+    filters      => \@filters,
+);
+</%INIT>
+<%ARGS>
+%pane_name
+ at filters
+ at sections
+%selected
+</%ARGS>
diff --git a/share/static/css/elevator-light/forms.css b/share/static/css/elevator-light/forms.css
index e422e40bab..83516af15a 100644
--- a/share/static/css/elevator-light/forms.css
+++ b/share/static/css/elevator-light/forms.css
@@ -393,3 +393,125 @@ textarea.code {
 #EditConfig ul.plugins {
     margin: 0;
 }
+
+/* javascript selection box */
+
+.selectionbox-js {
+    width: 750px;
+}
+
+.selectionbox-js .source,
+.selectionbox-js .destinations {
+    width: 350px;
+}
+
+.selectionbox-js .destinations {
+    float: right;
+}
+
+.selectionbox-js .source li .remove {
+    display: none;
+}
+
+/* make it look more like we're cloning */
+.selectionbox-js .source .placeholder {
+    display: none;
+}
+
+.selectionbox-js .destination {
+    margin-bottom: 2em;
+}
+
+.selectionbox-js .source .contents {
+    height: 450px;
+}
+
+/* include ul rule specifically to make the drop target work when there are
+   no selected searches */
+.selectionbox-js .destination .contents,
+.selectionbox-js .destination .contents ul {
+    height: 200px;
+}
+
+.selectionbox-js h2 {
+    margin: 0 0 .5em 0;
+}
+
+.selectionbox-js .contents {
+    border: 2px solid #aaa;
+    overflow-y: scroll;
+    padding: 0 .5em;
+}
+
+.selectionbox-js .contents ul {
+    list-style-type: none;
+    padding: 0;
+    margin: 0;
+}
+
+.selectionbox-js .contents li,
+.selectionbox-js .contents .placeholder {
+    border: 2px solid #ddd;
+    padding: .5em;
+    margin: .5em 0;
+    background-color: #eee;
+    cursor: default;
+}
+
+.selectionbox-js .contents li {
+    position: relative;
+
+    /* wrap very long search names */
+    word-wrap: break-word;
+    padding-right: 2em;
+}
+
+.selectionbox-js .contents li .warning {
+    display: block;
+    font-size: .9em;
+    font-style: italic;
+    color: #a00;
+}
+
+.selectionbox-js .contents li a.remove {
+    position: absolute;
+    right: .5em;
+}
+
+.selectionbox-js .contents li a.remove img {
+    height: 1em;
+    border-style: none;
+}
+
+.selectionbox-js .contents .placeholder {
+    box-sizing: border-box;
+    border-style: dashed;
+    opacity: 0.5;
+    padding-left: 0;
+    padding-right: 0;
+    width: 100%;
+}
+
+.selectionbox-js .contents h3 {
+    margin: .5em;
+}
+
+.selectionbox-js .contents .section {
+    margin-bottom: 1em;
+}
+
+.selectionbox-js select[name=filter] {
+    float: right;
+}
+
+/* always show scrollbars */
+.selectionbox-js .contents::-webkit-scrollbar {
+  -webkit-appearance: none;
+  width: 7px;
+}
+
+.selectionbox-js .contents::-webkit-scrollbar-thumb {
+  border-radius: 4px;
+  background-color: rgba(0, 0, 0, .5);
+  -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, .5);
+}
diff --git a/share/static/js/forms.js b/share/static/js/forms.js
index 3c6f9b93d9..efd368ad9c 100644
--- a/share/static/js/forms.js
+++ b/share/static/js/forms.js
@@ -15,4 +15,173 @@ jQuery(function() {
             form.data('submitted', true);
         }
     });
+
+    jQuery('.selectionbox-js').each(function () {
+        var container = jQuery(this);
+        var source = container.find('.source');
+        var form = container.closest('form');
+        var submit = form.find('input[name=UpdateSearches]');
+
+        var copyHelper;
+        var draggedIntoDestination;
+
+        container.find('.destination ul').sortable({
+            connectWith: '.destination ul',
+            placeholder: 'placeholder',
+            forcePlaceholderSize: true,
+            cancel: '.remove',
+
+            // drag a clone of the source item
+            receive: function (e, ui) {
+                draggedIntoDestination = true;
+                copyHelper = null;
+            },
+           over: function () {
+               removeIntent = false;
+           },
+           out: function () {
+               removeIntent = true;
+           },
+           beforeStop: function (event, ui) {
+               if(removeIntent == true){
+                   ui.item.remove();
+               }
+           },
+        }).on('click', '.remove', function (e) {
+            e.preventDefault();
+            jQuery(e.target).closest('li').remove();
+            return false;
+        });
+
+        source.find('ul').sortable({
+            connectWith: '.destination ul',
+            containment: container,
+            placeholder: 'placeholder',
+            forcePlaceholderSize: true,
+
+            // drag a clone of the source item
+            helper: function (e, li) {
+                copyHelper = li.clone().insertAfter(li);
+                return li.clone();
+            },
+
+            start: function (e, ui) {
+                draggedIntoDestination = false;
+            },
+
+            stop: function (e, ui) {
+                if (copyHelper) {
+                    copyHelper.remove();
+                }
+
+                if (!draggedIntoDestination) {
+                    jQuery(this).sortable('cancel');
+                }
+            }
+        });
+
+        var searchField = source.find('input[name=search]');
+        var filterField = source.find('select[name=filter]');
+
+        var refreshSource = function () {
+            var searchTerm = searchField.val().toLowerCase();
+            var filterType = filterField.val();
+
+            source.find('.section').each(function () {
+                var section = jQuery(this);
+                var sectionLabel = section.find('h3').text();
+                var sectionMatches = sectionLabel.toLowerCase().indexOf(searchTerm) > -1;
+
+                var visibleItem = false;
+                section.find('li').each(function () {
+                    var item = jQuery(this);
+                    var itemType = item.data('type');
+
+                    if (filterType) {
+                        // component and dashboard matches on data-type
+                        if (filterType == 'component' || itemType == 'component' || filterType == 'dashboard' || itemType == 'dashboard') {
+                            if (itemType != filterType) {
+                                item.hide();
+                                return;
+                            }
+                        }
+                        // everything else matches on data-search-type
+                        else {
+                            var searchType = item.data('search-type');
+                            if (searchType === '') { searchType = 'ticket' }
+
+                            if (searchType.toLowerCase() != filterType) {
+                                item.hide();
+                                return;
+                            }
+                        }
+                    }
+
+                    if (sectionMatches || item.text().toLowerCase().indexOf(searchTerm) > -1) {
+                        visibleItem = true;
+                        item.show();
+                    }
+                    else {
+                        item.hide();
+                    }
+                });
+
+                if (visibleItem) {
+                    section.show();
+                }
+                else {
+                    section.hide();
+                }
+            });
+
+            source.find('.contents').scrollTop(0);
+        };
+
+        searchField.on('propertychange change keyup paste input', function () {
+            refreshSource();
+        });
+        filterField.on('change keyup', function () {
+            refreshSource();
+        });
+        refreshSource();
+
+        submit.click(function (e) {
+            e.preventDefault();
+
+            var url = form.attr('action');
+            var method = form.attr('method');
+            var params = form.data();
+
+            params.panes = {}
+            container.find('.destination').each(function () {
+                var pane = jQuery(this);
+                var name = pane.data('pane');
+                var items = [];
+                pane.find('li').each(function () {
+                    var item = jQuery(this).data();
+                    delete item.sortableItem;
+                    items.push(item);
+                });
+                params.panes[name] = items;
+            });
+
+            jQuery.ajax({
+                url: url,
+                method: method,
+                data: { content: JSON.stringify(params) },
+                timeout: 30000, /* 30 seconds */
+                success: function (response) {
+                    if (response.ok) {
+                        window.location.reload();
+                    }
+                    else {
+                        alert(response.msg);
+                    }
+                },
+                error: function (xhr, reason) {
+                    alert(reason);
+                }
+            });
+        });
+    });
 });

commit e542ff268e2d750e4deca622c8c4d4f4ccd039a2
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Tue Jan 28 14:34:19 2020 -0600

    Update DashboardSelectionUI to elevator-light
    
    Other than general design changes, this commit also fixes the reset
    to defaults for the pages by using the reset logic from
    Prefs/MyRT.html.

diff --git a/share/html/Admin/Global/MyRT.html b/share/html/Admin/Global/MyRT.html
index 2ec3a597cd..f0ab35c3cb 100644
--- a/share/html/Admin/Global/MyRT.html
+++ b/share/html/Admin/Global/MyRT.html
@@ -49,14 +49,14 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT' data-is_global=True>
+<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT' data-is_global=True>
   <& /Widgets/SearchSelection,
     pane_name => \%pane_name,
     sections  => \@sections,
     selected  => \%selected,
     filters   => \@filters,
   &>
-  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+  <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
 </form>
 
 <%INIT>
diff --git a/share/html/Admin/Users/MyRT.html b/share/html/Admin/Users/MyRT.html
index fefda20b23..82625f4973 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -49,7 +49,7 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@actions &>
 
-<form method="post" action="<% RT->Config->Get('WebPath')  %>/Helpers/UpdateDashboard" data-dashboard_id = "MyRT" data-user_id = '<% $id %>'>
+<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath')  %>/Helpers/UpdateDashboard" data-dashboard_id = "MyRT" data-user_id = '<% $id %>'>
   <& /Widgets/SearchSelection,
                     pane_name => \%pane_name,
                     sections  => \@sections,
@@ -57,12 +57,11 @@
                     filters   => \@filters,
     &>
 <input type="hidden" name="id" value="<% $id %>"/>
-<input type"submit" class="button" name="UpdateSearches" value= "<% loc('Save') %>"  >
-<div class="form-row">
-  <div class="col-md-12">
-    <input type="submit" class="button form-control btn btn-primary" value="<%loc('Reset to default')%>">
-  </div>
-</div>
+<& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
+</form>
+<form method="post" action="MyRT.html?id=<% $id %>">
+  <input type="hidden" name="Reset" value="1" />
+  <& /Elements/Submit, Label => loc('Reset to default') &>
 </form>
 
 <%init>
@@ -72,10 +71,19 @@ $UserObj->Load($id) || Abort("Couldn't load user '" . ($id || '') . "'");
 my $user = RT::User->new($session{'CurrentUser'});
 my $title = loc("RT at a glance for the user [_1]", $UserObj->Name);
 
+if ($ARGS{Reset}) {
+    for my $pref_name ('HomepageSettings', 'SummaryRows') {
+        next unless $UserObj->Preferences($pref_name);
+        my ($ok, $msg) = $UserObj->DeletePreferences($pref_name);
+        push @actions, $msg unless $ok;
+    }
+    push @actions, loc('Preferences saved for user [_1].', $UserObj->Name) unless @actions;
+}
+
 my $portlets = $UserObj->Preferences('HomepageSettings');
 unless ($portlets) {
-   my ($defaults) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
-   $portlets = $defaults ? $defaults->Content : {};
+    my ($defaults) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
+    $portlets = $defaults ? $defaults->Content : {};
 }
 
 my @sections;
diff --git a/share/html/Dashboards/Queries.html b/share/html/Dashboards/Queries.html
index dfbb8522c7..3e0b13c912 100644
--- a/share/html/Dashboards/Queries.html
+++ b/share/html/Dashboards/Queries.html
@@ -49,14 +49,14 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='<%$Dashboard->Id%>'>
+<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='<%$Dashboard->Id%>'>
   <& /Widgets/SearchSelection,
     pane_name => \%pane_name,
     sections  => \@sections,
     selected  => \%selected,
     filters   => \@filters,
   &>
-  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+  <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
 </form>
 <%INIT>
 my @results;
diff --git a/share/html/Elements/ShowSelectSearch b/share/html/Elements/ShowSelectSearch
index 6b2ea4da98..812756fa2b 100644
--- a/share/html/Elements/ShowSelectSearch
+++ b/share/html/Elements/ShowSelectSearch
@@ -53,10 +53,14 @@ $search_id => undef
 $search_type => undef
 $possibly_hidden => 0
 </%ARGS>
-<li data-type="<% $type %>" data-name="<% $name %>" data-search-type="<% $search_type || ''  %>" data-search-id="<% $search_id || '' %>" data-description="<% $label %>"><a href="#" class="remove"><img src="<% RT->Config->Get('WebPath') %>/static/images/close.png" alt="<% loc('Remove') %>" title="<% loc('Remove') %>" /></a> <% $prefix ? $prefix . ': ' : '' %>
+% my $alt = loc('Remove');
+<li data-type="<% $type %>" data-name="<% $name %>" data-search-type="<% $search_type || ''  %>" data-search-id="<% $search_id || '' %>" data-description="<% $label %>">
+  <a href="#" class="remove">
+    <span class="far fa-times-circle" alt="<% $alt %>" data-toggle="tooltip" data-placement="top" data-original-title="<% $alt %>"></span>
+  </a> <% $prefix ? $prefix . ': ' : '' %>
 <% $label %>
 % if ($possibly_hidden) {
-<span class="warning"><&|/l&>Warning: may not be visible to all viewers</&></span>
+<span class="text-warning"><&|/l&>Warning: may not be visible to all viewers</&></span>
 % }
 </li>
 <%INIT>
diff --git a/share/html/Prefs/MyRT.html b/share/html/Prefs/MyRT.html
index 15c6bccb45..e674fea7a1 100644
--- a/share/html/Prefs/MyRT.html
+++ b/share/html/Prefs/MyRT.html
@@ -49,17 +49,17 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT'>
+<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT'>
   <& /Widgets/SearchSelection,
     pane_name => \%pane_name,
     sections  => \@sections,
     selected  => \%selected,
     filters   => \@filters,
   &>
-  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+  <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
 </form>
 
-<&|/Widgets/TitleBox, title => loc('Options'), bodyclass => "" &>
+<&|/Widgets/TitleBox, title => loc('Options'), bodyclass => "", class => "mx-auto max-width-lg" &>
 <form method="post" action="MyRT.html">
 <div class="form-row">
   <div class="col-md-auto label">
@@ -69,15 +69,15 @@
     <input class="form-control" type="text" name="SummaryRows" value="<% $ARGS{SummaryRows} %>" />
   </div>
   <div class="col-md-auto">
-    <input type="submit" class="button form-control btn btn-primary"" name="UpdateSummaryRows" value="<% loc('Save') %>" />
+    <input type="submit" class="button form-control btn btn-primary" name="UpdateSummaryRows" value="<% loc('Save') %>" />
   </div>
 </div>
 </form>
 </&>
-<&|/Widgets/TitleBox, title => loc("Reset RT at a glance") &>
+<&|/Widgets/TitleBox, title => loc("Reset RT at a glance"), class => "mx-auto max-width-lg" &>
 <form method="post" action="MyRT.html">
 <input type="hidden" name="Reset" value="1" />
-<input type="submit" class="button form-control btn btn-primary"" value="<% loc('Reset to default') %>">
+<input type="submit" class="button form-control btn btn-primary" value="<% loc('Reset to default') %>">
 </form>
 </&>
 
diff --git a/share/html/Widgets/SearchSelection b/share/html/Widgets/SearchSelection
index 7416e4a4bc..bc6f4e07b0 100644
--- a/share/html/Widgets/SearchSelection
+++ b/share/html/Widgets/SearchSelection
@@ -46,54 +46,63 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <div class="selectionbox-js">
-  <div class="destinations">
-%   for my $pane (sort keys %pane_name) {
-      <div class="destination" data-pane="<% $pane %>">
-        <h2><% $pane_name{$pane} %></h2>
+  <div class="form-row">
+    <div class="col-md-6">
+      <div class="source">
+        <div class="filters">
+          <div class="form-row">
+            <div class="col-md-auto">
+              <input type="search" class="field form-control" name="search" placeholder="<&|/l&>Search…</&>" autocomplete="off">
+            </div>
+            <div class="col-md-auto">
+              <select class="form-control selectpicker" name="filter">
+                  <option value=""><&|/l&>All Types</&></option>
+%             for (@filters) {
+%               my ($value, $label) = @$_;
+                  <option value="<% $value %>"><% $label %></option>
+% }
+              </select>
+            </div>
+          </div>
+        </div>
+
         <div class="contents">
-          <ul>
-%           for my $item (@{ $selected{$pane} }) {
-              <& /Elements/ShowSelectSearch, %$item &>
-%           }
-          </ul>
+%       for my $section (@sections) {
+%         my $label = $section->{label};
+%         my $items = $section->{items};
+
+          <div class="section">
+            <h3><% $label | n %></h3>
+            <ul>
+%             for my $item (sort {$a->{'label'} cmp $b->{'label'}} @$items) {
+                <& /Elements/ShowSelectSearch, %$item &>
+%             }
+            </ul>
+          </div>
+%       }
         </div>
       </div>
-%   }
-  </div>
-  <div class="source">
-    <h2><&|/l&>Saved Searches</&></h2>
-
-    <div class="filters">
-      <input type="search" name="search" placeholder="<&|/l&>Search…</&>" autocomplete="off">
-      <select name="filter">
-          <option value=""><&|/l&>All Types</&></option>
-%         for (@filters) {
-%           my ($value, $label) = @$_;
-            <option value="<% $value %>"><% $label %></option>
-% }
-      </select>
     </div>
 
-    <div class="contents">
-%     for my $section (@sections) {
-%       my $label = $section->{label};
-%       my $items = $section->{items};
-
-        <div class="section">
-          <h3><% $label | n %></h3>
-          <ul>
-%           for my $item (sort {$a->{'label'} cmp $b->{'label'}} @$items) {
+    <div class="col-md-6">
+      <div class="destinations">
+%     for my $pane (sort keys %pane_name) {
+        <div class="destination" data-pane="<% $pane %>">
+          <div class="contents">
+            <h3><% $pane_name{$pane} %></h3>
+            <ul>
+%           for my $item (@{ $selected{$pane} }) {
               <& /Elements/ShowSelectSearch, %$item &>
 %           }
-          </ul>
+            </ul>
+          </div>
         </div>
-%     }
+%   }
+      </div>
     </div>
   </div>
 </div>
 
-<div class="clear"></div>
-
 <%INIT>
 use utf8;
 $m->callback(
diff --git a/share/static/css/elevator-light/forms.css b/share/static/css/elevator-light/forms.css
index 83516af15a..5b57b850a0 100644
--- a/share/static/css/elevator-light/forms.css
+++ b/share/static/css/elevator-light/forms.css
@@ -396,19 +396,6 @@ textarea.code {
 
 /* javascript selection box */
 
-.selectionbox-js {
-    width: 750px;
-}
-
-.selectionbox-js .source,
-.selectionbox-js .destinations {
-    width: 350px;
-}
-
-.selectionbox-js .destinations {
-    float: right;
-}
-
 .selectionbox-js .source li .remove {
     display: none;
 }
@@ -423,22 +410,31 @@ textarea.code {
 }
 
 .selectionbox-js .source .contents {
-    height: 450px;
+    height: 526px;
 }
 
 /* include ul rule specifically to make the drop target work when there are
    no selected searches */
 .selectionbox-js .destination .contents,
 .selectionbox-js .destination .contents ul {
-    height: 200px;
+    height: 250px;
 }
 
 .selectionbox-js h2 {
     margin: 0 0 .5em 0;
 }
 
+.selectionbox-js .source .filters {
+    margin-bottom: 1em;
+}
+
+.selectionbox-js .destinations {
+    margin-top: 3.75rem;
+}
+
 .selectionbox-js .contents {
-    border: 2px solid #aaa;
+    border: 1px solid #ccc;
+    border-radius: 0.25rem;
     overflow-y: scroll;
     padding: 0 .5em;
 }
@@ -451,7 +447,8 @@ textarea.code {
 
 .selectionbox-js .contents li,
 .selectionbox-js .contents .placeholder {
-    border: 2px solid #ddd;
+    border: 1px solid #aaa;
+    border-radius: 0.25rem;
     padding: .5em;
     margin: .5em 0;
     background-color: #eee;
@@ -466,21 +463,16 @@ textarea.code {
     padding-right: 2em;
 }
 
-.selectionbox-js .contents li .warning {
+.selectionbox-js .contents li .text-warning {
     display: block;
     font-size: .9em;
     font-style: italic;
-    color: #a00;
 }
 
 .selectionbox-js .contents li a.remove {
     position: absolute;
     right: .5em;
-}
-
-.selectionbox-js .contents li a.remove img {
-    height: 1em;
-    border-style: none;
+    color: #5C6273;
 }
 
 .selectionbox-js .contents .placeholder {
@@ -493,7 +485,7 @@ textarea.code {
 }
 
 .selectionbox-js .contents h3 {
-    margin: .5em;
+    margin: .5em 0;
 }
 
 .selectionbox-js .contents .section {
diff --git a/share/static/js/forms.js b/share/static/js/forms.js
index efd368ad9c..7ded228799 100644
--- a/share/static/js/forms.js
+++ b/share/static/js/forms.js
@@ -50,6 +50,13 @@ jQuery(function() {
         }).on('click', '.remove', function (e) {
             e.preventDefault();
             jQuery(e.target).closest('li').remove();
+
+            // dispose of the bootstrap tooltip.
+            // without manually clearing here, the tooltip lingers after clicking remove.
+            var bs_tooltip = jQuery('div[id^="tooltip"]');
+            bs_tooltip.tooltip('hide');
+            bs_tooltip.tooltip('dispose');
+
             return false;
         });
 

commit 1bcdd32b1af1885ed9c5016a97e8ff067f99086d
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Tue Feb 4 17:34:50 2020 -0600

    Refactor DashboardSelectionUI
    
    To align this feature with the rest of RT, this commit removes the
    AJAX call, moves the helper code into a subroutine, and updates
    the pages to post to themselves.
    
    Rather than post to the helper, the JS now writes the values to the
    form as hidden inputs.
    
    Additional information about each arg previously contained in the
    JSON is now passed to the new subroutine to be used before updating
    the preferences.

diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index a6f62fe06e..5523fdbca9 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4608,6 +4608,133 @@ sub GetDefaultQueue {
     return $queue;
 }
 
+=head2 UpdateDashboard
+
+Update global and user-level dashboard preferences.
+
+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 };
+        }
+    }
+
+    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 {
+        use RT::Dashboard;
+        my $Dashboard = RT::Dashboard->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 'system' || $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};
+
+                    if ( $item->{type} eq 'system' ) {
+                        $saved{privacy} = 'RT::System-1';
+                        $saved{id}      = $item->{searchId};
+                    } else {
+                        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 );
+    }
+}
+
 package RT::Interface::Web;
 RT::Base->_ImportOverlays();
 
diff --git a/share/html/Admin/Global/MyRT.html b/share/html/Admin/Global/MyRT.html
index f0ab35c3cb..fea88ce37c 100644
--- a/share/html/Admin/Global/MyRT.html
+++ b/share/html/Admin/Global/MyRT.html
@@ -49,20 +49,21 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT' data-is_global=True>
+<form method="post" name="UpdateSearches" class="mx-auto max-width-lg">
   <& /Widgets/SearchSelection,
     pane_name => \%pane_name,
     sections  => \@sections,
     selected  => \%selected,
     filters   => \@filters,
   &>
+  <input type="hidden" name="dashboard_id" value="MyRT">
+  <input type="hidden" name="is_global" value="1">
   <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
 </form>
 
 <%INIT>
 my @results;
 my $title = loc("Customize").' '.loc("Global RT at a glance");
-my $user = $session{'CurrentUser'}->UserObj;
 
 my $portlets;
 my ($defaults) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
@@ -165,4 +166,14 @@ $m->callback(
     filters      => \@filters,
 );
 
+if ($ARGS{UpdateSearches}) {
+    my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
+    push @results, $ok ? loc('Preferences saved.') : $msg;
+
+    MaybeRedirectForResults(
+        Actions   => \@results,
+        Path      => "/Admin/Global/MyRT.html",
+    );
+}
+
 </%INIT>
diff --git a/share/html/Admin/Users/MyRT.html b/share/html/Admin/Users/MyRT.html
index 82625f4973..e7ae65e1d6 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -49,7 +49,7 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@actions &>
 
-<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath')  %>/Helpers/UpdateDashboard" data-dashboard_id = "MyRT" data-user_id = '<% $id %>'>
+<form method="post" action="MyRT.html" name="UpdateSearches" class="mx-auto max-width-lg">
   <& /Widgets/SearchSelection,
                     pane_name => \%pane_name,
                     sections  => \@sections,
@@ -57,9 +57,10 @@
                     filters   => \@filters,
     &>
 <input type="hidden" name="id" value="<% $id %>"/>
+<input type="hidden" name="dashboard_id" value="MyRT">
 <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
 </form>
-<form method="post" action="MyRT.html?id=<% $id %>">
+<form method="post" action="MyRT.html?id=<% $id %>" class="mx-auto max-width-lg">
   <input type="hidden" name="Reset" value="1" />
   <& /Elements/Submit, Label => loc('Reset to default') &>
 </form>
@@ -176,6 +177,18 @@ $m->callback(
     filters      => \@filters,
 );
 
+if ( $ARGS{UpdateSearches} ) {
+    $ARGS{user_id} = $ARGS{id};
+    my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
+    push @actions, $ok ? loc('Preferences saved for user [_1].', $UserObj->Name) : $msg;
+
+    MaybeRedirectForResults(
+        Actions   => \@actions,
+        Path      => "/Admin/Users/MyRT.html",
+        Arguments => { id => $id },
+    );
+}
+
 </%INIT>
 <%ARGS>
   $id => undef
diff --git a/share/html/Dashboards/Queries.html b/share/html/Dashboards/Queries.html
index 3e0b13c912..45bdf997c4 100644
--- a/share/html/Dashboards/Queries.html
+++ b/share/html/Dashboards/Queries.html
@@ -49,7 +49,7 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='<%$Dashboard->Id%>'>
+<form method="post" name="UpdateSearches" class="mx-auto max-width-lg">
   <& /Widgets/SearchSelection,
     pane_name => \%pane_name,
     sections  => \@sections,
@@ -211,6 +211,19 @@ $m->callback(
     selected     => \%selected,
     filters      => \@filters,
 );
+
+if ( $ARGS{UpdateSearches} ) {
+    $ARGS{dashboard_id} = $id;
+    my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
+    push @results, $ok ? loc('Dashboard updated') : $msg;
+
+    MaybeRedirectForResults(
+        Actions   => \@results,
+        Path      => "/Dashboards/Queries.html",
+        Arguments => { id => $id },
+    );
+}
+
 </%INIT>
 <%ARGS>
 $id => '' unless defined $id
diff --git a/share/html/Helpers/UpdateDashboard b/share/html/Helpers/UpdateDashboard
deleted file mode 100644
index fb686ac61a..0000000000
--- a/share/html/Helpers/UpdateDashboard
+++ /dev/null
@@ -1,137 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2016 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>
-$content
-</%ARGS>
-<%INIT>
-my $args = JSON::from_json($content);
-my $id = $args->{dashboard_id};
-my $user_id = $args->{user_id};
-my $is_global = $args->{is_global};
-
-my ($ok, $msg);
-if ($id eq 'MyRT') {
-        my $user = $session{CurrentUser};
-        if($user_id){
-            $user->Load($user_id);
-            ($ok, $msg) = $user->SetPreferences('HomepageSettings', $args->{panes});
-      }
-      elsif($is_global){
-        my $sys = RT::System->new($session{'CurrentUser'});
-        my ($default_portlets) = $sys->Attributes->Named('HomepageSettings');
-        ($ok, $msg) = $default_portlets->SetContent( $args->{panes} );
-      }
-      else{
-          ($ok, $msg) = $user->SetPreferences('HomepageSettings', $args->{panes});
-        }
-}
-else {
-    use RT::Dashboard;
-    my $Dashboard = RT::Dashboard->new($session{'CurrentUser'});
-    ($ok, $msg) = $Dashboard->LoadById($id);
-
-    # report error at the bottom
-    goto DONE unless $ok && $Dashboard->Id;
-
-    my $content;
-    for my $pane_name (keys %{ $args->{panes} }) {
-        my @pane;
-
-        for my $item (@{ $args->{panes}{$pane_name} }) {
-            my %saved;
-            $saved{pane} = $pane_name;
-            $saved{portlet_type} = $item->{type};
-            $saved{description} = $item->{description};
-
-            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 'system' || $item->{type} eq 'saved') {
-                $saved{portlet_type} = 'search';
-                my $type = $item->{searchType};
-                $type = 'Saved Search' if !$type || $type eq 'Ticket';
-                $saved{description} = loc($type) . ': ' . $saved{description};
-
-                if ($item->{type} eq 'system') {
-                    $saved{privacy} = 'RT::System-1';
-                    $saved{id} = $item->{searchId};
-                }
-                else {
-                    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;
-    }
-
-    ($ok, $msg) = $Dashboard->Update(Panes => $content);
-}
-
-DONE:
-$r->content_type('application/json; charset=utf-8');
-$m->print(JSON({ ok => $ok, msg => $msg}));
-$m->abort;
-</%INIT>
diff --git a/share/html/Prefs/MyRT.html b/share/html/Prefs/MyRT.html
index e674fea7a1..61b2ea1557 100644
--- a/share/html/Prefs/MyRT.html
+++ b/share/html/Prefs/MyRT.html
@@ -49,13 +49,14 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" class="mx-auto max-width-lg" action="<% RT->Config->Get('WebPath') %>/Helpers/UpdateDashboard" data-dashboard_id='MyRT'>
+<form method="post" name="UpdateSearches" class="mx-auto max-width-lg">
   <& /Widgets/SearchSelection,
     pane_name => \%pane_name,
     sections  => \@sections,
     selected  => \%selected,
     filters   => \@filters,
   &>
+  <input type="hidden" name="dashboard_id" value="MyRT">
   <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
 </form>
 
@@ -211,4 +212,14 @@ $m->callback(
     filters      => \@filters,
 );
 
+if ($ARGS{UpdateSearches}) {
+    my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
+    push @results, $ok ? loc('Preferences saved.') : $msg;
+
+    MaybeRedirectForResults(
+        Actions   => \@results,
+        Path      => "/Prefs/MyRT.html",
+    );
+}
+
 </%INIT>
diff --git a/share/static/js/forms.js b/share/static/js/forms.js
index 7ded228799..cfb3b3071a 100644
--- a/share/static/js/forms.js
+++ b/share/static/js/forms.js
@@ -1,21 +1,4 @@
 jQuery(function() {
-    // reset form submit info when user goes backward or forward for Safari
-    // other browsers don't need this trick and they can work directly.
-    if ( window.addEventListener ) {
-        window.addEventListener("popstate", function(e) {
-            jQuery('form').data('submitted', false);
-        });
-    }
-
-    jQuery('form').submit(function(e) {
-        var form = jQuery(this);
-        if (form.data('submitted') === true) {
-            e.preventDefault();
-        } else {
-            form.data('submitted', true);
-        }
-    });
-
     jQuery('.selectionbox-js').each(function () {
         var container = jQuery(this);
         var source = container.find('.source');
@@ -152,43 +135,19 @@ jQuery(function() {
         });
         refreshSource();
 
-        submit.click(function (e) {
-            e.preventDefault();
-
-            var url = form.attr('action');
-            var method = form.attr('method');
-            var params = form.data();
-
-            params.panes = {}
+        submit.click(function () {
             container.find('.destination').each(function () {
                 var pane = jQuery(this);
                 var name = pane.data('pane');
-                var items = [];
+
                 pane.find('li').each(function () {
                     var item = jQuery(this).data();
                     delete item.sortableItem;
-                    items.push(item);
+                    form.append('<input type="hidden" name="' + name + '" value="' + item.type + '-' + item.name + '" />');
                 });
-                params.panes[name] = items;
             });
 
-            jQuery.ajax({
-                url: url,
-                method: method,
-                data: { content: JSON.stringify(params) },
-                timeout: 30000, /* 30 seconds */
-                success: function (response) {
-                    if (response.ok) {
-                        window.location.reload();
-                    }
-                    else {
-                        alert(response.msg);
-                    }
-                },
-                error: function (xhr, reason) {
-                    alert(reason);
-                }
-            });
+            return true;
         });
     });
 });

commit f86c871ffc59322579265e31e2a9957c1f8efc71
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Wed Feb 12 20:30:22 2020 -0600

    Update tests for the new DashboardSelectionUI

diff --git a/t/mail/dashboard-chart-with-utf8.t b/t/mail/dashboard-chart-with-utf8.t
index cef5170dac..960d3de818 100644
--- a/t/mail/dashboard-chart-with-utf8.t
+++ b/t/mail/dashboard-chart-with-utf8.t
@@ -34,15 +34,29 @@ $m->form_name('ModifyDashboard');
 $m->field( 'Name' => 'dashboard foo' );
 $m->click_button( value => 'Create' );
 
+my ( $dashboard_id ) = ( $m->uri =~ /id=(\d+)/ );
+ok( $dashboard_id, "got an ID for the dashboard, $dashboard_id" );
+
 $m->follow_link_ok( { text => 'Content' } );
-my $form  = $m->form_name('Dashboard-Searches-body');
-my @input = $form->find_input('Searches-body-Available');
-my ($dashboards_component) =
-  map { ( $_->possible_values )[1] }
-  grep { ( $_->value_names )[1] =~ /^Chart/ } @input;
-$form->value( 'Searches-body-Available' => $dashboards_component );
-$m->click_button( name => 'add' );
-$m->content_contains('Dashboard updated');
+
+# add content, Chart: chart foo, to dashboard body
+# we need to get the saved search id from the content before submitting the form.
+my $regex = qr/data-type="(\w+)" data-name="RT::User-/ . $root->id . qr/-SavedSearch-(\d+)"/;
+my ( $saved_search_type, $saved_search_id ) = $m->content =~ /$regex/;
+ok( $saved_search_type, "got a type for the saved search, $saved_search_type" );
+ok( $saved_search_id, "got an ID for the saved search, $saved_search_id" );
+
+$m->submit_form_ok({
+    form_name => 'UpdateSearches',
+    fields    => {
+        dashboard_id => $dashboard_id,
+        body         => $saved_search_type . "-" . "RT::User-" . $root->id . "-SavedSearch-" . $saved_search_id,
+    },
+    button => 'UpdateSearches',
+}, "add content 'Chart: chart foo' to dashboard body" );
+
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
 
 $m->follow_link_ok( { text => 'Subscription' } );
 $m->form_name('SubscribeDashboard');
diff --git a/t/mail/dashboard-empty.t b/t/mail/dashboard-empty.t
index dcb70209bf..5b36b7d871 100644
--- a/t/mail/dashboard-empty.t
+++ b/t/mail/dashboard-empty.t
@@ -18,18 +18,33 @@ sub create_dashboard {
     $m->field( 'Name' => $name );
     $m->click_button( value => 'Create' );
 
+    my ( $dashboard_id ) = ( $m->uri =~ /id=(\d+)/ );
+    ok( $dashboard_id, "got a dashboard ID, $dashboard_id" );
+
     $m->follow_link_ok( { text => 'Content' } );
-    my $form  = $m->form_name('Dashboard-Searches-body');
-    my @input = $form->find_input('Searches-body-Available');
 
     my $add_component = sub {
-        my $name = shift;
-        my ($dashboards_component) =
-          map { ( $_->possible_values )[1] }
-          grep { ( $_->value_names )[1] =~ $name } @input;
-        $form->value( 'Searches-body-Available' => $dashboards_component );
-        $m->click_button( name => 'add' );
-        $m->content_contains('Dashboard updated');
+        my $component_name = shift;
+        my $arg;
+
+        if ( $component_name eq 'My Tickets' ) {
+            $arg = 'system-My Tickets';
+        }
+        else {  # component_name is 'My Assets'
+            $arg = 'component-MyAssets';
+        }
+
+        $m->submit_form_ok({
+            form_name => 'UpdateSearches',
+            fields    => {
+                dashboard_id => $dashboard_id,
+                body         => $arg,
+        },
+        button => 'UpdateSearches',
+        }, "added '$component_name' to dashboard '$name'" );
+
+        like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+        $m->content_contains( 'Dashboard updated' );
     };
 
     $add_component->('My Tickets') unless $assets;
diff --git a/t/mail/dashboards.t b/t/mail/dashboards.t
index 05ae73c2b0..e2eafaa2b2 100644
--- a/t/mail/dashboards.t
+++ b/t/mail/dashboards.t
@@ -17,17 +17,23 @@ sub create_dashboard {
     $m->click_button(value => 'Create');
     $m->title_is('Modify the dashboard Testing!');
 
+    my ( $dashboard_id ) = ( $m->uri =~ /id=(\d+)/ );
+    ok( $dashboard_id, "got a dashboard ID, $dashboard_id" );  # 8
+
     $m->follow_link_ok({text => 'Content'});
     $m->title_is('Modify the content of dashboard Testing!');
 
-    my $form = $m->form_name('Dashboard-Searches-body');
-    my @input = $form->find_input('Searches-body-Available');
-    my ($dashboards_component) =
-        map { ( $_->possible_values )[1] }
-        grep { ( $_->value_names )[1] =~ /Dashboards/ } @input;
-    $form->value('Searches-body-Available' => $dashboards_component );
-    $m->click_button(name => 'add');
-    $m->content_contains('Dashboard updated');
+    $m->submit_form_ok({
+        form_name => 'UpdateSearches',
+        fields    => {
+            dashboard_id => $dashboard_id,
+            body         => 'component-Dashboards',
+        },
+        button => 'UpdateSearches',
+    }, "added 'Dashboards' to dashboard 'Testing!'" );
+
+    like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+    $m->content_contains( 'Dashboard updated' );
 
     $m->follow_link_ok({text => 'Show'});
     $m->title_is('Testing! Dashboard');
diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t
index ee5e9f58a5..d997f56a8e 100644
--- a/t/web/custom_frontpage.t
+++ b/t/web/custom_frontpage.t
@@ -1,7 +1,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 19;
+use RT::Test tests => undef;
 my ($baseurl, $m) = RT::Test->started_ok;
 
 my $url = $m->rt_base_url;
@@ -33,33 +33,59 @@ $m->content_contains('stupid tickets', 'saved search listed in rt at a glance it
 
 ok $m->login('root', 'password', logout => 1), 'we did log in as root';
 
-$m->get ( $url.'Prefs/MyRT.html' );
-$m->form_name ('SelectionBox-body');
-# can't use submit form for mutli-valued select as it uses set_fields
-$m->field ('body-Selected' => ['component-QuickCreate', 'system-Unowned Tickets', 'system-My Tickets']);
-$m->click_button (name => 'remove');
-$m->form_name ('SelectionBox-body');
-#$m->click_button (name => 'body-Save');
-$m->get ( $url );
-$m->content_lacks ('highest priority tickets', 'remove everything from body pane');
+my $args = {
+    UpdateSearches => "Save",
+    dashboard_id   => "MyRT",
+    body           => [],
+    sidebar        => [],
+};
+
+# remove all portlets from the body pane except 'newest unowned tickets'
+push(
+    @{$args->{body}},
+    ( "system-Unowned Tickets", )
+);
 
-$m->get ( $url.'Prefs/MyRT.html' );
-$m->form_name ('SelectionBox-body');
-$m->field ('body-Available' => ['component-QuickCreate', 'system-Unowned Tickets', 'system-My Tickets']);
-$m->click_button (name => 'add');
+my $res = $m->post(
+    $url . 'Prefs/MyRT.html',
+    $args,
+);
 
-$m->form_name ('SelectionBox-body');
-$m->field ('body-Selected' => ['component-QuickCreate']);
-$m->click_button (name => 'movedown');
+is( $res->code, 200, "remove all portlets from body except 'newest unowned tickets'" );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Preferences saved' );
 
-$m->form_name ('SelectionBox-body');
-$m->click_button (name => 'movedown');
+$m->get( $url );
+$m->content_contains( 'newest unowned tickets', "'newest unowned tickets' is present" );
+$m->content_lacks( 'highest priority tickets', "'highest priority tickets' is not present" );
+$m->content_lacks( 'Bookmarked Tickets<span class="results-count">', "'Bookmarked Tickets' is not present" );  # 'Bookmarked Tickets' also shows up in the nav, so we need to be more specific
+$m->content_lacks( 'Quick ticket creation', "'Quick ticket creation' is not present" );
 
-$m->form_name ('SelectionBox-body');
-#$m->click_button (name => 'body-Save');
-$m->get ( $url );
-$m->content_contains('highest priority tickets', 'adds them back');
+# add back the previously removed portlets
+push(
+    @{$args->{body}},
+    ( "system-My Tickets", "system-Bookmarked Tickets", "component-QuickCreate" )
+);
+
+push(
+    @{$args->{sidebar}},
+    ( "component-MyReminders", "component-QueueList", "component-Dashboards", "component-RefreshHomepage", )
+);
 
+$res = $m->post(
+    $url . 'Prefs/MyRT.html',
+    $args,
+);
+
+is( $res->code, 200, 'add back previously removed portlets' );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Preferences saved' );
+
+$m->get( $url );
+$m->content_contains( 'newest unowned tickets', "'newest unowned tickets' is present" );
+$m->content_contains( 'highest priority tickets', "'highest priority tickets' is present" );
+$m->content_contains( 'Bookmarked Tickets<span class="results-count">', "'Bookmarked Tickets' is present" );
+$m->content_contains( 'Quick ticket creation', "'Quick ticket creation' is present" );
 
 #create a saved search with special chars
 $m->get( $url . "Search/Build.html" );
@@ -73,23 +99,25 @@ $m->get( $url . 'Prefs/MyRT.html' );
 $m->content_contains( 'special chars [test] [_1] ~[_1~]',
     'saved search listed in rt at a glance items' );
 
-$m->get( $url . 'Prefs/MyRT.html' );
-$m->form_name('SelectionBox-body');
-$m->field(
-    'body-Available' => [
-        'component-QuickCreate',
-        'system-Unowned Tickets',
-        'system-My Tickets',
-        'saved-' . $name,
-    ]
+# add saved search to body
+push(
+    @{$args->{body}},
+    ( "saved-" . $name )
 );
-$m->click_button( name => 'add' );
+
+$res = $m->post(
+    $url . 'Prefs/MyRT.html',
+    $args,
+);
+
+is( $res->code, 200, 'add saved search to body' );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Preferences saved' );
 
 $m->get($url);
 $m->content_like( qr/special chars \[test\] \d+ \[_1\]/,
     'special chars in titlebox' );
 
-
 # Edit a system saved search to contain "[more]"
 {
     my $search = RT::Attribute->new( RT->SystemUser );
@@ -107,3 +135,4 @@ $m->content_like( qr/special chars \[test\] \d+ \[_1\]/,
     $m->content_contains($desc . " [more]", "found description: $desc");
 }
 
+done_testing;
diff --git a/t/web/dashboards-basics.t b/t/web/dashboards-basics.t
index fd93c6a799..567eb0635b 100644
--- a/t/web/dashboards-basics.t
+++ b/t/web/dashboards-basics.t
@@ -90,22 +90,31 @@ $m->content_lacks("Subscription", "we don't have the SubscribeDashboard right");
 $m->follow_link_ok({text => "Basics"});
 $m->content_contains("Modify the dashboard different dashboard");
 
+# add 'Unowned Tickets' to body of 'different dashboard' dashboard
 $m->follow_link_ok({text => "Content"});
 $m->content_contains("Modify the content of dashboard different dashboard");
-my $form = $m->form_name('Dashboard-Searches-body');
-my @input = $form->find_input('Searches-body-Available');
-my ($unowned) =
-  map { ( $_->possible_values )[1] }
-  grep { ( $_->value_names )[1] =~ /Saved Search: Unowned Tickets/ } @input;
-$form->value('Searches-body-Available' => $unowned );
-$m->click_button(name => 'add');
-$m->content_contains("Dashboard updated");
+
+my ( $id ) = ( $m->uri =~ /id=(\d+)/ );
+ok( $id, "got a dashboard ID, $id" );  # 8
+
+my $args = {
+    UpdateSearches => "Save",
+    body           => ["system-Unowned Tickets"],
+    sidebar        => [],
+};
+
+my $res = $m->post(
+    $url . "Dashboards/Queries.html?id=$id",
+    $args,
+);
+
+is( $res->code, 200, "add 'unowned tickets' to body" );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
 
 my $dashboard = RT::Dashboard->new($currentuser);
-my ($id) = $m->content =~ /name="id" value="(\d+)"/;
-ok($id, "got an ID, $id");
 $dashboard->LoadById($id);
-is($dashboard->Name, "different dashboard");
+is($dashboard->Name, 'different dashboard', "'different dashboard' name is correct");
 
 is($dashboard->Privacy, 'RT::User-' . $user_obj->Id, "correct privacy");
 is($dashboard->PossibleHiddenSearches, 0, "all searches are visible");
@@ -114,19 +123,23 @@ my @searches = $dashboard->Searches;
 is(@searches, 1, "one saved search in the dashboard");
 like($searches[0]->Name, qr/newest unowned tickets/, "correct search name");
 
-$form = $m->form_name('Dashboard-Searches-body');
- at input = $form->find_input('Searches-body-Available');
-my ($my_tickets) =
-  map { ( $_->possible_values )[1] }
-  grep { ( $_->value_names )[1] =~ /Saved Search: My Tickets/ } @input;
-$form->value('Searches-body-Available' => $my_tickets );
-$m->click_button(name => 'add');
-$m->content_contains("Dashboard updated");
+push(
+    @{$args->{body}},
+    ( "system-My Tickets", )
+);
 
-$dashboard = RT::Dashboard->new($currentuser);
-$dashboard->LoadById($id);
+$res = $m->post(
+    $url . 'Dashboards/Queries.html?id=' . $id,
+    $args,
+);
 
+is( $res->code, 200, "add 'My Tickets' to body" );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
+
+$dashboard->LoadById($id);
 @searches = $dashboard->Searches;
+
 is(@searches, 2, "two saved searches in the dashboard");
 like($searches[0]->Name, qr/newest unowned tickets/, "correct existing search name");
 like($searches[1]->Name, qr/highest priority tickets I own/, "correct new search name");
@@ -139,6 +152,8 @@ $ticket->Create(
     Subject   => 'dashboard test',
 );
 
+$m->get_ok($url."Dashboards/index.html");
+$m->follow_link_ok({text => "different dashboard"});
 $m->follow_link_ok({id => 'page-show'});
 $m->content_contains("50 highest priority tickets I own");
 $m->content_contains("50 newest unowned tickets");
@@ -202,16 +217,29 @@ $m->content_contains("Saved dashboard system dashboard");
 
 $m->follow_link_ok({id => 'page-content'});
 
-$form = $m->form_name('Dashboard-Searches-body');
- at input = $form->find_input('Searches-body-Available');
-my ($personal) =
-  map { ( $_->possible_values )[1] }
-  grep { ( $_->value_names )[1] =~ /Saved Search: personal search/ } @input;
-$form->value('Searches-body-Available' => $personal );
-$m->click_button(name => 'add');
-$m->content_contains("Dashboard updated");
+my ( $system_id ) = ( $m->uri =~ /id=(\d+)/ );
+ok( $system_id, "got a dashboard ID for the system dashboard, $system_id" );
+
+# get the saved search name from the content
+my ( $saved_search_name ) = ( $m->content =~ /(RT::User-\d+-SavedSearch-\d+)/ );
+ok( $saved_search_name, "got a saved search name, $saved_search_name" );  # RT::User-27-SavedSearch-9
+
+push(
+    @{$args->{body}},
+    ( "saved-" . $saved_search_name, )
+);
+
+$res = $m->post(
+    $url . 'Dashboards/Queries.html?id=' . $system_id,
+    $args,
+);
+
+is( $res->code, 200, "add 'personal search' to body" );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
 
-$m->content_contains("The following queries may not be visible to all users who can see this dashboard.");
+$m->get_ok($url."Dashboards/Queries.html?id=$system_id");
+$m->content_contains("Warning: may not be visible to all viewers");
 
 $m->follow_link_ok({id => 'page-show'});
 $m->content_contains("personal search", "saved search shows up");
@@ -231,7 +259,7 @@ $omech->content_lacks("dashboard test", "matched ticket doesn't show up");
 $omech->warning_like(qr/User .* tried to load container user /, "can't see other users' personal searches");
 
 # make sure that navigating to dashboard pages with bad IDs throws an error
-my ($bad_id) = $personal =~ /^search-(\d+)/;
+my $bad_id = $system_id + 1;
 
 for my $page (qw/Modify Queries Render Subscription/) {
     $m->get("/Dashboards/$page.html?id=$bad_id");
diff --git a/t/web/dashboards-deleted-saved-search.t b/t/web/dashboards-deleted-saved-search.t
index cb96acaf01..a961363fab 100644
--- a/t/web/dashboards-deleted-saved-search.t
+++ b/t/web/dashboards-deleted-saved-search.t
@@ -1,7 +1,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 20;
+use RT::Test tests => undef;
 my ( $url, $m ) = RT::Test->started_ok;
 ok( $m->login, 'logged in' );
 
@@ -45,14 +45,21 @@ $m->get_ok( $url . "/Dashboards/Queries.html?id=$dashboard_id" );
 
 $m->content_lacks( 'value="Update"', 'no update button' );
 
-$m->submit_form(
-    form_name => 'Dashboard-Searches-body',
-    fields =>
-      { 'Searches-body-Available' => "search-$search_id-RT::User-$user_id" },
-    button => 'add',
-);
+# add foo saved search to the dashboard
+
+my $args = {
+    "dashboard_id" => $dashboard_id,
+    "body"         => "saved-" . "RT::User-" . $user_id . "-SavedSearch-" . $search_id,
+};
+
+$m->submit_form_ok({
+    form_name => 'UpdateSearches',
+    fields    => $args,
+    button    => 'UpdateSearches',
+}, "added search foo to dashboard bar" );
 
-$m->content_contains('Dashboard updated', 'added search foo to dashboard bar' );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
 
 # delete the created search
 
@@ -71,18 +78,22 @@ $m->content_lacks( $search_uri, 'deleted search foo' );
 # here is what we really want to test
 
 $m->get_ok( $url . "/Dashboards/Queries.html?id=$dashboard_id" );
-$m->content_contains('Deleted queries', 'found deleted message' );
+$m->content_contains('Unable to find search Saved Search: foo', 'found deleted message' );
 
-# Update button shows so we can update the deleted search easily
-$m->content_contains( 'value="Update"', 'found update button' );
+$args = {
+    "dashboard_id" => $dashboard_id,
+};
 
-$m->submit_form(
-    form_name => 'Dashboard-Searches-body',
-    button    => 'update',
-);
+$m->submit_form_ok({
+    form_name => 'UpdateSearches',
+    fields    => $args,
+    button    => 'UpdateSearches',
+}, "removed search foo from dashboard" );
 
-$m->content_lacks('Deleted queries', 'deleted message is gone' );
-$m->content_lacks( 'value="Update"', 'update button is gone too' );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
 
-$m->get_warnings; # we'll get a lot of warnings because the deleted search
+$m->get_ok( $url . "/Dashboards/Queries.html?id=$dashboard_id" );
+$m->content_lacks('Unable to find search Saved Search: foo', 'deleted message is gone' );
 
+done_testing;
diff --git a/t/web/dashboards-search-cache.t b/t/web/dashboards-search-cache.t
index 18989d54e8..8950cece76 100644
--- a/t/web/dashboards-search-cache.t
+++ b/t/web/dashboards-search-cache.t
@@ -1,7 +1,9 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 33;
+use RT::Test tests => undef;
+
+my $root = RT::Test->load_or_create_user( Name => 'root' );
 my ($baseurl, $m) = RT::Test->started_ok;
 
 my $url = $m->rt_base_url;
@@ -42,25 +44,36 @@ ok($dashboard_id, "got an ID, $dashboard_id");
 
 # add the search to the dashboard
 $m->follow_link_ok({text => 'Content'});
-my $form = $m->form_name('Dashboard-Searches-body');
-my @input = $form->find_input('Searches-body-Available');
-my ($search_value) =
-  map { ( $_->possible_values )[1] }
-  grep { ( $_->value_names )[1] =~ /Saved Search: Original Name/ } @input;
-$form->value('Searches-body-Available' => $search_value );
-$m->click_button(name => 'add');
-$m->text_contains('Dashboard updated');
 
-# add the dashboard to the dashboard
-$m->follow_link_ok({text => 'Content'});
-$form = $m->form_name('Dashboard-Searches-body');
- at input = $form->find_input('Searches-body-Available');
-my ($dashboard_value) =
-  map { ( $_->possible_values )[1] }
-  grep { ( $_->value_names )[1] =~ /Dashboard: inner dashboard/ } @input;
-$form->value('Searches-body-Available' => $dashboard_value );
-$m->click_button(name => 'add');
-$m->text_contains('Dashboard updated');
+# we need to get the saved search id from the content before submitting the args.
+my $regex = 'data-type="saved" data-name="RT::User-' . $root->id . '-SavedSearch-(\d+)"';
+my ($saved_search_id) = $m->content =~ /$regex/;
+ok($saved_search_id, "got an ID for the saved search, $saved_search_id");
+
+my $args = {
+    UpdateSearches => "Save",
+    dashboard_id   => $dashboard_id,
+    body           => [],
+    sidebar        => [],
+};
+
+# add 'Original Name' and 'inner dashboard' portlets to body
+push(
+    @{$args->{body}},
+    (
+      "saved-" . "RT::User-" . $root->id . "-SavedSearch-" . $saved_search_id,
+      "dashboard-dashboard-" . $inner_id . "-RT::User-" . $root->id,
+    )
+);
+
+my $res = $m->post(
+    $url . 'Dashboards/Queries.html?id=' . $dashboard_id,
+    $args,
+);
+
+is( $res->code, 200, "add 'Original Name' and 'inner dashboard' portlets to body" );
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
 
 # subscribe to the dashboard
 $m->follow_link_ok({text => 'Subscription'});
@@ -72,9 +85,9 @@ $m->text_contains('Subscribed to dashboard cachey dashboard');
 
 # rename the search
 $m->follow_link_ok({text => 'Tickets'}, 'to query builder');
-$form = $m->form_name('BuildQuery');
- at input = $form->find_input('SavedSearchLoad');
-($search_value) =
+my $form = $m->form_name('BuildQuery');
+my @input = $form->find_input('SavedSearchLoad');
+my ($search_value) =
   map { ( $_->possible_values )[1] }
   grep { ( $_->value_names )[1] =~ /Original Name/ } @input;
 $form->value('SavedSearchLoad' => $search_value );
@@ -107,3 +120,5 @@ TODO: {
 $m->get_ok("/Dashboards/Render.html?id=$dashboard_id");
 $m->text_contains('New Name');
 $m->text_unlike(qr/Original Name/); # t-w-m lacks text_lacks
+
+done_testing;

commit 252a0bca0379895a1f18ba3410e2cd5df8fa9dc7
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Apr 28 02:34:13 2020 +0800

    Load system saved searches correctly
    
    $object_id was intentionally set from "system-1" to "system" for
    rendering, but SharedSetting can't parse "system" and errors out:
    
        Could not load object system when loading search
    
    Instead, we can pass $object directly here.

diff --git a/share/html/Dashboards/Queries.html b/share/html/Dashboards/Queries.html
index 45bdf997c4..da793c10e8 100644
--- a/share/html/Dashboards/Queries.html
+++ b/share/html/Dashboards/Queries.html
@@ -122,7 +122,7 @@ for my $object (@objs) {
             $item = { type => 'saved', name => $oid, search_type => $SearchType, label => $loc_desc };
 
             my $setting = RT::SavedSearch->new($session{CurrentUser});
-            $setting->Load($object_id, $search->Id);
+            $setting->Load($object, $search->Id);
 
             $item->{possibly_hidden} = !$setting->IsVisibleTo($Dashboard->Privacy);
         }

commit 32cbce87df4222e82a68e4b3fcd6c36d9f066978
Merge: 243b18e826 252a0bca03
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Apr 28 05:17:55 2020 +0800

    Merge branch '4.6/search-selection-next' into 5.0-trunk


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


More information about the rt-commit mailing list