[Rt-commit] rt branch, 5.0/use-dashboard-for-homepage-select-ui, created. rt-5.0.1-337-gfaeb3ca7c0

? sunnavy sunnavy at bestpractical.com
Fri May 28 19:04:37 EDT 2021


The branch, 5.0/use-dashboard-for-homepage-select-ui has been created
        at  faeb3ca7c043f8f6bbe84e97a8fc41db8be02ca9 (commit)

- Log -----------------------------------------------------------------
commit bb94ab4cf265c6a53e0ece871955abc7e5e478e2
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri May 14 02:10:29 2021 +0800

    Migrate "RT at a glance" homepage to dashboard
    
    Technically, "HomepageSettings" attributes are converted to dashboards
    named "Homepage", and "DefaultDashboard" attributes keep the id of the
    chosen dashboards to render "RT at a glance".
    
    As there is no special format for "HomepageSettings", method
    UpdateDashboard is simplifed to handle dashboards only.

diff --git a/etc/initialdata b/etc/initialdata
index 1ebe6d7fba..8e27963059 100644
--- a/etc/initialdata
+++ b/etc/initialdata
@@ -900,50 +900,6 @@ Hour:         { $SubscriptionObj->SubValue('Hour') }
         OrderBy => 'LastUpdated',
         Order   => 'DESC' },
     },
-    {
-        Name        => 'HomepageSettings',
-        Description => 'HomepageSettings',
-        Content     => {
-            'body' =>                               # loc_left_pair
-              [
-                {
-                    type => 'system',
-                    name => 'My Tickets',           # loc
-                },
-                {
-                    type => 'system',
-                    name => 'Unowned Tickets'       # loc
-                },
-                {
-                    type => 'system',
-                    name => 'Bookmarked Tickets'    # loc
-                },
-                {
-                    type => 'component',
-                    name => 'QuickCreate'           # loc
-                },
-              ],
-            'sidebar' =>                            # loc_left_pair
-              [
-                {
-                    type => 'component',
-                    name => 'MyReminders'           # loc
-                },
-                {
-                    type => 'component',
-                    name => 'QueueList'           # loc
-                },
-                {
-                    type => 'component',
-                    name => 'Dashboards'            # loc
-                },
-                {
-                    type => 'component',
-                    name => 'RefreshHomepage'       # loc
-                },
-              ],
-        },
-    },
 # initial reports
     { Name => 'ReportsInMenu',
       Description => 'Content of the Reports menu', #loc
@@ -983,3 +939,95 @@ Hour:         { $SubscriptionObj->SubValue('Hour') }
         MaxValues         => 1,
     },
 );
