[Rt-commit] rt branch, gui-help, created. rt-5.0.1-431-gef71d493e4

Steve Burr steve at bestpractical.com
Tue Jun 1 13:44:26 EDT 2021


The branch, gui-help has been created
        at  ef71d493e435d91080a69600e46a61ff3f74ca27 (commit)

- Log -----------------------------------------------------------------
commit 18875fe557b428641f6be8bd200818f083ba8eeb
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 97140de731..c15054be06 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4799,81 +4799,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
@@ -5184,6 +5166,84 @@ 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 $title;
+        if ( ref $object eq 'RT::User' && $object->Id == $session{CurrentUser}->Id ) {
+            $title = loc("My dashboards");
+        }
+        else {
+            $title = loc( "[_1]'s dashboards", $object->Name );
+        }
+
+        while ( my $dashboard = $list->Next ) {
+            my $name = join( ': ', $title, $dashboard->Name );
+
+            # Use current logged in user to determine if to return link or not
+            $dashboard->CurrentUser( $session{CurrentUser} );
+            push @dashboards,
+                {
+                id   => $dashboard->Id,
+                name => $name,
+                link => $dashboard->CurrentUserCanSee( $args{CurrentUser} )
+                ? join( '/', RT->Config->Get('WebPath'), 'Dashboards', $dashboard->Id, $dashboard->Name )
+                : '',
+                };
+
+            if ( $dashboard->Id == $default_dashboard_id ) {
+                %system_default = %{ $dashboards[-1] };
+            }
+        }
+    }
+
+    if (%system_default) {
+        push @dashboards,
+            {
+            id   => 0,
+            name => loc('System Default') . " ($system_default{name})",
+            link => $system_default{link},
+            };
+    }
+    else {
+        push @dashboards, {
+            id   => 0,
+            name => loc('System Default'),
+            link => '',
+        };
+    }
+
+    @dashboards = sort { lc $a->{name} cmp lc $b->{name} } @dashboards;
+    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..f6c469cfb8 100644
--- a/share/html/Admin/Global/MyRT.html
+++ b/share/html/Admin/Global/MyRT.html
@@ -49,129 +49,36 @@
 <& /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>
+  <div class="form-row">
+    <div class="col-12">
+      <& /Elements/Submit, Name => "UpdateDefaultDashboard", Label => loc('Save') &>
+    </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;
 
+if ( $ARGS{UpdateDefaultDashboard} && $ARGS{DefaultDashboard} ) {
+    my ( $ret, $msg ) = RT->System->SetAttribute( Name => 'DefaultDashboard', Description => 'Default Dashboard', Content => $ARGS{DefaultDashboard} );
+    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..91f14aa7d9 100644
--- a/share/html/Admin/Users/MyRT.html
+++ b/share/html/Admin/Users/MyRT.html
@@ -49,138 +49,45 @@
 <& /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>
+  <div class="form-row">
+    <div class="col-12">
+      <& /Elements/Submit, Name => "UpdateDefaultDashboard", Label => loc('Save') &>
+    </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;
+if ( $ARGS{UpdateDefaultDashboard} && defined $ARGS{DefaultDashboard} ) {
+    my ( $ret, $msg );
+    if ( $ARGS{DefaultDashboard} ) {
+        ( $ret, $msg ) = $UserObj->SetPreferences( 'DefaultDashboard', $ARGS{DefaultDashboard} );
+    }
+    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..10382a99fd
--- /dev/null
+++ b/share/html/Elements/SelectDashboard
@@ -0,0 +1,69 @@
+%# 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 $dashboard ( @Dashboards ) {
+%   next if !$ShowEmpty && !$dashboard->{id};
+  <div class="custom-control custom-radio">
+    <input type="radio" id="<% "$Name-$dashboard->{id}" %>" name="<% $Name %>" class="custom-control-input" value="<% $dashboard->{id} %>" <% $dashboard->{id} == $Default ? ' checked="checked"': '' |n %>>
+    <label class="custom-control-label" for="<% "$Name-$dashboard->{id}" %>">
+%   if ( $dashboard->{link} ) {
+      <a href="<% $dashboard->{link} %>" target="_blank"><% $dashboard->{name} %></a>
+%   } else {
+      <% $dashboard->{name} %>
+%   }
+    </label>
+  </div>
+% }
+
+
+<%ARGS>
+$Name => 'DefaultDashboard'
+ at 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..77acef718b 100644
--- a/share/html/Prefs/MyRT.html
+++ b/share/html/Prefs/MyRT.html
@@ -49,16 +49,20 @@
 <& /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-lg" &>
+<form method="post" name="UpdateDefaultDashboard" class="mx-auto max-width-lg">
+  <div class="form-row">
+    <div class="col-12">
+      <& /Elements/SelectDashboard, Dashboards => [ GetDashboards() ], Default => $default_dashboard_id &>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-12">
+      <& /Elements/Submit, Name => "UpdateDefaultDashboard", Label => loc('Save') &>
+    </div>
+  </div>
 </form>
+</&>
 
 <&|/Widgets/TitleBox, title => loc('Options'), bodyclass => "", class => "mx-auto max-width-lg" &>
 <form method="post" action="MyRT.html">
@@ -75,13 +79,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 +97,27 @@ 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 $default_dashboard_id = $session{'CurrentUser'}->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;
+if ( $ARGS{UpdateDefaultDashboard} && defined $ARGS{DefaultDashboard} ) {
+    my ( $ret, $msg );
+    if ( $ARGS{DefaultDashboard} ) {
+        ( $ret, $msg ) = $session{CurrentUser}->SetPreferences( 'DefaultDashboard', $ARGS{DefaultDashboard} );
     }
-
-    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",
     );
 }
 

