[Rt-commit] rt branch 5.0/search-url-shortener created. rt-5.0.2-294-gc9de22f2ec

BPS Git Server git at git.bestpractical.com
Tue Jun 28 19:38:52 UTC 2022


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "rt".

The branch, 5.0/search-url-shortener has been created
        at  c9de22f2ec4bc4426a207eee0df06ad66b6c9518 (commit)

- Log -----------------------------------------------------------------
commit c9de22f2ec4bc4426a207eee0df06ad66b6c9518
Author: Jim Brandt <jbrandt at bestpractical.com>
Date:   Wed Oct 13 14:17:39 2021 -0400

    Add docs for user-visible permalink features

diff --git a/docs/query_builder.pod b/docs/query_builder.pod
index bed320905f..77e2418a80 100644
--- a/docs/query_builder.pod
+++ b/docs/query_builder.pod
@@ -254,6 +254,85 @@ you can search for :
 
     'CF.{Transport Type}' IS NULL
 
+=head1 Saved Searches
+
+If you build a search you want to use again, you can save it using the options in
+the Saved searches section on the Query Builder page. You can save ticket searches,
+charts, transaction searches, and asset searches. In addition to being able to
+quickly reload these in the Query Builder, you can also use saved searches
+when building dashboards (see L<Dashboard and Reports|docs/dashboards_reporting.pod>).
+
+There are several rights that manage access to saved searches, so some users may
+not see this section initially. The global rights "ShowSavedSearches", "CreateSavedSearch",
+"LoadSavedSearch", and "EditSavedSearches" can be granted to allow users to
+interact with saved searches (Admin > Global > Group Rights).
+
+Saved searches have a Privacy setting, which controls which other users can load
+the saved search. The privacy setting controls only the saved search itself and
+doesn't apply to the tickets returned, so even if a user can load a saved
+search, if they don't have rights to some tickets in that search, they won't
+see those tickets.
+
+"My saved searches" are just for you (the logged in user) and they can't be
+seen by other users. You need to grant the ModifySelf right in addition to the
+saved search rights above to allow users to save these.
+
+"RT System's saved searches" are system-wide searches and can only be created
+and updated by users with the SuperUser right. They can be used for dashboards,
+but only SuperUsers can view and load them on the Query Builder page.
+
+Saved searches can also be scoped to groups. To set up rights for group-level
+saved searches, find the group (Admin > Groups), then click on the Group Rights
+option in the submenu. You can grant "ShowSavedSearches" and "EditSavedSearch"
+to group members, including members of the same group you are viewing. Once
+added, members of that group can then load or save searches with the Privacy
+set to the group. You must be in a group for it to appear in the Privacy menu,
+and this applies to SuperUsers as well.
+
+=head1 Sharing Links to Searches
+
+There are several ways to share a saved search with another user, and which
+one you use will depend on how much you plan to use the search. The sections
+below refer to ticket searches, but these options also apply to saved charts.
+
+=head2 Permalink
+
+The easiest way to share a search is to click on the Permalink icon in the submenu
+and share the link (URL). Another RT user can load the search using the
+link and clicking Permalink will make sure it remains available in RT. However,
+you can't modify the search after you share the link. If you need to make a
+change, you can create a new Permalink and share that.
+
+This option is good for a search you only need for a short period of time.
+For example, you may be working on something with a co-worker and you want
+to quickly show them a set of tickets you want them to look at. You can
+create the search that finds the correct tickets and share the link. After
+they load the tickets, they might make some updates (maybe resolving some
+tickets) and after that you no longer need the search link.
+
+=head2 Saved Search Links
+
+L</"Saved Searches"> also have a Permalink created when you save them. If
+another RT user has rights to load the saved search, they could go to the
+Query Builder page and load it, but you could also click to View the Permalink
+and share the link. This will load the saved search for them automatically.
+
+The Permalink for a saved search links to the saved search entry, which means
+if you update the saved search, anyone with the link will see the updated search
+when they next load it. This makes it more flexible than a Permalink directly
+to an ad hoc search since you can update it over time if needed and users
+can use the same link. Users can also always load the search from the menu
+on the Query Builder.
+
+=head2 Saved Searches in Dashboards
+
+For searches that you want users to be able to easily use often and possibly
+for a longer period of time, you can create a dashboard. Dashboards can
+contain many searches and charts and can be set as the user's home page.
+So useful searches like "Most Due Support Tickets" or "My Tasks for This Week"
+are good candidates for Dashboards. See L<Dashboard and Reports|docs/dashboards_reporting.pod>
+for more information on setting up dashboards.
+
 =head1 Transaction Query Builder
 
 Similar to the Ticket Query Builder, the Transaction Query Builder provides an

commit 9877f1f7d227f534c6dfd8395d8cf5329866663c
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Oct 6 02:54:05 2021 +0800

    Add Shorteners to serializer in clone mode

diff --git a/lib/RT/Migrate/Serializer.pm b/lib/RT/Migrate/Serializer.pm
index bce8530f22..49b66a5e44 100644
--- a/lib/RT/Migrate/Serializer.pm
+++ b/lib/RT/Migrate/Serializer.pm
@@ -191,6 +191,9 @@ sub PushAll {
 
     # Attributes
     $self->PushCollections(qw(Attributes));
+
+    # Shorteners
+    $self->PushCollections(qw(Shorteners));
 }
 
 sub PushCollections {

commit b64c5223e9dc3a0f94eabf573faaa510ccdd38ce
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Oct 6 01:43:07 2021 +0800

    Add tests for saved search shortener

diff --git a/t/web/search_shortener.t b/t/web/search_shortener.t
index 89772c2f6c..0be2740f94 100644
--- a/t/web/search_shortener.t
+++ b/t/web/search_shortener.t
@@ -64,4 +64,46 @@ $m->form_id('shredder-search-form');
 is( $m->value('Tickets:query'), 'id < 10', 'Tickets:query in shredder' );
 is( $m->value('Tickets:limit'), 50,        'Tickets:limit in shredder' );
 
+
+$m->get_ok('/Search/Build.html?Query=Queue="General"');
+$m->submit_form_ok(
+    {   form_name => 'BuildQuery',
+        fields    => { SavedSearchDescription => 'my saved search' },
+        button    => 'SavedSearchSave',
+    },
+    'Created saved search'
+);
+$m->follow_link_ok( { text => 'View', url_regex => qr{/Search/Build\.html\?sc=\w+} } );
+$m->form_name('BuildQuery');
+is( $m->value('SavedSearchDescription'), 'my saved search', 'Loaded saved search' );
+
+$m->follow_link_ok( { text => 'Chart', url_regex => qr{/Search/Chart\.html\?.*\bsc=\w+} } );
+$m->text_contains(q{Queue = 'General'});
+
+$m->submit_form_ok(
+    {   form_number => 3,
+        fields      => { Width => 800, Height => 400 },
+        button      => 'Update',
+    },
+    'Updaetd chart search'
+);
+$m->form_number(3);
+is( $m->value('Width'),  800, 'Width is updated' );
+is( $m->value('Height'), 400, 'Height is updated' );
+
+$m->submit_form_ok(
+    {   form_name => 'SaveSearch',
+        fields    => { SavedSearchDescription => 'my chart saved search' },
+        button    => 'SavedSearchSave',
+    },
+    'Created chart saved search'
+);
+$m->follow_link_ok( { text => 'View', url_regex => qr{/Search/Chart\.html\?sc=\w+} } );
+$m->form_name('SaveSearch');
+is( $m->value('SavedSearchDescription'), 'my chart saved search', 'Loaded chart saved search' );
+
+$m->form_number(3);
+is( $m->value('Width'),  800, 'Width is set' );
+is( $m->value('Height'), 400, 'Height is set' );
+
 done_testing;

commit b0034f589b904c8ccd5221881f626893d21f6ee0
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Oct 5 23:10:47 2021 +0800

    Add shortener support to saved searches

diff --git a/lib/RT/Attribute.pm b/lib/RT/Attribute.pm
index d8d2db1434..af49b66b87 100644
--- a/lib/RT/Attribute.pm
+++ b/lib/RT/Attribute.pm
@@ -457,6 +457,17 @@ sub Delete {
                 RT->Logger->error( "Couldn't delete link #" . $link->id . ": $msg" );
             }
         }