+
+ at Final = (
+    sub {
+        my $dashboard = RT::Dashboard->new( RT->SystemUser );
+        my ( $ret, $msg ) = $dashboard->Save(
+            Name    => 'Homepage',
+            Privacy => join( '-', ref( RT->System ), RT->System->Id ),
+        );
+
+        if ($ret) {
+            my @searches;
+            for my $search_name ( 'My Tickets', 'Unowned Tickets', 'Bookmarked Tickets' ) {
+                my ($search) = RT::System->new( RT->SystemUser )->Attributes->Named( 'Search - ' . $search_name );
+                if ($search) {
+                    push @searches,
+                        {
+                        pane         => 'body',
+                        portlet_type => 'search',
+                        id           => $search->Id,
+                        description  => "Saved Search: $search_name",
+                        privacy      => join( '-', ref( RT->System ), RT->System->Id ),
+                        };
+                }
+                else {
+                    RT->Logger->error("Couldn't find search $search_name");
+                }
+            }
+
+            my $panes = {
+                body => [
+                    @searches,
+                    {   pane         => 'body',
+                        portlet_type => 'component',
+                        component    => 'QuickCreate',
+                        description  => 'QuickCreate',
+                        path         => '/Elements/QuickCreate',
+                    },
+                ],
+                sidebar => [
+                    {   pane         => 'sidebar',
+                        portlet_type => 'component',
+                        component    => 'MyReminders',
+                        description  => 'MyReminders',
+                        path         => '/Elements/MyReminders',
+                    },
+                    {   pane         => 'sidebar',
+                        portlet_type => 'component',
+                        component    => 'QueueList',
+                        description  => 'QueueList',
+                        path         => '/Elements/QueueList',
+                    },
+                    {   pane         => 'sidebar',
+                        portlet_type => 'component',
+                        component    => 'MyReminders',
+                        description  => 'MyReminders',
+                        path         => '/Elements/MyReminders',
+                    },
+                    {   pane         => 'sidebar',
+                        portlet_type => 'component',
+                        component    => 'Dashboards',
+                        description  => 'Dashboards',
+                        path         => '/Elements/Dashboards',
+                    },
+                    {   pane         => 'sidebar',
+                        portlet_type => 'component',
+                        component    => 'RefreshHomepage',
+                        description  => 'RefreshHomepage',
+                        path         => '/Elements/RefreshHomepage',
+                    },
+                ]
+            };
+
+            # fill content
+            my ( $ret, $msg ) = $dashboard->Update( Panes => $panes );
+            if ( !$ret ) {
+                RT->Logger->error("Couldn't update content for dashboard Homepage: $msg");
+            }
+
+            ( $ret, $msg ) = RT->System->SetAttribute(
+                'Name'        => 'DefaultDashboard',
+                'Description' => 'Default Dashboard',
+                'Content'     => $dashboard->Id,
+            );
+            if ( !$ret ) {
+                RT->Logger->error("Couldn't set DefaultDashboard: $msg");
+            }
+        }
+        else {
+            RT->Logger->error("Couldn't create dashboard Homepage: $msg");
+        }
+    },
+);
diff --git a/etc/upgrade/5.0.2/content b/etc/upgrade/5.0.2/content
new file mode 100644
index 0000000000..0fba4ab9fd
--- /dev/null
+++ b/etc/upgrade/5.0.2/content
@@ -0,0 +1,149 @@
+use strict;
+use warnings;
+
+our @Final = (
+    sub {
+        my $attrs = RT::Attributes->new( RT->SystemUser );
+        $attrs->Limit( FIELD => 'Name', VALUE => [ 'Pref-HomepageSettings', 'HomepageSettings' ], OPERATOR => 'IN' );
+    OUTER: while ( my $attr = $attrs->Next ) {
+            my $attr_id = $attr->Id;
+            my $object  = $attr->Object;
+            my $content = $attr->Content;
+
+            if ( $object && ( $object->isa('RT::User') || $object->isa('RT::System') ) && $content ) {
+                my $dashboard = RT::Dashboard->new( RT->SystemUser );
+                my $panes     = {};
+
+                for my $pane ( sort keys %$content ) {
+                    my $list = $content->{$pane} or next;
+                    for my $entry (@$list) {
+                        my $new_entry = { pane => $pane };
+                        if ( $entry->{type} eq 'system' ) {
+                            if ( my $name = $entry->{name} ) {
+                                my ($search)
+                                    = RT::System->new( RT->SystemUser )->Attributes->Named( 'Search - ' . $name );
+
+                                # Check user created system searches
+                                if ( !$search ) {
+                                    my (@searches)
+                                        = RT::System->new( RT->SystemUser )->Attributes->Named('SavedSearch');
+                                    for my $custom (@searches) {
+                                        if ( $custom->Description eq $entry->{name} ) {
+                                            $search = $custom;
+                                            last;
+                                        }
+                                    }
+                                }
+
+                                if ( $search ) {
+                                    $new_entry->{portlet_type} = 'search';
+                                    $new_entry->{id}           = $search->Id;
+                                    $new_entry->{description}  = "Saved Search: $name";
+                                    $new_entry->{privacy}      = 'RT::System-1';
+                                }
+                                else {
+                                    RT->Logger->warning(
+                                        "System search $name in attribute #$attr_id not found, skipping");
+                                    next;
+                                }
+                            }
+                            else {
+                                RT->Logger->warning("Missing system search name in attribute #$attr_id, skipping");
+                                next;
+                            }
+                        }
+                        elsif ( $entry->{type} eq 'saved' ) {
+                            if ( my $name = $entry->{name} ) {
+                                if ( $name =~ /(.+)-SavedSearch-(\d+)/ ) {
+                                    $new_entry->{privacy}      = $1;
+                                    $new_entry->{id}           = $2;
+                                    $new_entry->{portlet_type} = 'search';
+                                    my $search = RT::Attribute->new( RT->SystemUser );
+                                    $search->Load( $new_entry->{id} );
+                                    if ( $search->Id ) {
+                                        $new_entry->{description} = "Saved Search: " . $search->Description;
+                                    }
+                                    else {
+                                        RT->Logger->warning(
+                                            "Saved search $name in attribute #$attr_id not found, skipping");
+                                        next;
+                                    }
+                                }
+                                else {
+                                    RT->Logger->warning(
+                                        "System search $name in attribute #$attr_id not found, skipping");
+                                    next;
+                                }
+                            }
+                            else {
+                                RT->Logger->warning("Missing system search name in attribute #$attr_id, skipping");
+                                next;
+                            }
+                        }
+                        elsif ( $entry->{type} eq 'component' ) {
+                            $new_entry->{portlet_type} = 'component';
+                            $new_entry->{component}    = $entry->{name};
+                            $new_entry->{description}  = $entry->{name};
+                            $new_entry->{path}         = "/Elements/$entry->{name}";
+                        }
+                        else {
+                            RT->Logger->warning("Unsupported type $entry->{type} in attribute #$attr_id, skipping");
+                            next;
+                        }
+                        push @{$panes->{$pane}}, $new_entry;
+                    }
+                }
+
+                $RT::Handle->BeginTransaction;
+                my %new_values = (
+                    'Name'        => 'Dashboard',
+                    'Description' => 'Homepage',
+                    'Content'     => { Panes => $panes },
+                );
+
+                for my $field ( sort keys %new_values ) {
+                    my $method = "Set$field";
+                    my ( $ret, $msg ) = $attr->$method( $new_values{$field} );
+                    if ( !$ret ) {
+                        RT->Logger->error("Couldn't update $field of attribute #$attr_id: $msg");
+                        $RT::Handle->Rollback;
+                        next OUTER;
+                    }
+                }
+
+                my ( $id, $msg ) = $object->SetAttribute(
+                    'Name'        => $object->isa('RT::User') ? 'Pref-DefaultDashboard' : 'DefaultDashboard',
+                    'Description' => 'Default Dashboard',
+                    'Content'     => $attr_id,
+                );
+                if ($id) {
+                    $RT::Handle->Commit;
+                }
+                else {
+                    RT->Logger->error("Couldn't set DefaultDashboard to $id for attribute #$attr_id: $msg");
+                    $RT::Handle->Rollback;
+                }
+            }
+        }
+    },
+    sub {
+        my $acl = RT::ACL->new(RT->SystemUser);
+
+        # Grant dashboard rights so users with ModifySelf can still
+        # customize MyRT
+        $acl->Limit( FIELD => 'RightName', VALUE => 'ModifySelf' );
+        while ( my $ace = $acl->Next ) {
+            my $object = $ace->Object;
+            my $principal = $ace->PrincipalObj;
+
+            for my $right ( 'SeeOwnDashboard', 'CreateOwnDashboard', 'ModifyOwnDashboard', 'DeleteOwnDashboard' ) {
+                if ( !$principal->HasRight( Object => $object, Right => $right ) ) {
+                    my ( $ret, $msg ) = $principal->GrantRight( Object => $object, Right => $right );
+                    if ( !$ret ) {
+                        RT->Logger->error( "Couldn't grant $right to user #" . $object->Id . ": $msg" );
+                    }
+                }
+            }
+        }
+    },
+);
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 3170c298ba..67d8b31ebd 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4761,81 +4761,63 @@ sub UpdateDashboard {
     }
 
     my ( $ok, $msg );
