[Rt-commit] rt branch, 4.6/core-group-management-extensions, created. rt-4.4.4-294-g159df5954

Blaine Motsinger blaine at bestpractical.com
Mon Aug 26 20:26:41 EDT 2019


The branch, 4.6/core-group-management-extensions has been created
        at  159df5954c8433143f5c96d3b37156d0bf7a0d3f (commit)

- Log -----------------------------------------------------------------
commit bb7d5370a1e56af384f82eec2d31131e42296858
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Thu May 23 09:58:52 2019 -0500

    Core RT-Extension-GroupLinks

diff --git a/lib/RT/Group.pm b/lib/RT/Group.pm
index 19a41ef7a..5dfc3fe7a 100644
--- a/lib/RT/Group.pm
+++ b/lib/RT/Group.pm
@@ -74,12 +74,11 @@ use warnings;
 use base 'RT::Record';
 
 use Role::Basic 'with';
-with "RT::Record::Role::Rights";
+with "RT::Record::Role::Rights",
+     "RT::Record::Role::Links";
 
 sub Table {'Groups'}
 
-
-
 use RT::Users;
 use RT::GroupMembers;
 use RT::Principals;
@@ -96,6 +95,7 @@ __PACKAGE__->AddRight( Staff => SeeGroupDashboard    => 'View group dashboards')
 __PACKAGE__->AddRight( Admin => CreateGroupDashboard => 'Create group dashboards'); # loc
 __PACKAGE__->AddRight( Admin => ModifyGroupDashboard => 'Modify group dashboards'); # loc
 __PACKAGE__->AddRight( Admin => DeleteGroupDashboard => 'Delete group dashboards'); # loc
+__PACKAGE__->AddRight( Staff => ModifyGroupLinks     => 'Modify group links' ); # loc
 
 =head1 METHODS
 
@@ -1712,6 +1712,22 @@ sub _CustomRoleObj {
     return;
 }
 
+sub ModifyLinkRight {'ModifyGroupLinks'}
+
+=head2 URI
+
+Returns this group's URI
+
+=cut
+
+sub URI {
+    my $self = shift;
+
+    require RT::URI::group;
+    my $uri = RT::URI::group->new($self->CurrentUser);
+    return $uri->URIForObject($self);
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 5e9fbed9c..754cf8deb 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -2,7 +2,7 @@
 #
 # COPYRIGHT:
 #
-# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
 #                                          <sales at bestpractical.com>
 #
 # (Except where explicitly superseded by other copyright notices)
@@ -1077,9 +1077,19 @@ sub _BuildAdminMenu {
                 $page->child( basics         => title => loc('Basics'),       path => "/Admin/Groups/Modify.html?id=" . $obj->id );
                 $page->child( members        => title => loc('Members'),      path => "/Admin/Groups/Members.html?id=" . $obj->id );
                 $page->child( memberships    => title => loc('Memberships'),  path => "/Admin/Groups/Memberships.html?id=" . $obj->id );
+                $page->child( 'links'     =>
+                              title       => loc("Links"),
+                              path        => "/Admin/Groups/ModifyLinks.html?id=" . $obj->id,
+                              description => loc("Group links"),
+                );
                 $page->child( 'group-rights' => title => loc('Group Rights'), path => "/Admin/Groups/GroupRights.html?id=" . $obj->id );
                 $page->child( 'user-rights'  => title => loc('User Rights'),  path => "/Admin/Groups/UserRights.html?id=" . $obj->id );
                 $page->child( history        => title => loc('History'),      path => "/Admin/Groups/History.html?id=" . $obj->id );
+                $page->child( 'summary'   =>
+                              title       => loc("Group Summary"),
+                              path        => "/Group/Summary.html?id=" . $obj->id,
+                              description => loc("Group summary page"),
+                );
             }
         }
     }