commit 287d6649d55c7e77ab0c327b336c813a3965da67
Author: Steven Burr <steve at bestpractical.com>
Date:   Tue May 25 14:31:48 2021 -0400

    WIP

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index a89ad6a4b9..a444e5b552 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -706,6 +706,33 @@ sub DeleteValue {
 }
 
 
+sub ValidateValue {
+    my $self = shift;
+    my $value = shift;
+
+    my $valid = $self->SUPER::ValidateValue($value);
+    warn "XXX super valid = $valid";
+    return 0 unless $valid;
+
+    # NB: ensuring that the value (including possibly an empty one)
+    #     matches any validation pattern defined is already checked in
+    #     AddValueForObject and DeleteValueForObject
+
+    # make sure the value is legal for Select custom fields
+    if ( $self->Type eq "Select" ) {
+        if ( $value ) {
+            my $cfvs = $self->Values;
+            while (my $cfv = $cfvs->Next) {
+                my $name = $cfv->Name;
+                return 1 if $name eq $value;
+            }
+            return 0;
+        }
+    }
+
+    return 1;
+}
+
 =head2 ValidateQueue Queue
 
 Make sure that the name specified is valid
diff --git a/lib/RT/Record.pm b/lib/RT/Record.pm
index 0cd12b73e5..67b70725fe 100644
--- a/lib/RT/Record.pm
+++ b/lib/RT/Record.pm
@@ -943,16 +943,13 @@ sub Update {
         # items. If it fails, we don't care
         do {
             no warnings "uninitialized";
-
-            if ( $attribute ne 'Lifecycle' ) {
-                local $@;
-                my $name = eval {
-                    my $object = $attribute . "Obj";
-                    $self->$object->Name;
-                };
-                unless ($@) {
-                    next if $name eq $value || $name eq ( $value || 0 );
-                }
+            local $@;
+            my $name = eval {
+                my $object = $attribute . "Obj";
+                $self->$object->Name;
+            };
+            unless ($@) {
+                next if $name eq $value || $name eq ($value || 0);
             }
 
             next if $truncated_value eq $self->$attribute();
@@ -2181,7 +2178,7 @@ sub CustomFieldValueIsEmpty {
            : $self->LoadCustomFieldByIdentifier( $args{'Field'} );
 
     if ($cf) {
-        if ( $cf->__Value('Type') =~ /^Date(?:Time)?$/ ) {
+        if ( $cf->Type =~ /^Date(?:Time)?$/ ) {
             my $DateObj = RT::Date->new( $self->CurrentUser );
             $DateObj->Set(
                 Format => 'unknown',
@@ -2592,9 +2589,7 @@ sub CustomDateRange {
         # Prefer the schedule/timezone specified in %ServiceAgreements for current object
         if ( $self->isa('RT::Ticket') && !$self->QueueObj->SLADisabled && $self->SLA ) {
             if ( my $config = RT->Config->Get('ServiceAgreements') ) {
-                if ( ref( $config->{QueueDefault}{ $self->QueueObj->Name } ) eq 'HASH' ) {
-                    $timezone = $config->{QueueDefault}{ $self->QueueObj->Name }{Timezone};
-                }
+                $timezone = $config->{QueueDefault}{ $self->QueueObj->Name }{Timezone};
 
                 # Each SLA could have its own schedule and timezone
                 if ( my $agreement = $config->{Levels}{ $self->SLA } ) {
diff --git a/share/html/Elements/PopupHelp b/share/html/Elements/PopupHelp
new file mode 100644
index 0000000000..1053a95a78
--- /dev/null
+++ b/share/html/Elements/PopupHelp
@@ -0,0 +1,150 @@
+<%args>
+$key => ''
+</%args>
+<%init>
+my $has_help;
+my $help_class;
+my $help_content;
+
+# TODO: put in RT::Site::Siemens
+# TODO: add support for langs (cf?)
+sub GetArticleContent {
+    my $article_class = shift || return '';    # required
+    my $article_name = shift || return '';     # required
+    my $article_context = shift || "";         # optional
+    my $article_locales = shift || [];         # optional
+
+    # lookup the article class
+    my $Class = RT::Class->new( RT->SystemUser );
+    my ($ret, $msg) = $Class->Load( $article_class );
+    my $article_classid = $Class->Id;
+    if ($ret and $Class->Id) {
+        RT::Logger->debug("Found article class id: " . $Class->Id);
+    } else {
+        RT::Logger->debug("No article class found for '$article_class' : $msg");
+        return '';
+    }
+
+    # find the article of the given class
+    my $Article = RT::Article->new( RT->SystemUser );
+    ($ret, $msg) = $Article->LoadByCols( Name => $article_name, Class => $Class->Id );
+    if ( $Article and $Article->Id ) {
+        RT::Logger->debug("Found article id: " . $Article->Id);
+        my $class = $Article->ClassObj;
+        my $cfs = $class->ArticleCustomFields;
+        while (my $cf = $cfs->Next) {
+            if ($cf->Name eq 'Content') {
+                my $ocfvs = $Article->CustomFieldValues($cf->Id);
+                my $ocfv = $ocfvs->First;
+                return HTML::Mason::Commands::ScrubHTML($ocfv->Content);
+            }
+        }
+    } else {
+        RT::Logger->debug("No article found for '$article_name'");
+        return '';
+    }
+    return;
+}
+
+# TODO: put in RT::Site::Siemens
+sub GetArticleContentNext {
+    my $article_class = shift || return '';    # required
+    my $article_name = shift || return '';     # required
+    my $article_context = shift || "";         # optional
+    my $article_locales = shift || [];         # optional
+
+    # lookup the article class
+    my $cls = RT::Class->new( RT->SystemUser );
+    my ($ret, $msg) = $cls->Load( $article_class );
+    my $article_classid = $cls->Id;
+    if ($ret and $article_classid) {
+        RT::Logger->debug("Found article class id: $article_classid");
+    } else {
+        RT::Logger->debug("No article class found for '$article_class' : $msg");
+        return '';
+    }
+
+    # find the article of the given class
+    my $art = RT::Article->new( RT->SystemUser );
+    ($ret, $msg) = $art->LoadByCols( Name => $article_name, Class => $article_classid );
+    my $article_id = $art->Id;
+    if ( $ret and $article_id ) {
+        RT::Logger->debug("Found article id: $article_id");
+        my $cls = $art->ClassObj;
+        my $cfs = $cls->ArticleCustomFields;
+        while (my $cf = $cfs->Next) {
+            if ($cf->Name eq 'Content') {
+                my $ocfvs = $art->CustomFieldValues($cf->Id);
+                my $ocfv = $ocfvs->First;
+                return HTML::Mason::Commands::ScrubHTML($ocfv->Content);
+            }
+        }
+    } else {
+        RT::Logger->debug("No article found for '$article_name'");
+        return '';
+    }
+    return;
+}
+# TODO: put in RT::Site::Siemens
+#sub GetArticleContentWithClass {
+#    my $article_class = shift || return '';    # required
+#    my $article_name = shift || return '';     # required
+#    my $article_context = shift || "";         # optional
+#    my $article_locales = shift || [];         # optional
+#
+#    # lookup the article class
+#    my $cls = RT::Class->new( RT->SystemUser );
+#    my ($ret, $msg) = $cls->Load( $article_class );
+#    my $article_classid = $cls->Id;
+#    if ($ret and $cls->Id) {
+#        RT::Logger->debug("Found article class id: " . $cls->Id);
+#    } else {
+#        RT::Logger->debug("No article class found for '$article_class' : $msg");
+#        return '';
+#    }
+#
+#    # find the article of the given class
+#    my $articles = RT::Articles->new( RT->SystemUser );
+#    $articles->Limit({ FIELD => 'Name', OPERATOR => '=', VALUE => $article_name });
+#    $articles->Limit({ FIELD => 'Class', OPERATOR => '=', VALUE => $cls->Id, ENTRYAGGREGATOR => 'AND' });
+#    if ($article_context) {
+#        my $ctx_cf = RT::CustomField->new( RT->SystemUser );
+#        my ($ret, $msg) = $ctx_cf->Load( 'Context' );
+#        $articles->LimitCustomField({ FIELD => $ctx_cf->Name, OPERATOR => '=', VALUE => $article_context, ENTRYAGGREGATOR => 'AND' });
+#    }
+#    if ($article_locales->length) {
+#        my $loc_cf = RT::CustomField->new( RT->SystemUser );
+#        my ($ret, $msg) = $loc_cf->Load( 'Locale' );
+#        $articles->LimitCustomField({ FIELD => $loc_cf->Name, OPERATOR => '=', VALUE => $loc, ENTRYAGGREGATOR => 'AND' });
+#    }
+#    
+#    $Articles->LimitCustomField({ FIELD => '' })
+#    if ( $Article and $Article->Id ) {
+#        RT::Logger->debug("Found article id: " . $Article->Id);
+#        my $class = $Article->ClassObj;
+#        my $cfs = $class->ArticleCustomFields;
+#        while (my $cf = $cfs->Next) {
+#            my $ocfvs = $Article->CustomFieldValues($cf->Id);
+#            my $ocfv = $ocfvs->First;
+#            HTML::Mason::Commands::ScrubHTML($ocfv->Content);
+#        }
+#    } else {
+#        RT::Logger->debug("No article found for " . $article_name);
+#        return '';
+#    }
+#    return;
+#}
+
+if ($key) {
+    my $lh = $session{'CurrentUser'}->LanguageHandle;
+    my @locs = [ $lh->language_tag(), $lh->fallback_languages() ];
+    my $help_class = RT::Config->Get( 'PopupHelpArticleClass' ) || 'Help';
+    $help_content = GetArticleContent( $help_class, $key, \@locs ); 
+    $has_help = $help_content;
+}
+</%init>
+% if ($has_help) {
+<a class="popup-help" tabindex="0" role="button" data-toggle="popover" title="<% $key %>" data-content="<% $help_content %>" data-html="true" data-trigger="focus">
+    <img src="/static/images/question.svg" data-toggle="popover" title="<% $key %>" data-content="<% $help_content %>" data-html="true" data-trigger="focus" />
+</a>
+% }
diff --git a/share/html/Helpers/InitialPrefs b/share/html/Helpers/InitialPrefs
new file mode 100644
index 0000000000..75b8d47d95
--- /dev/null
+++ b/share/html/Helpers/InitialPrefs
@@ -0,0 +1,66 @@
+<%args>
+$Update => 0,
+$PreferredKey => undef,
+</%args>
+<%init>
+my @results = ();
+my $UserObj = $session{'CurrentUser'}->UserObj;
+my $preferences = $UserObj->Preferences( $RT::System );
+
+if (defined($PreferredKey) and (not $UserObj->FirstAttribute('PreferredKey')
+        or $PreferredKey ne $UserObj->FirstAttribute('PreferredKey')->Content)) {
+    my ($code, $msg) = $UserObj->SetAttribute(Name => 'PreferredKey', Content => $PreferredKey);
+    push @results, loc('Preferred Key: [_1]', $msg) unless $code;
+}
+
+if ( $Update ) {
+    warn "XXX - updating";
+    $preferences ||= {};
+    $m->comp( '/Widgets/BulkProcess', Meta => {
+                map { $_ => RT->Config->Meta($_) } RT->Config->Options
+            },
+            Store => $preferences,
+            Types => [RT->Config->Options], Default => 1, Arguments => \%ARGS,
+            DefaultValue => { map { $_ => RT->Config->Get($_) }
+                RT->Config->Options
+            }, );
+
+    my ($ok, $msg) = $UserObj->SetPreferences( $RT::System, $preferences );
+    push @results, $ok ? loc("Preferences saved.") : $msg;
+    warn "XXX - updated";
+    $m->redirect( '/' );
+}
+</%init>
+<div class="modal-dialog modal-dialog-centered" role="document">
+  <div class="modal-content">
+    <div class="modal-header">
+      <h5 class="modal-title"><&|/l&>Welcome to Vurious</&></h5>
+      <a href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
+        <span aria-hidden="true">×</span>
+      </a>
+    </div>
+    <div class="modal-body">
+        <p>Since this appears to be your first time accessing Vurious, please take a moment to help us better customize the experience for you.</p>
+        <form method="post" action="/Helpers/InitialPrefs" name="InitialPreferences" id="InitialPreferences">
+            <&|/Widgets/TitleBox, title => loc( 'Preferences' ) &>
+% my $option = 'EmailFrequency';  # 'DashboardEmailLanguage'
+% my $meta = RT->Config->Meta( $option );
+            <& $meta->{'Widget'},
+                Default      => 1,
+                %{ $m->comp('/Widgets/FinalizeWidgetArguments', WidgetArguments => $meta->{'WidgetArguments'} ) },
+                Name         => $option,
+                DefaultValue => scalar RT->Config->Get( $option ),
+                CurrentValue => $preferences->{ $option },
+            &>
+            </&>
+
+            <div class="form-row">
+                <div class="col-12">
+                    <& /Elements/Submit, Name => 'Update', Label => loc('Save Changes') &>
+                </div>
+            </div>
+        </form>
+    </div>
+  </div>
+</div>
+% $m->abort;
diff --git a/share/html/Ticket/Display.html b/share/html/Ticket/Display.html
index 87883d5419..192d333fbb 100644
--- a/share/html/Ticket/Display.html
+++ b/share/html/Ticket/Display.html
@@ -56,6 +56,11 @@
 <& Elements/ShowUpdateStatus, Ticket => $TicketObj &>
 <& Elements/ShowDependencyStatus, Ticket => $TicketObj &>
 
+<div border="2px solid #1278c6; margin: 10px; padding: 10px;">
+<button class="btn btn-danger" id="show-help">Show Help</button>
+<button class="btn btn-danger" id="hide-help">Hide Help</button>
+</div>
+
 % $m->callback( %ARGS, Ticket => $TicketObj, Transactions => $transactions, Attachments => $attachments, CallbackName => 'BeforeShowSummary' );
 
 <%PERL>
@@ -181,6 +186,7 @@ if ($ARGS{'id'} eq 'new') {
     my $SkipProcessing;
 
     $TicketObj->Atomic(sub{
+
         $m->callback( CallbackName => 'BeforeProcessArguments',
             TicketObj => $TicketObj,
             ActionsRef => \@Actions, ARGSRef => \%ARGS,
diff --git a/share/html/Ticket/Elements/ShowBasics b/share/html/Ticket/Elements/ShowBasics
index ae90b2d13f..e3574f86c5 100644
--- a/share/html/Ticket/Elements/ShowBasics
+++ b/share/html/Ticket/Elements/ShowBasics
@@ -45,13 +45,27 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
+
+<script>
+jQuery(document).ready(function () {
+    jQuery('[data-toggle="popover"]').popover({trigger: 'focus'})
+    jQuery('#show-help').click(function(e) { jQuery('.popup-help').show() })
+    jQuery('#hide-help').click(function(e) { jQuery('.popup-help').hide() })
+})
+</script>
+<style>
+.popup-help:active, .popup-help:hover { text-decoration: none; }
+.popup-help, .popup-help:focus { outline: none; }
+.popup-help img { padding: 3px; }
+</style>
+
 <div>
   <div class="id form-row">
-    <div class="label col-3"><&|/l&>Id</&>:</div>
+    <div class="label col-3"><&|/l&>Id</&><& /Elements/PopupHelp, key => 'Ticket Id' &>:</div>
     <div class="value col-9"><span><%$Ticket->Id %></span></div>
   </div>
   <div class="status form-row">
-    <div class="label col-3"><&|/l&>Status</&>:</div>
+    <div class="label col-3" data-rt-help-key="TicketStatus"><&|/l&>Status</&><& /Elements/PopupHelp, key => 'Ticket Status' &>:</div>
     <div class="value col-9"><span class="current-value"><% loc($Ticket->Status) %></span></div>
   </div>
 % if ( !$Ticket->QueueObj->SLADisabled ) {
@@ -113,7 +127,7 @@
 %# This will check SeeQueue at the ticket role level, queue level, and global level
 % if ($Ticket->CurrentUserHasRight('SeeQueue')) {
   <div class="queue form-row">
-    <div class="label col-3"><&|/l&>Queue</&>:</div>
+    <div class="label col-3"><&|/l&>Queue</&><& /Elements/PopupHelp, key => 'Ticket Queue' &>:</div>
     <div class="value col-9"><span class="current-value"><& ShowQueue, Ticket => $Ticket, QueueObj => $Ticket->QueueObj &></span></div>
   </div>
 % }
diff --git a/share/static/images/question.svg b/share/static/images/question.svg
new file mode 100644
index 0000000000..89ebf0e2fc
--- /dev/null
+++ b/share/static/images/question.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zM6.92 6.085c.081-.16.19-.299.34-.398.145-.097.371-.187.74-.187.28 0 .553.087.738.225A.613.613 0 019 6.25c0 .177-.04.264-.077.318a.956.956 0 01-.277.245c-.076.051-.158.1-.258.161l-.007.004a7.728 7.728 0 00-.313.195 2.416 2.416 0 00-.692.661.75.75 0 001.248.832.956.956 0 01.276-.245 6.3 6.3 0 01.26-.16l.006-.004c.093-.057.204-.123.313-.195.222-.149.487-.355.692-.662.214-.32.329-.702.329-1.15 0-.76-.36-1.348-.863-1.725A2.76 2.76 0 008 4c-.631 0-1.155.16-1.572.438-.413.276-.68.638-.849.977a.75.75 0 101.342.67z"></path></svg>
\ No newline at end of file

commit f462fd8263e4c127e9dcd406c928b647d9862e02
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 3b3b67c554..6d42200acb 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 0d8924a76f2903e41eeaca56589b4a0ad96cd0df
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 a5b2b68329..69d364e123 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..3db735e054 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,29 @@ 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',
+        fields    => { DefaultDashboard => $id },
+        button    => 'UpdateDefaultDashboard',
+    },
+);
+
 $m->content_contains( 'Preferences saved' );
 
 $m->get( $url );
@@ -61,10 +89,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 +105,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 +127,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 +139,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 +194,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 +202,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 17a375ecb20e2ca72e36c79c8c74fc519e5b4987
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 f759f624acbd204ee3906197d614f04f41e48324
Merge: 287d6649d5 17a375ecb2
Author: Steven Burr <steve at bestpractical.com>
Date:   Tue May 25 14:34:16 2021 -0400

    Merge branch '5.0/use-dashboard-for-homepage' into siemens-gui


commit ef71d493e435d91080a69600e46a61ff3f74ca27
Author: Steven Burr <steve at bestpractical.com>
Date:   Tue Jun 1 13:32:24 2021 -0400

    WIP - help updates

diff --git a/share/html/Elements/PopupHelp b/share/html/Elements/PopupHelp
index 1053a95a78..67cf857aeb 100644
--- a/share/html/Elements/PopupHelp
+++ b/share/html/Elements/PopupHelp
@@ -7,7 +7,7 @@ my $help_class;
 my $help_content;
 
 # TODO: put in RT::Site::Siemens
-# TODO: add support for langs (cf?)
+# TODO: add support for langs
 sub GetArticleContent {
     my $article_class = shift || return '';    # required
     my $article_name = shift || return '';     # required
@@ -36,7 +36,7 @@ sub GetArticleContent {
             if ($cf->Name eq 'Content') {
                 my $ocfvs = $Article->CustomFieldValues($cf->Id);
                 my $ocfv = $ocfvs->First;
-                return HTML::Mason::Commands::ScrubHTML($ocfv->Content);
+                return $ocfv->Content;
             }
         }
     } else {
@@ -46,95 +46,6 @@ sub GetArticleContent {
     return;
 }
 
-# TODO: put in RT::Site::Siemens
-sub GetArticleContentNext {
-    my $article_class = shift || return '';    # required
-    my $article_name = shift || return '';     # required
-    my $article_context = shift || "";         # optional
-    my $article_locales = shift || [];         # optional
-
-    # lookup the article class
-    my $cls = RT::Class->new( RT->SystemUser );
-    my ($ret, $msg) = $cls->Load( $article_class );
-    my $article_classid = $cls->Id;
-    if ($ret and $article_classid) {
-        RT::Logger->debug("Found article class id: $article_classid");
-    } else {
-        RT::Logger->debug("No article class found for '$article_class' : $msg");
-        return '';
-    }
-
-    # find the article of the given class
-    my $art = RT::Article->new( RT->SystemUser );
-    ($ret, $msg) = $art->LoadByCols( Name => $article_name, Class => $article_classid );
-    my $article_id = $art->Id;
-    if ( $ret and $article_id ) {
-        RT::Logger->debug("Found article id: $article_id");
-        my $cls = $art->ClassObj;
-        my $cfs = $cls->ArticleCustomFields;
-        while (my $cf = $cfs->Next) {
-            if ($cf->Name eq 'Content') {
-                my $ocfvs = $art->CustomFieldValues($cf->Id);
-                my $ocfv = $ocfvs->First;
-                return HTML::Mason::Commands::ScrubHTML($ocfv->Content);
-            }
-        }
-    } else {
-        RT::Logger->debug("No article found for '$article_name'");
-        return '';
-    }
-    return;
-}
-# TODO: put in RT::Site::Siemens
-#sub GetArticleContentWithClass {
-#    my $article_class = shift || return '';    # required
-#    my $article_name = shift || return '';     # required
-#    my $article_context = shift || "";         # optional
-#    my $article_locales = shift || [];         # optional
-#
-#    # lookup the article class
-#    my $cls = RT::Class->new( RT->SystemUser );
-#    my ($ret, $msg) = $cls->Load( $article_class );
-#    my $article_classid = $cls->Id;
-#    if ($ret and $cls->Id) {
-#        RT::Logger->debug("Found article class id: " . $cls->Id);
-#    } else {
-#        RT::Logger->debug("No article class found for '$article_class' : $msg");
-#        return '';
-#    }
-#
-#    # find the article of the given class
-#    my $articles = RT::Articles->new( RT->SystemUser );
-#    $articles->Limit({ FIELD => 'Name', OPERATOR => '=', VALUE => $article_name });
-#    $articles->Limit({ FIELD => 'Class', OPERATOR => '=', VALUE => $cls->Id, ENTRYAGGREGATOR => 'AND' });
-#    if ($article_context) {
-#        my $ctx_cf = RT::CustomField->new( RT->SystemUser );
-#        my ($ret, $msg) = $ctx_cf->Load( 'Context' );
-#        $articles->LimitCustomField({ FIELD => $ctx_cf->Name, OPERATOR => '=', VALUE => $article_context, ENTRYAGGREGATOR => 'AND' });
-#    }
-#    if ($article_locales->length) {
-#        my $loc_cf = RT::CustomField->new( RT->SystemUser );
-#        my ($ret, $msg) = $loc_cf->Load( 'Locale' );
-#        $articles->LimitCustomField({ FIELD => $loc_cf->Name, OPERATOR => '=', VALUE => $loc, ENTRYAGGREGATOR => 'AND' });
-#    }
-#    
-#    $Articles->LimitCustomField({ FIELD => '' })
-#    if ( $Article and $Article->Id ) {
-#        RT::Logger->debug("Found article id: " . $Article->Id);
-#        my $class = $Article->ClassObj;
-#        my $cfs = $class->ArticleCustomFields;
-#        while (my $cf = $cfs->Next) {
-#            my $ocfvs = $Article->CustomFieldValues($cf->Id);
-#            my $ocfv = $ocfvs->First;
-#            HTML::Mason::Commands::ScrubHTML($ocfv->Content);
-#        }
-#    } else {
-#        RT::Logger->debug("No article found for " . $article_name);
-#        return '';
-#    }
-#    return;
-#}
-
 if ($key) {
     my $lh = $session{'CurrentUser'}->LanguageHandle;
     my @locs = [ $lh->language_tag(), $lh->fallback_languages() ];
diff --git a/share/html/Elements/ShowCustomFields b/share/html/Elements/ShowCustomFields
index eff8fba8e3..307b656d76 100644
--- a/share/html/Elements/ShowCustomFields
+++ b/share/html/Elements/ShowCustomFields
@@ -66,10 +66,7 @@
 % $m->callback( CallbackName => 'ModifyFieldClasses', CustomField => $CustomField,
 %               Object => $Object, Classes => \@classes, Grouping => $Grouping );
   <div class="form-row <% join(' ', @classes) %>" id="CF-<%$CustomField->id%>-ShowRow">
-    <div class="label col-<% $LabelCols %>"><% $CustomField->Name %>:
-%   if ( $CustomField->EntryHint ) {
-      <span class="far fa-question-circle icon-helper" data-toggle="tooltip" data-placement="top" data-original-title="<% $CustomField->EntryHint %>"></span>
-%   }
+    <div class="label col-<% $LabelCols %>" title="<% $CustomField->EntryHint || '' %>"><% $CustomField->Name %><& /Elements/PopupHelp, key => $CustomField->Name &>:
     </div>
     <div class="value col-<% $ValueCols %> <% $count ? '' : ' no-value' %>">
       <span class="current-value">
diff --git a/share/html/Ticket/Elements/EditBasics b/share/html/Ticket/Elements/EditBasics
index 376b9e9749..0319633fe3 100644
--- a/share/html/Ticket/Elements/EditBasics
+++ b/share/html/Ticket/Elements/EditBasics
@@ -186,10 +186,7 @@ for my $field (@fields) {
 % for my $field (@fields) {
 %# Prefer input name as css class, e.g. "FinalPriority" instead of "Final_Priority"
   <div class="form-row <% CSSClass( $field->{args}{Name} || $field->{'name'} ) %>">
-    <div class="label col-3"><% loc($field->{'name'}) %>:
-%   if ( my $hint = $field->{hint} ) {
-      <span class="far fa-question-circle icon-helper" data-toggle="tooltip" data-placement="top" data-original-title="<% $hint %>"></span>
-%   }
+    <div class="label col-3" title="<% $field->{hint} || '' %>"><% loc($field->{'name'}) %><& /Elements/PopupHelp, key => $field->{'name'} &> :
     </div>
     <div class="value col-9"><% $field->{'html'} |n %></div>
   </div>

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


More information about the rt-commit mailing list