+
+        if ( $name eq 'SavedSearch' ) {
+            my $shortener = RT::Shortener->new( $self->CurrentUser );
+            $shortener->LoadByCols( Content => 'SavedSearchId=' . $self->Id );
+            if ( $shortener->Id ) {
+                my ( $ret, $msg ) = $shortener->Delete;
+                if ( !$ret ) {
+                    RT->Logger->error( "Couldn't delete shortener #" . $shortener->Id . ": $msg" );
+                }
+            }
+        }
     }
 
     return @return;
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 0a5a577bb4..d1f53a871c 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -1994,6 +1994,33 @@ sub ExpandShortenerCode {
             my $content = $shortener->DecodedContent;
             $shortener->_SetLastAccessed;
 
+            if ( my $search_id = delete $content->{SavedSearchId} ) {
+                my $search = RT::SavedSearch->new( $HTML::Mason::Commands::session{CurrentUser} );
+                my ( $ret, $msg ) = $search->LoadById($search_id);
+                if ($ret) {
+                    my %search_content = %{ $search->{Attribute}->Content || {} };
+                    my $type           = delete $search_content{SearchType} || 'Ticket';
+                    my $id             = join '-',
+                        $search->_build_privacy( $search->{Attribute}->ObjectType, $search->{Attribute}->ObjectId ),
+                        'SavedSearch', $search_id;
+                    if ( $type eq 'Chart' ) {
+                        $content->{SavedChartSearchId} = $id;
+                    }
+                    else {
+                        $content->{SavedSearchId} = $id;
+                        $content->{Class}         = "RT::${type}s";
+                    }
+
+                    $content->{SearchFields}    = [ keys %search_content ];
+                    $content->{SavedSearchLoad} = $content->{SavedSearchId} || $content->{SavedChartSearchId};
+                }
+                else {
+                    RT->Logger->warning("Could not load saved search $sc: $msg");
+                    push @{ $HTML::Mason::Commands::session{Actions}{''} },
+                        HTML::Mason::Commands::loc( "Could not load saved search [_1]: [_2]", $sc, $msg );
+                }
+            }
+
             # Shredder uses different parameters from search pages
             if ( $HTML::Mason::Commands::r->path_info =~ m{^/+Admin/Tools/Shredder} ) {
                 if ( $content->{Class} eq 'RT::Tickets' ) {
diff --git a/lib/RT/SavedSearch.pm b/lib/RT/SavedSearch.pm
index d35de03c58..043a521aca 100644
--- a/lib/RT/SavedSearch.pm
+++ b/lib/RT/SavedSearch.pm
@@ -222,6 +222,20 @@ sub ObjectsForCreating {
     return @create_objects;
 }
 
+=head2 ShortenerObj
+
+Return the corresponding shortener object
+
+=cut
+
+sub ShortenerObj {
+    my $self = shift;
+    require RT::Shortener;
+    my $shortener = RT::Shortener->new( $self->CurrentUser );
+    $shortener->LoadOrCreate( Content => 'SavedSearchId=' . $self->Id, Permanent => 1 );
+    return $shortener;
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/share/html/Helpers/Permalink b/share/html/Helpers/Permalink
index 3ce59e2946..6f27df849d 100644
--- a/share/html/Helpers/Permalink
+++ b/share/html/Helpers/Permalink
@@ -54,11 +54,17 @@
       </a>
     </div>
     <div class="modal-body text-center">
+%   if ( $shortener->Id && $shortener->DecodedContent->{SavedSearchId} ) {
+      <p class="description mt-1 ml-3">
+        <&|/l&>If you share this link, other users will need rights to load your saved search. Note that My saved searches are visible only to you.</&>
+      </p>
+%   }
+
       <div class="my-2">
         <a href="<% $URL %>"><% $URL %></a><br>
       </div>
       <div>
-        <button class="button btn btn-primary clipboard-copy" data-copied-text=<% loc('Copied') %> data-clipboard-text="<% $URL %>"><% loc('Copy') %></button>
+        <button class="button btn btn-primary form-control clipboard-copy" data-copied-text=<% loc('Copied') %> data-clipboard-text="<% $URL %>"><% loc('Copy') %></button>
       </div>
     </div>
   </div>
diff --git a/share/html/Search/Elements/EditSearches b/share/html/Search/Elements/EditSearches
index 6871e702bb..96eebe4e2d 100644
--- a/share/html/Search/Elements/EditSearches
+++ b/share/html/Search/Elements/EditSearches
@@ -89,7 +89,22 @@
   </div>
 % }
 
-% if ( $Object && $Object->Id && $Object->DependedOnBy->Count ) {
+% if ( $Object && $Object->Id ) {
+
+% if ( RT->Config->Get( 'EnableURLShortener', $session{CurrentUser} ) ) {
+% my $saved_search = RT::SavedSearch->new( $session{CurrentUser} );
+% $saved_search->LoadById($Object->Id);
+  <div class="form-row">
+    <div class="label col-4"><&|/l&>Permalink</&>:</div>
+    <div class="col-8">
+      <span class="form-control current-value">
+        <a href="<% $m->request_path %>?sc=<% $saved_search->ShortenerObj->Code %>" class="permalink" data-toggle="tooltip" data-original-title="<% loc('Permalink to this saved search') %>" data-code="<% $saved_search->ShortenerObj->Code %>" data-url="<% $m->request_path %>?sc=<% $saved_search->ShortenerObj->Code %>"><% loc('View') %></a>
+      </span>
+    </div>
+  </div>
+% }
+
+% if ( $Object->DependedOnBy->Count ) {
   <div class="form-row">
     <div class="label col-4"><&|/l&>Depended on by</&>:</div>
     <div class="col-8">
@@ -98,6 +113,8 @@
       </span>
     </div>
   </div>
+% }
+
 % }
 
   <hr />

commit a5e4555369f4100e025befa321097d011e87ab0e
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Oct 5 22:59:26 2021 +0800

    Do not set SavedSearchId to chart search id
    
    SavedSearchLoad param is used in both chart and non-chart saved
    searches, so it's possible that $search_id represents a chart saved
    search. On the other hand, SavedSearchId param is only for non-chart
    saved searches, to show them on query builder page.
    
    This commit fixes the issue that chart saved search could show up on
    query builder page. To reproduce it, you can load a saved chart search
    on chart page and then go to query builder page via "Edit Search" page
    menu.

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 9fe55289cc..e1963f9171 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -600,13 +600,13 @@ sub BuildMainNav {
             $HTML::Mason::Commands::DECODED_ARGS->{ObjectType} || ( $class eq 'RT::Transactions' ? 'RT::Ticket' : () );
         my $current_search = $HTML::Mason::Commands::session{$hash_name} || {};
         my $search_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchLoad'} || $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchId'} || $current_search->{'SearchId'} || '';
-        my $chart_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedChartSearchId'} || $current_search->{SavedChartSearchId};
+        my $chart_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedChartSearchId'} || $current_search->{SavedChartSearchId} || '';
 
         $has_query = 1 if ( $HTML::Mason::Commands::DECODED_ARGS->{'Query'} or $current_search->{'Query'} );
 
         my %query_args;
         my %fallback_query_args = (
-            SavedSearchId => ( $search_id eq 'new' ) ? undef : $search_id,
+            SavedSearchId => ( $search_id eq 'new' || $search_id eq $chart_id ) ? undef : $search_id,
             SavedChartSearchId => $chart_id,
             (
                 map {

commit edb32eef68546a81fad19c23b3c03313cf667ed4
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Sep 24 02:45:04 2021 +0800

    Add basic tests for shortener viewer

diff --git a/t/web/admin_tools_shortener.t b/t/web/admin_tools_shortener.t
new file mode 100644
index 0000000000..83f8161cb9
--- /dev/null
+++ b/t/web/admin_tools_shortener.t
@@ -0,0 +1,39 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+
+my ( $baseurl, $m ) = RT::Test->started_ok;
+
+RT::Test->create_ticket(
+    Queue   => 'General',
+    Subject => 'Shortener test',
+    Content => 'test',
+);
+
+ok $m->login, 'logged in';
+
+$m->get_ok('/Search/Results.html?Query=id<10');
+$m->follow_link_ok( { text => 'Show Results' } );
+my ( $sc ) = ( $m->uri =~ /\bsc=(\w+)/ );
+$m->follow_link_ok( { text => 'Shortener Viewer' } );
+$m->title_is('Shortener Viewer');
+$m->submit_form_ok(
+    {   form_name => 'LoadShortener',
+        fields    => { sc => $sc },
+    }
+);
+
+$m->text_contains(q{'Query' => 'id<10'});
+
+$m->submit_form_ok(
+    {   form_name => 'LoadShortener',
+        fields    => { sc => 'somefake' },
+    }
+);
+
+$m->content_contains(q{Could not find short code somefake});
+$m->text_lacks(q{'Query' => 'id<10'});
+$m->warning_like(qr/Could not find short code somefake/);
+
+done_testing;

commit 3c2865956613dcd25639109fdebd19a02a94bccc
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Sep 24 02:24:58 2021 +0800

    Add Shortener page to show related info of specified code

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 6b6c485c36..9fe55289cc 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -1363,6 +1363,12 @@ sub _BuildAdminMenu {
         );
     }
 
+    $admin_tools->child(
+        'shortener' => title => loc('Shortener Viewer'),
+        description => loc('View shortener details'),
+        path        => '/Admin/Tools/Shortener.html',
+    );
+
     if ( $request_path =~ m{^/Admin/(Queues|Users|Groups|CustomFields|CustomRoles)} ) {
         my $type = $1;
 
diff --git a/share/html/Admin/Tools/Shortener.html b/share/html/Admin/Tools/Shortener.html
new file mode 100644
index 0000000000..a5ee3767e9
--- /dev/null
+++ b/share/html/Admin/Tools/Shortener.html
@@ -0,0 +1,170 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => $title &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<&| /Widgets/TitleBox, hideable => 0, content_class => 'mx-auto width-md', class => 'border-0' &>
+  <form name="LoadShortener" action="<% RT->Config->Get('WebPath') %>/Admin/Tools/Shortener.html" class="mx-auto">
+    <div class="form-row">
+      <div class="col-3 label">
+        <&|/l&>Code</&>:
+      </div>
+      <div class="col-9 input-group">
+        <input name="sc" class="form-control" value="<% $sc %>" />
+        <input type="submit" class="button btn btn-primary" value="<% loc('Go!') %>" />
+      </div>
+    </div>
+  </form>
+</&>
+
+% if ( $shortener && $shortener->Id ) {
+<&|/Widgets/TitleBox, title => loc('Details of [_1]', $sc) &>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Code</&>:
+    </div>
+    <div class="col-9 value">
+      <% $shortener->Code %>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Content</&>:
+    </div>
+    <div class="col-9 value">
+      <% $shortener->Content %>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Decoded Content</&>:
+    </div>
+    <div class="col-9 value">
+%     use Data::Dumper;
+%     local $Data::Dumper::Terse = 1;
+%     local $Data::Dumper::Sortkeys = 1;
+      <pre><% Dumper($shortener->DecodedContent) %></pre>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Permanent</&>:
+    </div>
+    <div class="col-9 value">
+      <% $shortener->Permanent ? loc('Yes') : loc('No') %>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Creator</&>:
+    </div>
+    <div class="col-9 value">
+      <& /Elements/ShowUser, User => $shortener->CreatorObj &>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Created</&>:
+    </div>
+    <div class="col-9 value">
+      <% $shortener->CreatedObj->AsString %>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Last Updated By</&>:
+    </div>
+    <div class="col-9 value">
+      <& /Elements/ShowUser, User => $shortener->LastUpdatedByObj &>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Last Updated</&>:
+    </div>
+    <div class="col-9 value">
+      <% $shortener->LastUpdatedObj->AsString %>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Last Accessed By</&>:
+    </div>
+    <div class="col-9 value">
+      <& /Elements/ShowUser, User => $shortener->LastAccessedByObj &>
+    </div>
+  </div>
+  <div class="form-row">
+    <div class="col-3 label">
+      <&|/l&>Last Accessed</&>:
+    </div>
+    <div class="col-9 value">
+      <% $shortener->LastAccessedObj->AsString %>
+    </div>
+  </div>
+</&>
+% }
+
+<%INIT>
+my $title = loc('Shortener Viewer');
+unless ( $session{'CurrentUser'}->HasRight( Object => $RT::System, Right => 'SuperUser' ) ) {
+    Abort( loc('This feature is only available to system administrators.') );
+}
+
+my $shortener;
+my @results;
+if ( $sc ) {
+    $shortener = RT::Shortener->new($session{CurrentUser});
+    $shortener->LoadByCode($sc);
+}
+</%INIT>
+
+<%ARGS>
+$sc => ''
+</%ARGS>

commit 2fdfcf75aa1cd3f0573a4b74842577332b714499
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Sep 24 00:31:24 2021 +0800

    Add basic tests for search url shortener

diff --git a/t/api/shortener.t b/t/api/shortener.t
new file mode 100644
index 0000000000..449fb4d523
--- /dev/null
+++ b/t/api/shortener.t
@@ -0,0 +1,25 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+
+use_ok('RT::Shortener');
+
+my $s = RT::Shortener->new( RT->SystemUser );
+my ( $ret, $msg ) = $s->Create( Content => 'Query=id<10&Rows=50' );
+ok( $ret, $msg );
+
+is( $s->Content, 'Query=id<10&Rows=50', 'Content' );
+is( $s->Code,    'dc4195253b',          'Code is auto generated' );
+for my $field (qw/Creator LastUpdatedBy LastAccessedBy/) {
+    is( $s->$field, RT->SystemUser->Id, "$field" );
+}
+
+for my $field (qw/Created LastUpdated LastAccessed/) {
+    ok( $s->$field, "$field" );
+}
+
+( $ret, $msg ) = $s->SetPermanent(1);
+ok( $ret, $msg );
+
+done_testing();
diff --git a/t/api/shorteners.t b/t/api/shorteners.t
new file mode 100644
index 0000000000..2dc46cf16c
--- /dev/null
+++ b/t/api/shorteners.t
@@ -0,0 +1,64 @@
+use strict;
+use warnings;
+
+use Test::MockTime qw(set_fixed_time restore_time);
+set_fixed_time("2020-01-01T00:00:00Z");
+
+use RT::Test tests => undef;
+
+use_ok('RT::Shorteners');
+
+my %id;
+my $s = RT::Shortener->new( RT->SystemUser );
+my ( $ret, $msg ) = $s->Create( Content => 'Query=id<10&Rows=50' );
+ok( $ret, $msg );
+$id{old} = $s->Id;
+
+( $ret, $msg ) = $s->Create( Content => 'Query=id<20&Rows=50', Permanent => 1 );
+ok( $ret, $msg );
+$id{permanent} = $s->Id;
+
+restore_time();
+
+( $ret, $msg ) = $s->Create( Content => 'Query=id<30&Rows=50' );
+ok( $ret, $msg );
+$id{new} = $s->Id;
+
+my $items = RT::Shorteners->new( RT->SystemUser );
+$items->UnLimit;
+is( $items->Count, 3, 'Found all shorteners' );
+
+$items->CleanSlate;
+$items->Limit( FIELD => 'Content', VALUE => 'Query=id<10&Rows=50' );
+
+is( $items->Count,          1,                     'Found one shortener' );
+is( $items->First->Content, 'Query=id<10&Rows=50', 'Found the shortener' );
+
+( $ret, $msg ) = RT::Test->run_and_capture(
+    command => $RT::SbinPath . '/rt-clean-shorteners',
+    older   => '1Y',
+    verbose => 1,
+);
+is( $ret >> 8, 0, 'rt-clean-shorteners exited normally' );
+like( $msg, qr/deleted 1 shortener/, 'Deleted one shortener' );
+
+$s->Load( $id{old} );
+ok( !$s->Id, 'The old one is deleted' );
+$s->Load( $id{new} );
+ok( $s->Id, 'The new one is not deleted' );
+
+( $ret, $msg ) = RT::Test->run_and_capture(
+    command => $RT::SbinPath . '/rt-clean-shorteners',
+    older   => '0H',
+    verbose => 1,
+);
+is( $ret >> 8, 0, 'rt-clean-shorteners exited normally' );
+like( $msg, qr/deleted 1 shortener/, 'Deleted one shortener' );
+
+$s->Load( $id{new} );
+ok( !$s->Id, 'The new one is deleted' );
+
+$s->Load( $id{permanent} );
+ok( $s->Id, 'The permanent one is not deleted' );
+
+done_testing();
diff --git a/t/web/search_shortener.t b/t/web/search_shortener.t
new file mode 100644
index 0000000000..89772c2f6c
--- /dev/null
+++ b/t/web/search_shortener.t
@@ -0,0 +1,67 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+RT::Config->Set('ShredderStoragePath', RT::Test->temp_directory . '');
+
+my ( $baseurl, $m ) = RT::Test->started_ok;
+
+RT::Test->create_ticket(
+    Queue   => 'General',
+    Subject => 'Shortener test',
+    Content => 'test',
+);
+
+ok $m->login, 'logged in';
+
+$m->follow_link_ok( { text => 'New Search' } );
+$m->submit_form_ok(
+    {   form_name => 'BuildQuery',
+        fields    => { ValueOfid => 10 },
+        button    => 'DoSearch',
+    }
+);
+
+my @menus = (
+    { text        => 'Edit Search',     url_regex => qr{/Search/Build\.html\?sc=\w+} },
+    { text        => 'Advanced',        url_regex => qr{/Search/Edit\.html\?sc=\w+} },
+    { class_regex => qr/\bpermalink\b/, url_regex => qr{/Search/Edit\.html\?sc=\w+} },
+    { text        => 'Show Results',    url_regex => qr{/Search/Results\.html\?sc=\w+} },
+    { class_regex => qr/\bpermalink\b/, url_regex => qr{/Search/Results\.html\?sc=\w+} },
+    { text        => 'Bulk Update',     url_regex => qr{/Search/Bulk\.html\?sc=\w+} },
+    { class_regex => qr/\bpermalink\b/, url_regex => qr{/Search/Bulk\.html\?sc=\w+} },
+    { text        => 'Chart',           url_regex => qr{/Search/Chart\.html\?sc=\w+} },
+
+    # Chart page has new code which contains chart arguments.
+    { class_regex => qr/\bpermalink\b/, url_regex => qr{/Search/Chart\.html\?sc=\w+} },
+);
+
+for my $menu (@menus) {
+    $m->follow_link_ok($menu);
+}
+
+$m->follow_link_ok( { text => 'Advanced', url_regex => qr{/Search/Edit\.html\?sc=\w+} } );
+$m->form_name('BuildQueryAdvanced');
+is( $m->value('Query'), 'id < 10', 'Query on Advanced' );
+
+$m->follow_link_ok( { text => 'Show Results', url_regex => qr{/Search/Results\.html\?sc=\w+} } );
+$m->content_contains('Shortener test', 'Found the ticket');
+
+my @feeds = (
+    { text => 'Spreadsheet', url_regex => qr/\bsc=\w+/ },
+    { text => 'RSS',         url_regex => qr/\bsc=\w+/ },
+    { text => 'iCal',        url_regex => qr/\bsc-\w+/ },
+);
+for my $feed (@feeds) {
+    $m->follow_link_ok($feed);
+    $m->content_contains('Shortener test', 'Found the ticket');
+    $m->back;
+    last;
+}
+
+$m->follow_link_ok( { text => 'Shredder', url_regex => qr/\bsc=\w+/ } );
+$m->form_id('shredder-search-form');
+is( $m->value('Tickets:query'), 'id < 10', 'Tickets:query in shredder' );
+is( $m->value('Tickets:limit'), 50,        'Tickets:limit in shredder' );
+
+done_testing;

commit 371ed430dde0c015b79b3ff1953637c97bf327f7
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Sep 18 02:50:37 2021 +0800

    Add rt-clean-shorteners to clean temporary shorteners

diff --git a/.gitignore b/.gitignore
index d69cfd9672..5c75d7cba5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@
 /t/tmp/
 /sbin/rt-attributes-viewer
 /sbin/rt-clean-sessions
+/sbin/rt-clean-shorteners
 /sbin/rt-dump-database
 /sbin/rt-dump-initialdata
 /sbin/rt-dump-metadata
diff --git a/configure.ac b/configure.ac
index eeb333e8d3..80c0570a7c 100755
--- a/configure.ac
+++ b/configure.ac
@@ -475,6 +475,7 @@ AC_CONFIG_FILES([
                  sbin/rt-email-dashboards
                  sbin/rt-externalize-attachments
                  sbin/rt-clean-sessions
+                 sbin/rt-clean-shorteners
                  sbin/rt-shredder
                  sbin/rt-validator
                  sbin/rt-validate-aliases
diff --git a/lib/RT/Shorteners.pm b/lib/RT/Shorteners.pm
index 029b6e2912..7bacce3805 100644
--- a/lib/RT/Shorteners.pm
+++ b/lib/RT/Shorteners.pm
@@ -77,4 +77,63 @@ sub Table { 'Shorteners'}
 
 RT::Base->_ImportOverlays();
 
+
+=head2 ClearOld TIME
+
+Delete all temporary Shorteners that haven't been accessed for the specified
+TIME.
+
+TIME is in the C<< <NUM>[<unit>] >> format. Default unit is D(ays). H(our),
+M(onth) and Y(ear) are also supported.
+
+Passing 0 to delete all temporary Shorteners. Default is 1M(i.e. 1 month).
+
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+
+=cut
+
+sub ClearOld {
+    my $self  = shift;
+    my $older = shift // '1M';
+
+    my $seconds;
+    if ($older) {
+        unless ( $older =~ /^\s*([0-9]+)\s*(H|D|M|Y)?$/i ) {
+            return ( 0, $self->loc("wrong format of the 'older' argumnet") );
+        }
+        my ( $num, $unit ) = ( $1, uc( $2 || 'D' ) );
+        my %factor = ( H => 60 * 60 );
+        $factor{'D'} = $factor{'H'} * 24;
+        $factor{'M'} = $factor{'D'} * 31;
+        $factor{'Y'} = $factor{'D'} * 365;
+        $seconds     = $num * $factor{$unit};
+    }
+
+    my $dbh = RT->DatabaseHandle->dbh;
+    my $rows;
+    if ($seconds) {
+        require POSIX;
+        my $date = POSIX::strftime( "%Y-%m-%d %H:%M", gmtime( time - int $seconds ) );
+        my $sth  = $dbh->prepare("DELETE FROM Shorteners WHERE Permanent = ? AND LastAccessed < ?");
+        return ( 0, $self->loc( "Couldn't prepare query: [_1]", $dbh->errstr ) ) unless $sth;
+        $rows = $sth->execute( 0, $date );
+        return ( 0, $self->loc( "Couldn't execute query: [_1]", $dbh->errstr ) ) unless defined $rows;
+    }
+    else {
+        my $sth = $dbh->prepare("DELETE FROM Shorteners WHERE Permanent = ?");
+        return ( 0, $self->loc( "Couldn't prepare query: [_1]", $dbh->errstr ) ) unless $sth;
+        $rows = $sth->execute(0);
+        return ( 0, $self->loc( "Couldn't execute query: [_1]", $dbh->errstr ) ) unless defined $rows;
+    }
+
+    # $rows could be 0E0, here we want to show it 0
+    $rows = sprintf '%d', $rows;
+    if ( $rows == 0 ) {
+        return ( 1, $self->loc("No qualified shorteners found, nothing to do") );
+    }
+    else {
+        return ( 1, $self->loc( "Successfully deleted [quant,_1,shortener,shorteners]", $rows ) );
+    }
+}
+
 1;
diff --git a/lib/RT/Shorteners.pm b/sbin/rt-clean-shorteners.in
similarity index 58%
copy from lib/RT/Shorteners.pm
copy to sbin/rt-clean-shorteners.in
index 029b6e2912..9af8457da6 100644
--- a/lib/RT/Shorteners.pm
+++ b/sbin/rt-clean-shorteners.in
@@ -1,3 +1,4 @@
+#!@PERL@
 # BEGIN BPS TAGGED BLOCK {{{
 #
 # COPYRIGHT:
@@ -45,36 +46,75 @@
 # those contributions and any derivatives thereof.
 #
 # END BPS TAGGED BLOCK }}}
+use strict;
+use warnings;
+
+# fix lib paths, some may be relative
+BEGIN { # BEGIN RT CMD BOILERPLATE
+    require File::Spec;
+    require Cwd;
+    my @libs = ("@RT_LIB_PATH@", "@LOCAL_LIB_PATH@");
+    my $bin_path;
+
+    for my $lib (@libs) {
+        unless ( File::Spec->file_name_is_absolute($lib) ) {
+            $bin_path ||= ( File::Spec->splitpath(Cwd::abs_path(__FILE__)) )[1];
+            $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
+        }
+        unshift @INC, $lib;
+    }
+
+}
+
+use RT::Interface::CLI qw(GetCurrentUser Init);
+my %opt = ();
+Init( \%opt, 'older=s' );
+
+my $shorteners = RT::Shorteners->new( GetCurrentUser() );
+my ( $ret, $msg ) = $shorteners->ClearOld( $opt{'older'} );
+
+if ($ret) {
+    RT->Logger->info($msg);
+}
+else {
+    print STDERR $msg;
+    exit 1;
+}
+
+__END__
 
 =head1 NAME
 
-  RT::Shorteners - Collection of RT::Shortener objects
+rt-clean-shorteners - clean old temporary RT shorteners
 
 =head1 SYNOPSIS
 
-  use RT::Shorteners;
+     rt-clean-shorteners [--verbose] --older <NUM>[H|D|M|Y]
 
+     rt-clean-shorteners --older 3M
+     rt-clean-shorteners --verbose --older 1Y
 
 =head1 DESCRIPTION
 
+Script cleans RT temporary shorteners from DB.
 
-=head1 METHODS
-
+=head1 OPTIONS
 
-=cut
+=over 4
 
+=item older
 
-package RT::Shorteners;
-
-use strict;
-use warnings;
+Date interval in the C<< <NUM>[<unit>] >> format. Default unit is D(ays).
+H(our), M(onth) and Y(ear) are also supported.
 
-use base 'RT::SearchBuilder';
+For example: C<rt-clean-shorteners --older 1M> would delete all temporary
+shorteners that haven't been accessed for 1 month.
 
-use RT::Shortener;
+Default value is 1M, you can specify C<--older 0> to delete all temporary
+shorteners.
 
-sub Table { 'Shorteners'}
+=item verbose
 
-RT::Base->_ImportOverlays();
+print additional info to STDOUT
 
-1;
+=back

commit fec3b56239722ea7727e13161361b27ed4e47ee2
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Sep 18 02:17:25 2021 +0800

    Show warning to end user if the short code is invalid

diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 491fbf7aee..0a5a577bb4 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -2015,6 +2015,12 @@ sub ExpandShortenerCode {
                 }
             }
         }
+        else {
+            RT->Logger->warning("Could not find short code $sc");
+            push @{ $HTML::Mason::Commands::session{Actions}{''} },
+                HTML::Mason::Commands::loc( "Could not find short code [_1]", $sc );
+            $HTML::Mason::Commands::session{'i'}++;
+        }
     }
 }
 
diff --git a/share/html/Search/Build.html b/share/html/Search/Build.html
index 08cc382347..a4897cf03c 100644
--- a/share/html/Search/Build.html
+++ b/share/html/Search/Build.html
@@ -67,6 +67,7 @@
 %#
 <& /Elements/Header, Title => $title &>
 <& /Elements/Tabs, %TabArgs &>
+<& /Elements/ListActions &>
 
 <form method="post" action="Build.html" name="BuildQuery" id="BuildQuery">
 <input type="hidden" class="hidden" name="SavedSearchId" value="<% $saved_search{'Id'} %>" />
diff --git a/share/html/Search/Edit.html b/share/html/Search/Edit.html
index a46bfb737e..ff7f41403e 100644
--- a/share/html/Search/Edit.html
+++ b/share/html/Search/Edit.html
@@ -47,6 +47,7 @@
 %# END BPS TAGGED BLOCK }}}
 <& /Elements/Header, Title => $title&>
 <& /Elements/Tabs &>
+<& /Elements/ListActions &>
 
 <& Elements/NewListActions, actions => \@actions &>
 
diff --git a/share/html/Search/Results.html b/share/html/Search/Results.html
index 695ea7994c..dbc2bf34c6 100644
--- a/share/html/Search/Results.html
+++ b/share/html/Search/Results.html
@@ -49,6 +49,7 @@
     Refresh => $refresh,
     LinkRel => \%link_rel &>
 <& /Elements/Tabs &>
+<& /Elements/ListActions &>
 
 % my $DisplayFormat;
 % $m->callback( ARGSRef => \%ARGS, Format => \$Format, DisplayFormat => \$DisplayFormat, CallbackName => 'BeforeResults' );

commit 69c2bc97aa20a29c03d04cb319d0cb9cf39653c5
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Sep 18 02:16:43 2021 +0800

    Update tests as URL shortener is enabled by default

diff --git a/t/web/charting.t b/t/web/charting.t
index bb7ecb87b2..5cacfb637b 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -80,19 +80,17 @@ ok( length($m->content), "Has content" );
 diag "Confirm subnav links use Query param before saved search in session.";
 
 $m->get_ok( "/Search/Chart.html?Query=id>0" );
-my $advanced = $m->find_link( text => 'Advanced' )->URI->equery;
-like( $advanced, qr{Query=id%3E0},
-      'Advanced link has Query param with id search'
-    );
+$m->follow_link_ok( { text => 'Advanced' } );
+is( $m->form_name('BuildQueryAdvanced')->find_input('Query')->value,
+    'id>0', 'Advanced page has Query param with id search' );
 
 # Load the session with another search.
 $m->get_ok( "/Search/Results.html?Query=Queue='General'" );
 
 $m->get_ok( "/Search/Chart.html?Query=id>0" );
-$advanced = $m->find_link( text => 'Advanced' )->URI->equery;
-like( $advanced, qr{Query=id%3E0},
-      'Advanced link still has Query param with id search'
-    );
+$m->follow_link_ok( { text => 'Advanced' } );
+is( $m->form_name('BuildQueryAdvanced')->find_input('Query')->value,
+    'id>0', 'Advanced page still has Query param with id search' );
 
 # Test query with JOINs
 $m->get_ok( "/Search/Chart.html?Query=Requestor.Name LIKE 'root'" );

commit 0f22f4e0da2e9d24a5483642339c3e666d8835db
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Sep 18 02:04:33 2021 +0800

    Support to shorten search URLs

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 1b7720241e..65074f76ec 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -2074,6 +2074,14 @@ L<https://nagix.github.io/chartjs-plugin-colorschemes/colorchart.html>
 
 Set($JSChartColorScheme, 'brewer.Paired12');
 
+=item C<$EnableURLShortener>
+
+Set this to 0 to disable URL shortener.
+
+=cut
+
+Set($EnableURLShortener, 1);
+
 =back
 
 
diff --git a/etc/acl.Pg b/etc/acl.Pg
index dc3ca03f37..3c6c50e29d 100644
--- a/etc/acl.Pg
+++ b/etc/acl.Pg
@@ -70,6 +70,8 @@ sub acl {
         Configurations
         authtokens_id_seq
         AuthTokens
+        shorteners_id_seq
+        Shorteners
     );
 
     my $db_user = RT->Config->Get('DatabaseUser');
diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index 324f790d19..1997a7ab2d 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -571,3 +571,20 @@ CREATE TABLE AuthTokens (
 );
 
 CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
+
+CREATE SEQUENCE SHORTENERS_seq;
+CREATE TABLE Shorteners (
+  id                NUMBER(19,0)
+                    CONSTRAINT SHORTENERS_seq PRIMARY KEY,
+  Code              VARCHAR2(40)    NOT NULL,
+  Content           CLOB            NOT NULL,
+  Permanent         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  Creator           NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  Created           DATE,
+  LastUpdatedBy     NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  LastUpdated       DATE,
+  LastAccessedBy    NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  LastAccessed      DATE
+);
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/etc/schema.Pg b/etc/schema.Pg
index 9f34ec4b0a..91d54f5e86 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -813,3 +813,20 @@ CREATE TABLE AuthTokens (
 );
 
 CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
+
+CREATE SEQUENCE shorteners_id_seq;
+CREATE TABLE Shorteners (
+  id                INTEGER         DEFAULT nextval('shorteners_id_seq'),
+  Code              VARCHAR(40)     NOT NULL,
+  Content           TEXT            NOT NULL,
+  Permanent         INTEGER         NOT NULL DEFAULT 0,
+  Creator           INTEGER         NOT NULL DEFAULT 0,
+  Created           TIMESTAMP                DEFAULT NULL,
+  LastUpdatedBy     INTEGER         NOT NULL DEFAULT 0,
+  LastUpdated       TIMESTAMP                DEFAULT NULL,
+  LastAccessedBy    INTEGER         NOT NULL DEFAULT 0,
+  LastAccessed      TIMESTAMP                DEFAULT NULL,
+  PRIMARY KEY (id)
+);
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index d2e455f9e5..3cfcae1ebb 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -600,3 +600,18 @@ CREATE TABLE AuthTokens (
 );
 
 CREATE INDEX AuthTokensOwner on AuthTokens (Owner);
+
+CREATE TABLE Shorteners (
+  id                INTEGER PRIMARY KEY,
+  Code              VARCHAR(40)     NOT NULL,
+  Content           LONGTEXT        NOT NULL,
+  Permanent         INT2            NOT NULL DEFAULT 0,
+  Creator           INTEGER         NOT NULL DEFAULT 0,
+  Created           DATETIME        NULL,
+  LastUpdatedBy     INTEGER         NULL DEFAULT 0,
+  LastUpdated       DATETIME        NULL,
+  LastAccessedBy    INTEGER         NULL DEFAULT 0,
+  LastAccessed      DATETIME        NULL
+);
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/etc/schema.mysql b/etc/schema.mysql
index f773ffd472..c1fd1f5b71 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -592,3 +592,19 @@ CREATE TABLE AuthTokens (
 ) ENGINE=InnoDB CHARACTER SET utf8mb4;
 
 CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
+
+CREATE TABLE Shorteners (
+  id             INTEGER     NOT NULL AUTO_INCREMENT,
+  Code           VARCHAR(40) NOT NULL,
+  Content        LONGTEXT    NOT NULL,
+  Permanent      INT2        NOT NULL DEFAULT 0,
+  Creator        INTEGER     NOT NULL DEFAULT 0,
+  Created        DATETIME    NULL,
+  LastUpdatedBy  INTEGER     NULL DEFAULT 0,
+  LastUpdated    DATETIME    NULL,
+  LastAccessedBy INTEGER     NULL DEFAULT 0,
+  LastAccessed   DATETIME    NULL,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB CHARACTER SET utf8mb4;
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/etc/upgrade/5.0.4/acl.Pg b/etc/upgrade/5.0.4/acl.Pg
new file mode 100644
index 0000000000..26dae5217f
--- /dev/null
+++ b/etc/upgrade/5.0.4/acl.Pg
@@ -0,0 +1,29 @@
+sub acl {
+    my $dbh = shift;
+
+    my @acls;
+    my @tables = qw (
+        shorteners_id_seq
+        Shorteners
+    );
+
+    my $db_user = RT->Config->Get('DatabaseUser');
+
+    my $sequence_right
+        = ( $dbh->{pg_server_version} >= 80200 )
+        ? "USAGE, SELECT, UPDATE"
+        : "SELECT, UPDATE";
+
+    foreach my $table (@tables) {
+        # Tables are upper-case, sequences are lowercase in @tables
+        if ( $table =~ /^[a-z]/ ) {
+            push @acls, "GRANT $sequence_right ON $table TO \"$db_user\";"
+        }
+        else {
+            push @acls, "GRANT SELECT, INSERT, UPDATE, DELETE ON $table TO \"$db_user\";"
+        }
+    }
+    return (@acls);
+}
+
+1;
diff --git a/etc/upgrade/5.0.4/schema.Oracle b/etc/upgrade/5.0.4/schema.Oracle
new file mode 100644
index 0000000000..2c688b4441
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.Oracle
@@ -0,0 +1,16 @@
+CREATE SEQUENCE SHORTENERS_seq;
+CREATE TABLE Shorteners (
+  id                NUMBER(19,0)
+                    CONSTRAINT SHORTENERS_seq PRIMARY KEY,
+  Code              VARCHAR2(40)    NOT NULL,
+  Content           CLOB            NOT NULL,
+  Permanent         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  Creator           NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  Created           DATE,
+  LastUpdatedBy     NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  LastUpdated       DATE,
+  LastAccessedBy    NUMBER(11,0)    DEFAULT 0 NOT NULL,
+  LastAccessed      DATE
+);
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/etc/upgrade/5.0.4/schema.Pg b/etc/upgrade/5.0.4/schema.Pg
new file mode 100644
index 0000000000..a1279a5e53
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.Pg
@@ -0,0 +1,16 @@
+CREATE SEQUENCE shorteners_id_seq;
+CREATE TABLE Shorteners (
+  id                INTEGER         DEFAULT nextval('shorteners_id_seq'),
+  Code              VARCHAR(40)     NOT NULL,
+  Content           TEXT            NOT NULL,
+  Permanent         INTEGER         NOT NULL DEFAULT 0,
+  Creator           INTEGER         NOT NULL DEFAULT 0,
+  Created           TIMESTAMP                DEFAULT NULL,
+  LastUpdatedBy     INTEGER         NOT NULL DEFAULT 0,
+  LastUpdated       TIMESTAMP                DEFAULT NULL,
+  LastAccessedBy    INTEGER         NOT NULL DEFAULT 0,
+  LastAccessed      TIMESTAMP                DEFAULT NULL,
+  PRIMARY KEY (id)
+);
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/etc/upgrade/5.0.4/schema.SQLite b/etc/upgrade/5.0.4/schema.SQLite
new file mode 100644
index 0000000000..3542116d0e
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.SQLite
@@ -0,0 +1,14 @@
+CREATE TABLE Shorteners (
+  id                INTEGER PRIMARY KEY,
+  Code              VARCHAR(40)     NOT NULL,
+  Content           LONGTEXT        NOT NULL,
+  Permanent         INT2            NOT NULL DEFAULT 0,
+  Creator           INTEGER         NOT NULL DEFAULT 0,
+  Created           DATETIME        NULL,
+  LastUpdatedBy     INTEGER         NULL DEFAULT 0,
+  LastUpdated       DATETIME        NULL,
+  LastAccessedBy    INTEGER         NULL DEFAULT 0,
+  LastAccessed      DATETIME        NULL
+);
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/etc/upgrade/5.0.4/schema.mysql b/etc/upgrade/5.0.4/schema.mysql
new file mode 100644
index 0000000000..c396d49f66
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.mysql
@@ -0,0 +1,15 @@
+CREATE TABLE Shorteners (
+  id             INTEGER     NOT NULL AUTO_INCREMENT,
+  Code           VARCHAR(40) NOT NULL,
+  Content        LONGTEXT    NOT NULL,
+  Permanent      INT2        NOT NULL DEFAULT 0,
+  Creator        INTEGER     NOT NULL DEFAULT 0,
+  Created        DATETIME    NULL,
+  LastUpdatedBy  INTEGER     NULL DEFAULT 0,
+  LastUpdated    DATETIME    NULL,
+  LastAccessedBy INTEGER     NULL DEFAULT 0,
+  LastAccessed   DATETIME    NULL,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB CHARACTER SET utf8mb4;
+
+CREATE UNIQUE INDEX Shorteners1 ON Shorteners(Code);
diff --git a/lib/RT.pm b/lib/RT.pm
index 4f3a3ebff2..e7dc8e06bd 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -508,6 +508,8 @@ sub InitClasses {
     require RT::Configurations;
     require RT::REST2;
     require RT::Authen::Token;
+    require RT::Shortener;
+    require RT::Shorteners;
 
     _BuildTableAttributes();
 
@@ -569,6 +571,7 @@ sub _BuildTableAttributes {
         RT::Catalog
         RT::CustomRole
         RT::ObjectCustomRole
+        RT::Shortener
     );
 }
 
diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 89276eeba7..65b96bc806 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -414,6 +414,15 @@ our %META;
             Description => 'JavaScript chart color scheme', #loc
         },
     },
+    EnableURLShortener => {
+        Section         => 'General',                       #loc
+        Overridable     => 1,
+        SortOrder       => 12,
+        Widget          => '/Widgets/Form/Boolean',
+        WidgetArguments => {
+            Description => 'Enable URL shortener',             #loc
+        },
+    },
 
     # User overridable options for RT at a glance
     HomePageRefreshInterval => {
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 93976757a4..491fbf7aee 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -67,6 +67,7 @@ package RT::Interface::Web;
 use RT::SavedSearches;
 use RT::CustomRoles;
 use URI qw();
+use URI::QueryParam;
 use RT::Interface::Web::Menu;
 use RT::Interface::Web::Session;
 use RT::Interface::Web::Scrubber;
@@ -76,6 +77,11 @@ use JSON qw();
 use Plack::Util;
 use HTTP::Status qw();
 use Regexp::Common;
+use RT::Shortener;
+
+our @SHORTENER_SEARCH_FIELDS
+    = qw/Class ObjectType Query Format RowsPerPage Order OrderBy ExtraQueryParams ResultPage/;
+our @SHORTENER_CHART_FIELDS = qw/Width Height ChartStyle GroupBy ChartFunction StackedGroupBy/;
 
 =head2 SquishedCSS $style
 
@@ -691,6 +697,8 @@ sub ShowRequestedPage {
     # session-id has been modified in any way
     SendSessionCookie();
 
+    ExpandShortenerCode($ARGS);
+
     # precache all system level rights for the current user
     $HTML::Mason::Commands::session{CurrentUser}->PrincipalObj->HasRights( Object => RT->System );
 
@@ -1504,6 +1512,9 @@ our @GLOBAL_WHITELISTED_ARGS = (
     # The NotMobile flag is fine for any page; it's only used to toggle a flag
     # in the session related to which interface you get.
     'NotMobile',
+
+    # The Shortener code
+    'sc',
 );
 
 our %WHITELISTED_COMPONENT_ARGS = (
@@ -1968,6 +1979,45 @@ sub ClientIsIE {
     return RequestENV('HTTP_USER_AGENT') =~ m{MSIE|Trident/} ? 1 : 0;
 }
 
+=head2 ExpandShortenerCode $ARGS
+
+Expand shortener code and put expanded ones into C<$ARGS>.
+
+=cut
+
+sub ExpandShortenerCode {
+    my $ARGS = shift;
+    if ( my $sc = $ARGS->{sc} ) {
+        my $shortener = RT::Shortener->new( $HTML::Mason::Commands::session{CurrentUser} );
+        $shortener->LoadByCode($sc);
+        if ( $shortener->Id ) {
+            my $content = $shortener->DecodedContent;
+            $shortener->_SetLastAccessed;
+
+            # Shredder uses different parameters from search pages
+            if ( $HTML::Mason::Commands::r->path_info =~ m{^/+Admin/Tools/Shredder} ) {
+                if ( $content->{Class} eq 'RT::Tickets' ) {
+                    $ARGS->{'Tickets:query'} = $content->{Query}
+                        unless exists $ARGS->{'Tickets:query'};
+                    $ARGS->{'Tickets:limit'} = $content->{RowsPerPage}
+                        unless exists $ARGS->{'Tickets:limit'};
+                }
+            }
+            else {
+                for my $key ( keys %$content ) {
+
+                    # Direct passed in arguments have higher priority, so
+                    # people can easily create a new search based on an
+                    # existing shortener.
+                    if ( !exists $ARGS->{$key} ) {
+                        $ARGS->{$key} = $content->{$key};
+                    }
+                }
+            }
+        }
+    }
+}
+
 package HTML::Mason::Commands;
 
 use vars qw/$r $m %session/;
@@ -5273,6 +5323,101 @@ sub GetDashboards {
     return \%dashboards;
 }
 
+sub QueryString {
+    my %args = @_;
+    my $u    = URI->new();
+    $u->query_form(map { $_ => $args{$_} } sort keys %args);
+    return $u->query;
+}
+
+sub ShortenSearchQuery {
+    return @_ unless RT->Config->Get( 'EnableURLShortener', $session{CurrentUser} );
+    my %query_args = @_;
+
+    # Clean up
+    delete $query_args{Page} unless ( $query_args{Page} || 1 ) > 1;
+    for my $param (qw/SavedSearchId SavedChartSearchId/) {
+        delete $query_args{$param} unless ( $query_args{$param} || 'new' ) ne 'new';
+    }
+
+    my $fallback;
+    if ( my $sc = $HTML::Mason::Commands::DECODED_ARGS->{sc} ) {
+        my $shortener = RT::Shortener->new( $session{CurrentUser} );
+        $shortener->LoadByCode($sc);
+        if ( $shortener->Id ) {
+            $fallback = $shortener->DecodedContent;
+        }
+        else {
+            RT->Logger->warning("Couldn't load shortener $sc");
+        }
+    }
+
+    my %short_args;
+    my %supported = map { $_ => 1 } @SHORTENER_SEARCH_FIELDS, @SHORTENER_CHART_FIELDS;
+    for my $field ( keys %supported ) {
+        my $value;
+        if ( exists $query_args{$field} ) {
+            $value = delete $query_args{$field};
+        }
+        elsif ( $field eq 'RowsPerPage' && exists $query_args{Rows} ) {
+            # Pages like search results support Rows too
+            $value = delete $query_args{Rows};
+        }
+        else {
+            $value = $fallback->{$field};
+        }
+
+        next unless defined $value;
+
+        if ( $field eq 'ResultPage' && $value eq RT->Config->Get('WebPath') . '/Search/Results.html' ) {
+            undef $value;
+        }
+        elsif ( $field =~ /^(?:Order|OrderBy)$/ ) {
+            if ( ref $value eq 'ARRAY' ) {
+                $value = join '|', @$value;
+            }
+
+            # Clean up empty items
+            $value = join '|', grep length, split /\|/, $value;
+        }
+
+        if ( defined $value && length $value ) {
+
+            # Make sure data saved in db is clean
+            if ( $field eq 'Format' ) {
+                $value = ScrubHTML($value);
+            }
+
+            $short_args{$field} = $value;
+            if ( $field eq 'ExtraQueryParams' ) {
+                for my $param (
+                    ref $short_args{$field} eq 'ARRAY'
+                    ? @{ $short_args{$field} }
+                    : $short_args{$field}
+                    )
+                {
+                    my $value = delete $query_args{$param};
+                    $short_args{$param} = $value if defined $value && length $value;
+                }
+            }
+        }
+    }
+    return ( %query_args, ShortenQuery(%short_args) );
+}
+
+sub ShortenQuery {
+    my $query     = QueryString(@_) or return;
+    my $shortener = RT::Shortener->new( $session{CurrentUser} );
+    my ( $ret, $msg ) = $shortener->LoadOrCreate( Content => $query );
+    if ($ret) {
+        return ( sc => $shortener->Code );
+    }
+    else {
+        RT->Logger->error("Couldn't load or create Shortener for $query: $msg");
+        return @_;
+    }
+}
+
 package RT::Interface::Web;
 RT::Base->_ImportOverlays();
 
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 6eb84b0b9c..6b6c485c36 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -58,13 +58,8 @@ use warnings;
 package RT::Interface::Web::MenuBuilder;
 
 sub loc { HTML::Mason::Commands::loc( @_ ); }
-
-sub QueryString {
-    my %args = @_;
-    my $u    = URI->new();
-    $u->query_form(map { $_ => $args{$_} } sort keys %args);
-    return $u->query;
-}
+sub QueryString { HTML::Mason::Commands::QueryString( @_ ); }
+sub ShortenSearchQuery { HTML::Mason::Commands::ShortenSearchQuery( @_ ); }
 
 sub BuildMainNav {
     my $request_path = shift;
@@ -635,11 +630,13 @@ sub BuildMainNav {
         $fallback_query_args{Class} ||= $class;
         $fallback_query_args{ObjectType} ||= 'RT::Ticket' if $class eq 'RT::Transactions';
 
+        my %final_query_args;
         if ($query_string) {
-            $args = '?' . $query_string;
+            my $uri = URI->new;
+            $uri->query($query_string);
+            %final_query_args = %{ $uri->query_form_hash };
         }
         else {
-            my %final_query_args = ();
             # key => callback to avoid unnecessary work
 
             if ( my $extra_params = $query_args->{ExtraQueryParams} ) {
@@ -666,9 +663,14 @@ sub BuildMainNav {
                 }
             }
 
-            $args = '?' . QueryString(%final_query_args);
+            for my $chart_field (@RT::Interface::Web::SHORTENER_CHART_FIELDS) {
+                $final_query_args{$chart_field} = $query_args->{$chart_field} if length $query_args->{$chart_field};
+            }
         }
 
+        my %short_query = ShortenSearchQuery(%final_query_args);
+        $args = '?' . QueryString(%short_query);
+
         my $current_search_menu;
         if (   $class eq 'RT::Tickets' && $request_path =~ m{^/Ticket}
             || $class eq 'RT::Transactions' && $request_path =~ m{^/Transaction}
@@ -695,8 +697,37 @@ sub BuildMainNav {
             $current_search_menu = $page;
         }
 
+        if (   $has_query
+            && $short_query{sc}
+            && $request_path =~ m{^/Search/}
+            && RT->Config->Get( 'EnableURLShortener', $current_user ) )
+        {
+            my $shortener = RT::Shortener->new($current_user);
+            $shortener->LoadByCode( $short_query{sc} );
+            if ( $shortener->Id ) {
+
+                # Storing url in data-url instead of path(href) is to not
+                # highlight the permalink in page menu.
+                $current_search_menu->child(
+                    'permalink',
+                    sort_order   => 2, # Put it between "Edit Search" and "Advanced"
+                    title        => '<span class="fas fa-link"></span>',
+                    escape_title => 0,
+                    class        => 'permalink',
+                    path         => "$request_path?sc=$short_query{sc}",
+                    attributes   => {
+                        'data-code'           => $short_query{sc},
+                        'data-url'            => "$request_path?sc=$short_query{sc}",
+                        'data-toggle'         => 'tooltip',
+                        'data-original-title' => loc('Permalink to this search'),
+                        alt                   => loc('Permalink to this search'),
+                    },
+                );
+            }
+        }
+
         $current_search_menu->child( edit_search =>
-            title => loc('Edit Search'), path => "/Search/Build.html" . ( ($has_query) ? $args : '' ) );
+            title => loc('Edit Search'), sort_order => 1, path => "/Search/Build.html" . ( ($has_query) ? $args : '' ) );
         if ( $current_user->HasRight( Right => 'ShowSearchAdvanced', Object => RT->System ) ) {
             $current_search_menu->child( advanced => title => loc('Advanced'), path => "/Search/Edit.html$args" );
         }
@@ -734,20 +765,23 @@ sub BuildMainNav {
                     = map { $_ => $query_args->{$_} || $fallback_query_args{$_} || '' } qw(Query Order OrderBy);
                 my $RSSQueryString = "?"
                     . QueryString(
-                    Query   => $rss_data{Query},
-                    Order   => $rss_data{Order},
-                    OrderBy => $rss_data{OrderBy}
+                        $short_query{sc}
+                        ? ( sc => $short_query{sc} )
+                        : ( Query   => $rss_data{Query},
+                            Order   => $rss_data{Order},
+                            OrderBy => $rss_data{OrderBy}
+                          )
                     );
                 my $RSSPath = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes( $_, 'u' ),
                     $current_user->UserObj->Name,
-                    $current_user->UserObj->GenerateAuthString(
-                    $rss_data{Query} . $rss_data{Order} . $rss_data{OrderBy} );
+                    $current_user->UserObj->GenerateAuthString( $short_query{sc}
+                        || ( $rss_data{Query} . $rss_data{Order} . $rss_data{OrderBy} ) );
 
                 $more->child( rss => title => loc('RSS'), path => "/NoAuth/rss/$RSSPath/$RSSQueryString" );
                 my $ical_path = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes( $_, 'u' ),
                     $current_user->UserObj->Name,
                     $current_user->UserObj->GenerateAuthString( $rss_data{Query} ),
-                    $rss_data{Query};
+                    $short_query{sc} ? "sc-$short_query{sc}" : $rss_data{Query};
                 $more->child( ical => title => loc('iCal'), path => '/NoAuth/iCal/' . $ical_path );
 
                 #XXX TODO better abstraction of SuperUser right check
@@ -755,8 +789,11 @@ sub BuildMainNav {
                     my $shred_args = QueryString(
                         Search          => 1,
                         Plugin          => 'Tickets',
-                        'Tickets:query' => $rss_data{'Query'},
-                        'Tickets:limit' => $query_args->{'RowsPerPage'},
+                        $short_query{sc}
+                            ? ( sc => $short_query{sc} )
+                            : ( 'Tickets:query' => $rss_data{'Query'},
+                                'Tickets:limit' => $query_args->{'RowsPerPage'},
+                              ),
                     );
 
                     $more->child(
diff --git a/lib/RT/Record.pm b/lib/RT/Record.pm
index c627480d24..641676024a 100644
--- a/lib/RT/Record.pm
+++ b/lib/RT/Record.pm
@@ -313,6 +313,12 @@ sub Create {
     $attribs{'LastUpdatedBy'} = $self->CurrentUser->id || '0'
       if ( $self->_Accessible( 'LastUpdatedBy', 'auto' ) && !$attribs{'LastUpdatedBy'});
 
+    $attribs{'LastAccessed'} = $now_iso
+      if ( $self->_Accessible( 'LastAccessed', 'auto' ) && !$attribs{'LastAccessed'});
+
+    $attribs{'LastAccessedBy'} = $self->CurrentUser->id || '0'
+      if ( $self->_Accessible( 'LastAccessedBy', 'auto' ) && !$attribs{'LastAccessedBy'});
+
     my $id = $self->SUPER::Create(%attribs);
     if ( UNIVERSAL::isa( $id, 'Class::ReturnValue' ) ) {
         if ( $id->errno ) {
diff --git a/lib/RT/Shortener.pm b/lib/RT/Shortener.pm
new file mode 100644
index 0000000000..908f2fd3d0
--- /dev/null
+++ b/lib/RT/Shortener.pm
@@ -0,0 +1,341 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+=head1 NAME
+
+RT::Shortener - RT Shortener object
+
+=head1 SYNOPSIS
+
+  use RT::Shortener;
+
+=head1 DESCRIPTION
+
+Object to operate on a single RT Shortener record.
+
+=head1 METHODS
+
+=cut
+
+
+package RT::Shortener;
+
+use strict;
+use warnings;
+
+use base 'RT::Record';
+
+sub Table {'Shorteners'}
+
+use Digest::SHA 'sha1_hex';
+use URI;
+use URI::QueryParam;
+use RT::Interface::Web;
+
+=head2 Create { PARAMHASH }
+
+=cut
+
+sub Create {
+    my $self = shift;
+    my %args = (
+        Content => undef,
+        @_,
+    );
+
+    unless ( $args{'Content'} ) {
+        return ( 0, $self->loc("Must specify 'Content' attribute") );
+    }
+
+    $args{Code} ||= substr sha1_hex( $args{Content} ), 0, 10;
+
+    return $self->SUPER::Create(%args);
+}
+
+sub LoadOrCreate {
+    my $self = shift;
+    my %args = (
+        Content   => undef,
+        Permanent => 0,
+        @_,
+    );
+
+    if ( $args{Content} ) {
+        my $sha1 = sha1_hex( $args{Content} );
+        my $code;
+
+        # In case there is a conflict, which should be quite rare.
+        for my $length ( 8 .. 40 ) {
+            $code = substr $sha1, 0, $length;
+            $self->LoadByCode($code);
+            if ( $self->Id ) {
+                if ( $self->Content eq $args{Content} ) {
+                    if ( $args{Permanent} && !$self->Permanent ) {
+                        my ( $ret, $msg ) = $self->SetPermanent( $args{Permanent} );
+                        unless ($ret) {
+                            RT->Logger->error( "Could not set shortener #" . $self->Id . " to permanent: $msg" );
+                        }
+                    }
+                    return $self->Id;
+                }
+            }
+            else {
+                last;
+            }
+        }
+
+        return $self->Create( Code => $code, Content => $args{Content}, Permanent => $args{Permanent} );
+    }
+    else {
+        return ( 0, $self->loc("Must specify 'Content' attribute") );
+    }
+}
+
+sub LoadByCode {
+    my $self = shift;
+    my $code  = shift;
+    return $self->LoadByCols( Code => $code );
+}
+
+sub DecodedContent {
+    my $self    = shift;
+    my $content = shift || $self->Content;
+    my $uri     = URI->new;
+    $uri->query($content);
+
+    my $query = $uri->query_form_hash;
+    RT::Interface::Web::DecodeARGS($query);
+    return $query;
+}
+
+=head2 id
+
+Returns the current value of id.
+(In the database, id is stored as int(11).)
+
+
+=cut
+
+
+=head2 Code
+
+Returns the current value of Code.
+(In the database, Code is stored as varchar(64).)
+
+=cut
+
+=head2 Content
+
+Returns the current value of Content.
+(In the database, Content is stored as blob.)
+
+=head2 Permanent
+
+Returns the current value of Permanent.
+(In the database, Permanent is stored as smallint(6).)
+
+=head2 Creator
+
+Returns the current value of Creator.
+(In the database, Creator is stored as int(11).)
+
+
+=cut
+
+
+=head2 Created
+
+Returns the current value of Created.
+(In the database, Created is stored as datetime.)
+
+
+=cut
+
+=head2 LastUpdatedBy
+
+Returns the current value of LastUpdatedBy.
+(In the database, LastUpdatedBy is stored as int(11).)
+
+
+=cut
+
+
+=head2 LastUpdated
+
+Returns the current value of LastUpdated.
+(In the database, LastUpdated is stored as datetime.)
+
+=cut
+
+=head2 LastAccessedBy
+
+Returns the current value of LastAccessedBy.
+(In the database, LastAccessedBy is stored as int(11).)
+
+
+=cut
+
+=head2 LastAccessedByObj
+
+  Returns an RT::User object of the last user to access this object
+
+=cut
+
+sub LastAccessedByObj {
+    my $self = shift;
+    unless ( exists $self->{LastAccessedByObj} ) {
+        $self->{'LastAccessedByObj'} = RT::User->new( $self->CurrentUser );
+        $self->{'LastAccessedByObj'}->Load( $self->LastAccessedBy );
+    }
+    return $self->{'LastAccessedByObj'};
+}
+
+
+=head2 LastAccessed
+
+Returns the current value of LastAccessed.
+(In the database, LastAccessed is stored as datetime.)
+
+=cut
+
+=head2 LastAccessedObj
+
+Returns an RT::Date object of the current value of LastAccessed.
+
+=cut
+
+sub LastAccessedObj {
+    my $self = shift;
+    my $obj  = RT::Date->new( $self->CurrentUser );
+
+    $obj->Set( Format => 'sql', Value => $self->LastAccessed );
+    return $obj;
+}
+
+=head2 LastAccessedAsString
+
+Returns the localized string of C<LastAccessedObj> with current user's
+preferred format and timezone.
+
+=cut
+
+sub LastAccessedAsString {
+    my $self = shift;
+    if ( $self->LastAccessed ) {
+        return ( $self->LastAccessedObj->AsString() );
+    } else {
+        return "never";
+    }
+}
+
+=head2 _SetLastAccessed
+
+This routine updates the LastAccessed and LastAccessedBy columns of the row in question
+It takes no options.
+
+=cut
+
+sub _SetLastAccessed {
+    my $self = shift;
+    my $now  = RT::Date->new( $self->CurrentUser );
+    $now->SetToNow();
+
+    my ( $ret, $msg );
+    if ( $self->LastAccessed ne $now->ISO ) {
+        ( $ret, $msg ) = $self->__Set(
+            Field => 'LastAccessed',
+            Value => $now->ISO,
+        );
+        if ( !$ret ) {
+            RT->Logger->error( "Couldn't set LastAccessed for " . $self->Id . ": $msg" );
+        }
+    }
+
+    if ( $self->LastAccessedBy != $self->CurrentUser->id ) {
+        ( $ret, $msg ) = $self->__Set(
+            Field => 'LastAccessedBy',
+            Value => $self->CurrentUser->id,
+        );
+        if ( !$ret ) {
+            RT->Logger->error( "Couldn't set LastAccessedBy for " . $self->Id . ": $msg" );
+        }
+    }
+
+    return wantarray ? ( $ret, $msg ) : $ret;
+}
+
+
+sub _CoreAccessible {
+    {
+        id =>
+            {read => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => ''},
+        Code =>
+            {read => 1, write => 1, sql_type => 12, length => 64,  is_blob => 0,  is_numeric => 0,  type => 'varchar(40)', default => ''},
+        Content =>
+            {read => 1, write => 1, sql_type => -4, length => 0,  is_blob => 1,  is_numeric => 0,  type => 'longtext', default => ''},
+        Permanent =>
+            {read => 1, write => 1, sql_type => 5, length => 6,  is_blob => 0,  is_numeric => 1,  type => 'smallint(6)', default => '1'},
+        Creator =>
+            {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        Created =>
+            {read => 1, auto => 1, sql_type => 11, length => 0,  is_blob => 0,  is_numeric => 0,  type => 'datetime', default => ''},
+        LastUpdatedBy =>
+            {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        LastUpdated =>
+            {read => 1, auto => 1, sql_type => 11, length => 0,  is_blob => 0,  is_numeric => 0,  type => 'datetime', default => ''},
+        LastAccessedBy =>
+            {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
+        LastAccessed =>
+            {read => 1, auto => 1, sql_type => 11, length => 0,  is_blob => 0,  is_numeric => 0,  type => 'datetime', default => ''},
+    }
+};
+
+RT::Base->_ImportOverlays();
+
+
+1;
diff --git a/lib/RT/Shorteners.pm b/lib/RT/Shorteners.pm
new file mode 100644
index 0000000000..029b6e2912
--- /dev/null
+++ b/lib/RT/Shorteners.pm
@@ -0,0 +1,80 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+=head1 NAME
+
+  RT::Shorteners - Collection of RT::Shortener objects
+
+=head1 SYNOPSIS
+
+  use RT::Shorteners;
+
+
+=head1 DESCRIPTION
+
+
+=head1 METHODS
+
+
+=cut
+
+
+package RT::Shorteners;
+
+use strict;
+use warnings;
+
+use base 'RT::SearchBuilder';
+
+use RT::Shortener;
+
+sub Table { 'Shorteners'}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/share/html/Elements/CollectionAsTable/Header b/share/html/Elements/CollectionAsTable/Header
index 162642ba8a..4d9566347b 100644
--- a/share/html/Elements/CollectionAsTable/Header
+++ b/share/html/Elements/CollectionAsTable/Header
@@ -153,7 +153,7 @@ foreach my $col ( @Format ) {
         $m->out(
             '<a href="' . $m->interp->apply_escapes($BaseURL
             . $m->comp( '/Elements/QueryString',
-                %$generic_query_args,
+                ShortenSearchQuery(%$generic_query_args),
                 OrderBy => $attr, Order => $new_order
             ), 'h')
             . '">'. $loc_title .'</a>'
diff --git a/share/html/Elements/ShowSearch b/share/html/Elements/ShowSearch
index c6f4f4ea76..2f7e03129f 100644
--- a/share/html/Elements/ShowSearch
+++ b/share/html/Elements/ShowSearch
@@ -180,7 +180,7 @@ foreach ( $SearchArg, $ProcessedSearchArg ) {
     $_->{'Format'} =~ s/__loc\(["']?(\w+)["']?\)__/my $f = "$1"; loc($f)/ge;
 }
 
-my $QueryString = '?' . $m->comp( '/Elements/QueryString', %$SearchArg );
+my $QueryString = '?' . QueryString( ShortenSearchQuery(%$SearchArg) );
 
 my $title_raw;
 if ($ShowCount) {
diff --git a/share/html/NoAuth/rss/dhandler b/share/html/Helpers/Permalink
similarity index 56%
copy from share/html/NoAuth/rss/dhandler
copy to share/html/Helpers/Permalink
index b0cb73d2aa..3ce59e2946 100644
--- a/share/html/NoAuth/rss/dhandler
+++ b/share/html/Helpers/Permalink
@@ -2,7 +2,7 @@
 %#
 %# COPYRIGHT:
 %#
-%# This software is Copyright (c) 1996-2022 Best Practical Solutions, LLC
+%# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
 %#                                          <sales at bestpractical.com>
 %#
 %# (Except where explicitly superseded by other copyright notices)
@@ -45,37 +45,55 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%init>
-my $path = $m->dhandler_arg;
+<div class="modal-dialog modal-dialog-centered" role="document">
+  <div class="modal-content">
+    <div class="modal-header">
+      <h5 class="modal-title"><&|/l&>Permalink</&></h5>
+      <a href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
+        <span aria-hidden="true">×</span>
+      </a>
+    </div>
+    <div class="modal-body text-center">
+      <div class="my-2">
+        <a href="<% $URL %>"><% $URL %></a><br>
+      </div>
+      <div>
+        <button class="button btn btn-primary clipboard-copy" data-copied-text=<% loc('Copied') %> data-clipboard-text="<% $URL %>"><% loc('Copy') %></button>
+      </div>
+    </div>
+  </div>
+</div>
+<script type="text/javascript">
+    jQuery(function() {
+        var clipboard = new ClipboardJS('.clipboard-copy');
+        clipboard.on('success', function(e) {
+            var btn = jQuery(e.trigger);
+            btn.text(btn.data('copied-text'));
+        });
+    });
+</script>
+% $m->abort;
 
-my $notfound = sub {
-    my $mesg = shift;
-    $r->headers_out->{'Status'} = '404 Not Found';
-    $RT::Logger->info("Error encountered in rss generation: $mesg");
-    $m->clear_and_abort;
-};
+<%INIT>
+my $shortener = RT::Shortener->new( $session{CurrentUser} );
+$shortener->LoadByCode($Code);
 
-$notfound->("Invalid path: $path") unless $path =~ m!^([^/]+)/([^/]+)/?!;
+if ( $URL =~ m{^/} ) {
+    $URL = RT->Config->Get('WebBaseURL') . RT->Config->Get('WebPath') .  $URL;
+}
 
-my ( $name, $auth ) = ( $1, $2 );
+my %data;
+if ( $shortener->Id ) {
+    if ( !$shortener->Permanent ) {
+        my ( $ret, $msg ) = $shortener->SetPermanent(1);
+        unless ( $ret ) {
+            RT->Logger->error("Couldn't update Permanent for $Code: $msg");
+        }
+    }
+}
+</%INIT>
 
-# Unescape parts
-$name =~ s/\%([0-9a-z]{2})/chr(hex($1))/gei;
-
-# convert to perl strings
-$name = Encode::decode( "UTF-8", $name);
-
-my $user = RT::User->new(RT->SystemUser);
-$user->Load($name);
-$notfound->("Invalid user: $user") unless $user->id;
-
-$notfound->("Invalid authstring")
-  unless $user->ValidateAuthString( $auth,
-          $ARGS{Query} . $ARGS{Order} . $ARGS{OrderBy} );
-
-my $cu = RT::CurrentUser->new;
-$cu->Load($user);
-local $session{'CurrentUser'} = $cu;
-
-$m->comp("/Search/Elements/ResultsRSSView", %ARGS);
-</%init>
+<%ARGS>
+$Code => ''
+$URL => ''
+</%ARGS>
diff --git a/share/html/NoAuth/iCal/dhandler b/share/html/NoAuth/iCal/dhandler
index ea03178bc0..bacdc72443 100644
--- a/share/html/NoAuth/iCal/dhandler
+++ b/share/html/NoAuth/iCal/dhandler
@@ -68,6 +68,18 @@ my $user = RT::User->new( RT->SystemUser );
 $user->Load( $name );
 $notfound->() unless $user->id;
 
+if ( $search =~ /^sc-(\w+)$/ ) {
+    my $sc        = $1;
+    my $shortener = RT::Shortener->new( $session{CurrentUser} );
+    $shortener->LoadByCode($sc);
+    if ( $shortener->Id ) {
+        $search = $shortener->DecodedContent->{Query};
+    }
+    else {
+        RT->Logger->warning("Couldn't load shortener $sc");
+    }
+}
+
 $notfound->() unless $user->ValidateAuthString( $auth, $search );
 
 my $cu = RT::CurrentUser->new;
diff --git a/share/html/NoAuth/rss/dhandler b/share/html/NoAuth/rss/dhandler
index b0cb73d2aa..cf8b6d1671 100644
--- a/share/html/NoAuth/rss/dhandler
+++ b/share/html/NoAuth/rss/dhandler
@@ -71,11 +71,27 @@ $notfound->("Invalid user: $user") unless $user->id;
 
 $notfound->("Invalid authstring")
   unless $user->ValidateAuthString( $auth,
-          $ARGS{Query} . $ARGS{Order} . $ARGS{OrderBy} );
+          $ARGS{sc} || ( $ARGS{Query} . $ARGS{Order} . $ARGS{OrderBy} ) );
 
 my $cu = RT::CurrentUser->new;
 $cu->Load($user);
 local $session{'CurrentUser'} = $cu;
 
+if ( my $sc = $ARGS{sc} ) {
+    my $shortener = RT::Shortener->new( $session{CurrentUser} );
+    $shortener->LoadByCode($sc);
+    if ( $shortener->Id ) {
+        my $content = $shortener->DecodedContent;
+        for my $key ( keys %$content ) {
+            if ( !exists $ARGS{$key} ) {
+                $ARGS{$key} = $content->{$key};
+            }
+        }
+    }
+    else {
+        RT->Logger->warning("Couldn't load shortener $sc");
+    }
+}
+
 $m->comp("/Search/Elements/ResultsRSSView", %ARGS);
 </%init>
diff --git a/share/html/Search/Build.html b/share/html/Search/Build.html
index 03d6411a7a..08cc382347 100644
--- a/share/html/Search/Build.html
+++ b/share/html/Search/Build.html
@@ -361,11 +361,12 @@ $session{$hash_name} = {
 # Show the results, if we were asked.
 
 if ( $ARGS{'DoSearch'} ) {
-    my $redir_query_string = $m->comp(
-        '/Elements/QueryString',
-        %query,
-        SavedChartSearchId => $ARGS{'SavedChartSearchId'},
-        SavedSearchId => $saved_search{'Id'},
+    my $redir_query_string = QueryString(
+        ShortenSearchQuery(
+            %query,
+            SavedChartSearchId => $ARGS{'SavedChartSearchId'},
+            SavedSearchId      => $saved_search{'Id'},
+        )
     );
     RT::Interface::Web::Redirect("$ResultPage?$redir_query_string");
     $m->abort;
diff --git a/share/html/Search/Results.html b/share/html/Search/Results.html
index 9329f74673..695ea7994c 100644
--- a/share/html/Search/Results.html
+++ b/share/html/Search/Results.html
@@ -76,12 +76,13 @@
     SavedChartSearchId => $ARGS{'SavedChartSearchId'},
     ObjectType => $ObjectType,
     @ExtraQueryParams ? ( map { $_ => $ARGS{$_} } grep { defined $ARGS{$_} } 'ExtraQueryParams', @ExtraQueryParams ) : (),
-    PassArguments => [qw(Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId Class ObjectType ExtraQueryParams), @ExtraQueryParams],
+    sc => $ARGS{sc},
+    PassArguments => [qw(sc Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId Class ObjectType ExtraQueryParams), @ExtraQueryParams],
 &>
 % }
 % $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
 
-% my %hiddens = (Query => $Query, Format => $Format, Class => $Class, ObjectType => $ObjectType, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
+% my %hiddens = ( sc => $ARGS{sc}, Query => $Query, Format => $Format, Class => $Class, ObjectType => $ObjectType, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
 <div align="right" class="refresh">
 <form method="POST" action="<%RT->Config->Get('WebPath')%>/Search/Results.html">
 % foreach my $key (keys(%hiddens)) {
@@ -261,14 +262,15 @@ if (RT->Config->Get('RestrictReferrer') and $refresh and not $m->request_args->{
 
 my %link_rel;
 my $genpage = sub {
-    return $m->comp(
-        '/Elements/QueryString',
-        Query   => $Query,
-        Format  => $Format,
-        Rows    => $Rows,
-        OrderBy => $OrderBy,
-        Order   => $Order,
-        Page    => shift(@_),
+    return QueryString(
+        ShortenSearchQuery(
+            Query   => $Query,
+            Format  => $Format,
+            Rows    => $Rows,
+            OrderBy => $OrderBy,
+            Order   => $Order,
+            Page    => shift(@_),
+        )
     );
 };
 
diff --git a/share/static/js/util.js b/share/static/js/util.js
index e5e93311e0..b9d1e15b45 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -864,6 +864,16 @@ jQuery(function() {
             file_input.prop('checked', false);
         });
     });
+
+    jQuery('a.permalink').click(function() {
+        var link = jQuery(this);
+        jQuery.get(
+            RT.Config.WebPath + "/Helpers/Permalink",
+            { Code: link.data('code'), URL: link.data('url') },
+            showModal
+        );
+        return false;
+    });
 });
 
 /* inline edit */

commit 77896765f25415bc818b5375b91ecf49220458fe
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Sep 17 04:37:43 2021 +0800

    Add clipboard.js to RT web

diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 82bd3d04c6..93976757a4 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -143,6 +143,7 @@ sub JSFiles {
         Chart.min.js
         chartjs-plugin-colorschemes.min.js
         jquery.jgrowl.min.js
+        clipboard.min.js
         }, RT->Config->Get('JSFiles');
 }
 
diff --git a/share/static/js/clipboard.min.js b/share/static/js/clipboard.min.js
new file mode 100644
index 0000000000..98d4ccba69
--- /dev/null
+++ b/share/static/js/clipboard.min.js
@@ -0,0 +1,7 @@
+/*!
+ * clipboard.js v2.0.6
+ * https://clipboardjs.com/
+ *
+ * Licensed MIT © Zeno Rocha
+ */
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={134:(t,e,n)=>{"use strict";n.d(e,{default:()=>r});var e=n(817),o=n.n(e);function i(t){return(i="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(t,e){for(var n=0;n<e.length;n++){var r=e[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,r.key,r)}}const c=function(){function e(t){!function(t){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}(this),this.resolveOptions(t),this.initSelection()}var t,n,r;return t=e,(n=[{key:"resolveOptions",value:function(){var t=0<arguments.length&&void 0!==arguments[0]?arguments[0]:{
 };this.action=t.action,this.container=t.container,this.emitter=t.emitter,this.target=t.target,this.text=t.text,this.trigger=t.trigger,this.selectedText=""}},{key:"initSelection",value:function(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function(){var t=this,e="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return t.removeFake()},this.fakeHandler=this.container.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[e?"right":"left"]="-9999px";e=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top="".concat(e,"px"),this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,this.container.appendChild(this.fakeE
 lem),this.selectedText=o()(this.fakeElem),this.copyText()}},{key:"removeFake",value:function(){this.fakeHandler&&(this.container.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(this.container.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function(){this.selectedText=o()(this.target),this.copyText()}},{key:"copyText",value:function(){var e;try{e=document.execCommand(this.action)}catch(t){e=!1}this.handleResult(e)}},{key:"handleResult",value:function(t){this.emitter.emit(t?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function(){this.trigger&&this.trigger.focus(),document.activeElement.blur(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function(){this.removeFake()}},{key:"action",set:function(){var t=0<arguments.length&&void 0!==arguments[0]?arguments[
 0]:"copy";if(this._action=t,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function(){return this._action}},{key:"target",set:function(t){if(void 0!==t){if(!t||"object"!==i(t)||1!==t.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&t.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(t.hasAttribute("readonly")||t.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=t}},get:function(){return this._target}}])&&a(t.prototype,n),r&&a(t,r),e}();var e=n(279),l=n.n(e),e=n(370),u=n.n(e);function s(t){return(s="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&
 t!==Symbol.prototype?"symbol":typeof t})(t)}function f(t,e){for(var n=0;n<e.length;n++){var r=e[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,r.key,r)}}function h(t,e){return(h=Object.setPrototypeOf||function(t,e){return t.__proto__=e,t})(t,e)}function d(n){var r=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Date.prototype.toString.call(Reflect.construct(Date,[],function(){})),!0}catch(t){return!1}}();return function(){var t,e=p(n);return t=r?(t=p(this).constructor,Reflect.construct(e,arguments,t)):e.apply(this,arguments),e=this,!(t=t)||"object"!==s(t)&&"function"!=typeof t?function(t){if(void 0!==t)return t;throw new ReferenceError("this hasn't been initialised - super() hasn't been called")}(e):t}}function p(t){return(p=Object.setPrototypeOf?Object.getPrototypeOf:function(t){return t.__proto__||Object.getPrototypeOf(t
 )})(t)}function y(t,e){t="data-clipboard-".concat(t);if(e.hasAttribute(t))return e.getAttribute(t)}const r=function(){!function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),e&&h(t,e)}(o,l());var t,e,n,r=d(o);function o(t,e){var n;return function(t){if(!(t instanceof o))throw new TypeError("Cannot call a class as a function")}(this),(n=r.call(this)).resolveOptions(e),n.listenClick(t),n}return t=o,n=[{key:"isSupported",value:function(){var t=0<arguments.length&&void 0!==arguments[0]?arguments[0]:["copy","cut"],t="string"==typeof t?[t]:t,e=!!document.queryCommandSupported;return t.forEach(function(t){e=e&&!!document.queryCommandSupported(t)}),e}}],(e=[{key:"resolveOptions",value:function(){var t=0<arguments.length&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof t.action?t.action:this.defaultAction,this.ta
 rget="function"==typeof t.target?t.target:this.defaultTarget,this.text="function"==typeof t.text?t.text:this.defaultText,this.container="object"===s(t.container)?t.container:document.body}},{key:"listenClick",value:function(t){var e=this;this.listener=u()(t,"click",function(t){return e.onClick(t)})}},{key:"onClick",value:function(t){t=t.delegateTarget||t.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new c({action:this.action(t),target:this.target(t),text:this.text(t),container:this.container,trigger:t,emitter:this})}},{key:"defaultAction",value:function(t){return y("action",t)}},{key:"defaultTarget",value:function(t){t=y("target",t);if(t)return document.querySelector(t)}},{key:"defaultText",value:function(t){return y("text",t)}},{key:"destroy",value:function(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}])&&f(t.prototype,e),n&&f(t,n),o}()},828:t=>{var e;"undefined"==typeof Element||E
 lement.prototype.matches||((e=Element.prototype).matches=e.matchesSelector||e.mozMatchesSelector||e.msMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector),t.exports=function(t,e){for(;t&&9!==t.nodeType;){if("function"==typeof t.matches&&t.matches(e))return t;t=t.parentNode}}},438:(t,e,n)=>{var a=n(828);function i(t,e,n,r,o){var i=function(e,n,t,r){return function(t){t.delegateTarget=a(t.target,n),t.delegateTarget&&r.call(e,t)}}.apply(this,arguments);return t.addEventListener(n,i,o),{destroy:function(){t.removeEventListener(n,i,o)}}}t.exports=function(t,e,n,r,o){return"function"==typeof t.addEventListener?i.apply(null,arguments):"function"==typeof n?i.bind(null,document).apply(null,arguments):("string"==typeof t&&(t=document.querySelectorAll(t)),Array.prototype.map.call(t,function(t){return i(t,e,n,r,o)}))}},879:(t,n)=>{n.node=function(t){return void 0!==t&&t instanceof HTMLElement&&1===t.nodeType},n.nodeList=function(t){var e=Object.prototype.toString.call(t);return void 0!
 ==t&&("[object NodeList]"===e||"[object HTMLCollection]"===e)&&"length"in t&&(0===t.length||n.node(t[0]))},n.string=function(t){return"string"==typeof t||t instanceof String},n.fn=function(t){return"[object Function]"===Object.prototype.toString.call(t)}},370:(t,e,n)=>{var u=n(879),s=n(438);t.exports=function(t,e,n){if(!t&&!e&&!n)throw new Error("Missing required arguments");if(!u.string(e))throw new TypeError("Second argument must be a String");if(!u.fn(n))throw new TypeError("Third argument must be a Function");if(u.node(t))return c=e,l=n,(a=t).addEventListener(c,l),{destroy:function(){a.removeEventListener(c,l)}};if(u.nodeList(t))return r=t,o=e,i=n,Array.prototype.forEach.call(r,function(t){t.addEventListener(o,i)}),{destroy:function(){Array.prototype.forEach.call(r,function(t){t.removeEventListener(o,i)})}};if(u.string(t))return t=t,e=e,n=n,s(document.body,t,e,n);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList");var r,o,i,a,c,l}},81
 7:t=>{t.exports=function(t){var e,n="SELECT"===t.nodeName?(t.focus(),t.value):"INPUT"===t.nodeName||"TEXTAREA"===t.nodeName?((e=t.hasAttribute("readonly"))||t.setAttribute("readonly",""),t.select(),t.setSelectionRange(0,t.value.length),e||t.removeAttribute("readonly"),t.value):(t.hasAttribute("contenteditable")&&t.focus(),n=window.getSelection(),(e=document.createRange()).selectNodeContents(t),n.removeAllRanges(),n.addRange(e),n.toString());return n}},279:t=>{function e(){}e.prototype={on:function(t,e,n){var r=this.e||(this.e={});return(r[t]||(r[t]=[])).push({fn:e,ctx:n}),this},once:function(t,e,n){var r=this;function o(){r.off(t,o),e.apply(n,arguments)}return o._=e,this.on(t,o,n)},emit:function(t){for(var e=[].slice.call(arguments,1),n=((this.e||(this.e={}))[t]||[]).slice(),r=0,o=n.length;r<o;r++)n[r].fn.apply(n[r].ctx,e);return this},off:function(t,e){var n=this.e||(this.e={}),r=n[t],o=[];if(r&&e)for(var i=0,a=r.length;i<a;i++)r[i].fn!==e&&r[i].fn._!==e&&o.push(r[i]);return o.leng
 th?n[t]=o:delete n[t],this}},t.exports=e,t.exports.TinyEmitter=e}},o={},r.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return r.d(e,{a:e}),e},r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r(134).default;function r(t){if(o[t])return o[t].exports;var e=o[t]={exports:{}};return n[t](e,e.exports,r),e.exports}var n,o});
\ No newline at end of file

commit 21d2c5a262d118b8724a65dbd17befe3d0acc070
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Sep 17 04:35:46 2021 +0800

    Import clipboard.js source code
    
    Note that the version in source code says 2.0.6, but the dist tarball is
    actually versioned 2.0.8.

diff --git a/devel/third-party/README b/devel/third-party/README
index d18a8a2374..479227558e 100644
--- a/devel/third-party/README
+++ b/devel/third-party/README
@@ -39,6 +39,11 @@ Description: WYSIWYG text editor
 Origin: https://github.com/ckeditor/ckeditor4
 License: GPL 2
 
+* clipboard-2.0.8
+Description: A modern approach to copy text to clipboard
+Origin: https://clipboardjs.com/
+License: MIT
+
 * d3
 Description: Bring data to life with SVG, Canvas and HTML
 Origin: https://d3js.org
diff --git a/devel/third-party/clipboard-2.0.8.js b/devel/third-party/clipboard-2.0.8.js
new file mode 100644
index 0000000000..23e2bfc80c
--- /dev/null
+++ b/devel/third-party/clipboard-2.0.8.js
@@ -0,0 +1,944 @@
+/*!
+ * clipboard.js v2.0.6
+ * https://clipboardjs.com/
+ *
+ * Licensed MIT © Zeno Rocha
+ */
+(function webpackUniversalModuleDefinition(root, factory) {
+	if(typeof exports === 'object' && typeof module === 'object')
+		module.exports = factory();
+	else if(typeof define === 'function' && define.amd)
+		define([], factory);
+	else if(typeof exports === 'object')
+		exports["ClipboardJS"] = factory();
+	else
+		root["ClipboardJS"] = factory();
+})(this, function() {
+return /******/ (() => { // webpackBootstrap
+/******/ 	var __webpack_modules__ = ({
+
+/***/ 134:
+/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
+
+"use strict";
+
+// EXPORTS
+__webpack_require__.d(__webpack_exports__, {
+  "default": () => /* binding */ clipboard
+});
+
+// EXTERNAL MODULE: ./node_modules/select/src/select.js
+var src_select = __webpack_require__(817);
+var select_default = /*#__PURE__*/__webpack_require__.n(src_select);
+;// CONCATENATED MODULE: ./src/clipboard-action.js
+function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
+
+
+/**
+ * Inner class which performs selection from either `text` or `target`
+ * properties and then executes copy or cut operations.
+ */
+
+var ClipboardAction = /*#__PURE__*/function () {
+  /**
+   * @param {Object} options
+   */
+  function ClipboardAction(options) {
+    _classCallCheck(this, ClipboardAction);
+
+    this.resolveOptions(options);
+    this.initSelection();
+  }
+  /**
+   * Defines base properties passed from constructor.
+   * @param {Object} options
+   */
+
+
+  _createClass(ClipboardAction, [{
+    key: "resolveOptions",
+    value: function resolveOptions() {
+      var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
+      this.action = options.action;
+      this.container = options.container;
+      this.emitter = options.emitter;
+      this.target = options.target;
+      this.text = options.text;
+      this.trigger = options.trigger;
+      this.selectedText = '';
+    }
+    /**
+     * Decides which selection strategy is going to be applied based
+     * on the existence of `text` and `target` properties.
+     */
+
+  }, {
+    key: "initSelection",
+    value: function initSelection() {
+      if (this.text) {
+        this.selectFake();
+      } else if (this.target) {
+        this.selectTarget();
+      }
+    }
+    /**
+     * Creates a fake textarea element, sets its value from `text` property,
+     * and makes a selection on it.
+     */
+
+  }, {
+    key: "selectFake",
+    value: function selectFake() {
+      var _this = this;
+
+      var isRTL = document.documentElement.getAttribute('dir') == 'rtl';
+      this.removeFake();
+
+      this.fakeHandlerCallback = function () {
+        return _this.removeFake();
+      };
+
+      this.fakeHandler = this.container.addEventListener('click', this.fakeHandlerCallback) || true;
+      this.fakeElem = document.createElement('textarea'); // Prevent zooming on iOS
+
+      this.fakeElem.style.fontSize = '12pt'; // Reset box model
+
+      this.fakeElem.style.border = '0';
+      this.fakeElem.style.padding = '0';
+      this.fakeElem.style.margin = '0'; // Move element out of screen horizontally
+
+      this.fakeElem.style.position = 'absolute';
+      this.fakeElem.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically
+
+      var yPosition = window.pageYOffset || document.documentElement.scrollTop;
+      this.fakeElem.style.top = "".concat(yPosition, "px");
+      this.fakeElem.setAttribute('readonly', '');
+      this.fakeElem.value = this.text;
+      this.container.appendChild(this.fakeElem);
+      this.selectedText = select_default()(this.fakeElem);
+      this.copyText();
+    }
+    /**
+     * Only removes the fake element after another click event, that way
+     * a user can hit `Ctrl+C` to copy because selection still exists.
+     */
+
+  }, {
+    key: "removeFake",
+    value: function removeFake() {
+      if (this.fakeHandler) {
+        this.container.removeEventListener('click', this.fakeHandlerCallback);
+        this.fakeHandler = null;
+        this.fakeHandlerCallback = null;
+      }
+
+      if (this.fakeElem) {
+        this.container.removeChild(this.fakeElem);
+        this.fakeElem = null;
+      }
+    }
+    /**
+     * Selects the content from element passed on `target` property.
+     */
+
+  }, {
+    key: "selectTarget",
+    value: function selectTarget() {
+      this.selectedText = select_default()(this.target);
+      this.copyText();
+    }
+    /**
+     * Executes the copy operation based on the current selection.
+     */
+
+  }, {
+    key: "copyText",
+    value: function copyText() {
+      var succeeded;
+
+      try {
+        succeeded = document.execCommand(this.action);
+      } catch (err) {
+        succeeded = false;
+      }
+
+      this.handleResult(succeeded);
+    }
+    /**
+     * Fires an event based on the copy operation result.
+     * @param {Boolean} succeeded
+     */
+
+  }, {
+    key: "handleResult",
+    value: function handleResult(succeeded) {
+      this.emitter.emit(succeeded ? 'success' : 'error', {
+        action: this.action,
+        text: this.selectedText,
+        trigger: this.trigger,
+        clearSelection: this.clearSelection.bind(this)
+      });
+    }
+    /**
+     * Moves focus away from `target` and back to the trigger, removes current selection.
+     */
+
+  }, {
+    key: "clearSelection",
+    value: function clearSelection() {
+      if (this.trigger) {
+        this.trigger.focus();
+      }
+
+      document.activeElement.blur();
+      window.getSelection().removeAllRanges();
+    }
+    /**
+     * Sets the `action` to be performed which can be either 'copy' or 'cut'.
+     * @param {String} action
+     */
+
+  }, {
+    key: "destroy",
+
+    /**
+     * Destroy lifecycle.
+     */
+    value: function destroy() {
+      this.removeFake();
+    }
+  }, {
+    key: "action",
+    set: function set() {
+      var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'copy';
+      this._action = action;
+
+      if (this._action !== 'copy' && this._action !== 'cut') {
+        throw new Error('Invalid "action" value, use either "copy" or "cut"');
+      }
+    }
+    /**
+     * Gets the `action` property.
+     * @return {String}
+     */
+    ,
+    get: function get() {
+      return this._action;
+    }
+    /**
+     * Sets the `target` property using an element
+     * that will be have its content copied.
+     * @param {Element} target
+     */
+
+  }, {
+    key: "target",
+    set: function set(target) {
+      if (target !== undefined) {
+        if (target && _typeof(target) === 'object' && target.nodeType === 1) {
+          if (this.action === 'copy' && target.hasAttribute('disabled')) {
+            throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');
+          }
+
+          if (this.action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {
+            throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');
+          }
+
+          this._target = target;
+        } else {
+          throw new Error('Invalid "target" value, use a valid Element');
+        }
+      }
+    }
+    /**
+     * Gets the `target` property.
+     * @return {String|HTMLElement}
+     */
+    ,
+    get: function get() {
+      return this._target;
+    }
+  }]);
+
+  return ClipboardAction;
+}();
+
+/* harmony default export */ const clipboard_action = (ClipboardAction);
+// EXTERNAL MODULE: ./node_modules/tiny-emitter/index.js
+var tiny_emitter = __webpack_require__(279);
+var tiny_emitter_default = /*#__PURE__*/__webpack_require__.n(tiny_emitter);
+// EXTERNAL MODULE: ./node_modules/good-listener/src/listen.js
+var listen = __webpack_require__(370);
+var listen_default = /*#__PURE__*/__webpack_require__.n(listen);
+;// CONCATENATED MODULE: ./src/clipboard.js
+function clipboard_typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return clipboard_typeof(obj); }
+
+function clipboard_classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function clipboard_defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function clipboard_createClass(Constructor, protoProps, staticProps) { if (protoProps) clipboard_defineProperties(Constructor.prototype, protoProps); if (staticProps) clipboard_defineProperties(Constructor, staticProps); return Constructor; }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }
+
+function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
+
+function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }
+
+function _possibleConstructorReturn(self, call) { if (call && (clipboard_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); }
+
+function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
+
+function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } }
+
+function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
+
+
+
+
+/**
+ * Base class which takes one or more elements, adds event listeners to them,
+ * and instantiates a new `ClipboardAction` on each click.
+ */
+
+var Clipboard = /*#__PURE__*/function (_Emitter) {
+  _inherits(Clipboard, _Emitter);
+
+  var _super = _createSuper(Clipboard);
+
+  /**
+   * @param {String|HTMLElement|HTMLCollection|NodeList} trigger
+   * @param {Object} options
+   */
+  function Clipboard(trigger, options) {
+    var _this;
+
+    clipboard_classCallCheck(this, Clipboard);
+
+    _this = _super.call(this);
+
+    _this.resolveOptions(options);
+
+    _this.listenClick(trigger);
+
+    return _this;
+  }
+  /**
+   * Defines if attributes would be resolved using internal setter functions
+   * or custom functions that were passed in the constructor.
+   * @param {Object} options
+   */
+
+
+  clipboard_createClass(Clipboard, [{
+    key: "resolveOptions",
+    value: function resolveOptions() {
+      var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
+      this.action = typeof options.action === 'function' ? options.action : this.defaultAction;
+      this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;
+      this.text = typeof options.text === 'function' ? options.text : this.defaultText;
+      this.container = clipboard_typeof(options.container) === 'object' ? options.container : document.body;
+    }
+    /**
+     * Adds a click event listener to the passed trigger.
+     * @param {String|HTMLElement|HTMLCollection|NodeList} trigger
+     */
+
+  }, {
+    key: "listenClick",
+    value: function listenClick(trigger) {
+      var _this2 = this;
+
+      this.listener = listen_default()(trigger, 'click', function (e) {
+        return _this2.onClick(e);
+      });
+    }
+    /**
+     * Defines a new `ClipboardAction` on each click event.
+     * @param {Event} e
+     */
+
+  }, {
+    key: "onClick",
+    value: function onClick(e) {
+      var trigger = e.delegateTarget || e.currentTarget;
+
+      if (this.clipboardAction) {
+        this.clipboardAction = null;
+      }
+
+      this.clipboardAction = new clipboard_action({
+        action: this.action(trigger),
+        target: this.target(trigger),
+        text: this.text(trigger),
+        container: this.container,
+        trigger: trigger,
+        emitter: this
+      });
+    }
+    /**
+     * Default `action` lookup function.
+     * @param {Element} trigger
+     */
+
+  }, {
+    key: "defaultAction",
+    value: function defaultAction(trigger) {
+      return getAttributeValue('action', trigger);
+    }
+    /**
+     * Default `target` lookup function.
+     * @param {Element} trigger
+     */
+
+  }, {
+    key: "defaultTarget",
+    value: function defaultTarget(trigger) {
+      var selector = getAttributeValue('target', trigger);
+
+      if (selector) {
+        return document.querySelector(selector);
+      }
+    }
+    /**
+     * Returns the support of the given action, or all actions if no action is
+     * given.
+     * @param {String} [action]
+     */
+
+  }, {
+    key: "defaultText",
+
+    /**
+     * Default `text` lookup function.
+     * @param {Element} trigger
+     */
+    value: function defaultText(trigger) {
+      return getAttributeValue('text', trigger);
+    }
+    /**
+     * Destroy lifecycle.
+     */
+
+  }, {
+    key: "destroy",
+    value: function destroy() {
+      this.listener.destroy();
+
+      if (this.clipboardAction) {
+        this.clipboardAction.destroy();
+        this.clipboardAction = null;
+      }
+    }
+  }], [{
+    key: "isSupported",
+    value: function isSupported() {
+      var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut'];
+      var actions = typeof action === 'string' ? [action] : action;
+      var support = !!document.queryCommandSupported;
+      actions.forEach(function (action) {
+        support = support && !!document.queryCommandSupported(action);
+      });
+      return support;
+    }
+  }]);
+
+  return Clipboard;
+}((tiny_emitter_default()));
+/**
+ * Helper function to retrieve attribute value.
+ * @param {String} suffix
+ * @param {Element} element
+ */
+
+
+function getAttributeValue(suffix, element) {
+  var attribute = "data-clipboard-".concat(suffix);
+
+  if (!element.hasAttribute(attribute)) {
+    return;
+  }
+
+  return element.getAttribute(attribute);
+}
+
+/* harmony default export */ const clipboard = (Clipboard);
+
+/***/ }),
+
+/***/ 828:
+/***/ ((module) => {
+
+var DOCUMENT_NODE_TYPE = 9;
+
+/**
+ * A polyfill for Element.matches()
+ */
+if (typeof Element !== 'undefined' && !Element.prototype.matches) {
+    var proto = Element.prototype;
+
+    proto.matches = proto.matchesSelector ||
+                    proto.mozMatchesSelector ||
+                    proto.msMatchesSelector ||
+                    proto.oMatchesSelector ||
+                    proto.webkitMatchesSelector;
+}
+
+/**
+ * Finds the closest parent that matches a selector.
+ *
+ * @param {Element} element
+ * @param {String} selector
+ * @return {Function}
+ */
+function closest (element, selector) {
+    while (element && element.nodeType !== DOCUMENT_NODE_TYPE) {
+        if (typeof element.matches === 'function' &&
+            element.matches(selector)) {
+          return element;
+        }
+        element = element.parentNode;
+    }
+}
+
+module.exports = closest;
+
+
+/***/ }),
+
+/***/ 438:
+/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
+
+var closest = __webpack_require__(828);
+
+/**
+ * Delegates event to a selector.
+ *
+ * @param {Element} element
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} callback
+ * @param {Boolean} useCapture
+ * @return {Object}
+ */
+function _delegate(element, selector, type, callback, useCapture) {
+    var listenerFn = listener.apply(this, arguments);
+
+    element.addEventListener(type, listenerFn, useCapture);
+
+    return {
+        destroy: function() {
+            element.removeEventListener(type, listenerFn, useCapture);
+        }
+    }
+}
+
+/**
+ * Delegates event to a selector.
+ *
+ * @param {Element|String|Array} [elements]
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} callback
+ * @param {Boolean} useCapture
+ * @return {Object}
+ */
+function delegate(elements, selector, type, callback, useCapture) {
+    // Handle the regular Element usage
+    if (typeof elements.addEventListener === 'function') {
+        return _delegate.apply(null, arguments);
+    }
+
+    // Handle Element-less usage, it defaults to global delegation
+    if (typeof type === 'function') {
+        // Use `document` as the first parameter, then apply arguments
+        // This is a short way to .unshift `arguments` without running into deoptimizations
+        return _delegate.bind(null, document).apply(null, arguments);
+    }
+
+    // Handle Selector-based usage
+    if (typeof elements === 'string') {
+        elements = document.querySelectorAll(elements);
+    }
+
+    // Handle Array-like based usage
+    return Array.prototype.map.call(elements, function (element) {
+        return _delegate(element, selector, type, callback, useCapture);
+    });
+}
+
+/**
+ * Finds closest match and invokes callback.
+ *
+ * @param {Element} element
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Function}
+ */
+function listener(element, selector, type, callback) {
+    return function(e) {
+        e.delegateTarget = closest(e.target, selector);
+
+        if (e.delegateTarget) {
+            callback.call(element, e);
+        }
+    }
+}
+
+module.exports = delegate;
+
+
+/***/ }),
+
+/***/ 879:
+/***/ ((__unused_webpack_module, exports) => {
+
+/**
+ * Check if argument is a HTML element.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.node = function(value) {
+    return value !== undefined
+        && value instanceof HTMLElement
+        && value.nodeType === 1;
+};
+
+/**
+ * Check if argument is a list of HTML elements.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.nodeList = function(value) {
+    var type = Object.prototype.toString.call(value);
+
+    return value !== undefined
+        && (type === '[object NodeList]' || type === '[object HTMLCollection]')
+        && ('length' in value)
+        && (value.length === 0 || exports.node(value[0]));
+};
+
+/**
+ * Check if argument is a string.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.string = function(value) {
+    return typeof value === 'string'
+        || value instanceof String;
+};
+
+/**
+ * Check if argument is a function.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.fn = function(value) {
+    var type = Object.prototype.toString.call(value);
+
+    return type === '[object Function]';
+};
+
+
+/***/ }),
+
+/***/ 370:
+/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
+
+var is = __webpack_require__(879);
+var delegate = __webpack_require__(438);
+
+/**
+ * Validates all params and calls the right
+ * listener function based on its target type.
+ *
+ * @param {String|HTMLElement|HTMLCollection|NodeList} target
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listen(target, type, callback) {
+    if (!target && !type && !callback) {
+        throw new Error('Missing required arguments');
+    }
+
+    if (!is.string(type)) {
+        throw new TypeError('Second argument must be a String');
+    }
+
+    if (!is.fn(callback)) {
+        throw new TypeError('Third argument must be a Function');
+    }
+
+    if (is.node(target)) {
+        return listenNode(target, type, callback);
+    }
+    else if (is.nodeList(target)) {
+        return listenNodeList(target, type, callback);
+    }
+    else if (is.string(target)) {
+        return listenSelector(target, type, callback);
+    }
+    else {
+        throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');
+    }
+}
+
+/**
+ * Adds an event listener to a HTML element
+ * and returns a remove listener function.
+ *
+ * @param {HTMLElement} node
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listenNode(node, type, callback) {
+    node.addEventListener(type, callback);
+
+    return {
+        destroy: function() {
+            node.removeEventListener(type, callback);
+        }
+    }
+}
+
+/**
+ * Add an event listener to a list of HTML elements
+ * and returns a remove listener function.
+ *
+ * @param {NodeList|HTMLCollection} nodeList
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listenNodeList(nodeList, type, callback) {
+    Array.prototype.forEach.call(nodeList, function(node) {
+        node.addEventListener(type, callback);
+    });
+
+    return {
+        destroy: function() {
+            Array.prototype.forEach.call(nodeList, function(node) {
+                node.removeEventListener(type, callback);
+            });
+        }
+    }
+}
+
+/**
+ * Add an event listener to a selector
+ * and returns a remove listener function.
+ *
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listenSelector(selector, type, callback) {
+    return delegate(document.body, selector, type, callback);
+}
+
+module.exports = listen;
+
+
+/***/ }),
+
+/***/ 817:
+/***/ ((module) => {
+
+function select(element) {
+    var selectedText;
+
+    if (element.nodeName === 'SELECT') {
+        element.focus();
+
+        selectedText = element.value;
+    }
+    else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
+        var isReadOnly = element.hasAttribute('readonly');
+
+        if (!isReadOnly) {
+            element.setAttribute('readonly', '');
+        }
+
+        element.select();
+        element.setSelectionRange(0, element.value.length);
+
+        if (!isReadOnly) {
+            element.removeAttribute('readonly');
+        }
+
+        selectedText = element.value;
+    }
+    else {
+        if (element.hasAttribute('contenteditable')) {
+            element.focus();
+        }
+
+        var selection = window.getSelection();
+        var range = document.createRange();
+
+        range.selectNodeContents(element);
+        selection.removeAllRanges();
+        selection.addRange(range);
+
+        selectedText = selection.toString();
+    }
+
+    return selectedText;
+}
+
+module.exports = select;
+
+
+/***/ }),
+
+/***/ 279:
+/***/ ((module) => {
+
+function E () {
+  // Keep this empty so it's easier to inherit from
+  // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)
+}
+
+E.prototype = {
+  on: function (name, callback, ctx) {
+    var e = this.e || (this.e = {});
+
+    (e[name] || (e[name] = [])).push({
+      fn: callback,
+      ctx: ctx
+    });
+
+    return this;
+  },
+
+  once: function (name, callback, ctx) {
+    var self = this;
+    function listener () {
+      self.off(name, listener);
+      callback.apply(ctx, arguments);
+    };
+
+    listener._ = callback
+    return this.on(name, listener, ctx);
+  },
+
+  emit: function (name) {
+    var data = [].slice.call(arguments, 1);
+    var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
+    var i = 0;
+    var len = evtArr.length;
+
+    for (i; i < len; i++) {
+      evtArr[i].fn.apply(evtArr[i].ctx, data);
+    }
+
+    return this;
+  },
+
+  off: function (name, callback) {
+    var e = this.e || (this.e = {});
+    var evts = e[name];
+    var liveEvents = [];
+
+    if (evts && callback) {
+      for (var i = 0, len = evts.length; i < len; i++) {
+        if (evts[i].fn !== callback && evts[i].fn._ !== callback)
+          liveEvents.push(evts[i]);
+      }
+    }
+
+    // Remove event from queue to prevent memory leak
+    // Suggested by https://github.com/lazd
+    // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
+
+    (liveEvents.length)
+      ? e[name] = liveEvents
+      : delete e[name];
+
+    return this;
+  }
+};
+
+module.exports = E;
+module.exports.TinyEmitter = E;
+
+
+/***/ })
+
+/******/ 	});
+/************************************************************************/
+/******/ 	// The module cache
+/******/ 	var __webpack_module_cache__ = {};
+/******/ 	
+/******/ 	// The require function
+/******/ 	function __webpack_require__(moduleId) {
+/******/ 		// Check if module is in cache
+/******/ 		if(__webpack_module_cache__[moduleId]) {
+/******/ 			return __webpack_module_cache__[moduleId].exports;
+/******/ 		}
+/******/ 		// Create a new module (and put it into the cache)
+/******/ 		var module = __webpack_module_cache__[moduleId] = {
+/******/ 			// no module.id needed
+/******/ 			// no module.loaded needed
+/******/ 			exports: {}
+/******/ 		};
+/******/ 	
+/******/ 		// Execute the module function
+/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
+/******/ 	
+/******/ 		// Return the exports of the module
+/******/ 		return module.exports;
+/******/ 	}
+/******/ 	
+/************************************************************************/
+/******/ 	/* webpack/runtime/compat get default export */
+/******/ 	(() => {
+/******/ 		// getDefaultExport function for compatibility with non-harmony modules
+/******/ 		__webpack_require__.n = (module) => {
+/******/ 			var getter = module && module.__esModule ?
+/******/ 				() => module['default'] :
+/******/ 				() => module;
+/******/ 			__webpack_require__.d(getter, { a: getter });
+/******/ 			return getter;
+/******/ 		};
+/******/ 	})();
+/******/ 	
+/******/ 	/* webpack/runtime/define property getters */
+/******/ 	(() => {
+/******/ 		// define getter functions for harmony exports
+/******/ 		__webpack_require__.d = (exports, definition) => {
+/******/ 			for(var key in definition) {
+/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
+/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
+/******/ 				}
+/******/ 			}
+/******/ 		};
+/******/ 	})();
+/******/ 	
+/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
+/******/ 	(() => {
+/******/ 		__webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop)
+/******/ 	})();
+/******/ 	
+/************************************************************************/
+/******/ 	// module exports must be returned from runtime so entry inlining is disabled
+/******/ 	// startup
+/******/ 	// Load entry module and return exports
+/******/ 	return __webpack_require__(134);
+/******/ })()
+.default;
+});
\ No newline at end of file

commit 42ee04c5908e1c3a1c9ebf29a13c3cb8a27eaad1
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Thu Oct 21 14:16:49 2021 +0800

    Add the mssing Class/ObjectType params to refresh form on search results page

diff --git a/share/html/Search/Results.html b/share/html/Search/Results.html
index 700ffeaf11..9329f74673 100644
--- a/share/html/Search/Results.html
+++ b/share/html/Search/Results.html
@@ -81,7 +81,7 @@
 % }
 % $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
 
-% my %hiddens = (Query => $Query, Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
+% my %hiddens = (Query => $Query, Format => $Format, Class => $Class, ObjectType => $ObjectType, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
 <div align="right" class="refresh">
 <form method="POST" action="<%RT->Config->Get('WebPath')%>/Search/Results.html">
 % foreach my $key (keys(%hiddens)) {

commit 87826415bcb433fb4a39897cd173d4c89aa7c3e1
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Thu Oct 21 04:46:03 2021 +0800

    Switch method to POST for search refresh form
    
    The form contains various search parameters including Query, Format,
    etc. Switching to 'POST' can get around the size limitation of URL.
    
    Note that we need to explicitly set RefreshURL because parameters are
    not saved in URL when method is POST.

diff --git a/share/html/Search/Results.html b/share/html/Search/Results.html
index e0de84d99f..700ffeaf11 100644
--- a/share/html/Search/Results.html
+++ b/share/html/Search/Results.html
@@ -83,7 +83,7 @@
 
 % my %hiddens = (Query => $Query, Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
 <div align="right" class="refresh">
-<form method="get" action="<%RT->Config->Get('WebPath')%>/Search/Results.html">
+<form method="POST" action="<%RT->Config->Get('WebPath')%>/Search/Results.html">
 % foreach my $key (keys(%hiddens)) {
 <input type="hidden" class="hidden" name="<%$key%>" value="<% defined($hiddens{$key})?$hiddens{$key}:'' %>" />
 % }
@@ -287,6 +287,8 @@ $link_rel{first} = $BaseURL . $genpage->(1)         if $Page > 1;
 $link_rel{prev}  = $BaseURL . $genpage->($Page - 1) if $Page > 1;
 $link_rel{next}  = $BaseURL . $genpage->($Page + 1) if ($Page * $Rows) < $count;
 $link_rel{last}  = $BaseURL . $genpage->(POSIX::ceil($count/$Rows)) if $Rows and ($Page * $Rows) < $count;
+
+$m->notes( RefreshURL => '?' . QueryString( ShortenSearchQuery(%ARGS) ) );
 </%INIT>
 <%CLEANUP>
 $session{$session_name}->PrepForSerialization();

commit 1819c59231c091fd0891233ecb7586a53d7e083a
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Sep 11 04:08:31 2021 +0800

    Switch method to POST for search chart form
    
    The form contains various search parameters including Query, Format,
    etc. Switching to 'POST' can get around the size limitation of URL.

diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index c856b6278a..5d937323a2 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -144,7 +144,7 @@ $m->callback( ARGSRef => \%ARGS, QueryArgsRef => \%query );
 <div class="form-row">
   <div class="col-xl-6">
 
-<form method="get" action="<% RT->Config->Get('WebPath') %>/Search/Chart.html">
+<form method="POST" action="<% RT->Config->Get('WebPath') %>/Search/Chart.html">
 <input type="hidden" class="hidden" name="Query" value="<% $query{Query} %>" />
 <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $saved_search->{SearchId} || 'new' %>" />
 

commit c5853afafba1e9a2894a44c6a7349735b302c805
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Sep 11 04:05:05 2021 +0800

    Fix limit parameter for shredder URL on search pages
    
    "Rows" was a typo, should be "RowsPerPage".

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index c68de8ed1c..6eb84b0b9c 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -756,7 +756,7 @@ sub BuildMainNav {
                         Search          => 1,
                         Plugin          => 'Tickets',
                         'Tickets:query' => $rss_data{'Query'},
-                        'Tickets:limit' => $query_args->{'Rows'},
+                        'Tickets:limit' => $query_args->{'RowsPerPage'},
                     );
 
                     $more->child(

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


hooks/post-receive
-- 
rt


More information about the rt-commit mailing list