diff --git a/lib/RT/URI/group.pm b/lib/RT/URI/group.pm
new file mode 100644
index 000000000..becddbb37
--- /dev/null
+++ b/lib/RT/URI/group.pm
@@ -0,0 +1,223 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 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 }}}
+
+use strict;
+use warnings;
+
+package RT::URI::group;
+use base qw/RT::URI::base/;
+
+require RT::Group;
+
+=head1 NAME
+
+RT::URI::group - Internal URIs for linking to an L<RT::Group>
+
+=head1 DESCRIPTION
+
+This class should rarely be used directly, but via L<RT::URI> instead.
+
+Represents, parses, and generates internal RT URIs such as:
+
+    group:42
+    group://example.com/42
+
+These URIs are used to link between objects in RT such as associating a group
+with another group.
+
+=head1 METHODS
+
+Much of the interface below is dictated by L<RT::URI> and L<RT::URI::base>.
+
+=head2 Scheme
+
+Return the URI scheme for groups
+
+=cut
+
+sub Scheme { "group" }
+
+=head2 LocalURIPrefix
+
+Returns the site-specific prefix for a local group URI
+
+=cut
+
+sub LocalURIPrefix {
+    my $self = shift;
+    return $self->Scheme . "://" . RT->Config->Get('Organization');
+}
+
+=head2 IsLocal
+
+Returns a true value, the grouup ID, if this object represents a local group,
+undef otherwise.
+
+=cut
+
+sub IsLocal {
+    my $self   = shift;
+    my $prefix = $self->LocalURIPrefix;
+    return $1 if $self->{uri} =~ qr!^\Q$prefix\E/(\d+)!i;
+    return undef;
+}
+
+=head2 URIForObject RT::Group
+
+Returns the URI for a local L<RT::Group> object
+
+=cut
+
+sub URIForObject {
+    my $self = shift;
+    my $obj  = shift;
+    return $self->LocalURIPrefix . '/' . $obj->Id;
+}
+
+=head2 ParseURI URI
+
+Primarily used by L<RT::URI> to set internal state.
+
+Figures out from an C<group:> URI whether it refers to a local group and the
+group ID.
+
+Returns the group ID if local, otherwise returns false.
+
+=cut
+
+sub ParseURI {
+    my $self = shift;
+    my $uri  = shift;
+
+    my $scheme = $self->Scheme;
+
+    # canonicalize "42" and "group:42" -> group://example.com/42
+    if ($uri =~ /^(?:\Q$scheme\E:)?(\d+)$/i) {
+        my $group_obj = RT::Group->new( $self->CurrentUser );
+        my ($ret, $msg) = $group_obj->Load($1);
+
+        if ( $ret ) {
+            $self->{'uri'} = $group_obj->URI;
+            $self->{'object'} = $group_obj;
+        }
+        else {
+            RT::Logger->error("Unable to load group for id: $1: $msg");
+            return;
+        }
+    }
+    else {
+        $self->{'uri'} = $uri;
+    }
+
+    my $group = RT::Group->new( $self->CurrentUser );
+    if ( my $id = $self->IsLocal ) {
+        $group->Load($id);
+
+        if ($group->id) {
+            $self->{'object'} = $group;
+        } else {
+            RT->Logger->error("Can't load Group #$id by URI '$uri'");
+            return;
+        }
+    }
+    return $group->id;
+}
+
+=head2 Object
+
+Returns the object for this URI, if it's local. Otherwise returns undef.
+
+=cut
+
+sub Object {
+    my $self = shift;
+    return $self->{'object'};
+}
+
+=head2 HREF
+
+If this is a local group, return an HTTP URL for it.
+
+Otherwise, return its URI.
+
+=cut
+
+sub HREF {
+    my $self = shift;
+    if ($self->IsLocal and $self->Object) {
+        return RT->Config->Get('WebURL')
+#             . ( $self->CurrentUser->Privileged ? "" : "SelfService/" )
+#             . "Admin/Groups/Modify.html?id="
+             . "Group/Summary.html?id="
+             . $self->Object->Id;
+    } else {
+        return $self->URI;
+    }
+}
+
+=head2 AsString
+
+Returns a description of this object
+
+=cut
+
+sub AsString {
+    my $self = shift;
+    if ($self->IsLocal and $self->Object) {
+        my $object = $self->Object;
+        if ( $object->Name ) {
+            return $self->loc('[_1] (Group #[_2])', $object->Name, $object->id);
+        } else {
+            return $self->loc('Group #[_1]', $object->id);
+        }
+    } else {
+        return $self->SUPER::AsString(@_);
+    }
+}
+
+1;
diff --git a/lib/RT/URI/user.pm b/lib/RT/URI/user.pm
new file mode 100644
index 000000000..d8f2f605c
--- /dev/null
+++ b/lib/RT/URI/user.pm
@@ -0,0 +1,221 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 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 }}}
+
+use strict;
+use warnings;
+
+package RT::URI::user;
+use base qw/RT::URI::base/;
+
+require RT::User;
+
+=head1 NAME
+
+RT::URI::user - Internal URIs for linking to an L<RT::User>
+
+=head1 DESCRIPTION
+
+This class should rarely be used directly, but via L<RT::URI> instead.
+
+Represents, parses, and generates internal RT URIs such as:
+
+    user:42
+    user://example.com/42
+
+These URIs are used to link between objects in RT such as referencing an RT user
+record from a ticket in the Links section.
+
+=head1 METHODS
+
+Much of the interface below is dictated by L<RT::URI> and L<RT::URI::base>.
+
+=head2 Scheme
+
+Return the URI scheme for groups
+
+=cut
+
+sub Scheme { "user" }
+
+=head2 LocalURIPrefix
+
+Returns the site-specific prefix for a local group URI
+
+=cut
+
+sub LocalURIPrefix {
+    my $self = shift;
+    return $self->Scheme . "://" . RT->Config->Get('Organization');
+}
+
+=head2 IsLocal
+
+Returns a true value, the grouup ID, if this object represents a local group,
+undef otherwise.
+
+=cut
+
+sub IsLocal {
+    my $self   = shift;
+    my $prefix = $self->LocalURIPrefix;
+    return $1 if $self->{uri} =~ qr!^\Q$prefix\E/(\d+)!i;
+    return undef;
+}
+
+=head2 URIForObject RT::Group
+
+Returns the URI for a local L<RT::Group> object
+
+=cut
+
+sub URIForObject {
+    my $self = shift;
+    my $obj  = shift;
+    return $self->LocalURIPrefix . '/' . $obj->Id;
+}
+
+=head2 ParseURI URI
+
+Primarily used by L<RT::URI> to set internal state.
+
+Figures out from an C<user:> URI whether it refers to a local user and the
+user ID.
+
+Returns the user ID if local, otherwise returns false.
+
+=cut
+
+sub ParseURI {
+    my $self = shift;
+    my $uri  = shift;
+
+    my $scheme = $self->Scheme;
+
+    # canonicalize "42" and "user:42" -> user://example.com/42
+    if ($uri =~ /^(?:\Q$scheme\E:)?(\d+)$/i) {
+        my $user_obj = RT::User->new( $self->CurrentUser );
+        my ($ret, $msg) = $user_obj->Load($1);
+
+        if ( $ret ) {
+            $self->{'uri'} = $user_obj->URI;
+            $self->{'object'} = $user_obj;
+        }
+        else {
+            RT::Logger->error("Unable to load user for id: $1: $msg");
+            return;
+        }
+    }
+    else {
+        $self->{'uri'} = $uri;
+    }
+
+    my $user = RT::User->new( $self->CurrentUser );
+    if ( my $id = $self->IsLocal ) {
+        $user->Load($id);
+
+        if ($user->id) {
+            $self->{'object'} = $user;
+        } else {
+            RT->Logger->error("Can't load User #$id by URI '$uri'");
+            return;
+        }
+    }
+    return $user->id;
+}
+
+=head2 Object
+
+Returns the object for this URI, if it's local. Otherwise returns undef.
+
+=cut
+
+sub Object {
+    my $self = shift;
+    return $self->{'object'};
+}
+
+=head2 HREF
+
+If this is a local group, return an HTTP URL for it.
+
+Otherwise, return its URI.
+
+=cut
+
+sub HREF {
+    my $self = shift;
+    if ($self->IsLocal and $self->Object) {
+        return RT->Config->Get('WebURL')
+             . "User/Summary.html?id="
+             . $self->Object->Id;
+    } else {
+        return $self->URI;
+    }
+}
+
+=head2 AsString
+
+Returns a description of this object
+
+=cut
+
+sub AsString {
+    my $self = shift;
+    if ($self->IsLocal and $self->Object) {
+        my $object = $self->Object;
+        if ( $object->Name ) {
+            return $self->loc('[_1] (User #[_2])', $object->Name, $object->id);
+        } else {
+            return $self->loc('User #[_1]', $object->id);
+        }
+    } else {
+        return $self->SUPER::AsString(@_);
+    }
+}
+
+1;
diff --git a/share/html/Elements/ShowPrincipal b/share/html/Admin/Elements/AddLinks
similarity index 57%
copy from share/html/Elements/ShowPrincipal
copy to share/html/Admin/Elements/AddLinks
index f8d1e0ed1..06b0285af 100644
--- a/share/html/Elements/ShowPrincipal
+++ b/share/html/Admin/Elements/AddLinks
@@ -45,28 +45,42 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-%# Released under the terms of version 2 of the GNU Public License
-<%args>
-$Object
-$PostUser => undef
-$Separator => ", "
-$Link => 1
-</%args>
-<%init>
-if ($Object->isa("RT::Group")) {
-    # Link the users (non-recursively)
-    my @ret = map {$m->scomp("ShowPrincipal", Object => $_->[1], PostUser => $PostUser, Link => $Link)}
-        sort {$a->[0] cmp $b->[0]}
-        map {+[($_->EmailAddress||''), $_]}
-        @{ $Object->UserMembersObj( Recursively => 0 )->ItemsArrayRef };
+% if (ref($Object) eq 'RT::Group') {
+<i><&|/l&>Enter names or IDs of other groups to link. Start typing a group name to see matching groups. Separate multiple entries with a comma.</&>
+% $m->callback( CallbackName => 'ExtraLinkInstructions' );
+</i><br />
+% } else {
+<i><&|/l&>Enter objects or URIs to link objects to. Separate multiple entries with spaces.</&></i><br />
+% }
+<table>
+  <tr>
+    <td class="label"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Links to').':', Relation => 'RefersTo' &></td>
+    <td class="entry"><input name="<%$id%>-RefersTo" value="<% $ARGSRef->{"$id-RefersTo"} || '' %>" <% $exclude |n%>/></td>
+  </tr>
+  <tr>
+    <td class="label"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Linked to by').':', Relation => 'ReferredToBy' &></td>
+    <td class="entry"> <input name="RefersTo-<%$id%>" value="<% $ARGSRef->{"RefersTo-$id"} || '' %>" <% $exclude |n%>/></td>
+  </tr>
+  <& /Elements/EditCustomFields,
+        Object          => $Object,
+        Grouping        => 'Links',
+        InTable         => 1,
+        ($CustomFields
+            ? (CustomFields => $CustomFields)
+            : ()),
+        &>
+% $m->callback( CallbackName => 'NewLink' );
+</table>
+<%INIT>
+my $id = ($Object and $Object->id)
+    ? $Object->id
+    : "new";
 
-    # But don't link the groups
-    push @ret, sort map {$m->interp->apply_escapes( loc("Group: [_1]", $_->Name), 'h' )}
-        @{ $Object->GroupMembersObj( Recursively => 0)->ItemsArrayRef };
-
-    $m->out( join($Separator, @ret) );
-} else {
-    $m->comp("/Elements/ShowUser", User => $Object, Link => $Link);
-    $m->out( $PostUser->($Object) ) if $PostUser;
-}
-</%init>
+my $exclude = qq| data-autocomplete="Groups" data-autocomplete-multiple="1" |;
+$exclude .= qq| data-autocomplete-exclude="$id"| if $Object->id;
+</%INIT>
+<%ARGS>
+$Object => undef
+$CustomFields => undef
+$ARGSRef => $DECODED_ARGS
+</%ARGS>
diff --git a/share/html/Elements/ShowPrincipal b/share/html/Admin/Elements/EditLinks
similarity index 66%
copy from share/html/Elements/ShowPrincipal
copy to share/html/Admin/Elements/EditLinks
index f8d1e0ed1..4671f3ebe 100644
--- a/share/html/Elements/ShowPrincipal
+++ b/share/html/Admin/Elements/EditLinks
@@ -45,28 +45,41 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-%# Released under the terms of version 2 of the GNU Public License
-<%args>
-$Object
-$PostUser => undef
-$Separator => ", "
-$Link => 1
-</%args>
-<%init>
-if ($Object->isa("RT::Group")) {
-    # Link the users (non-recursively)
-    my @ret = map {$m->scomp("ShowPrincipal", Object => $_->[1], PostUser => $PostUser, Link => $Link)}
-        sort {$a->[0] cmp $b->[0]}
-        map {+[($_->EmailAddress||''), $_]}
-        @{ $Object->UserMembersObj( Recursively => 0 )->ItemsArrayRef };
+<table width="100%">
+  <tr>
+    <td valign="top" width="50%">
+      <h3><&|/l&>Current Links</&></h3>
 
-    # But don't link the groups
-    push @ret, sort map {$m->interp->apply_escapes( loc("Group: [_1]", $_->Name), 'h' )}
-        @{ $Object->GroupMembersObj( Recursively => 0)->ItemsArrayRef };
+<table>
+  <tr>
+    <td class="labeltop"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Links to').':', Relation => 'RefersTo' &></td>
+    <td class="value">
+% while (my $link = $Object->RefersTo->Next) {
+      <& /Elements/EditLink, Link => $link, Mode => 'Target' &>
+%}
+    </td>
+  </tr>
+  <tr>
+    <td class="labeltop group-link edit-referredtoby"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Linked to by').':', Relation => 'ReferredToBy' &></td>
+    <td class="value group-link edit-referredtoby">
+% while (my $link = $Object->ReferredToBy->Next) {
+      <& /Elements/EditLink, Link => $link, Mode => 'Base' &>
+% }
+    </td>
+  </tr>
+  <tr>
+    <td></td>
+    <td><i><&|/l&>(Check box to remove link)</&></i></td>
+  </tr>
+</table>
 
-    $m->out( join($Separator, @ret) );
-} else {
-    $m->comp("/Elements/ShowUser", User => $Object, Link => $Link);
-    $m->out( $PostUser->($Object) ) if $PostUser;
-}
-</%init>
+</td>
+<td valign="top">
+<h3><&|/l&>New Links</&></h3>
+<& AddLinks, %ARGS &>
+</td>
+</tr>
+</table>
+<%ARGS>
+$Object => undef
+</%ARGS>
diff --git a/share/html/Admin/Groups/ModifyLinks.html b/share/html/Admin/Groups/ModifyLinks.html
new file mode 100644
index 000000000..77a44d57d
--- /dev/null
+++ b/share/html/Admin/Groups/ModifyLinks.html
@@ -0,0 +1,116 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<& /Admin/Elements/Header, Title => $title  &>
+
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+% $m->callback(CallbackName => 'BeforeActionList', Actions => \@results, ARGSRef => \%ARGS, GroupObj => $Group);
+<& /Elements/ListActions, actions => \@results &>
+
+<form action="<% RT->Config->Get('WebPath') %>/Admin/Groups/ModifyLinks.html" name="ModifyLinks" method="post">
+<input type="hidden" class="hidden" name="id" value="<%$Group->Id%>" />
+
+% $m->callback( CallbackName => 'FormStart', ARGSRef => \%ARGS );
+
+<&| /Widgets/TitleBox, title => loc('Manage Links for Group [_1]', $Group->Label) &>
+
+<& /Admin/Elements/EditLinks, Object => $Group &>
+</&>
+
+<& /Elements/Submit, Name => 'SubmitGroupLinks', Label => loc('Save Changes') &>
+</form>
+
+% $m->callback(CallbackName => 'AfterForm', ARGSRef => \%ARGS, GroupObj => $Group);
+
+<%INIT>
+my $Group = RT::Group->new($session{'CurrentUser'});
+$Group->Load($id) || Abort(loc('Could not load group'));
+my @results;
+
+$m->callback(CallbackName => 'Init', GroupObj => $Group, ARGSRef => \%ARGS, Results => \@results);
+
+my $title = loc("Modify Links for group [_1]", $Group->Label);
+
+if ( $ARGS{'SubmitGroupLinks'} ){
+
+    foreach my $link_type ( "RefersTo-$id", "$id-RefersTo" ){
+        next unless $ARGS{$link_type};
+
+        # List is comma delimited, which allows for group names with spaces
+        my @values = split ', ', $ARGS{$link_type};
+        foreach my $input ( @values ) {
+            if ( $input =~ /^\d+$/ ){
+                # Default scheme for link ids assumes a ticket. Since we're on the group
+                # links page, allow ids as input and prepend 'group:' here to
+                # create group links
+                $input = 'group:' . $input;
+                next;
+            }
+            else {
+                # Could be a group name. Try to look it up.
+                my $group = RT::Group->new($session{'CurrentUser'});
+                my ($ret, $msg) = $group->LoadUserDefinedGroup($input);
+                RT::Logger->info("Unable to load group from name $input: $msg") unless $ret;
+                $input = 'group:' . $group->Id if $ret and $group->Id;
+            }
+        }
+        $ARGS{$link_type} = join ' ', @values;
+    }
+
+    (@results) = ProcessRecordLinks(RecordObj => $Group, ARGSRef => \%ARGS);
+
+    MaybeRedirectForResults(
+        Actions     => \@results,
+        Arguments   => { id => $id },
+    );
+}
+</%INIT>
+<%ARGS>
+$id => undef
+</%ARGS>
diff --git a/share/html/Elements/AddLinks b/share/html/Elements/AddLinks
index ce5228b86..76efeb312 100644
--- a/share/html/Elements/AddLinks
+++ b/share/html/Elements/AddLinks
@@ -62,6 +62,8 @@ $exclude .= qq| data-autocomplete-exclude="$id"| if $Object->id;
 <i><&|/l&>Enter tickets or URIs to link tickets to. Separate multiple entries with spaces.</&>
 <br /><&|/l&>You may enter links to Articles as "a:###", where ### represents the number of the Article.</&>
 <br /><&|/l&>Enter links to assets as "asset:###", where ### represents the asset ID.</&>