-    if ( $id eq 'MyRT' ) {
-        my $user = $session{CurrentUser};
-
-        if ( my $user_id = $args->{user_id} ) {
-            my $UserObj = RT::User->new( $session{'CurrentUser'} );
-            ( $ok, $msg ) = $UserObj->Load($user_id);
-            return ( $ok, $msg ) unless $ok;
-
-            return ( $ok, $msg ) = $UserObj->SetPreferences( 'HomepageSettings', $data->{panes} );
-        } elsif ( $args->{is_global} ) {
-            my $sys = RT::System->new( $session{'CurrentUser'} );
-            my ($default_portlets) = $sys->Attributes->Named('HomepageSettings');
-            return ( $ok, $msg ) = $default_portlets->SetContent( $data->{panes} );
-        } else {
-            return ( $ok, $msg ) = $user->SetPreferences( 'HomepageSettings', $data->{panes} );
-        }
-    } else {
-        my $class = $args->{self_service_dashboard} ? 'RT::Dashboard::SelfService' : 'RT::Dashboard';
-        my $Dashboard = $class->new( $session{'CurrentUser'} );
-        ( $ok, $msg ) = $Dashboard->LoadById($id);
-
-        # report error at the bottom
-        return ( $ok, $msg ) unless $ok && $Dashboard->Id;
-
-        my $content;
-        for my $pane_name ( keys %{ $data->{panes} } ) {
-            my @pane;
-
-            for my $item ( @{ $data->{panes}{$pane_name} } ) {
-                my %saved;
-                $saved{pane}         = $pane_name;
-                $saved{portlet_type} = $item->{type};
-
-                $saved{description} = $available_items->{ $item->{type} }{ $item->{name} }{label};
-
-                if ( $item->{type} eq 'component' ) {
-                    $saved{component} = $item->{name};
-
-                    # Absolute paths stay absolute, relative paths go into
-                    # /Elements. This way, extensions that add portlets work.
-                    my $path = $item->{name};
-                    $path = "/Elements/$path" if substr( $path, 0, 1 ) ne '/';
-
-                    $saved{path} = $path;
-                } elsif ( $item->{type} eq 'saved' ) {
-                    $saved{portlet_type} = 'search';
-
-                    $item->{searchType} = $available_items->{ $item->{type} }{ $item->{name} }{search_type}
-                                          if exists $available_items->{ $item->{type} }{ $item->{name} }{search_type};
-
-                    my $type = $item->{searchType};
-                    $type = 'Saved Search' if !$type || $type eq 'Ticket';
-                    $saved{description} = loc($type) . ': ' . $saved{description};
-
-                    $item->{searchId} = $available_items->{ $item->{type} }{ $item->{name} }{search_id}
-                                        if exists $available_items->{ $item->{type} }{ $item->{name} }{search_id};
-
-                    my ( $obj_type, $obj_id, undef, $search_id ) = split '-', $item->{name};
-                    $saved{privacy} = "$obj_type-$obj_id";
-                    $saved{id}      = $search_id;
-                } elsif ( $item->{type} eq 'dashboard' ) {
-                    my ( undef, $dashboard_id, $obj_type, $obj_id ) = split '-', $item->{name};
-                    $saved{privacy}     = "$obj_type-$obj_id";
-                    $saved{id}          = $dashboard_id;
-                    $saved{description} = loc('Dashboard') . ': ' . $saved{description};
-                }
-
-                push @pane, \%saved;
+    my $class = $args->{self_service_dashboard} ? 'RT::Dashboard::SelfService' : 'RT::Dashboard';
+    my $Dashboard = $class->new( $session{'CurrentUser'} );
+    ( $ok, $msg ) = $Dashboard->LoadById($id);
+
+    # report error at the bottom
+    return ( $ok, $msg ) unless $ok && $Dashboard->Id;
+
+    my $content;
+    for my $pane_name ( keys %{ $data->{panes} } ) {
+        my @pane;
+
+        for my $item ( @{ $data->{panes}{$pane_name} } ) {
+            my %saved;
+            $saved{pane}         = $pane_name;
+            $saved{portlet_type} = $item->{type};
+
+            $saved{description} = $available_items->{ $item->{type} }{ $item->{name} }{label};
+
+            if ( $item->{type} eq 'component' ) {
+                $saved{component} = $item->{name};
+
+                # Absolute paths stay absolute, relative paths go into
+                # /Elements. This way, extensions that add portlets work.
+                my $path = $item->{name};
+                $path = "/Elements/$path" if substr( $path, 0, 1 ) ne '/';
+
+                $saved{path} = $path;
+            } elsif ( $item->{type} eq 'saved' ) {
+                $saved{portlet_type} = 'search';
+
+                $item->{searchType} = $available_items->{ $item->{type} }{ $item->{name} }{search_type}
+                                      if exists $available_items->{ $item->{type} }{ $item->{name} }{search_type};
+
+                my $type = $item->{searchType};
+                $type = 'Saved Search' if !$type || $type eq 'Ticket';
+                $saved{description} = loc($type) . ': ' . $saved{description};
+
+                $item->{searchId} = $available_items->{ $item->{type} }{ $item->{name} }{search_id}
+                                    if exists $available_items->{ $item->{type} }{ $item->{name} }{search_id};
+
+                my ( $obj_type, $obj_id, undef, $search_id ) = split '-', $item->{name};
+                $saved{privacy} = "$obj_type-$obj_id";
+                $saved{id}      = $search_id;
+            } elsif ( $item->{type} eq 'dashboard' ) {
+                my ( undef, $dashboard_id, $obj_type, $obj_id ) = split '-', $item->{name};
+                $saved{privacy}     = "$obj_type-$obj_id";
+                $saved{id}          = $dashboard_id;
+                $saved{description} = loc('Dashboard') . ': ' . $saved{description};
             }
 
-            $content->{$pane_name} = \@pane;
+            push @pane, \%saved;
         }
 
-        return ( $ok, $msg ) = $Dashboard->Update( Panes => $content );
+        $content->{$pane_name} = \@pane;
     }
+
+    return ( $ok, $msg ) = $Dashboard->Update( Panes => $content );
 }
 
 =head2 ListOfReports
@@ -5146,6 +5128,85 @@ sub PreprocessTimeUpdates {
     RT::Interface::Web::PreprocessTimeUpdates(@_);
 }
 
+
+=head2 GetDashboards Objects => ARRAY, CurrentUser => CURRENT_USER
+
+Return available dashboards that are saved in the name of objects for
+specified user.
+
+=cut
+
+sub GetDashboards {
+    my %args = (
+        Objects     => undef,
+        CurrentUser => $session{CurrentUser},
+        @_,
+    );
+
+    return unless $args{CurrentUser};
+
+    $args{Objects} ||= [ RT::Dashboard->new( $args{CurrentUser} )->ObjectsForLoading( IncludeSuperuserGroups => 1 ) ];
+
+    my ($system_default) = RT::System->new( $args{'CurrentUser'} )->Attributes->Named('DefaultDashboard');
+    my $default_dashboard_id = $system_default ? $system_default->Content : 0;
+
+    my $found_system_default;
+
+    require RT::Dashboards;
+    my %dashboards;
+    my %system_default;
+    foreach my $object ( @{ $args{Objects} } ) {
+        my $list = RT::Dashboards->new( $args{CurrentUser} );
+        $list->LimitToPrivacy( join( '-', ref($object), $object->Id ) );
+        my $section;
+        if ( ref $object eq 'RT::User' && $object->Id == $session{CurrentUser}->Id ) {
+            $section = loc("My dashboards");
+        }
+        else {
+            $section = loc( "[_1]'s dashboards", $object->Name );
+        }
+
+        while ( my $dashboard = $list->Next ) {
+            # Use current logged in user to determine if to return link or not
+            $dashboard->CurrentUser( $session{CurrentUser} );
+            push @{ $dashboards{$section} },
+                {   id        => $dashboard->Id,
+                    name      => $dashboard->Name,
+                    view_link => $dashboard->CurrentUserCanSee()
+                    ? join( '/', RT->Config->Get('WebPath'), 'Dashboards', $dashboard->Id, $dashboard->Name )
+                    : '',
+                    edit_link => $dashboard->CurrentUserCanModify()
+                    ? join( '/', RT->Config->Get('WebPath'), 'Dashboards', 'Queries.html?id=' . $dashboard->Id )
+                    : '',
+                };
+
+            if ( $dashboard->Id == $default_dashboard_id ) {
+                %system_default = ( section => $section, %{ $dashboards{$section}[-1] } );
+            }
+        }
+    }
+
+    if (%system_default) {
+        push @{ $dashboards{ $system_default{section} } },
+            {   id        => 0,
+                name      => loc('System Default') . " ($system_default{name})",
+                view_link => $system_default{view_link},
+                edit_link => $system_default{edit_link},
+            };
+    }
+    else {
+        push @{$dashboards{"System's dashboards"}}, {
+            id   => 0,
+            name => loc('System Default'),
+        };
+    }
+
+    for my $section ( keys %dashboards ) {
+        @{ $dashboards{$section} } = sort { lc $a->{name} cmp lc $b->{name} } @{ $dashboards{$section} };
+    }
+    return \%dashboards;
+}
+
 package RT::Interface::Web;
 RT::Base->_ImportOverlays();
 
diff --git a/share/html/Admin/Global/MyRT.html b/share/html/Admin/Global/MyRT.html
index f5728279a1..bd375c3b8e 100644
--- a/share/html/Admin/Global/MyRT.html
+++ b/share/html/Admin/Global/MyRT.html
@@ -49,129 +49,32 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" name="UpdateSearches" class="mx-auto max-width-lg">
-  <& /Widgets/SearchSelection,
-    pane_name => \%pane_name,
-    sections  => \@sections,
-    selected  => \%selected,
-    filters   => \@filters,
-  &>
-  <input type="hidden" name="dashboard_id" value="MyRT">
-  <input type="hidden" name="is_global" value="1">
-  <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
+<&|/Widgets/TitleBox, title => loc('Set Homepage'), bodyclass => "", class => "mx-auto max-width-lg" &>
+<form method="post" name="UpdateDefaultDashboard" class="mx-auto max-width-lg">
+  <div class="form-row">
+    <div class="col-12">
+      <& /Elements/SelectDashboard, Dashboards => GetDashboards( Objects => [ RT->System ] ), Default => $default_dashboard_id, ShowEmpty => 0 &>
+    </div>
+  </div>
 </form>
+</&>
 
 <%INIT>
+Abort( loc("Permission Denied") ) unless $session{CurrentUser}->HasRight( Right => 'SuperUser', Object => RT->System );
+
 my @results;
 my $title = loc("Customize").' '.loc("Global RT at a glance");
 
-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;
-
-    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 %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'),
-);
-
-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,
-);
-
-if ($ARGS{UpdateSearches}) {
-    my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
-    push @results, $ok ? loc('Preferences saved.') : $msg;
+my ($system_default) = RT::System->new( $session{'CurrentUser'} )->Attributes->Named('DefaultDashboard');
+my $default_dashboard_id = $system_default ? $system_default->Content : 0;
 
+my ($default) = map { /^DefaultDashboard-(\d+)/ ? $1 : () } keys %ARGS;
+if ( $default ) {
+    my ( $ret, $msg ) = RT->System->SetAttribute( Name => 'DefaultDashboard', Description => 'Default Dashboard', Content => $default );
+    push @results, $ret ? loc('Preferences saved.') : $msg;
     MaybeRedirectForResults(
-        Actions   => \@results,
-        Path      => "/Admin/Global/MyRT.html",
+        Actions => \@results,
+        Path    => "/Admin/Global/MyRT.html",
     );
 }
 
diff --git a/share/html/Admin/Users/MyRT.html b/share/html/Admin/Users/MyRT.html
index 7f97e567a1..e832f9dcd9 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -49,138 +49,41 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@actions &>
 
+<&|/Widgets/TitleBox, title => loc('Set Homepage'), bodyclass => "", class => "mx-auto max-width-lg" &>
 <form method="post" action="MyRT.html" name="UpdateSearches" class="mx-auto max-width-lg">
-  <& /Widgets/SearchSelection,
-                    pane_name => \%pane_name,
-                    sections  => \@sections,
-                    selected  => \%selected,
-                    filters   => \@filters,
-    &>
-<input type="hidden" name="id" value="<% $id %>"/>
-<input type="hidden" name="dashboard_id" value="MyRT">
-<& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
-</form>
-<form method="post" action="MyRT.html?id=<% $id %>" class="mx-auto max-width-lg">
-  <input type="hidden" name="Reset" value="1" />
-  <& /Elements/Submit, Label => loc('Reset to default') &>
+  <input type="hidden" name="id" value="<% $id %>" />
+  <div class="form-row">
+    <div class="col-12">
+      <& /Elements/SelectDashboard, Dashboards => GetDashboards( CurrentUser => $UserObj ), Default => $default_dashboard_id &>
+    </div>
+  </div>
 </form>
+</&>
 
 <%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}) {
-    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 @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'});
-$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;
-
-     for ($m->comp("/Search/Elements/SearchesForObject", Object => $object)) {
-         my ($desc, $loc_desc, $search) = @$_;
+my $default_dashboard_id = $UserObj->Preferences( DefaultDashboard => 0 );
 
-         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;
+my ($default) = map { /^DefaultDashboard-(\d+)/ ? $1 : () } keys %ARGS;
+if ( defined $default ) {
+    my ( $ret, $msg );
+    if ( $default ) {
+        ( $ret, $msg ) = $UserObj->SetPreferences( 'DefaultDashboard', $default );
+    }
+    else {
+        if ( $default_dashboard_id ) {
+            ( $ret, $msg ) = $UserObj->DeletePreferences( 'DefaultDashboard' );
         }
         else {
-            push @actions, loc('Unable to find [_1] [_2]', $saved->{type}, $saved->{name});
+            $ret = 1;
         }
     }
-    $selected{$pane} = \@items;
-    }
-
-my %pane_name = (
-  'body'    => loc('Body'),
-  'sidebar' => loc('Sidebar'),
-);
-
-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,
-);
 
-if ( $ARGS{UpdateSearches} ) {
-    $ARGS{user_id} = $ARGS{id};
-    my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
-    push @actions, $ok ? loc('Preferences saved for user [_1].', $UserObj->Name) : $msg;
+    push @actions, $ret ? loc('Preferences saved for user [_1].', $UserObj->Name) : $msg;
 
     MaybeRedirectForResults(
         Actions   => \@actions,
diff --git a/share/html/Elements/MyRT b/share/html/Elements/MyRT
index 85271e7275..5d20c5b747 100644
--- a/share/html/Elements/MyRT
+++ b/share/html/Elements/MyRT
@@ -66,10 +66,18 @@ my %allowed_components = map {$_ => 1} @{RT->Config->Get('HomepageComponents')};
 
 my $user = $session{'CurrentUser'}->UserObj;
 unless ( $Portlets ) {
-    my ($defaults) = RT::System->new($session{'CurrentUser'})->Attributes->Named('HomepageSettings');
-    $Portlets = $user->Preferences(
-        HomepageSettings => $defaults ? $defaults->Content : {}
-    );
+    my ($system_default) = RT::System->new($session{'CurrentUser'})->Attributes->Named('DefaultDashboard');
+    my $system_default_id = $system_default ? $system_default->Content : 0;
+    my $dashboard_id = $user->Preferences( DefaultDashboard => $system_default_id ) or return;
+
+    # Allow any user to read system default dashboard
+    my $dashboard = RT::Dashboard->new($system_default_id == $dashboard_id ? RT->SystemUser : $session{'CurrentUser'});
+    my ( $ok, $msg ) = $dashboard->LoadById( $dashboard_id );
+    if ( !$ok ) {
+        $m->out($msg);
+        return;
+    }
+    $Portlets = $dashboard->Panes;
 }
 
 $m->callback( CallbackName => 'MassagePortlets', Portlets => $Portlets );
@@ -83,10 +91,14 @@ $sidebar = undef unless $sidebar && @$sidebar;
 
 my $Rows = $user->Preferences( 'SummaryRows', ( RT->Config->Get('DefaultSummaryRows') || 10 ) );
 
-my $show_cb = sub {
+my $show_cb;
+$show_cb = sub {
     my $entry = shift;
-    my $type  = $entry->{type};
-    my $name = $entry->{'name'};
+    my $depth = shift || 0;
+    Abort("Possible recursive dashboard detected.", SuppressHeader => 1) if $depth > 8;
+
+    my $type  = $entry->{portlet_type};
+    my $name = $entry->{component};
     if ( $type eq 'component' ) {
         if (!$allowed_components{$name}) {
             $m->out( $m->interp->apply_escapes( loc("Invalid portlet [_1]", $name), "h" ) );
@@ -98,10 +110,19 @@ my $show_cb = sub {
         else {
             $m->comp( $name, %{ $entry->{arguments} || {} } );
         }
-    } elsif ( $type eq 'system' ) {
-        $m->comp( '/Elements/ShowSearch', Name => $name, Override => { Rows => $Rows } );
-    } elsif ( $type eq 'saved' ) {
-        $m->comp( '/Elements/ShowSearch', SavedSearch => $name, Override => { Rows => $Rows } );
+    } elsif ( $type eq 'search' ) {
+        $m->comp( '/Elements/ShowSearch', RT::Dashboard->ShowSearchName($entry), Override => { Rows => $Rows } );
+    } elsif ( $type eq 'dashboard' ) {
+        my $current_dashboard = RT::Dashboard->new($session{CurrentUser});
+        my ($ok, $msg) = $current_dashboard->LoadById($entry->{id});
+        if (!$ok) {
+            $m->out($msg);
+            return;
+        }
+        my @panes = @{ $current_dashboard->Panes->{$entry->{pane}} || [] };
+        for my $portlet (@panes) {
+            $show_cb->($portlet, $depth + 1);
+        }
     } else {
         $RT::Logger->error("unknown portlet type '$type'");
     }
diff --git a/share/html/Elements/SelectDashboard b/share/html/Elements/SelectDashboard
new file mode 100644
index 0000000000..0814f3a90e
--- /dev/null
+++ b/share/html/Elements/SelectDashboard
@@ -0,0 +1,91 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2021 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 }}}
+
+% for my $section ( sort keys %Dashboards ) {
+<table class="table table-striped table-hover collection-as-table">
+  <thead>
+    <th class="w-50 collection-as-table"><% $section %></th>
+    <th class="w-15 collection-as-table"></th>
+    <th class="w-15 collection-as-table"></th>
+    <th class="w-15 collection-as-table"></th>
+  </thead>
+  <tbody>
+%   for my $dashboard ( @{$Dashboards{$section}} ) {
+%     next if !$ShowEmpty && !$dashboard->{id};
+    <tr>
+      <td class="collection-as-table"><% $dashboard->{name} %></td>
+      <td class="collection-as-table">
+%     if ( $dashboard->{view_link} ) {
+        <a href="<% $dashboard->{view_link} %>" target="_blank"><&|/l&>View Dashboard</&></a>
+%     }
+      </td>
+      <td class="collection-as-table">
+%     if ( $dashboard->{edit_link} ) {
+        <a href="<% $dashboard->{edit_link} %>" target="_blank"><&|/l&>Edit Dashboard</&></a>
+%     }
+      </td>
+      <td class="collection-as-table">
+%     if ( $dashboard->{id} == $Default ) {
+        <&|/l&>Current Homepage</&>
+%     } else {
+        <input name="DefaultDashboard-<% $dashboard->{id} %>" class="button btn btn-primary form-control" type="submit" value="<&|/l&>Set as Homepage</&>" />
+%     }
+      </td>
+    </tr>
+%   }
+  </tbody>
+</table>
+% }
+
+
+<%ARGS>
+$Name => 'DefaultDashboard'
+%Dashboards => ()
+$Default => 0
+$ShowEmpty => 1
+</%ARGS>
diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch
index f8c05d5ab2..d647a1c82f 100644
--- a/share/html/Elements/ShowSearch
+++ b/share/html/Elements/ShowSearch
@@ -78,6 +78,11 @@ if ($SavedSearch) {
         $m->out(loc("Saved search [_1] not found", $m->interp->apply_escapes($SavedSearch, 'h'))) unless $IgnoreMissing;
         return;
     }
+
+    if ( $search->Object->isa('RT::System') ) {
+        $SearchArg = $user->Preferences( $search, $search->Content );
+    }
+
     $SearchArg->{'SavedSearchId'} ||= $SavedSearch;
     $SearchArg->{'SearchType'} ||= 'Ticket';
     if ( $SearchArg->{SearchType} eq 'Transaction' ) {
@@ -108,11 +113,19 @@ if ($SavedSearch) {
         $query_link_url = RT->Config->Get('WebPath') . "/Search/$SearchArg->{SearchType}.html";
         $ShowCount = 0;
     } elsif ($ShowCustomize) {
-        $customize = RT->Config->Get('WebPath') . '/Search/Build.html?'
-            . $m->comp( '/Elements/QueryString',
-            SavedSearchLoad => $SavedSearch );
+        if ( $search->Object->isa('RT::System') ) {
+            $customize = RT->Config->Get('WebPath') . '/Prefs/Search.html?'
+                . $m->comp( '/Elements/QueryString',
+                    name => ref($search) . '-' . $search->Id );
+        }
+        else {
+            $customize = RT->Config->Get('WebPath') . '/Search/Build.html?'
+                . $m->comp( '/Elements/QueryString',
+                SavedSearchLoad => $SavedSearch );
+        }
     }
 } else {
+    RT->Deprecated( Message => 'Passing bare $Name is deprecated', Instead => '$SavedSearch', Remove => '5.2' );
     ($search) = RT::System->new( $session{'CurrentUser'} ) ->Attributes->Named( 'Search - ' . $Name );
     unless ( $search && $search->Id ) {
         my (@custom_searches) = RT::System->new( $session{'CurrentUser'} )->Attributes->Named('SavedSearch');
diff --git a/share/html/Prefs/MyRT.html b/share/html/Prefs/MyRT.html
index 2e38498e70..d46f0dfd7a 100644
--- a/share/html/Prefs/MyRT.html
+++ b/share/html/Prefs/MyRT.html
@@ -49,19 +49,18 @@
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form method="post" name="UpdateSearches" class="mx-auto max-width-lg">
-  <& /Widgets/SearchSelection,
-    pane_name => \%pane_name,
-    sections  => \@sections,
-    selected  => \%selected,
-    filters   => \@filters,
-  &>
-  <input type="hidden" name="dashboard_id" value="MyRT">
-  <& /Elements/Submit, Name => "UpdateSearches", Label => loc('Save') &>
+<&|/Widgets/TitleBox, title => loc('Set Homepage'), bodyclass => "", class => "mx-auto max-width-xl" &>
+<form method="post" name="UpdateDefaultDashboard" class="mx-auto max-width-xl">
+  <div class="form-row">
+    <div class="col-12">
+      <& /Elements/SelectDashboard, Dashboards => GetDashboards(), Default => $default_dashboard_id &>
+    </div>
+  </div>
 </form>
+</&>
 
-<&|/Widgets/TitleBox, title => loc('Options'), bodyclass => "", class => "mx-auto max-width-lg" &>
-<form method="post" action="MyRT.html">
+<&|/Widgets/TitleBox, title => loc('Options'), bodyclass => "", class => "mx-auto max-width-xl" &>
+<form method="post" action="MyRT.html" class="mx-auto max-width-xl">
 <div class="form-row">
   <div class="label col-auto">
     <&|/l&>Rows per box</&>:
@@ -75,13 +74,6 @@
 </div>
 </form>
 </&>
-<&|/Widgets/TitleBox, title => loc("Reset RT at a glance"), class => "mx-auto max-width-lg" &>
-<form method="post" action="MyRT.html">
-<input type="hidden" name="Reset" value="1" />
-<input type="submit" class="button form-control btn btn-primary" value="<% loc('Reset to default') %>">
-</form>
-</&>
-
 
 <%INIT>
 my @results;
@@ -100,124 +92,28 @@ if ( $ARGS{'UpdateSummaryRows'} ) {
 }
 $ARGS{'SummaryRows'} ||= $user->Preferences('SummaryRows', RT->Config->Get('DefaultSummaryRows'));
 
-if ($ARGS{Reset}) {
-    for my $pref_name ('HomepageSettings', 'SummaryRows') {
-        next unless $user->Preferences($pref_name);
-        my ($ok, $msg) = $user->DeletePreferences($pref_name);
-        push @results, $msg unless $ok;
-    }
-    push @results, loc('Preferences saved.') unless @results;
-}
-
-my $portlets = $user->Preferences('HomepageSettings');
-unless ($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;
-
-    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 $default_dashboard_id = $session{'CurrentUser'}->Preferences( DefaultDashboard => 0 );
 
-        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 ($default) = map { /^DefaultDashboard-(\d+)/ ? $1 : () } keys %ARGS;
+if ( defined $default ) {
+    my ( $ret, $msg );
+    if ( $default ) {
+        ( $ret, $msg ) = $session{CurrentUser}->SetPreferences( 'DefaultDashboard', $default );
     }
-
-    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 {
+        if ( $default_dashboard_id ) {
+            ( $ret, $msg ) = $session{CurrentUser}->DeletePreferences( 'DefaultDashboard' );
         }
         else {
-            push @results, loc('Unable to find [_1] [_2]', $saved->{type}, $saved->{name});
+            $ret = 1;
         }
     }
 
-    $selected{$pane} = \@items;
-}
-
-my %pane_name = (
-    'body'    => loc('Body'),
-    'sidebar' => loc('Sidebar'),
-);
-
-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,
-);
-
-if ($ARGS{UpdateSearches}) {
-    my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
-    push @results, $ok ? loc('Preferences saved.') : $msg;
+    push @results, $ret ? loc('Preferences saved.') : $msg;
 
     MaybeRedirectForResults(
-        Actions   => \@results,
-        Path      => "/Prefs/MyRT.html",
+        Actions => \@results,
+        Path    => "/Prefs/MyRT.html",
     );
 }
 
diff --git a/share/static/css/elevator-light/misc.css b/share/static/css/elevator-light/misc.css
index e8095bdf9b..c1a6f9a238 100644
--- a/share/static/css/elevator-light/misc.css
+++ b/share/static/css/elevator-light/misc.css
@@ -141,3 +141,7 @@ h1#transaction-extra-info {
     font-size: 1.4rem;
     padding-top: 0.4rem;
 }
+
+.w-15 {
+    width: 15% !important;
+}

commit e725ded2d9da85c537849c249dc22401522cf277
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat May 15 00:59:13 2021 +0800

    Add "New Dashboard" to prefs/global MyRT page menu for convenience

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 7c72d3ea07..29f165a2aa 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -933,6 +933,20 @@ sub BuildMainNav {
         }
     }
 
+    # Top menu already has the create link, adding it to page menu is just
+    # for convenience. As user admin page already has quite a few items in
+    # page menu and it's unlikely that admins want to create new dashboard
+    # when editing a user's preference, here we don't touch admin user page.
+    if ( $request_path =~ m{^/(Prefs|Admin/Global)/MyRT\.html} ) {
+        if ( RT::Dashboard->new($current_user)->CurrentUserCanCreateAny ) {
+            $page->child(
+                'dashboard_create' => title => loc('New Dashboard'),
+                path               => "/Dashboards/Modify.html?Create=1"
+            );
+        }
+    }
+
+
     if ( $request_path =~ /^\/(?:index.html|$)/ ) {
         my $alt = loc('Edit');
         $page->child( edit => raw_html => q[<a id="page-edit" class="menu-item" href="] . RT->Config->Get('WebPath') . qq[/Prefs/MyRT.html"><span class="fas fa-cog" alt="$alt" data-toggle="tooltip" data-placement="top" data-original-title="$alt"></span></a>] );

commit ec7a148e05cc8fa5e7bf2c915c817e52593cc9eb
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri May 14 06:13:19 2021 +0800

    Update tests for the migration of "RT at a glance" => dashboard

diff --git a/t/api/initialdata-roundtrip.t b/t/api/initialdata-roundtrip.t
index fbd349507d..e55223acc7 100644
--- a/t/api/initialdata-roundtrip.t
+++ b/t/api/initialdata-roundtrip.t
@@ -1026,10 +1026,10 @@ my @tests = (
         present => sub {
             # Provided in core initialdata
             my $homepage = RT::Attribute->new(RT->SystemUser);
-            $homepage->LoadByNameAndObject(Name => 'HomepageSettings', Object => RT->System);
+            $homepage->LoadByNameAndObject(Name => 'Dashboard', Description => 'Homepage', Object => RT->System);
             ok($homepage->Id, 'Loaded homepage attribute');
-            is($homepage->Name, 'HomepageSettings', 'Name is HomepageSettings');
-            is($homepage->Description, 'HomepageSettings', 'Description is HomepageSettings');
+            is($homepage->Name, 'Dashboard', 'Name is Dashboard');
+            is($homepage->Description, 'Homepage', 'Description is Homepage');
             is($homepage->ContentType, 'storable', 'ContentType is storable');
 
             my $root = RT::User->new(RT->SystemUser);
diff --git a/t/web/custom_frontpage.t b/t/web/custom_frontpage.t
index cbfcde0c55..9196c57683 100644
--- a/t/web/custom_frontpage.t
+++ b/t/web/custom_frontpage.t
@@ -16,6 +16,10 @@ $user_obj->PrincipalObj->GrantRight(Right => 'LoadSavedSearch');
 $user_obj->PrincipalObj->GrantRight(Right => 'EditSavedSearches');
 $user_obj->PrincipalObj->GrantRight(Right => 'CreateSavedSearch');
 $user_obj->PrincipalObj->GrantRight(Right => 'ModifySelf');
+$user_obj->PrincipalObj->GrantRight(Right => 'SeeDashboard');
+$user_obj->PrincipalObj->GrantRight(Right => 'SeeOwnDashboard');
+$user_obj->PrincipalObj->GrantRight(Right => 'CreateOwnDashboard');
+$user_obj->PrincipalObj->GrantRight(Right => 'ModifyOwnDashboard');
 
 ok $m->login( customer => 'customer' ), "logged in";
 
@@ -28,11 +32,24 @@ $m->field ( "ValueOfAttachment" => 'stupid');
 $m->field ( "SavedSearchDescription" => 'stupid tickets');
 $m->click_button (name => 'SavedSearchSave');
 
-$m->get ( $url.'Prefs/MyRT.html' );
+$m->get_ok( $url . "Dashboards/Modify.html?Create=1" );
+$m->form_name('ModifyDashboard');
+$m->field( Name => 'My homepage' );
+$m->click_button( value => 'Create' );
+
+$m->follow_link_ok( { text => 'Content' } );
 $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_ok( $url . "Dashboards/Modify.html?Create=1" );
+$m->form_name('ModifyDashboard');
+$m->field( Name => 'My homepage' );
+$m->click_button( value => 'Create' );
+
+my ($id) = ( $m->uri =~ /id=(\d+)/ );
+ok( $id, "got a dashboard ID, $id" );
+
 my $args = {
     UpdateSearches => "Save",
     dashboard_id   => "MyRT",
@@ -41,18 +58,28 @@ my $args = {
 };
 
 # remove all portlets from the body pane except 'newest unowned tickets'
+$m->follow_link_ok( { text => 'Content' } );
 push(
     @{$args->{body}},
-    ( "system-Unowned Tickets", )
+    "saved-" . $m->dom->find('[data-description="Unowned Tickets"]')->first->attr('data-name'),
 );
 
 my $res = $m->post(
-    $url . 'Prefs/MyRT.html',
+    $url . "Dashboards/Queries.html?id=$id",
     $args,
 );
 
 is( $res->code, 200, "remove all portlets from body except 'newest unowned tickets'" );
 like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
+$m->content_contains( 'Dashboard updated' );
+
+$m->get_ok( $url . 'Prefs/MyRT.html' );
+$m->submit_form_ok(
+    {   form_name => 'UpdateDefaultDashboard',
+        button    => "DefaultDashboard-$id",
+    },
+);
+
 $m->content_contains( 'Preferences saved' );
 
 $m->get( $url );
@@ -61,10 +88,14 @@ $m->content_lacks( 'highest priority tickets', "'highest priority tickets' is no
 $m->content_lacks( 'Bookmarked Tickets<span class="results-count">', "'Bookmarked Tickets' is not present" );  # 'Bookmarked Tickets' also shows up in the nav, so we need to be more specific
 $m->content_lacks( 'Quick ticket creation', "'Quick ticket creation' is not present" );
 
+$m->get_ok( $url . "Dashboards/Queries.html?id=$id" );
+
 # add back the previously removed portlets
 push(
     @{$args->{body}},
-    ( "system-My Tickets", "system-Bookmarked Tickets", "component-QuickCreate" )
+    "saved-" . $m->dom->find('[data-description="My Tickets"]')->first->attr('data-name'),
+    "saved-" . $m->dom->find('[data-description="Bookmarked Tickets"]')->first->attr('data-name'),
+    "component-QuickCreate",
 );
 
 push(
@@ -73,13 +104,13 @@ push(
 );
 
 $res = $m->post(
-    $url . 'Prefs/MyRT.html',
+    $url . "Dashboards/Queries.html?id=$id",
     $args,
 );
 
 is( $res->code, 200, 'add back previously removed portlets' );
 like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
-$m->content_contains( 'Preferences saved' );
+$m->content_contains( 'Dashboard updated' );
 
 $m->get( $url );
 $m->content_contains( 'newest unowned tickets', "'newest unowned tickets' is present" );
@@ -95,7 +126,8 @@ $m->field( "SavedSearchDescription" => 'special chars [test] [_1] ~[_1~]' );
 $m->click_button( name => 'SavedSearchSave' );
 my ($name) = $m->content =~ /value="(RT::User-\d+-SavedSearch-\d+)"/;
 ok( $name, 'saved search name' );
-$m->get( $url . 'Prefs/MyRT.html' );
+
+$m->get_ok( $url . "Dashboards/Queries.html?id=$id" );
 $m->content_contains( 'special chars [test] [_1] ~[_1~]',
     'saved search listed in rt at a glance items' );
 
@@ -106,13 +138,13 @@ push(
 );
 
 $res = $m->post(
-    $url . 'Prefs/MyRT.html',
+    $url . "Dashboards/Queries.html?id=$id",
     $args,
 );
 
 is( $res->code, 200, 'add saved search to body' );
 like( $m->uri, qr/results=[A-Za-z0-9]{32}/, 'URL redirected for results' );
-$m->content_contains( 'Preferences saved' );
+$m->content_contains( 'Dashboard updated' );
 
 $m->get($url);
 $m->content_like( qr/special chars \[test\] \d+ \[_1\]/,
@@ -161,7 +193,7 @@ $m->submit_form(
 # We don't show saved message on page :/
 $m->content_contains("Save as New", 'saved first txn search' );
 
-$m->get_ok( $url . 'Prefs/MyRT.html' );
+$m->get_ok( $url . "Dashboards/Queries.html?id=$id" );
 push(
     @{$args->{body}},
     "saved-" . $m->dom->find('[data-description="first chart"]')->first->attr('data-name'),
@@ -169,12 +201,12 @@ push(
 );
 
 $res = $m->post(
-    $url . 'Prefs/MyRT.html',
+    $url . "Dashboards/Queries.html?id=$id",
     $args,
 );
 
 is( $res->code, 200, 'add system saved searches to body' );
-$m->text_contains( 'Preferences saved' );
+$m->content_contains( 'Dashboard updated' );
 
 $m->get_ok($url);
 $m->text_contains('first chart');

commit a4248d208f6dbb0d1b2e75808b3aae824690fc03
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri May 14 22:20:53 2021 +0800

    Add docs for "RT at a glance" migration to use dashboard

diff --git a/docs/UPGRADING-5.0 b/docs/UPGRADING-5.0
index 5abf895152..50b18af29d 100644
--- a/docs/UPGRADING-5.0
+++ b/docs/UPGRADING-5.0
@@ -283,4 +283,18 @@ configuration setting.
 
 =back
 
+=head1 UPGRADING FROM 5.0.1 AND EARLIER
+
+=over 4
+
+=item *
+
+"RT at a glance" now uses dashboard as backend, thus users can switch
+different homepages very easily. Users who want to customize homepage need
+"ModifySelf", "SeeOwnDashboard", "ModifyOwnDashboard" and
+"DeleteOwnDashboard" rights granted. "SeeDashboard" is optional but required
+if they want to check out system dashboards too.
+
+=back
+
 =cut

commit faeb3ca7c043f8f6bbe84e97a8fc41db8be02ca9
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri May 28 23:24:44 2021 +0800

    Silently ignore saved searches current user doesn't have rights to view
    
    This could be a useful feature that for a dashboard used by multiple
    users, some searches in the dashboard are only shown to ones with proper
    rights. In that case, showing error message could confuse people.
    
    Previously the main rights check is done implicitly via:
    
        ref( $SearchArg = $search->Content ) eq 'HASH'
    
    Here we switch to CurrentUserHasRight('display') to make the logic a bit
    more obvious.

diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch
index d647a1c82f..95eca046e2 100644
--- a/share/html/Elements/ShowSearch
+++ b/share/html/Elements/ShowSearch
@@ -68,13 +68,27 @@ my $class = 'RT::Tickets';
 
 if ($SavedSearch) {
     my ( $container_object, $search_id ) = _parse_saved_search($SavedSearch);
-    unless ( $container_object ) {
-        $m->out(loc("Either you have no rights to view saved search [_1] or identifier is incorrect", $m->interp->apply_escapes($SavedSearch, 'h')));
-        return;
-    }
     $search = RT::Attribute->new( $session{'CurrentUser'} );
-    $search->Load($search_id);
-    unless ( $search->Id && ref( $SearchArg = $search->Content ) eq 'HASH' ) {
+    $search->Load($search_id) if $search_id;
+
+    if ( $search->Id ) {
+
+        # $container_object is undef if it's another user's personal saved
+        # search. We need to explicitly exclude this case as
+        # CurrentUserHasRight doesn't handle that.
+        if ( $container_object && $search->CurrentUserHasRight('display') ) {
+            $SearchArg = $search->Content;
+        }
+        else {
+            RT->Logger->debug( "User "
+                    . $session{CurrentUser}->Name
+                    . " does not have rights to view saved search: "
+                    . $search->__Value('Description')
+                    . "($SavedSearch)" );
+            return;
+        }
+    }
+    else {
         $m->out(loc("Saved search [_1] not found", $m->interp->apply_escapes($SavedSearch, 'h'))) unless $IgnoreMissing;
         return;
     }

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


More information about the rt-commit mailing list