[Rt-commit] rt branch, 4.6/search-selection-next, created. rt-5.0.0alpha1-33-gbc8e0b9c73

Blaine Motsinger blaine at bestpractical.com
Wed Apr 22 20:12:18 EDT 2020


The branch, 4.6/search-selection-next has been created
        at  bc8e0b9c73c6b14e3fbae4831b386cf8fe4c708f (commit)

- Log -----------------------------------------------------------------
commit 7f00869a70f1fc214421641ea4f3913f910ceb8f
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..706fd3e76d 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 7d84706d042d523de33264e78847c0de2bfab64d
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.  Additionally, it changes the post to use id as a
    query param instead of form variable, so the url is correctly
    including id after post.

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 706fd3e76d..88c1386675 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/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..570a341d02 100644
--- a/share/html/Widgets/SearchSelection
+++ b/share/html/Widgets/SearchSelection
@@ -46,32 +46,25 @@
 %#
 %# 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="contents">
-          <ul>
-%           for my $item (@{ $selected{$pane} }) {
-              <& /Elements/ShowSelectSearch, %$item &>
-%           }
-          </ul>
-        </div>
-      </div>
-%   }
-  </div>
-  <div class="source">
-    <h2><&|/l&>Saved Searches</&></h2>
+  <div class="form-row">
 
+  <div class="col-md-6">
+  <div class="source">
     <div class="filters">
-      <input type="search" name="search" placeholder="<&|/l&>Search…</&>" autocomplete="off">
-      <select name="filter">
-          <option value=""><&|/l&>All Types</&></option>
+      <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>
+              <option value="<% $value %>"><% $label %></option>
 % }
-      </select>
+          </select>
+        </div>
+      </div>
     </div>
 
     <div class="contents">
@@ -90,9 +83,27 @@
 %     }
     </div>
   </div>
-</div>
+  </div>
+
+  <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>
+        </div>
+      </div>
+%   }
+  </div>
+  </div>
 
-<div class="clear"></div>
+  </div>
+</div>
 
 <%INIT>
 use utf8;
