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

Blaine Motsinger blaine at bestpractical.com
Thu Mar 12 19:46:35 EDT 2020


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

- Log -----------------------------------------------------------------
commit 417ab98c42878bea387d99d2f6941de35a2c3c6f
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/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index a6f62fe06..19dcff044 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -115,6 +115,7 @@ sub JSFiles {
         jquery-ui.min.js
         jquery-ui-timepicker-addon.js
         jquery-ui-patch-datepicker.js
+        jquery-ui-disableSelection.min.js
         jquery.cookie.js
         selectize.min.js
         popper.min.js
diff --git a/share/html/Admin/Global/MyRT.html b/share/html/Admin/Global/MyRT.html
index f582af6e5..2ec3a597c 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 99d069cec..706fd3e76 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 ddc5649ed..a4e855a80 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 785ea588d..dfbb8522c 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 ddc5649ed..6b2ea4da9 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 000000000..fb686ac61
--- /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 1bc841f80..15c6bccb4 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 ddc5649ed..7416e4a4b 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 e422e40ba..83516af15 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 3c6f9b93d..a8d5392b8 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();
+               }
+           },
+        }).disableSelection().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');
+                }
+            }
+        }).disableSelection();
+
+        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 f1229ea32e236f76d891e76e8dc1d7a1568f3c4c
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Fri Jan 24 15:18:40 2020 -0600

    Add jQuery ui disableSelection
    
    Our jQuery UI build doesn't contain disableSelection which is
    used within the DashboardSelectionUI extension code.  Rather than
    upgrade our core jQuery UI build, this commit adds a minified
    disableSelection to be loaded through Web.pm.

diff --git a/share/static/js/jquery-ui-disableSelection.min.js b/share/static/js/jquery-ui-disableSelection.min.js
new file mode 100644
index 000000000..d6b2fec67
--- /dev/null
+++ b/share/static/js/jquery-ui-disableSelection.min.js
@@ -0,0 +1,6 @@
+/*! jQuery UI - v1.12.1 - 2020-01-24
+* http://jqueryui.com
+* Includes: disable-selection.js
+* Copyright jQuery Foundation and other contributors; Licensed MIT */
+
+(function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)})(function(t){t.ui=t.ui||{},t.ui.version="1.12.1",t.fn.extend({disableSelection:function(){var t="onselectstart"in document.createElement("div")?"selectstart":"mousedown";return function(){return this.on(t+".ui-disableSelection",function(t){t.preventDefault()})}}(),enableSelection:function(){return this.off(".ui-disableSelection")}})});

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

    Update DashboardSelectionUI to elevator-light
    
    General design updates were also made from the original import to
    make it look better with our elevator-light theme.

diff --git a/share/html/Admin/Global/MyRT.html b/share/html/Admin/Global/MyRT.html
index 2ec3a597c..2d503d416 100644
--- a/share/html/Admin/Global/MyRT.html
+++ b/share/html/Admin/Global/MyRT.html
@@ -56,7 +56,7 @@
     selected  => \%selected,
     filters   => \@filters,
   &>
-  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+  <input type="submit" class="button form-control btn btn-primary" name="UpdateSearches" value="<% loc('Save') %>" />
 </form>
 
 <%INIT>
diff --git a/share/html/Admin/Users/MyRT.html b/share/html/Admin/Users/MyRT.html
index 706fd3e76..ad4e2861f 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -57,7 +57,7 @@
                     filters   => \@filters,
     &>
 <input type="hidden" name="id" value="<% $id %>"/>
-<input type"submit" class="button" name="UpdateSearches" value= "<% loc('Save') %>"  >
+<input type="submit" class="button form-control btn btn-primary" 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')%>">
diff --git a/share/html/Dashboards/Queries.html b/share/html/Dashboards/Queries.html
index dfbb8522c..2bd687ca8 100644
--- a/share/html/Dashboards/Queries.html
+++ b/share/html/Dashboards/Queries.html
@@ -56,7 +56,7 @@
     selected  => \%selected,
     filters   => \@filters,
   &>
-  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+  <input type="submit" class="button form-control btn btn-primary" name="UpdateSearches" value="<% loc('Save') %>" />
 </form>
 <%INIT>
 my @results;
diff --git a/share/html/Prefs/MyRT.html b/share/html/Prefs/MyRT.html
index 15c6bccb4..5f4c2c6b0 100644
--- a/share/html/Prefs/MyRT.html
+++ b/share/html/Prefs/MyRT.html
@@ -56,7 +56,7 @@
     selected  => \%selected,
     filters   => \@filters,
   &>
-  <input type="submit" class="button" name="UpdateSearches" value="<% loc('Save') %>" />
+  <input type="submit" class="button form-control btn btn-primary" name="UpdateSearches" value="<% loc('Save') %>" />
 </form>
 
 <&|/Widgets/TitleBox, title => loc('Options'), bodyclass => "" &>
@@ -69,7 +69,7 @@
     <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>
@@ -77,7 +77,7 @@
 <&|/Widgets/TitleBox, title => loc("Reset RT at a glance") &>
 <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 7416e4a4b..54c19b7a5 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-4">
+  <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-4 ml-2">
+  <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 83516af15..957e6b243 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 da0a71c661344cec71fdd5e3f1feb6c0806d24da
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 6b2ea4da9..91b73af10 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 957e6b243..dc6836d25 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 a8d5392b8..95848550a 100644
--- a/share/static/js/forms.js
+++ b/share/static/js/forms.js
@@ -50,6 +50,13 @@ jQuery(function() {
         }).disableSelection().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 4f2f62a9f5f10bbe7316213fc467001f23fad7b8
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Thu Jan 30 13:36:35 2020 -0600

    Fix reset to defaults
    
    This commit fixes the reset to defaults for the page by using the
    reset logic from the 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/Users/MyRT.html b/share/html/Admin/Users/MyRT.html
index ad4e2861f..707f96946 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -58,11 +58,14 @@
     &>
 <input type="hidden" name="id" value="<% $id %>"/>
 <input type="submit" class="button form-control btn btn-primary" 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')%>">
+</form>
+<form method="post" action="MyRT.html?id=<% $id %>">
+  <input type="hidden" name="Reset" value="1" />
+  <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>
-</div>
 </form>
 
 <%init>
@@ -72,10 +75,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;

commit 622c603cd0331c46088f492fad539672b1eb0122
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 95848550a..f95c9e7ae 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 01ccfd4dce93ebe8dc0b58cd63b39016e17fd90d
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 cef5170da..d281a71b4 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 dcb70209b..6aa6e4de4 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 05ae73c2b..79f504fcf 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 ee5e9f58a..fe9704651 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 fd93c6a79..9d957b502 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 cb96acaf0..dc3a7b398 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 18989d54e..23745d831 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;

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


More information about the rt-commit mailing list