+<br /><&|/l&>Enter links to groups as "group:###", where ### represents the group ID.</&>
+<br /><&|/l&>Enter links to users as "user:###", where ### represents the user ID.</&>
 % $m->callback( CallbackName => 'ExtraLinkInstructions' );
 </i><br />
 % } elsif (ref($Object) eq 'RT::Queue') {
diff --git a/share/html/Elements/ShowPrincipal b/share/html/Elements/ShowPrincipal
index f8d1e0ed1..329266512 100644
--- a/share/html/Elements/ShowPrincipal
+++ b/share/html/Elements/ShowPrincipal
@@ -60,8 +60,9 @@ if ($Object->isa("RT::Group")) {
         map {+[($_->EmailAddress||''), $_]}
         @{ $Object->UserMembersObj( Recursively => 0 )->ItemsArrayRef };
 
-    # But don't link the groups
-    push @ret, sort map {$m->interp->apply_escapes( loc("Group: [_1]", $_->Name), 'h' )}
+    # Link to the group summary page
+    my $href = RT->Config->Get("WebPath") . "/Group/Summary.html?id=";
+    push @ret, sort map { "<a href=\"" . $href . $_->id . "\">" . loc("Group:") . " " . $_->Name . "</a>" }
         @{ $Object->GroupMembersObj( Recursively => 0)->ItemsArrayRef };
 
     $m->out( join($Separator, @ret) );

commit b315ecd65fcc79436b758dc143fc840732c88f30
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Wed May 29 13:16:08 2019 -0500

    Core RT-Extension-GroupSummary

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 46c00e409..da139b13c 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -1355,7 +1355,7 @@ user's customized homepage ("RT at a glance").
 Set(
     $HomepageComponents,
     [
-        qw(QuickCreate QueueList MyAdminQueues MySupportQueues MyReminders RefreshHomepage Dashboards SavedSearches FindUser MyAssets FindAsset) # loc_qw
+        qw(QuickCreate QueueList MyAdminQueues MySupportQueues MyReminders RefreshHomepage Dashboards SavedSearches FindUser MyAssets FindAsset FindGroup) # loc_qw
     ]
 );
 
@@ -1813,6 +1813,97 @@ Set($HideUnsetFieldsOnDisplay, 0);
 
 =back
 
+=head2 Group Summary Configuration
+
+Below are configuration options for the Group Summary page.
+
+=over
+
+=item C<$GroupSearchResultFormat>
+
+This controls the display of lists of groups returned from the Group
+Summary Search. The display of groups in the Admin interface is
+controlled by C<%AdminSearchResultFormat>.
+
+=cut
+
+Set($GroupSearchResultFormat,
+         q{ '<a href="__WebPath__/Group/Summary.html?id=__id__">__id__</a>/TITLE:#'}
+        .q{,'<a href="__WebPath__/Group/Summary.html?id=__id__">__Name__</a>/TITLE:Name'}
+);
+
+=item C<@GroupSummaryPortlets>
+
+A list of portlets to be displayed on the Group Summary page.
+By default, we show all of the available portlets.
+Extensions may provide their own portlets for this page.
+
+=cut
+
+Set(@GroupSummaryPortlets, (qw/ExtraInfo CreateTicket ActiveTickets InactiveTickets GroupAssets /));
+
+=item C<$GroupSummaryExtraInfo>
+
+This controls what information is displayed on the Group Summary
+portal. By default the group Name and Description are displayed.
+
+=cut
+
+Set($GroupSummaryExtraInfo, "id, Name, Description");
+
+=item C<$GroupSummaryTicketListFormat>
+
+Control the appearance of the Active and Inactive ticket lists in the
+Group Summary.
+
+=cut
+
+Set($GroupSummaryTicketListFormat, q{
+       '<B><A HREF="__WebPath__/Ticket/Display.html?id=__id__">__id__</a></B>/TITLE:#',
+       '<B><A HREF="__WebPath__/Ticket/Display.html?id=__id__">__Subject__</a></B>/TITLE:Subject',
+       Status,
+       QueueName,
+       Owner,
+       Priority,
+       '__NEWLINE__',
+       '',
+       '<small>__Requestors__</small>',
+       '<small>__CreatedRelative__</small>',
+       '<small>__ToldRelative__</small>',
+       '<small>__LastUpdatedRelative__</small>',
+       '<small>__TimeLeft__</small>'
+});
+
+=item C<$GroupSearchFields>
+
+Specifies which fields of L<RT::Group> to match against and how to match
+each field when performing a quick search on groups.  Valid match
+methods are LIKE, STARTSWITH, ENDSWITH, =, and !=.  Valid search fields
+are id, Name, Description, or custom fields, which are specified as
+"CF.1234" or "CF.Name"
+
+=cut
+
+Set($GroupSearchFields, {
+    id          => '=',
+    Name        => 'LIKE',
+    Description => 'LIKE',
+});
+
+=item C<$AllowGroupAutocompleteForUnprivileged>
+
+Defines whether unprivileged users (users of SelfService) are allowed
+to autocomplete groups when searching. Setting this option to 1 means
+unprivileged users will be able to search all your user created
+group names. Users will also need the SeeGroup privilege to use
+this feature.
+
+=cut
+
+Set($AllowGroupAutocompleteForUnprivileged, 0);
+
+=back
+
 =head2 Self Service Interface
 
 The Self Service Interface is a view automatically presented to Unprivileged
@@ -1938,7 +2029,6 @@ Users also need the ModifySelf right to have access to this page.
 
 Set( $SelfServiceDownloadUserData, 0 );
 
-
 =back
 
 =head2 Articles
diff --git a/lib/RT/Groups.pm b/lib/RT/Groups.pm
index 0d9784bc6..ae80e0e53 100644
--- a/lib/RT/Groups.pm
+++ b/lib/RT/Groups.pm
@@ -433,6 +433,110 @@ sub _DoSearch {
 
 }
 
+=head2 SimpleSearch
+
+Does a 'simple' search of Groups against a specified Term.
+
+This Term is compared to a number of fields using various types of SQL
+comparison operators.
+
+Ensures that the returned collection of Groups will have a value for Return.
+
+This method is passed the following.  You must specify a Term and a Return.
+
+    Fields     - Hashref of data - defaults to C<$GroupSearchFields> emulate that if you want to override
+    Term       - String that is in the fields specified by Fields
+    Return     - What field on the User you want to be sure isn't empty
+    Exclude    - Array reference of ids to exclude
+    Max        - Size to limit this collection to
+
+=cut
+
+sub SimpleSearch {
+    my $self = shift;
+    my %args = (
+        Fields      => RT->Config->Get('GroupSearchFields'),
+        Term        => undef,
+        Exclude     => [],
+        Return      => 'Name',
+        Max         => 10,
+        @_
+    );
+
+    return $self unless defined $args{Return}
+                        and defined $args{Term}
+                        and length $args{Term};
+
+    $self->RowsPerPage( $args{Max} );
+
+    $self->LimitToUserDefinedGroups();
+
+    while (my ($name, $op) = each %{$args{Fields}}) {
+        $op = 'STARTSWITH'
+            unless $op =~ /^(?:LIKE|(?:START|END)SWITH|=|!=)$/i;
+
+        if ($name =~ /^CF\.(?:\{(.*)}|(.*))$/) {
+            my $cfname = $1 || $2;
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            my ($ok, $msg) = $cf->LoadByName( Name => $cfname, LookupType => 'RT::Group');
+            if ( $ok ) {
+                $self->LimitCustomField(
+                    CUSTOMFIELD     => $cf->Id,
+                    OPERATOR        => $op,
+                    VALUE           => $args{Term},
+                    ENTRYAGGREGATOR => 'OR',
+                    SUBCLAUSE       => 'autocomplete',
+                    CASESENSITIVE   => 0,
+                );
+            } else {
+                RT->Logger->warning("Asked to search custom field $name but unable to load a Group CF with the name $cfname: $msg");
+            }
+        } elsif ($name eq 'id' and $op =~ /(?:LIKE|(?:START|END)SWITH)$/i) {
+            $self->Limit(
+                FUNCTION        => "CAST( main.$name AS TEXT )",
+                OPERATOR        => $op,
+                VALUE           => $args{Term},
+                ENTRYAGGREGATOR => 'OR',
+                SUBCLAUSE       => 'autocomplete',
+                CASESENSITIVE   => 0,
+            ) if $args{Term} =~ /^\d+$/;
+        } else {
+            $self->Limit(
+                FIELD           => $name,
+                OPERATOR        => $op,
+                VALUE           => $args{Term},
+                ENTRYAGGREGATOR => 'OR',
+                SUBCLAUSE       => 'autocomplete',
+                CASESENSITIVE   => 0,
+            ) unless $args{Term} =~ /\D/ and $name eq 'id';
+        }
+    }
+
+    # Exclude groups we don't want
+    $self->Limit(FIELD => 'id', OPERATOR => 'NOT IN', VALUE => $args{Exclude} )
+        if @{$args{Exclude}};
+
+    if ( RT->Config->Get('DatabaseType') eq 'Oracle' ) {
+        $self->Limit(
+            FIELD    => $args{Return},
+            OPERATOR => 'IS NOT',
+            VALUE    => 'NULL',
+        );
+    }
+    else {
+        $self->Limit( FIELD => $args{Return}, OPERATOR => '!=', VALUE => '', CASESENSITIVE => 0, );
+        $self->Limit(
+            FIELD           => $args{Return},
+            OPERATOR        => 'IS NOT',
+            VALUE           => 'NULL',
+            ENTRYAGGREGATOR => 'AND',
+            CASESENSITIVE   => 0,
+        );
+    }
+
+    return $self;
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 754cf8deb..df9b475f2 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -167,6 +167,12 @@ sub BuildMainNav {
 
     $search->child( users => title => loc('Users'),   path => "/User/Search.html" );
 
+    $search->child( groups      =>
+                    title       => loc('Groups'),
+                    path        => "/Group/Search.html",
+                    description => 'Group search'
+    );
+
     $search->child( assets => title => loc("Assets"), path => "/Asset/Search/" )
         if $current_user->HasRight( Right => 'ShowAssetsMenu', Object => RT->System );
 
@@ -1067,7 +1073,7 @@ sub _BuildAdminMenu {
 
     }
 
-    if ( $request_path =~ m{^/Admin/Groups} ) {
+    if ( $request_path =~ m{^(/Admin/Groups|/Group/(Summary|History)\.html)} ) {
         if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
             my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
             my $obj = RT::Group->new( $current_user );
diff --git a/share/html/Elements/FindGroup b/share/html/Elements/FindGroup
new file mode 100644
index 000000000..d5c0eeaac
--- /dev/null
+++ b/share/html/Elements/FindGroup
@@ -0,0 +1,50 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<&|/Widgets/TitleBox, title => loc('Find a group')&>
+<& /Elements/GotoGroup &>
+</&>
diff --git a/share/html/Elements/GotoGroup b/share/html/Elements/GotoGroup
new file mode 100644
index 000000000..6c84066de
--- /dev/null
+++ b/share/html/Elements/GotoGroup
@@ -0,0 +1,62 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<form name="GroupSearch" method="post" action="<% RT->Config->Get('WebPath') %>/Group/Search.html">
+<input type="text" name="GroupString" value="<% $Default %>" data-autocomplete="Groups" data-autocomplete-return="Name" id="autocomplete-GroupString" />
+<script type="text/javascript">
+jQuery(function(){
+    // Jump directly to the page if a group is chosen
+    jQuery("#autocomplete-GroupString").on("autocompleteselect", function( event, ui ) {
+        document.location = RT.Config.WebPath + "/Group/Summary.html?id=" + ui.item.id;
+    });
+});
+</script>
+<input type="submit" name="GroupSearch" value="<&|/l&>Search</&>" class="button" />
+</form>
+<%ARGS>
+$Default => ''
+</%ARGS>
diff --git a/share/html/Group/Elements/AssetList b/share/html/Group/Elements/AssetList
new file mode 100644
index 000000000..e88443bf5
--- /dev/null
+++ b/share/html/Group/Elements/AssetList
@@ -0,0 +1,80 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<%INIT>
+my $assets = RT::Assets->new($session{CurrentUser});
+$m->callback( CallbackName => 'ModifyAssetSearch', %ARGS, Assets => $assets, Roles => \@Roles );
+for my $role (@Roles) {
+    $assets->RoleLimit(
+        TYPE        => $role,
+        VALUE       => $Group->PrincipalId,
+        SUBCLAUSE   => "Role$role",
+    );
+}
+my $Format = q[
+    '<b><a href="__WebHomePath__/Asset/Display.html?id=__id__">__id__</a></b>/TITLE:#',
+    '<b><a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a></b>/TITLE:Name',
+    Description,
+];
+$m->callback( CallbackName => 'ModifyFormat', %ARGS, Format => \$Format );
+</%INIT>
+<&| /Widgets/TitleBox, title => $Title, class => "group asset-list" &>
+    <& /Elements/CollectionList,
+        Collection      => $assets,
+        OrderBy         => 'id',
+        Order           => 'ASC',
+        Format          => $Format,
+        AllowSorting    => 0,
+        HasResults      => $HasResults,
+        &>
+</&>
+<%ARGS>
+$Group
+$Title
+ at Roles
+$HasResults => undef
+</%ARGS>
diff --git a/share/html/Group/Elements/GroupInfo b/share/html/Group/Elements/GroupInfo
new file mode 100644
index 000000000..b594d146d
--- /dev/null
+++ b/share/html/Group/Elements/GroupInfo
@@ -0,0 +1,64 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<& /Elements/ShowRecord,
+    Object      => $Group,
+    Format      => $format,
+    TrustFormat => 1, # Only modifiable by the RT server admin, so no need to scrub.
+    Class       => "$ClassPrefix-extra",
+    &>
+<%INIT>
+return unless blessed($Group) and $Group->id;
+return unless $FormatConfig;
+my $format = RT->Config->Get($FormatConfig);
+return unless $format;
+</%INIT>
+<%ARGS>
+$Group        => undef
+$FormatConfig => undef
+$ClassPrefix  => undef
+</%ARGS>
diff --git a/share/html/Group/Elements/Portlets/ActiveTickets b/share/html/Group/Elements/Portlets/ActiveTickets
new file mode 100644
index 000000000..0cf5317ec
--- /dev/null
+++ b/share/html/Group/Elements/Portlets/ActiveTickets
@@ -0,0 +1,68 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<& /Group/Elements/TicketList ,
+    Group => $Group,
+    conditions => $conditions,
+    Rows => $Rows,
+    WatcherTypes => [qw(Watcher)],
+    Class => "group active-tickets",
+    Title => loc('Active Tickets'),
+    TitleBox => 1,
+    ShowHeader => 1,
+    Format => RT->Config->Get('GroupSummaryTicketListFormat'),
+&>
+<%INIT>
+unless ( @$conditions ) {
+    push @$conditions, { cond => "Status = '__Active__'" };
+}
+</%INIT>
+<%ARGS>
+$Group => undef
+$conditions => []
+$Rows => 10
+</%ARGS>
diff --git a/share/html/Group/Elements/Portlets/CreateTicket b/share/html/Group/Elements/Portlets/CreateTicket
new file mode 100644
index 000000000..eace3c9bb
--- /dev/null
+++ b/share/html/Group/Elements/Portlets/CreateTicket
@@ -0,0 +1,58 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<&| /Widgets/TitleBox, title => loc('Quick ticket creation'), class => "group create-ticket" &>
+<form name="CreateTicket" action="<%RT->Config->Get('WebPath')%>/Ticket/Create.html">
+<&|/l&>Create a ticket with this group as Cc in Queue</&>
+<input type="hidden" name="AddGroupCc" value="<%$Group->Id%>">
+<& /Elements/SelectNewTicketQueue &>
+<input type="submit" value="<&|/l&>Create</&>">
+</form>
+</&>
+<%ARGS>
+$Group
+</%ARGS>
diff --git a/share/html/Group/Elements/Portlets/ExtraInfo b/share/html/Group/Elements/Portlets/ExtraInfo
new file mode 100644
index 000000000..c065ccaea
--- /dev/null
+++ b/share/html/Group/Elements/Portlets/ExtraInfo
@@ -0,0 +1,56 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<&| /Widgets/TitleBox, title => loc('Group Information'), class => "group extra-info" &>
+
+% $m->callback( Group => $Group, CallbackName => 'BeforeExtraInfo' );
+<& /Group/Elements/GroupInfo, Group => $Group, FormatConfig => 'GroupSummaryExtraInfo', ClassPrefix => 'group-summary' &>
+
+</&>
+<%ARGS>
+$Group
+</%ARGS>
diff --git a/share/html/Group/Elements/Portlets/GroupAssets b/share/html/Group/Elements/Portlets/GroupAssets
new file mode 100644
index 000000000..88aa7a2ee
--- /dev/null
+++ b/share/html/Group/Elements/Portlets/GroupAssets
@@ -0,0 +1,52 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+%# Roles => [''] triggers the magical RoleLimit behavior that matches any role
+<& /Group/Elements/AssetList, Group => $Group, Roles => [''], Title => loc('Assigned Assets') &>
+<%ARGS>
+$Group
+</%ARGS>
diff --git a/share/html/Group/Elements/Portlets/InactiveTickets b/share/html/Group/Elements/Portlets/InactiveTickets
new file mode 100644
index 000000000..902b82614
--- /dev/null
+++ b/share/html/Group/Elements/Portlets/InactiveTickets
@@ -0,0 +1,68 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<& /Group/Elements/TicketList ,
+    Group => $Group,
+    conditions => $conditions,
+    Rows => $Rows,
+    WatcherTypes => [qw(Watcher)],
+    Class => "group inactive-tickets",
+    Title => loc('Inactive Tickets'),
+    TitleBox => 1,
+    ShowHeader => 1,
+    Format => RT->Config->Get('GroupSummaryTicketListFormat'),
+&>
+<%INIT>
+unless ( @$conditions ) {
+    push @$conditions, { cond => "Status = '__Inactive__'" };
+}
+</%INIT>
+<%ARGS>
+$Group => undef
+$conditions => []
+$Rows => 10
+</%ARGS>
diff --git a/share/html/Group/Elements/TicketList b/share/html/Group/Elements/TicketList
new file mode 100644
index 000000000..faaec0d8e
--- /dev/null
+++ b/share/html/Group/Elements/TicketList
@@ -0,0 +1,114 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+% if ( $TitleBox ) {
+    <& /Widgets/TitleBoxStart, title => $Title, title_href => $url, class => $Class &>
+% } else {
+    <span class="label"><a href="<% $url %>"><% $Title %>:</a></span>
+% }
+
+<& /Elements/CollectionList,
+    %QueryProperties,
+    Class          => 'RT::Tickets',
+    Page           => 1,
+    AllowSorting   => 0,
+    ShowNavigation => 0,
+&>
+
+% if ( $TitleBox ) {
+    <& /Widgets/TitleBoxEnd &>
+% }
+<%INIT>
+
+my $sql = '';
+
+$sql = join(' OR ', map { "$_.id = ".$Group->Id } @WatcherTypes );
+$sql = "( $sql )";
+
+$m->callback( CallbackName => 'ModifyWatcherSQL',
+        %ARGS,
+        sql => \$sql,
+);
+
+if (@$conditions) {
+    $sql .= " AND (".join( " OR ", map $_->{cond}, @$conditions).")";
+}
+
+my %QueryProperties = (
+    Query      => $sql,
+    OrderBy    => 'Priority|id',
+    Order      => 'DESC|DESC',
+    Rows       => $Rows || 10,
+    ShowHeader => $ShowHeader,
+    Format     => $Format,
+);
+
+$m->callback( CallbackName => 'ModifyQueryProperties',
+    %ARGS,
+    QueryProperties => \%QueryProperties,
+);
+
+my $url  = RT->Config->Get('WebPath') . '/Search/Results.html?';
+   $url .= $m->comp('/Elements/QueryString',
+                    Query       => $QueryProperties{'Query'},
+                    OrderBy     => $QueryProperties{'OrderBy'},
+                    Order       => $QueryProperties{'Order'},
+           );
+
+</%INIT>
+<%ARGS>
+$Title => ''
+$Class => ''
+ at WatcherTypes => (qw(Watcher))
+$Group => undef
+$conditions
+$Rows => 10
+$Description => ''
+$TitleBox => 0
+$Format => ''
+$ShowHeader => 0
+</%ARGS>
diff --git a/share/html/Group/Search.html b/share/html/Group/Search.html
new file mode 100644
index 000000000..0716c7298
--- /dev/null
+++ b/share/html/Group/Search.html
@@ -0,0 +1,100 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<& /Elements/Header, Title => loc('Group Search'), Focus => '#autocomplete-GroupString' &>
+<& /Elements/Tabs &>
+
+<& /Elements/GotoGroup, Default => $GroupString||'' &>
+
+<p> <&|/l&>This will search for groups by looking in the following fields:</&> <% $search_fields %></p>
+
+% if ($GroupString) {
+
+% unless ( $groups->Count ) {
+<p><&|/l&>No groups matching search criteria found.</&></p>
+% } else {
+<p><&|/l&>Select a group</&>:</p>
+
+<& /Elements/CollectionList,
+    OrderBy => 'Name',
+    Order => 'ASC',
+    Rows  => 100,
+    %ARGS,
+    Format => $Format,
+    Collection => $groups,
+    AllowSorting => 1,
+    PassArguments => [qw(Format Rows Page Order OrderBy GroupString)],
+&>
+
+% }
+% }
+
+<%INIT>
+my $groups;
+my $Format;
+if ( $GroupString ) {
+    $groups = RT::Groups->new($session{'CurrentUser'});
+    $groups->LimitToUserDefinedGroups();
+
+    $groups->SimpleSearch( Return    => 'Name',
+                           Term      => $GroupString,
+                           Max       => 100 );
+    my $first = $groups->First;
+    RT::Interface::Web::Redirect(RT->Config->Get('WebURL')."Group/Summary.html?id=".$first->Id)
+        if $groups->Count == 1;
+    $groups->GotoFirstItem;
+    $Format = RT->Config->Get('GroupSearchResultFormat');
+}
+
+my $search_fields = join ", ",
+  sort map {s/^CF\.(?:\{(.*)}|(.*))/$1 || $2/e; loc($_)}
+  keys %{RT->Config->Get('GroupSearchFields')};
+
+</%INIT>
+<%ARGS>
+$GroupString => undef
+</%ARGS>
diff --git a/share/html/Group/Summary.html b/share/html/Group/Summary.html
new file mode 100644
index 000000000..6048fc954
--- /dev/null
+++ b/share/html/Group/Summary.html
@@ -0,0 +1,103 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 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 }}}
+<& /Elements/Header, Title => loc('Group: [_1]', $Group->Name) &>
+<& /Elements/Tabs &>
+
+<& /Elements/GotoGroup &>
+<& /Elements/ListActions, actions => \@results &>
+
+<%perl>
+$m->callback( CallbackName => 'BeforePortlets', ARGSRef => \%ARGS, Group => $Group, Portlets => $portlets );
+for my $portlet (@$portlets) {
+   $show_portlet->($portlet);
+}
+$m->callback( CallbackName => 'AfterPortlets', ARGSRef => \%ARGS, Group => $Group, Portlets => $portlets );
+</%perl>
+
+<%INIT>
+my $Group = RT::Group->new( $session{'CurrentUser'} );
+my ($status, $msg) = $Group->Load($id);
+unless ($status) {
+    RT->Logger->error("Unable to load group $id: $msg");
+    Abort("Unable to load Group $id");
+}
+
+unless ( $Group->CurrentUserHasRight('SeeGroup') ){
+    Abort("No permission to view group");
+}
+
+my @results;
+if ( $Group->Disabled ){
+    if ( $session{'CurrentUser'}->HasRight(
+        Object => RT->System, Right => 'AdminGroup' ) ){
+        push @results, loc('Group [_1] is currently disabled. Edit the group and check "Enabled" to enable.', $Group->Name);
+    }
+    else{
+        push @results, loc('Group [_1] is currently disabled.', $Group->Name);
+    }
+}
+
+my $portlets = RT->Config->Get('GroupSummaryPortlets');
+
+my $show_portlet = sub {
+    my $portlet = shift;
+    my $full_path = "/Group/Elements/Portlets/$portlet";
+    unless ( RT::Interface::Web->ComponentPathIsSafe($full_path) ) {
+        RT->Logger->error("unsafe portlet $portlet specified in GroupSummaryPortlets");
+        return;
+    }
+    unless ( $m->comp_exists($full_path) ) {
+        RT->Logger->error("Unable to find $portlet in /Group/Elements/Portlets - specified in GroupSummaryPortlets");
+        return;
+    }
+    $m->comp( $full_path, Group => $Group );
+};
+</%INIT>
+<%ARGS>
+$id => undef
+</%ARGS>
diff --git a/share/html/Ticket/Create.html b/share/html/Ticket/Create.html
index 55a220f07..1503fb280 100644
--- a/share/html/Ticket/Create.html
+++ b/share/html/Ticket/Create.html
@@ -56,7 +56,11 @@
   <input type="submit" name="SubmitTicket" value="Create" style="display:none">
   <input type="hidden" class="hidden" name="id" value="new" />
   <input type="hidden" class="hidden" name="Token" value="<% $ARGS{'Token'} %>" />
-  
+
+% if ( $ARGS{'AddGroupCc'} ){
+<input type="hidden" class="hidden" name="AddGroupCc" value="<% $ARGS{'AddGroupCc'} %>" />
+% }
+
 % $m->callback( CallbackName => 'FormStart', QueueObj => $QueueObj, ARGSRef => \%ARGS );
 
 % if ($gnupg_widget) {
@@ -506,4 +510,5 @@ $DependedOnBy => undef
 $MemberOf => undef
 $QuoteTransaction => undef
 $CloneTicket => undef
+$AddGroupCc => undef
 </%ARGS>
diff --git a/share/html/Ticket/Display.html b/share/html/Ticket/Display.html
index 2582871c6..8c1920b2f 100644
--- a/share/html/Ticket/Display.html
+++ b/share/html/Ticket/Display.html
@@ -216,6 +216,26 @@ if ($ARGS{'id'} eq 'new') {
 
 $title = loc("#[_1]: [_2]", $TicketObj->Id, $TicketObj->Subject || '');
 
+if ( $ARGS{'id'} and $ARGS{'id'} eq 'new' ) {
+    if ( $ARGS{'AddGroupCc'} ){
+        my $group = RT::Group->new($session{'CurrentUser'});
+        my ($ret, $msg) = $group->LoadUserDefinedGroup($ARGS{'AddGroupCc'});
+
+        unless ( $ret ){
+            RT::Logger->warn("Unable to load group " . $ARGS{'AddGroupCc'} . ", $msg. Not adding as Cc.");
+            return;
+        }
+
+        ( $ret, $msg ) = $TicketObj->AddWatcher(
+            Type        => 'Cc',
+            PrincipalId => $group->Id
+        );
+
+        RT::Logger->warn("Unable to add group " . $group->Name . ": " . $group->Id . " as a Cc: $msg")
+            unless $ret;
+    }
+}
+
 $m->callback(
     CallbackName => 'BeforeDisplay',
     TicketObj => \$TicketObj,

commit 2fa18c6cc8cafc6aaee0da08ed8b738e8bb15116
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Wed May 29 18:32:18 2019 -0500

    Core RT-Extension-GroupSelfService

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index da139b13c..ee93fe91c 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -2029,6 +2029,15 @@ Users also need the ModifySelf right to have access to this page.
 
 Set( $SelfServiceDownloadUserData, 0 );
 
+=item C<$SelfServiceShowGroupTickets>
+
+Set this option to true to show a section with group tickets
+on self service pages.
+
+=cut
+
+Set($SelfServiceShowGroupTickets, 1);
+
 =back
 
 =head2 Articles
diff --git a/lib/RT/System.pm b/lib/RT/System.pm
index 05ef84738..d19be82c1 100644
--- a/lib/RT/System.pm
+++ b/lib/RT/System.pm
@@ -93,6 +93,7 @@ __PACKAGE__->AddRight( Staff   => ShowGlobalTemplates => 'Show global templates'
 __PACKAGE__->AddRight( General => LoadSavedSearch     => 'Allow loading of saved searches'); # loc
 __PACKAGE__->AddRight( General => CreateSavedSearch   => 'Allow creation of saved searches'); # loc
 __PACKAGE__->AddRight( Admin   => ExecuteCode         => 'Allow writing Perl code in templates, scrips, etc'); # loc
+__PACKAGE__->AddRight( Staff   => SeeSelfServiceGroupTicket => 'See tickets for other group members in SelfService' ); # loc
 
 =head2 AvailableRights
 
diff --git a/share/html/SelfService/Elements/MyRequests b/share/html/SelfService/Elements/MyGroupRequests
similarity index 66%
copy from share/html/SelfService/Elements/MyRequests
copy to share/html/SelfService/Elements/MyGroupRequests
index 0cd4e3781..8285cece2 100644
--- a/share/html/SelfService/Elements/MyRequests
+++ b/share/html/SelfService/Elements/MyGroupRequests
@@ -59,9 +59,47 @@
 </&>
 
 <%INIT>
+
+unless ( RT->Config->Get('SelfServiceShowGroupTickets')
+         and $session{'CurrentUser'}->HasRight(Right => 'SeeSelfServiceGroupTicket', Object => $RT::System) ){
+    return;
+}
+
 $title ||= loc("My [_1] tickets", $friendly_status);
-my $id = $session{'CurrentUser'}->id;
-my $Query = "( Watcher.id = $id )";
+
+# Load a system user to see all groups without a rights check on whether
+# the current user has ShowGroup.
+my $user = RT::User->new(RT->SystemUser);
+my ($ret, $msg) = $user->Load($session{'CurrentUser'}->Id);
+unless ( $ret ){
+    RT::Logger->error("Unable to load user record for user: " . $session{'CurrentUser'}->Name . " :$msg");
+    return;
+}
+my $groups_obj = $user->OwnGroups;
+
+my $Query = '';
+
+if ( $groups_obj->Count ){
+    my $group = $groups_obj->Next;
+
+    # Confirm we got a group. Count can report available groups, but
+    # if the current user doesn't have SeeGroup, it won't be loaded.
+    if ( $group ){
+        $Query = "(( WatcherGroup = " . $group->Id . " )";
+    }
+
+    # Handle multiple groups
+    while ( $group = $groups_obj->Next ){
+        $Query .= " OR ( WatcherGroup = " . $group->Id . " )";
+    }
+
+    $Query .= ")" if $Query;
+}
+
+# Exclude tickets where current user is requestor or cc since they will
+# appear in the My open tickets list
+$Query .= " AND" if $Query;
+$Query .= " $SortByRole.id != " . $session{'CurrentUser'}->Id;
 
 if ($status) {
     $status =~ s/(['\\])/\\$1/g;
@@ -72,11 +110,12 @@ my $Format = RT->Config->Get('DefaultSelfServiceSearchResultFormat');
 </%INIT>
 <%ARGS>
 $title => undef
-$friendly_status => loc('open')
+$friendly_status => loc("group's")
 $status => undef
 $BaseURL => undef
 $Page => 1
 @Order => ('ASC')
 @OrderBy => ('Created')
 $Rows => 50
+$SortByRole => 'Requestor' # Role to use when determining "My" tickets
 </%ARGS>
diff --git a/share/html/SelfService/Elements/MyRequests b/share/html/SelfService/Elements/MyRequests
index 0cd4e3781..0aa6bf344 100644
--- a/share/html/SelfService/Elements/MyRequests
+++ b/share/html/SelfService/Elements/MyRequests
@@ -61,7 +61,7 @@
 <%INIT>
 $title ||= loc("My [_1] tickets", $friendly_status);
 my $id = $session{'CurrentUser'}->id;
-my $Query = "( Watcher.id = $id )";
+my $Query = "( $SortByRole.id = $id )";
 
 if ($status) {
     $status =~ s/(['\\])/\\$1/g;
@@ -79,4 +79,5 @@ $Page => 1
 @Order => ('ASC')
 @OrderBy => ('Created')
 $Rows => 50
+$SortByRole => 'Requestor' # Role to use when determining "My" tickets
 </%ARGS>
diff --git a/share/html/SelfService/index.html b/share/html/SelfService/index.html
index 40f389538..9a2e3c1d2 100644
--- a/share/html/SelfService/index.html
+++ b/share/html/SelfService/index.html
@@ -59,6 +59,16 @@
 
 % $m->callback(CallbackName => 'AfterMyRequests', ARGSRef => \%ARGS, Page => $Page);
 
+<& /SelfService/Elements/MyGroupRequests,
+    %ARGS,
+    status  => '__Active__',
+    title   => loc('My group\'s tickets'),
+    BaseURL => RT->Config->Get('WebPath') ."/SelfService/?",
+    Page    => $Page,
+&>
+
+% $m->callback(CallbackName => 'AfterMyGroupRequests', ARGSRef => \%ARGS, Page => $Page);
+
 <%ARGS>
 $Page => 1
 </%ARGS>

commit d8e0d24967cd48913aee01b024f037492f7a83f9
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Thu Jun 6 12:56:58 2019 -0500

    Add note to UPGRADING-4.6 for callback changes

diff --git a/devel/docs/UPGRADING-4.6 b/devel/docs/UPGRADING-4.6
new file mode 100644
index 000000000..0e9175793
--- /dev/null
+++ b/devel/docs/UPGRADING-4.6
@@ -0,0 +1,19 @@
+=head1 UPGRADING FROM RT 4.4.0 and greater
+
+This documentation notes internals changes between the 4.4 and 4.6
+series that are primarily of interest to developers writing extensions
+or local customizations.  It is not an exhaustive list.
+
+=over
+
+=item *
+
+New group options were added to the ticket listings pages in SelfService. With
+the additions, the C<AfterMyRequests> callback is no longer at the bottom of the
+page. If you previously used this callback to add to the bottom of the SelfService
+page, a new callback C<AfterMyGroupRequests> is now available below the new group
+ticket listing.
+
+=back
+
+=cut

commit d0181082ba2d716bc5006ab6259ecb512a13413b
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Tue Jun 11 12:50:07 2019 -0500

    Update test for new group nav links

diff --git a/t/web/group_create.t b/t/web/group_create.t
index 4a7bc9cd9..b09529701 100644
--- a/t/web/group_create.t
+++ b/t/web/group_create.t
@@ -94,6 +94,9 @@ ok($m->logout(), 'Logged out');
     ok($tester->PrincipalObj->GrantRight(Right => 'SeeGroup', Object => $RT::System), 'Grant SeeGroup');
 
     load_group_admin_pages($m, $group_id, '200');
+
+    $m->get("/Group/Summary.html?id=$group_id");
+    is( $m->status, 200, "Got 200 for Group Summary page");
 }
 
 sub load_group_admin_pages{
@@ -101,7 +104,7 @@ sub load_group_admin_pages{
     my $group_id = shift;
     my $status = shift;
 
-    foreach my $page (qw(GroupRights Members Modify History Memberships UserRights)){
+    foreach my $page (qw(GroupRights Members Modify History Memberships ModifyLinks UserRights)){
         $m->get("/Admin/Groups/$page.html?id=$group_id");
         is( $m->status, $status, "Got $status for $page page");
     }

commit 2743d99af08e7befb30074db77173fb9d32a3787
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Tue Jun 11 13:17:39 2019 -0500

    Add test for group summary

diff --git a/t/web/group_summary.t b/t/web/group_summary.t
new file mode 100644
index 000000000..b2fe93ff2
--- /dev/null
+++ b/t/web/group_summary.t
@@ -0,0 +1,69 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+
+my ( $baseurl, $m ) = RT::Test->started_ok;
+ok $m->login, 'logged in as root';
+my $root = RT::User->new(RT->SystemUser);
+ok( $root->Load('root'), 'load root user' );
+
+my $group_name = 'test group';
+my $group_id;
+
+diag( 'Group Summary access and ticket creation' );
+{
+    $m->follow_link( id => 'admin-groups-create');
+
+    $m->submit_form(
+        form_name => 'ModifyGroup',
+        fields => {
+            Name => $group_name,
+        },
+    );
+    $m->content_contains( 'Group created', 'created group successfully' );
+
+    $group_id = $m->form_name( 'ModifyGroup' )->value( 'id' );
+    ok( $group_id, "Found id of the group in the form, #$group_id" );
+
+    $m->follow_link_ok({ id => 'page-summary', url_regex => qr|/Group/Summary\.html\?id=$group_id$!| },
+                         'Followed Group Summary link');
+
+    $m->submit_form_ok({ form_name => 'CreateTicket' },
+                         "Submitted form to create ticket with group $group_id as Cc" );
+    like( $m->uri, qr{/Ticket/Create\.html\?AddGroupCc=$group_id&Queue=1$},
+          "now on /Ticket/Create\.html with param AddGroupCc=$group_id" );
+
+    my $subject = 'test AddGroupCc ticket';
+    $m->submit_form_ok({
+        form_name => 'TicketCreate',
+        fields => {
+            Subject => $subject,
+        },
+    }, 'Submitted form to create ticket with group cc');
+    like( $m->uri, qr{/Ticket/Display\.html\?id},
+          "now on /Ticket/Display\.html" );
+
+    $m->get( "/Group/Summary.html?id=$group_id" );
+    $m->content_contains( 'test AddGroupCc ticket', 'Group Cc ticket was found on Group Summary page' );
+}
+
+ok( $m->logout(), 'Logged out' );
+
+diag( 'Access Group Summary with non-root user' );
+{
+    my $tester = RT::Test->load_or_create_user( Name => 'staff1', Password => 'password' );
+    ok( $m->login( $tester->Name, 'password' ), 'Logged in' );
+
+    $m->get( "/Group/Summary.html?id=$group_id" );
+    is( $m->status, 200, "Got 200 for Group Summary page" );
+    $m->warning_like( qr/No permission to view group/, "Got permission denied warning without SeeGroup right" );
+
+    ok( $tester->PrincipalObj->GrantRight( Right => 'SeeGroup', Object => $RT::System ), 'Grant SeeGroup' );
+
+    $m->get( "/Group/Summary.html?id=$group_id" );
+    is( $m->status, 200, "Got 200 for Group Summary page" );
+    $m->no_warnings_ok( "No warning with SeeGroup right" );
+}
+
+done_testing();

commit ded42b0279e63c6b48ed48ab490e7c23185224be
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Mon Aug 19 19:35:54 2019 -0500

    Migrate RT-Extension-GroupSummary pages to elevator themes

diff --git a/share/html/Elements/GotoGroup b/share/html/Elements/GotoGroup
index 6c84066de..8985780c4 100644
--- a/share/html/Elements/GotoGroup
+++ b/share/html/Elements/GotoGroup
@@ -45,8 +45,6 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<form name="GroupSearch" method="post" action="<% RT->Config->Get('WebPath') %>/Group/Search.html">
-<input type="text" name="GroupString" value="<% $Default %>" data-autocomplete="Groups" data-autocomplete-return="Name" id="autocomplete-GroupString" />
 <script type="text/javascript">
 jQuery(function(){
     // Jump directly to the page if a group is chosen
@@ -55,7 +53,15 @@ jQuery(function(){
     });
 });
 </script>
-<input type="submit" name="GroupSearch" value="<&|/l&>Search</&>" class="button" />
+<form name="GroupSearch" method="post" action="<% RT->Config->Get('WebPath') %>/Group/Search.html">
+  <div class="form-row">
+    <div class="col-md-auto">
+      <input type="text" class="form-control" name="GroupString" value="<% $Default %>" data-autocomplete="Groups" data-autocomplete-return="Name" id="autocomplete-GroupString" />
+    </div>
+    <div class="col-md-auto">
+      <input type="submit" name="GroupSearch" value="<&|/l&>Search</&>" class="form-control btn btn-primary button" />
+    </div>
+  </div>
 </form>
 <%ARGS>
 $Default => ''
diff --git a/share/html/Group/Elements/Portlets/CreateTicket b/share/html/Group/Elements/Portlets/CreateTicket
index eace3c9bb..6acad36f6 100644
--- a/share/html/Group/Elements/Portlets/CreateTicket
+++ b/share/html/Group/Elements/Portlets/CreateTicket
@@ -47,10 +47,16 @@
 %# END BPS TAGGED BLOCK }}}
 <&| /Widgets/TitleBox, title => loc('Quick ticket creation'), class => "group create-ticket" &>
 <form name="CreateTicket" action="<%RT->Config->Get('WebPath')%>/Ticket/Create.html">
-<&|/l&>Create a ticket with this group as Cc in Queue</&>
 <input type="hidden" name="AddGroupCc" value="<%$Group->Id%>">
-<& /Elements/SelectNewTicketQueue &>
-<input type="submit" value="<&|/l&>Create</&>">
+  <div class="form-row">
+    <div class="col-md-auto">
+      <&|/l&>Create a ticket with this group as Cc in Queue</&>
+      <& /Elements/SelectNewTicketQueue &>
+    </div>
+    <div class="col-md-auto">
+      <input type="submit" class="form-control btn btn-primary button" value="<&|/l&>Create</&>">
+    </div>
+  </div>
 </form>
 </&>
 <%ARGS>

commit 159df5954c8433143f5c96d3b37156d0bf7a0d3f
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Thu Aug 22 17:33:27 2019 -0500

    Migrate RT-Extension-GroupLinks pages to elevator themes

diff --git a/share/html/Admin/Elements/AddLinks b/share/html/Admin/Elements/AddLinks
index 06b0285af..ae80bfa0d 100644
--- a/share/html/Admin/Elements/AddLinks
+++ b/share/html/Admin/Elements/AddLinks
@@ -52,15 +52,24 @@
 % } else {
 <i><&|/l&>Enter objects or URIs to link objects to. Separate multiple entries with spaces.</&></i><br />
 % }
-<table>
-  <tr>
-    <td class="label"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Links to').':', Relation => 'RefersTo' &></td>
-    <td class="entry"><input name="<%$id%>-RefersTo" value="<% $ARGSRef->{"$id-RefersTo"} || '' %>" <% $exclude |n%>/></td>
-  </tr>
-  <tr>
-    <td class="label"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Linked to by').':', Relation => 'ReferredToBy' &></td>
-    <td class="entry"> <input name="RefersTo-<%$id%>" value="<% $ARGSRef->{"RefersTo-$id"} || '' %>" <% $exclude |n%>/></td>
-  </tr>
+
+  <div class="form-row">
+    <div class="col-md-3 label">
+      <& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Links to').':', Relation => 'RefersTo' &>
+    </div>
+    <div class="col-md-9 value">
+      <input type="text" class="form-control" name="<%$id%>-RefersTo" value="<% $ARGSRef->{"$id-RefersTo"} || '' %>" <% $exclude |n%>/>
+    </div>
+  </div>
+
+  <div class="form-row">
+    <div class="col-md-3 label">
+      <& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Linked to by').':', Relation => 'ReferredToBy' &>
+    </div>
+    <div class="col-md-9 value">
+      <input type="text" class="form-control" name="RefersTo-<%$id%>" value="<% $ARGSRef->{"RefersTo-$id"} || '' %>" <% $exclude |n%>/>
+    </div>
+  </div>
   <& /Elements/EditCustomFields,
         Object          => $Object,
         Grouping        => 'Links',
@@ -70,7 +79,7 @@
             : ()),
         &>
 % $m->callback( CallbackName => 'NewLink' );
-</table>
+
 <%INIT>
 my $id = ($Object and $Object->id)
     ? $Object->id
diff --git a/share/html/Admin/Elements/EditLinks b/share/html/Admin/Elements/EditLinks
index 4671f3ebe..2ca19141d 100644
--- a/share/html/Admin/Elements/EditLinks
+++ b/share/html/Admin/Elements/EditLinks
@@ -45,41 +45,65 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<table width="100%">
-  <tr>
-    <td valign="top" width="50%">
-      <h3><&|/l&>Current Links</&></h3>
+<div class="row">
 
-<table>
-  <tr>
-    <td class="labeltop"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Links to').':', Relation => 'RefersTo' &></td>
-    <td class="value">
+  <div class="col-md-6">
+    <div class="form-row">
+      <div class="col-md-12">
+        <h3><&|/l&>Current Links</&></h3>
+      </div>
+    </div>
+    <div class="form-row">
+      <div class="col-md-3 label">
+        <& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Links to').':', Relation => 'RefersTo' &>
+      </div>
+      <div class="col-md-9 value">
+        <div class="form-row">
+          <div class="col-md-auto">
 % while (my $link = $Object->RefersTo->Next) {
-      <& /Elements/EditLink, Link => $link, Mode => 'Target' &>
+            <& /Elements/EditLink, Link => $link, Mode => 'Target' &>
 %}
-    </td>
-  </tr>
-  <tr>
-    <td class="labeltop group-link edit-referredtoby"><& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Linked to by').':', Relation => 'ReferredToBy' &></td>
-    <td class="value group-link edit-referredtoby">
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="form-row">
+      <div class="col-md-3 label">
+        <& /Elements/ShowRelationLabel, Object => $Object, Label => loc('Linked to by').':', Relation => 'ReferredToBy' &>
+      </div>
+      <div class="col-md-9 value">
+        <div class="form-row">
+          <div class="col-md-auto">
 % while (my $link = $Object->ReferredToBy->Next) {
-      <& /Elements/EditLink, Link => $link, Mode => 'Base' &>
+            <& /Elements/EditLink, Link => $link, Mode => 'Base' &>
 % }
-    </td>
-  </tr>
-  <tr>
-    <td></td>
-    <td><i><&|/l&>(Check box to remove link)</&></i></td>
-  </tr>
-</table>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="form-row">
+      <div class="col-md-3 label"></div>
+      <div class="col-md-9 value">
+        <i><&|/l&>(Check box to remove link)</&></i>
+      </div>
+    </div>
+  </div>
+
+  <div class="col-md-6">
+    <div class="form-row">
+      <div class="col-md-12">
+        <h3><&|/l&>New Links</&></h3>
+      </div>
+    </div>
+    <div class="form-row">
+      <div class="col-md-12">
+        <& AddLinks, %ARGS &>
+      </div>
+    </div>
+  </div>
+
+</div>
 
-</td>
-<td valign="top">
-<h3><&|/l&>New Links</&></h3>
-<& AddLinks, %ARGS &>
-</td>
-</tr>
-</table>
 <%ARGS>
 $Object => undef
 </%ARGS>
diff --git a/share/html/Admin/Groups/ModifyLinks.html b/share/html/Admin/Groups/ModifyLinks.html
index 77a44d57d..e36b865eb 100644
--- a/share/html/Admin/Groups/ModifyLinks.html
+++ b/share/html/Admin/Groups/ModifyLinks.html
@@ -63,7 +63,11 @@
 <& /Admin/Elements/EditLinks, Object => $Group &>
 </&>
 
-<& /Elements/Submit, Name => 'SubmitGroupLinks', Label => loc('Save Changes') &>
+  <div class="form-row">
+    <div class="col-md-12">
+      <& /Elements/Submit, Name => 'SubmitGroupLinks', Label => loc('Save Changes') &>
+    </div>
+  </div>
 </form>
 
 % $m->callback(CallbackName => 'AfterForm', ARGSRef => \%ARGS, GroupObj => $Group);

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


More information about the rt-commit mailing list