diff --git a/share/static/css/elevator-light/forms.css b/share/static/css/elevator-light/forms.css
index 83516af15a..957e6b243a 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;
@@ -493,7 +490,7 @@ textarea.code {
 }
 
 .selectionbox-js .contents h3 {
-    margin: .5em;
+    margin: .5em 0;
 }
 
 .selectionbox-js .contents .section {

commit 9f800579363333677a4ba84e9420fac7167cb459
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Wed Jan 29 16:35:31 2020 -0600

    Switch remove icon to fontawesome
    
    This commit also updates the js for the search select to clear the
    bootstrap tooltip on remove.  The tooltip will otherwise linger
    after clicking remove.

diff --git a/share/html/Elements/ShowSelectSearch b/share/html/Elements/ShowSelectSearch
index 6b2ea4da98..91b73af101 100644
--- a/share/html/Elements/ShowSelectSearch
+++ b/share/html/Elements/ShowSelectSearch
@@ -53,7 +53,11 @@ $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>
diff --git a/share/static/css/elevator-light/forms.css b/share/static/css/elevator-light/forms.css
index 957e6b243a..dc6836d251 100644
--- a/share/static/css/elevator-light/forms.css
+++ b/share/static/css/elevator-light/forms.css
@@ -473,6 +473,7 @@ textarea.code {
 .selectionbox-js .contents li a.remove {
     position: absolute;
     right: .5em;
+    color: #5C6273;
 }
 
 .selectionbox-js .contents li a.remove img {
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 12cb85b573afcbdfa941ab74d6b80f2e67505939
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Tue Feb 4 17:34:50 2020 -0600

    Force redirect back to the current page on submit
    
    If the user submits a post to "reset to default", then immediately
    a post to save new changes, the JS was instructing the browser to
    reload which then submitted the reset post again.
    
    Instead of reload, the JS now redirects back to the current page
    to clear out the previous post "state" so not to immediately reset
    the new changes.

diff --git a/share/static/js/forms.js b/share/static/js/forms.js
index 7ded228799..661a5c0fea 100644
--- a/share/static/js/forms.js
+++ b/share/static/js/forms.js
@@ -179,7 +179,16 @@ jQuery(function() {
                 timeout: 30000, /* 30 seconds */
                 success: function (response) {
                     if (response.ok) {
-                        window.location.reload();
+                        // Force redirect back to the current page on submit.
+                        // If the user submits a post to "reset to default", then immediately
+                        // a post to save new changes, the JS was instructing the browser to
+                        // reload which then submitted the reset post again.
+
+                        // Instead of reload, the JS now redirects back to the current page
+                        // to clear out the previous post "state" so not to immediately reset
+                        // the new changes.
+                        var current_location = window.location;
+                        window.location.replace( current_location );
                     }
                     else {
                         alert(response.msg);

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

    Update tests for MyRT Helper changes
    
    Adding DashboardSelectionUI to core changed the way dashboards are
    updated to now post JSON to a Helper, instead of form values to
    the page itself.  This commit updates the tests to also post JSON
    to the Helper.

diff --git a/t/mail/dashboard-chart-with-utf8.t b/t/mail/dashboard-chart-with-utf8.t
index cef5170dac..d281a71b49 100644
--- a/t/mail/dashboard-chart-with-utf8.t
+++ b/t/mail/dashboard-chart-with-utf8.t
@@ -34,16 +34,43 @@ $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->get_ok( $baseurl . '/Dashboards/Queries.html?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 ($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 json.
+my $regex = qr/data-type="saved" data-name="RT::User-/ . $root->id . qr/-SavedSearch-(\d+)"/;
+my ( $saved_search_id ) = $m->content =~ /$regex/;
+ok( $saved_search_id, "got an ID for the saved search, $saved_search_id" );
+
+my $payload = {
+    "dashboard_id" => $dashboard_id,
+    "panes"        => {
+        "body"    => [
+            {
+              "description" => "chart foo",
+              "name" => "RT::User-" . $root->id . "-SavedSearch-" . $saved_search_id,
+              "searchId" => "",
+              "searchType" => "Chart",
+              "type" => "saved"
+            },
+        ],
+        "sidebar" => [
+        ],
+    }
+};
+
+my $json = JSON::to_json( $payload );
+my $res  = $m->post(
+    $baseurl . '/Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, "add content 'Chart: chart foo' to dashboard body" );
+
+$m->get_ok( $baseurl . '/Dashboards/Queries.html?id=' . $dashboard_id );
 $m->follow_link_ok( { text => 'Subscription' } );
 $m->form_name('SubscribeDashboard');
 $m->field( 'Frequency' => 'daily' );
diff --git a/t/mail/dashboard-empty.t b/t/mail/dashboard-empty.t
index dcb70209bf..6aa6e4de4f 100644
--- a/t/mail/dashboard-empty.t
+++ b/t/mail/dashboard-empty.t
@@ -10,31 +10,69 @@ my ( $baseurl, $m ) = RT::Test->started_ok;
 ok( $m->login, 'logged in' );
 
 sub create_dashboard {
-    my ($name, $suppress_if_empty, $assets) = @_;
+    my ($dashboard_name, $suppress_if_empty, $assets) = @_;
 
     # first, create and populate a "suppress if empty" dashboard
     $m->get_ok('/Dashboards/Modify.html?Create=1');
     $m->form_name('ModifyDashboard');
-    $m->field( 'Name' => $name );
+    $m->field( 'Name' => $dashboard_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 $payload = {
+            "dashboard_id" => $dashboard_id,
+            "panes"        => {
+                "body" => [
+                ],
+                "sidebar" => [
+                ],
+            },
+        };
+
+        my $component = {
+            "description" => "",
+            "name" => "",
+            "searchId" => "",
+            "searchType" => "",
+            "type" => "",
+        };
+
+        if ( $component_name eq 'My Tickets' ) {
+            my ( $search_id ) = $m->content =~ /data-search-id="(\d+)" data-description="My Tickets"/;
+            ok( $search_id, "got an ID for the search, $search_id");
+
+            $component->{type}        = 'system';
+            $component->{description} = 'My Tickets';
+            $component->{searchId}    = $search_id;
+            $component->{name}        = 'My Tickets';
+        }
+        else {  # component_name is 'My Assets'
+            $component->{type}        = 'component';
+            $component->{description} = 'MyAssets';
+            $component->{name}        = 'MyAssets';
+        }
+
+        push @{$payload->{panes}->{body}}, $component;
+
+        my $json = JSON::to_json( $payload );
+        my $res  = $m->post(
+            $baseurl . '/Helpers/UpdateDashboard',
+            [ content => $json ],
+        );
+        is( $res->code, 200, "added '$component_name' to dashboard '$dashboard_name'" );
     };
 
     $add_component->('My Tickets') unless $assets;
     $add_component->('MyAssets') if $assets;
 
+    $m->get_ok('/Dashboards/Queries.html?id=' . $dashboard_id);
     $m->follow_link_ok( { text => 'Subscription' } );
     $m->form_name('SubscribeDashboard');
     $m->field( 'Frequency' => 'daily' );
@@ -43,7 +81,7 @@ sub create_dashboard {
     $m->field( 'SuppressIfEmpty' => 1 ) if $suppress_if_empty;
 
     $m->click_button( name => 'Save' );
-    $m->content_contains("Subscribed to dashboard $name");
+    $m->content_contains("Subscribed to dashboard $dashboard_name");
 }
 
 create_dashboard('Suppress if empty', 1);
diff --git a/t/mail/dashboards.t b/t/mail/dashboards.t
index 05ae73c2b0..79f504fcf3 100644
--- a/t/mail/dashboards.t
+++ b/t/mail/dashboards.t
@@ -17,18 +17,37 @@ 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');
-
+    my $payload = {
+        "dashboard_id" => $dashboard_id,
+        "panes"        => {
+            "body" => [
+                {
+                  "description" => "Dashboards",
+                  "name" => "Dashboards",
+                  "searchId" => "",
+                  "searchType" => "",
+                  "type" => "component"
+                },
+            ],
+            "sidebar" => [
+            ],
+        },
+    };
+
+    my $json = JSON::to_json( $payload );
+    my $res  = $m->post(
+        $baseurl . '/Helpers/UpdateDashboard',
+        [ content => $json ],
+    );
+    is( $res->code, 200, "added 'Dashboards' to dashboard 'Testing!'" );
+
+    $m->get_ok($baseurl . '/Dashboards/Queries.html?id=' . $dashboard_id);
     $m->follow_link_ok({text => 'Show'});
     $m->title_is('Testing! Dashboard');
     $m->content_contains('My dashboards');
@@ -346,8 +365,6 @@ produces_dashboard_mail_ok(
     BodyUnlike => qr/My dashboards/,
 );
 
-
-
 @mails = RT::Test->fetch_caught_mails;
 is(@mails, 0, "no mail leftover");
 
diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t
index ee5e9f58a5..fe97046513 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;
@@ -17,7 +17,7 @@ $user_obj->PrincipalObj->GrantRight(Right => 'EditSavedSearches');
 $user_obj->PrincipalObj->GrantRight(Right => 'CreateSavedSearch');
 $user_obj->PrincipalObj->GrantRight(Right => 'ModifySelf');
 
-ok $m->login( customer => 'customer' ), "logged in";
+ok $m->login( customer => 'customer' ), "logged in as non-root user";
 
 $m->get ( $url."Search/Build.html");
 
@@ -31,35 +31,105 @@ $m->click_button (name => 'SavedSearchSave');
 $m->get ( $url.'Prefs/MyRT.html' );
 $m->content_contains('stupid tickets', 'saved search listed in rt at a glance items');
 
-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');
-
-$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');
-
-$m->form_name ('SelectionBox-body');
-$m->field ('body-Selected' => ['component-QuickCreate']);
-$m->click_button (name => 'movedown');
-
-$m->form_name ('SelectionBox-body');
-$m->click_button (name => 'movedown');
+ok $m->login('root', 'password', logout => 1), 'logged in as root';
+
+# remove all portlets from the body pane except 'newest unowned tickets'
+my $payload = {
+    "dashboard_id" => "MyRT",
+    "panes"        => {
+        "body"    => [
+            {
+              "description" => "Unowned Tickets",
+              "name" => "Unowned Tickets",
+              "searchId" => "",
+              "searchType" => "",
+              "type" => "system"
+            },
+        ],
+        "sidebar" => [
+            {
+              "description" => "MyReminders",
+              "name" => "MyReminders",
+              "searchId" => "",
+              "searchType" => "",
+              "type" => "component"
+            },
+            {
+              "description" => "QueueList",
+              "name" => "QueueList",
+              "searchId" => "",
+              "searchType" => "",
+              "type" => "component"
+            },
+            {
+              "description" => "Dashboards",
+              "name" => "Dashboards",
+              "searchId" => "",
+              "searchType" => "",
+              "type" => "component"
+            },
+            {
+              "description" => "RefreshHomepage",
+              "name" => "RefreshHomepage",
+              "searchId" => "",
+              "searchType" => "",
+              "type" => "component"
+            },
+        ]
+    }
+};
+
+my $json = JSON::to_json( $payload );
+my $res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, "remove all portlets from body except 'newest unowned tickets'" );
+
+$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" );
+
+# add back the previously removed portlets
+push(
+    @{$payload->{panes}->{body}},
+    {
+      "description" => "My Tickets",
+      "name" => "My Tickets",
+      "searchId" => "",
+      "searchType" => "",
+      "type" => "system"
+    },
+    {
+      "description" => "Bookmarked Tickets",
+      "name" => "Bookmarked Tickets",
+      "searchId" => "",
+      "searchType" => "",
+      "type" => "system"
+    },
+    {
+      "description" => "QuickCreate",
+      "name" => "QuickCreate",
+      "searchId" => "",
+      "searchType" => "",
+      "type" => "component"
+    },
+);
 
-$m->form_name ('SelectionBox-body');
-#$m->click_button (name => 'body-Save');
-$m->get ( $url );
-$m->content_contains('highest priority tickets', 'adds them back');
+$json = JSON::to_json( $payload );
+$res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, 'add back previously removed portlets' );
 
+$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,22 +143,28 @@ $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(
+    @{$payload->{panes}->{body}},
+    {
+      "description" => "special chars [test] [_1] ~[_1~]",
+      "name" => $name,
+      "searchId" => "",
+      "searchType" => "Ticket",
+      "type" => "saved"
+    },
 );
-$m->click_button( name => 'add' );
+
+$json = JSON::to_json( $payload );
+$res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, 'add saved search to body' );
 
 $m->get($url);
 $m->content_like( qr/special chars \[test\] \d+ \[_1\]/,
-    'special chars in titlebox' );
-
+    "'special chars' is present" );
 
 # Edit a system saved search to contain "[more]"
 {
@@ -107,3 +183,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..9d957b5027 100644
--- a/t/web/dashboards-basics.t
+++ b/t/web/dashboards-basics.t
@@ -90,22 +90,40 @@ $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 ( $dashboard_id ) = ( $m->uri =~ /id=(\d+)/ );
+ok( $dashboard_id, "got a dashboard ID, $dashboard_id" );  # 8
+
+my $payload = {
+    "dashboard_id" => $dashboard_id,
+    "panes"        => {
+        "body"    => [
+            {
+              "description" => "Unowned Tickets",
+              "name" => "Unowned Tickets",
+              "searchId" => "4",
+              "searchType" => "",
+              "type" => "system"
+            },
+        ],
+        "sidebar" => [
+        ]
+    }
+};
+
+my $json = JSON::to_json( $payload );
+my $res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, "add 'unowned tickets' to body" );
 
 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");
+$dashboard->LoadById($dashboard_id);
+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 +132,27 @@ 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(
+    @{$payload->{panes}->{body}},
+    {
+      "description" => "My Tickets",
+      "name" => "My Tickets",
+      "searchId" => "3",
+      "searchType" => "",
+      "type" => "system"
+    },
+);
 
-$dashboard = RT::Dashboard->new($currentuser);
-$dashboard->LoadById($id);
+$json = JSON::to_json( $payload );
+$res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, "add 'My Tickets' to body" );
 
+$dashboard->LoadById($dashboard_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 +165,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");
@@ -146,14 +174,14 @@ $m->content_unlike( qr/Bookmarked Tickets.*Bookmarked Tickets/s,
     'only dashboard queries show up' );
 $m->content_contains("dashboard test", "ticket subject");
 
-$m->get_ok("/Dashboards/$id/This fragment left intentionally blank");
+$m->get_ok("/Dashboards/$dashboard_id/This fragment left intentionally blank");
 $m->content_contains("50 highest priority tickets I own");
 $m->content_contains("50 newest unowned tickets");
 $m->content_unlike( qr/Bookmarked Tickets.*Bookmarked Tickets/s,
     'only dashboard queries show up' );
 $m->content_contains("dashboard test", "ticket subject");
 
-$m->get("/Dashboards/Modify.html?id=$id&Delete=1");
+$m->get("/Dashboards/Modify.html?id=$dashboard_id&Delete=1");
 is($m->status, HTTP::Status::HTTP_FORBIDDEN);
 $m->content_contains("Permission Denied", "unable to delete dashboard because we lack DeleteOwnDashboard");
 
@@ -161,16 +189,16 @@ $m->warning_like(qr/Couldn't delete dashboard.*Permission Denied/, "got a permis
 
 $user_obj->PrincipalObj->GrantRight(Right => 'DeleteOwnDashboard', Object => $RT::System);
 
-$m->get_ok("/Dashboards/Modify.html?id=$id");
+$m->get_ok("/Dashboards/Modify.html?id=$dashboard_id");
 $m->content_contains('Delete', "Delete button shows because we have DeleteOwnDashboard");
 
 $m->form_name('ModifyDashboard');
 $m->click_button(name => 'Delete');
 $m->content_contains("Deleted dashboard");
 
-$m->get("/Dashboards/Modify.html?id=$id");
+$m->get("/Dashboards/Modify.html?id=$dashboard_id");
 $m->content_lacks("different dashboard", "dashboard was deleted");
-$m->content_contains("Could not load dashboard $id");
+$m->content_contains("Could not load dashboard $dashboard_id");
 
 $m->next_warning_like(qr/Failed to load dashboard/, "the dashboard was deleted");
 $m->next_warning_like(qr/Could not load dashboard/, "the dashboard was deleted");
@@ -202,16 +230,40 @@ $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_dashboard_id ) = ( $m->uri =~ /id=(\d+)/ );
+ok( $system_dashboard_id, "got a dashboard ID for the system dashboard, $system_dashboard_id" );  # 10
+
+# 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
+
+# add 'personal search' to 'system dashboard' dashboard
+$payload = {
+    "dashboard_id" => $system_dashboard_id,
+    "panes"        => {
+        "body"    => [
+            {
+              "description" => "personal search",
+              "name" => $saved_search_name,
+              "searchId" => "4",
+              "searchType" => "Ticket",
+              "type" => "saved"
+            },
+        ],
+        "sidebar" => [
+        ]
+    }
+};
+
+$json = JSON::to_json( $payload );
+$res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, "add 'personal search' to body" );
 
-$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_dashboard_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 +283,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_dashboard_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..dc3a7b3983 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,31 @@ $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 $payload = {
+    "dashboard_id" => $dashboard_id,
+    "panes"        => {
+        "body"    => [
+            {
+              "description" => "foo",
+              "name" => "RT::User-" . $user_id . "-SavedSearch-" . $search_id,
+              "searchId" => "",
+              "searchType" => "Ticket",
+              "type" => "saved"
+            },
+        ],
+        "sidebar" => [
+        ]
+    }
+};
+
+my $json = JSON::to_json( $payload );
+my $res  = $m->post(
+    $url . '/Helpers/UpdateDashboard',
+    [ content => $json ],
 );
-
-$m->content_contains('Dashboard updated', 'added search foo to dashboard bar' );
+is( $res->code, 200, "added search foo to dashboard bar" );
 
 # delete the created search
 
@@ -71,18 +88,25 @@ $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' );
-
-# Update button shows so we can update the deleted search easily
-$m->content_contains( 'value="Update"', 'found update button' );
-
-$m->submit_form(
-    form_name => 'Dashboard-Searches-body',
-    button    => 'update',
+$m->content_contains('Unable to find search Saved Search: foo', 'found deleted message' );
+
+$payload = {
+    "dashboard_id" => $dashboard_id,
+    "panes"        => {
+        "body"    => [
+        ],
+        "sidebar" => [
+        ]
+    }
+};
+
+$json = JSON::to_json( $payload );
+$res  = $m->post(
+    $url . '/Helpers/UpdateDashboard',
+    [ content => $json ],
 );
+is( $res->code, 200, "added search foo to dashboard" );
 
-$m->content_lacks('Deleted queries', 'deleted message is gone' );
-$m->content_lacks( 'value="Update"', 'update button is gone too' );
-
-$m->get_warnings; # we'll get a lot of warnings because the deleted search
+$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..23745d8310 100644
--- a/t/web/dashboards-search-cache.t
+++ b/t/web/dashboards-search-cache.t
@@ -1,7 +1,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 33;
+use RT::Test tests => undef;
 my ($baseurl, $m) = RT::Test->started_ok;
 
 my $url = $m->rt_base_url;
@@ -21,48 +21,78 @@ $m->field(SavedSearchDescription => 'Original Name');
 $m->click('SavedSearchSave');
 
 # create the inner dashboard
-$m->get_ok("$url/Dashboards/Modify.html?Create=1");
+$m->get_ok($url . "Dashboards/Modify.html?Create=1");
 $m->form_name('ModifyDashboard');
 $m->field('Name' => 'inner dashboard');
 $m->click_button(value => 'Create');
 $m->text_contains('Saved dashboard inner dashboard');
 
 my ($inner_id) = $m->content =~ /name="id" value="(\d+)"/;
-ok($inner_id, "got an ID, $inner_id");
+ok($inner_id, "got an ID for inner dashboard, $inner_id");
 
 # create a dashboard
-$m->get_ok("$url/Dashboards/Modify.html?Create=1");
+$m->get_ok($url . "Dashboards/Modify.html?Create=1");
 $m->form_name('ModifyDashboard');
 $m->field('Name' => 'cachey dashboard');
 $m->click_button(value => 'Create');
 $m->text_contains('Saved dashboard cachey dashboard');
 
 my ($dashboard_id) = $m->content =~ /name="id" value="(\d+)"/;
-ok($dashboard_id, "got an ID, $dashboard_id");
+ok($dashboard_id, "got an ID for cachey dashboard, $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 json.
+my ($saved_search_id) = $m->content =~ /data-type="saved" data-name="RT::User-14-SavedSearch-(\d+)"/;
+ok($saved_search_id, "got an ID for the saved search, $saved_search_id");
+
+# add 'Original Name' portlet to body
+my $payload = {
+    "dashboard_id" => $dashboard_id,
+    "panes"        => {
+        "body"    => [
+            {
+              "description" => "Original Name",
+              "name" => "RT::User-14-SavedSearch-" . $saved_search_id,
+              "searchId" => "",
+              "searchType" => "Ticket",
+              "type" => "saved"
+            },
+        ],
+        "sidebar" => [
+        ]
+    }
+};
+
+my $json = JSON::to_json( $payload );
+my $res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, "add 'Original Name' portlet to body" );
+
+# add 'inner dashboard' portlet to body
+push(
+    @{$payload->{panes}->{body}},
+    {
+      "description" => "inner dashboard",
+      "name" => "dashboard-" . $inner_id . "-RT::User-14",
+      "searchId" => "",
+      "searchType" => "",
+      "type" => "dashboard"
+    },
+);
+
+$json = JSON::to_json( $payload );
+$res  = $m->post(
+    $url . 'Helpers/UpdateDashboard',
+    [ content => $json ],
+);
+is( $res->code, 200, "add 'inner dashboard' portlet to body" );
 
 # subscribe to the dashboard
+$m->get_ok($url . "Dashboards/" . $dashboard_id . "/cachey%20dashboard");
 $m->follow_link_ok({text => 'Subscription'});
 $m->text_contains('Saved Search: Original Name');
 $m->text_contains('Dashboard: inner dashboard');
@@ -72,9 +102,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 );
@@ -87,16 +117,16 @@ $m->click_button(value => 'Update');
 $m->text_contains('Updated saved search "New Name"');
 
 # rename the dashboard
-$m->get_ok("/Dashboards/Modify.html?id=$inner_id");
+$m->get_ok($url . "Dashboards/Modify.html?id=$inner_id");
 $m->form_name('ModifyDashboard');
 $m->field('Name' => 'recursive dashboard');
 $m->click_button(value => 'Save Changes');
 $m->text_contains('Dashboard recursive dashboard updated');
 
 # check subscription page again
-$m->get_ok("/Dashboards/Subscription.html?id=$dashboard_id");
 TODO: {
     local $TODO = 'we cache search names too aggressively';
+    $m->get_ok($url . "Dashboards/Subscription.html?id=$dashboard_id");
     $m->text_contains('Saved Search: New Name');
     $m->text_unlike(qr/Saved Search: Original Name/); # t-w-m lacks text_lacks
 
@@ -104,6 +134,8 @@ TODO: {
     $m->text_unlike(qr/Dashboard: inner dashboard/); # t-w-m lacks text_lacks
 }
 
-$m->get_ok("/Dashboards/Render.html?id=$dashboard_id");
+$m->get_ok($url . "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 9c500d8d2128e1a40bc885adedf190504305e107
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Tue Apr 14 09:52:21 2020 -0500

    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..26a6e85dcd 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4608,6 +4608,132 @@ 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 $user_id   = $args->{user_id};
+    my $is_global = $args->{is_global};
+
+    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 ($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 ($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..de671ad2ac 100644
--- a/share/html/Admin/Global/MyRT.html
+++ b/share/html/Admin/Global/MyRT.html
@@ -49,20 +49,22 @@
 <& /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 $UserObj = $session{'CurrentUser'}->UserObj;
 
 my $portlets;
 my ($defaults) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
@@ -88,8 +90,8 @@ push @objs, RT::SavedSearch->new( $session{CurrentUser} )->ObjectsForLoading
     if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
                                           Object => $RT::System );
 
+my @items;
 for my $object (@objs) {
-    my @items;
     my $object_id = ref($object) . '-' . $object->Id;
     $object_id = 'system' if $object eq $sys;
 
@@ -165,4 +167,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 88c1386675..f720099815 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -49,17 +49,18 @@
 <& /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?id=<% $id %>" name="UpdateSearches" class="mx-auto max-width-lg">
   <& /Widgets/SearchSelection,
                     pane_name => \%pane_name,
                     sections  => \@sections,
                     selected  => \%selected,
                     filters   => \@filters,
     &>
-<input type="hidden" name="id" value="<% $id %>"/>
+<input type="hidden" name="dashboard_id" value="MyRT">
+<input type="hidden" name="user_id" value=<% $id %>>
 <& /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>
@@ -103,8 +104,9 @@ my @objs = ($sys);
 push @objs, RT::SavedSearch->new ($session{'CurrentUser'})->ObjectsForLoading
     if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
                                           Object => $RT::System );
+
+my @items;
 for my $object (@objs) {
-     my @items;
      my $object_id = ref($object) . '-' . $object->Id;
      $object_id = 'system' if $object eq $sys;
 
@@ -176,6 +178,17 @@ $m->callback(
     filters      => \@filters,
 );
 
+if ( $ARGS{UpdateSearches} ) {
+    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..e516425c6d 100644
--- a/share/html/Dashboards/Queries.html
+++ b/share/html/Dashboards/Queries.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='<%$Dashboard->Id%>'>
+<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="<% $id %>">
   <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
 </form>
 <%INIT>
@@ -90,8 +91,8 @@ push @objs, RT::SavedSearch->new( $session{CurrentUser} )->ObjectsForLoading
     if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
                                           Object => $RT::System );
 
+my @items;
 for my $object (@objs) {
-    my @items;
     my $object_id = ref($object) . '-' . $object->Id;
     $object_id = 'system' if $object eq $sys;
 
@@ -211,6 +212,18 @@ $m->callback(
     selected     => \%selected,
     filters      => \@filters,
 );
+
+if ( $ARGS{UpdateSearches} ) {
+    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..34da11a5fc 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>
 
@@ -134,8 +135,8 @@ push @objs, RT::SavedSearch->new( $session{CurrentUser} )->ObjectsForLoading
     if $session{'CurrentUser'}->HasRight( Right  => 'LoadSavedSearch',
                                           Object => $RT::System );
 
+my @items;
 for my $object (@objs) {
-    my @items;
     my $object_id = ref($object) . '-' . $object->Id;
     $object_id = 'system' if $object eq $sys;
 
@@ -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 661a5c0fea..291bf7d1f6 100644
--- a/share/static/js/forms.js
+++ b/share/static/js/forms.js
@@ -152,52 +152,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) {
-                        // Force redirect back to the current page on submit.
-                        // If the user submits a post to "reset to default", then immediately
-                        // a post to save new changes, the JS was instructing the browser to
-                        // reload which then submitted the reset post again.
-
-                        // Instead of reload, the JS now redirects back to the current page
-                        // to clear out the previous post "state" so not to immediately reset
-                        // the new changes.
-                        var current_location = window.location;
-                        window.location.replace( current_location );
-                    }
-                    else {
-                        alert(response.msg);
-                    }
-                },
-                error: function (xhr, reason) {
-                    alert(reason);
-                }
-            });
+            return true;
         });
     });
 });

commit bc8e0b9c73c6b14e3fbae4831b386cf8fe4c708f
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Wed Apr 22 13:58:33 2020 -0500

    Update tests for refactor to post

diff --git a/t/mail/dashboard-chart-with-utf8.t b/t/mail/dashboard-chart-with-utf8.t
index d281a71b49..4bdedac0a3 100644
--- a/t/mail/dashboard-chart-with-utf8.t
+++ b/t/mail/dashboard-chart-with-utf8.t
@@ -41,34 +41,23 @@ $m->get_ok( $baseurl . '/Dashboards/Queries.html?id=' . $dashboard_id );
 $m->follow_link_ok( { text => 'Content' } );
 
 # add content, Chart: chart foo, to dashboard body
-# we need to get the saved search id from the content before submitting the json.
-my $regex = qr/data-type="saved" data-name="RT::User-/ . $root->id . qr/-SavedSearch-(\d+)"/;
-my ( $saved_search_id ) = $m->content =~ /$regex/;
+# 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" );
 
-my $payload = {
-    "dashboard_id" => $dashboard_id,
-    "panes"        => {
-        "body"    => [
-            {
-              "description" => "chart foo",
-              "name" => "RT::User-" . $root->id . "-SavedSearch-" . $saved_search_id,
-              "searchId" => "",
-              "searchType" => "Chart",
-              "type" => "saved"
-            },
-        ],
-        "sidebar" => [
-        ],
-    }
-};
-
-my $json = JSON::to_json( $payload );
-my $res  = $m->post(
-    $baseurl . '/Helpers/UpdateDashboard',
-    [ content => $json ],
-);
-is( $res->code, 200, "add content 'Chart: chart foo' to dashboard body" );
+$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->get_ok( $baseurl . '/Dashboards/Queries.html?id=' . $dashboard_id );
 $m->follow_link_ok( { text => 'Subscription' } );
diff --git a/t/mail/dashboard-empty.t b/t/mail/dashboard-empty.t
index 6aa6e4de4f..014372525b 100644
--- a/t/mail/dashboard-empty.t
+++ b/t/mail/dashboard-empty.t
@@ -25,48 +25,26 @@ sub create_dashboard {
 
     my $add_component = sub {
         my $component_name = shift;
-
-        my $payload = {
-            "dashboard_id" => $dashboard_id,
-            "panes"        => {
-                "body" => [
-                ],
-                "sidebar" => [
-                ],
-            },
-        };
-
-        my $component = {
-            "description" => "",
-            "name" => "",
-            "searchId" => "",
-            "searchType" => "",
-            "type" => "",
-        };
+        my $arg;
 
         if ( $component_name eq 'My Tickets' ) {
-            my ( $search_id ) = $m->content =~ /data-search-id="(\d+)" data-description="My Tickets"/;
-            ok( $search_id, "got an ID for the search, $search_id");
-
-            $component->{type}        = 'system';
-            $component->{description} = 'My Tickets';
-            $component->{searchId}    = $search_id;
-            $component->{name}        = 'My Tickets';
+            $arg = 'system-My Tickets';
         }
         else {  # component_name is 'My Assets'
-            $component->{type}        = 'component';
-            $component->{description} = 'MyAssets';
-            $component->{name}        = 'MyAssets';
+            $arg = 'component-MyAssets';
         }
 
-        push @{$payload->{panes}->{body}}, $component;
-
-        my $json = JSON::to_json( $payload );
-        my $res  = $m->post(
-            $baseurl . '/Helpers/UpdateDashboard',
-            [ content => $json ],
-        );
-        is( $res->code, 200, "added '$component_name' to dashboard '$dashboard_name'" );
+        $m->submit_form_ok({
+            form_name => 'UpdateSearches',
+            fields    => {
+                dashboard_id => $dashboard_id,
+                body         => $arg,
+        },
+        button => 'UpdateSearches',
+        }, "added '$component_name' to dashboard '$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 79f504fcf3..e13f1da8ac 100644
--- a/t/mail/dashboards.t
+++ b/t/mail/dashboards.t
@@ -23,29 +23,17 @@ sub create_dashboard {
     $m->follow_link_ok({text => 'Content'});
     $m->title_is('Modify the content of dashboard Testing!');
 
-    my $payload = {
-        "dashboard_id" => $dashboard_id,
-        "panes"        => {
-            "body" => [
-                {
-                  "description" => "Dashboards",
-                  "name" => "Dashboards",
-                  "searchId" => "",
-                  "searchType" => "",
-                  "type" => "component"
-                },
-            ],
-            "sidebar" => [
-            ],
+    $m->submit_form_ok({
+        form_name => 'UpdateSearches',
+        fields    => {
+            dashboard_id => $dashboard_id,
+            body         => 'component-Dashboards',
         },
-    };
-
-    my $json = JSON::to_json( $payload );
-    my $res  = $m->post(
-        $baseurl . '/Helpers/UpdateDashboard',
-        [ content => $json ],
-    );
-    is( $res->code, 200, "added 'Dashboards' to dashboard 'Testing!'" );
+        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->get_ok($baseurl . '/Dashboards/Queries.html?id=' . $dashboard_id);
     $m->follow_link_ok({text => 'Show'});
diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t
index fe97046513..ca08f7225f 100644
--- a/t/web/custom_frontpage.t
+++ b/t/web/custom_frontpage.t
@@ -33,58 +33,27 @@ $m->content_contains('stupid tickets', 'saved search listed in rt at a glance it
 
 ok $m->login('root', 'password', logout => 1), 'logged in as root';
 
-# remove all portlets from the body pane except 'newest unowned tickets'
-my $payload = {
-    "dashboard_id" => "MyRT",
-    "panes"        => {
-        "body"    => [
-            {
-              "description" => "Unowned Tickets",
-              "name" => "Unowned Tickets",
-              "searchId" => "",
-              "searchType" => "",
-              "type" => "system"
-            },
-        ],
-        "sidebar" => [
-            {
-              "description" => "MyReminders",
-              "name" => "MyReminders",
-              "searchId" => "",
-              "searchType" => "",
-              "type" => "component"
-            },
-            {
-              "description" => "QueueList",
-              "name" => "QueueList",
-              "searchId" => "",
-              "searchType" => "",
-              "type" => "component"
-            },
-            {
-              "description" => "Dashboards",
-              "name" => "Dashboards",
-              "searchId" => "",
-              "searchType" => "",
-              "type" => "component"
-            },
-            {
-              "description" => "RefreshHomepage",
-              "name" => "RefreshHomepage",
-              "searchId" => "",
-              "searchType" => "",
-              "type" => "component"
-            },
-        ]
-    }
+my $args = {
+    UpdateSearches => "Save",
+    dashboard_id   => "MyRT",
+    body           => [],
+    sidebar        => [],
 };
 
-my $json = JSON::to_json( $payload );
-my $res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
+# remove all portlets from the body pane except 'newest unowned tickets'
+push(
+    @{$args->{body}},
+    ( "system-Unowned Tickets", )
+);
+
+my $res = $m->post(
+    $url . 'Prefs/MyRT.html',
+    $args,
 );
+
 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->get( $url );
 $m->content_contains( 'newest unowned tickets', "'newest unowned tickets' is present" );
@@ -94,36 +63,23 @@ $m->content_lacks( 'Quick ticket creation', "'Quick ticket creation' is not pres
 
 # add back the previously removed portlets
 push(
-    @{$payload->{panes}->{body}},
-    {
-      "description" => "My Tickets",
-      "name" => "My Tickets",
-      "searchId" => "",
-      "searchType" => "",
-      "type" => "system"
-    },
-    {
-      "description" => "Bookmarked Tickets",
-      "name" => "Bookmarked Tickets",
-      "searchId" => "",
-      "searchType" => "",
-      "type" => "system"
-    },
-    {
-      "description" => "QuickCreate",
-      "name" => "QuickCreate",
-      "searchId" => "",
-      "searchType" => "",
-      "type" => "component"
-    },
+    @{$args->{body}},
+    ( "system-My Tickets", "system-Bookmarked Tickets", "component-QuickCreate" )
 );
 
-$json = JSON::to_json( $payload );
-$res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
+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" );
@@ -145,22 +101,18 @@ $m->content_contains( 'special chars [test] [_1] ~[_1~]',
 
 # add saved search to body
 push(
-    @{$payload->{panes}->{body}},
-    {
-      "description" => "special chars [test] [_1] ~[_1~]",
-      "name" => $name,
-      "searchId" => "",
-      "searchType" => "Ticket",
-      "type" => "saved"
-    },
+    @{$args->{body}},
+    ( "saved-" . $name )
 );
 
-$json = JSON::to_json( $payload );
-$res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
+$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\]/,
diff --git a/t/web/dashboards-basics.t b/t/web/dashboards-basics.t
index 9d957b5027..3987e6eec0 100644
--- a/t/web/dashboards-basics.t
+++ b/t/web/dashboards-basics.t
@@ -97,29 +97,28 @@ $m->content_contains("Modify the content of dashboard different dashboard");
 my ( $dashboard_id ) = ( $m->uri =~ /id=(\d+)/ );
 ok( $dashboard_id, "got a dashboard ID, $dashboard_id" );  # 8
 
-my $payload = {
-    "dashboard_id" => $dashboard_id,
-    "panes"        => {
-        "body"    => [
-            {
-              "description" => "Unowned Tickets",
-              "name" => "Unowned Tickets",
-              "searchId" => "4",
-              "searchType" => "",
-              "type" => "system"
-            },
-        ],
-        "sidebar" => [
-        ]
-    }
+my $args = {
+    UpdateSearches => "Save",
+    dashboard_id   => undef,
+    body           => [],
+    sidebar        => [],
 };
 
-my $json = JSON::to_json( $payload );
-my $res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
+$args->{dashboard_id} = $dashboard_id;
+
+push(
+    @{$args->{body}},
+    ( "system-Unowned Tickets", )
 );
+
+my $res = $m->post(
+    $url . 'Dashboards/Queries.html?id=' . $dashboard_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);
 $dashboard->LoadById($dashboard_id);
@@ -133,22 +132,18 @@ is(@searches, 1, "one saved search in the dashboard");
 like($searches[0]->Name, qr/newest unowned tickets/, "correct search name");
 
 push(
-    @{$payload->{panes}->{body}},
-    {
-      "description" => "My Tickets",
-      "name" => "My Tickets",
-      "searchId" => "3",
-      "searchType" => "",
-      "type" => "system"
-    },
+    @{$args->{body}},
+    ( "system-My Tickets", )
 );
 
-$json = JSON::to_json( $payload );
-$res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
+$res = $m->post(
+    $url . 'Dashboards/Queries.html?id=' . $dashboard_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($dashboard_id);
 @searches = $dashboard->Searches;
@@ -231,36 +226,28 @@ $m->content_contains("Saved dashboard system dashboard");
 $m->follow_link_ok({id => 'page-content'});
 
 my ( $system_dashboard_id ) = ( $m->uri =~ /id=(\d+)/ );
-ok( $system_dashboard_id, "got a dashboard ID for the system dashboard, $system_dashboard_id" );  # 10
+ok( $system_dashboard_id, "got a dashboard ID for the system dashboard, $system_dashboard_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
 
 # add 'personal search' to 'system dashboard' dashboard
-$payload = {
-    "dashboard_id" => $system_dashboard_id,
-    "panes"        => {
-        "body"    => [
-            {
-              "description" => "personal search",
-              "name" => $saved_search_name,
-              "searchId" => "4",
-              "searchType" => "Ticket",
-              "type" => "saved"
-            },
-        ],
-        "sidebar" => [
-        ]
-    }
-};
+$args->{dashboard_id} = $system_dashboard_id;
+
+push(
+    @{$args->{body}},
+    ( "saved-" . $saved_search_name, )
+);
 
-$json = JSON::to_json( $payload );
-$res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
+$res = $m->post(
+    $url . 'Dashboards/Queries.html?id=' . $system_dashboard_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->get_ok($url."Dashboards/Queries.html?id=$system_dashboard_id");
 $m->content_contains("Warning: may not be visible to all viewers");
diff --git a/t/web/dashboards-deleted-saved-search.t b/t/web/dashboards-deleted-saved-search.t
index dc3a7b3983..a961363fab 100644
--- a/t/web/dashboards-deleted-saved-search.t
+++ b/t/web/dashboards-deleted-saved-search.t
@@ -47,29 +47,19 @@ $m->content_lacks( 'value="Update"', 'no update button' );
 
 # add foo saved search to the dashboard
 
-my $payload = {
+my $args = {
     "dashboard_id" => $dashboard_id,
-    "panes"        => {
-        "body"    => [
-            {
-              "description" => "foo",
-              "name" => "RT::User-" . $user_id . "-SavedSearch-" . $search_id,
-              "searchId" => "",
-              "searchType" => "Ticket",
-              "type" => "saved"
-            },
-        ],
-        "sidebar" => [
-        ]
-    }
+    "body"         => "saved-" . "RT::User-" . $user_id . "-SavedSearch-" . $search_id,
 };
 
-my $json = JSON::to_json( $payload );
-my $res  = $m->post(
-    $url . '/Helpers/UpdateDashboard',
-    [ content => $json ],
-);
-is( $res->code, 200, "added search foo to dashboard bar" );
+$m->submit_form_ok({
+    form_name => 'UpdateSearches',
+    fields    => $args,
+    button    => 'UpdateSearches',
+}, "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
 
@@ -90,23 +80,20 @@ $m->content_lacks( $search_uri, 'deleted search foo' );
 $m->get_ok( $url . "/Dashboards/Queries.html?id=$dashboard_id" );
 $m->content_contains('Unable to find search Saved Search: foo', 'found deleted message' );
 
-$payload = {
+$args = {
     "dashboard_id" => $dashboard_id,
-    "panes"        => {
-        "body"    => [
-        ],
-        "sidebar" => [
-        ]
-    }
 };
 
-$json = JSON::to_json( $payload );
-$res  = $m->post(
-    $url . '/Helpers/UpdateDashboard',
-    [ content => $json ],
-);
-is( $res->code, 200, "added search foo to dashboard" );
+$m->submit_form_ok({
+    form_name => 'UpdateSearches',
+    fields    => $args,
+    button    => 'UpdateSearches',
+}, "removed search foo from dashboard" );
 
+like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
+
+$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 23745d8310..d77397d474 100644
--- a/t/web/dashboards-search-cache.t
+++ b/t/web/dashboards-search-cache.t
@@ -2,6 +2,8 @@ use strict;
 use warnings;
 
 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;
@@ -43,53 +45,35 @@ ok($dashboard_id, "got an ID for cachey dashboard, $dashboard_id");
 # add the search to the dashboard
 $m->follow_link_ok({text => 'Content'});
 
-# we need to get the saved search id from the content before submitting the json.
-my ($saved_search_id) = $m->content =~ /data-type="saved" data-name="RT::User-14-SavedSearch-(\d+)"/;
+# 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");
 
-# add 'Original Name' portlet to body
-my $payload = {
-    "dashboard_id" => $dashboard_id,
-    "panes"        => {
-        "body"    => [
-            {
-              "description" => "Original Name",
-              "name" => "RT::User-14-SavedSearch-" . $saved_search_id,
-              "searchId" => "",
-              "searchType" => "Ticket",
-              "type" => "saved"
-            },
-        ],
-        "sidebar" => [
-        ]
-    }
+my $args = {
+    UpdateSearches => "Save",
+    dashboard_id   => $dashboard_id,
+    body           => [],
+    sidebar        => [],
 };
 
-my $json = JSON::to_json( $payload );
-my $res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
-);
-is( $res->code, 200, "add 'Original Name' portlet to body" );
-
-# add 'inner dashboard' portlet to body
+# add 'Original Name' and 'inner dashboard' portlets to body
 push(
-    @{$payload->{panes}->{body}},
-    {
-      "description" => "inner dashboard",
-      "name" => "dashboard-" . $inner_id . "-RT::User-14",
-      "searchId" => "",
-      "searchType" => "",
-      "type" => "dashboard"
-    },
+    @{$args->{body}},
+    (
+      "saved-" . "RT::User-" . $root->id . "-SavedSearch-" . $saved_search_id,
+      "dashboard-dashboard-" . $inner_id . "-RT::User-" . $root->id,
+    )
 );
 
-$json = JSON::to_json( $payload );
-$res  = $m->post(
-    $url . 'Helpers/UpdateDashboard',
-    [ content => $json ],
+my $res = $m->post(
+    $url . 'Dashboards/Queries.html?id=' . $dashboard_id,
+    $args,
 );
-is( $res->code, 200, "add 'inner dashboard' portlet to body" );
+
+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->get_ok($url . "Dashboards/" . $dashboard_id . "/cachey%20dashboard");

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


More information about the rt-commit mailing list