[Rt-commit] rt branch 5.0/search-url-shortener created. rt-5.0.2-24-gb4a5c4923f

BPS Git Server git at git.bestpractical.com
Thu Oct 14 14:07:27 UTC 2021


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  b4a5c4923f7b627d7821770acf7ff41896d07b85 (commit)

- Log -----------------------------------------------------------------
commit b4a5c4923f7b627d7821770acf7ff41896d07b85
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 0a9bd3d4bd846341358ba146fc91156b444b80f1
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 e967fdf5e9..eb4152f59b 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 442d843452a1b3ee01d0a45ee8fd70651057742d
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 6563b2c752..70c8bcd2cd 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 => '/Search/Build.html?sc=34c1e4ea' } );
+$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=95a2992d} } );
+$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 => '/Search/Chart.html?sc=ac896925' } );
+$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 6285f246e5a266ab3adf50a1e3b6575b81d456f1
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 43e75d9123..62d72a0185 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 4ad3a12acc..353d935638 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -2025,6 +2025,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 bbd92f8990..a11b87ab67 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..c1aebc2bcc 100644
--- a/share/html/Helpers/Permalink
+++ b/share/html/Helpers/Permalink
@@ -54,6 +54,12 @@
       </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>
diff --git a/share/html/Search/Elements/EditSearches b/share/html/Search/Elements/EditSearches
index b7d71f2db3..5eb8e5b7bf 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 27190a1b123867ff4bc586ef4ac94fda0b30f011
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 cf0d0f90df..27d7648c3e 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 a48673799fd930e05b55bcb00a214b3d7f1ad27f
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..d3a4973496
--- /dev/null
+++ b/t/web/admin_tools_shortener.t
@@ -0,0 +1,37 @@
+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 => 'Shortener Viewer' } );
+$m->title_is('Shortener Viewer');
+$m->submit_form_ok(
+    {   form_name => 'LoadShortener',
+        fields    => { sc => 'f82f746a' },
+    }
+);
+
+$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 75734facabb567b149b9a031f58e16ba654310a1
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 7b0a2c5519..cf0d0f90df 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 0624e5049715394a9b6fba2a884caa485f3d329b
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..6563b2c752
--- /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 => '/Search/Build.html?sc=84e839cc' },
+    { text        => 'Advanced',        url => '/Search/Edit.html?sc=84e839cc' },
+    { class_regex => qr/\bpermalink\b/, url => '/Search/Edit.html?sc=84e839cc' },
+    { text        => 'Show Results',    url => '/Search/Results.html?sc=84e839cc' },
+    { class_regex => qr/\bpermalink\b/, url => '/Search/Results.html?sc=84e839cc' },
+    { text        => 'Bulk Update',     url => '/Search/Bulk.html?sc=84e839cc' },
+    { class_regex => qr/\bpermalink\b/, url => '/Search/Bulk.html?sc=84e839cc' },
+    { text        => 'Chart',           url => '/Search/Chart.html?sc=84e839cc' },
+
+    # Chart page has new code which contains chart arguments.
+    { class_regex => qr/\bpermalink\b/, url => '/Search/Chart.html?sc=418d31e4' },
+);
+
+for my $menu (@menus) {
+    $m->follow_link_ok($menu);
+}
+
+$m->get_ok('/Search/Edit.html?sc=84e839cc');
+$m->form_name('BuildQueryAdvanced');
+is( $m->value('Query'), 'id < 10', 'Query on Advanced' );
+
+$m->get_ok('/Search/Results.html?sc=84e839cc');
+$m->content_contains('Shortener test', 'Found the ticket');
+
+my @feeds = (
+    { text => 'Spreadsheet', url_regex => qr/sc=84e839cc/ },
+    { text => 'RSS',         url_regex => qr/sc=84e839cc/ },
+    { text => 'iCal',        url_regex => qr/sc-84e839cc/ },
+);
+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/sc=84e839cc/ } );
+$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 2ce38d7464f4e86d1a32a0e51d2318c64e2bbad5
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 a5d0228f9c..ae2333f429 100755
--- a/configure.ac
+++ b/configure.ac
@@ -478,6 +478,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/sbin/rt-clean-shorteners.in b/sbin/rt-clean-shorteners.in
new file mode 100644
index 0000000000..c7164e8d57
--- /dev/null
+++ b/sbin/rt-clean-shorteners.in
@@ -0,0 +1,143 @@
+#!@PERL@
+# 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 }}}
+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(Init);
+my %opt = ();
+Init( \%opt, 'older=s' );
+
+if ( $opt{'older'} ) {
+    unless ( $opt{'older'} =~ /^\s*([0-9]+)\s*(H|D|M|Y)?$/i ) {
+        print STDERR "wrong format of the 'older' argumnet\n";
+        exit(1);
+    }
+    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;
+    $opt{'older'} = $num * $factor{$unit};
+}
+else {
+    print STDERR "please specify the 'older' argumnet\n";
+    exit(1);
+}
+
+my $dbh = RT->DatabaseHandle->dbh;
+my $rows;
+if ( $opt{'older'} ) {
+    require POSIX;
+    my $date = POSIX::strftime( "%Y-%m-%d %H:%M", gmtime( time - int $opt{older} ) );
+    my $sth  = $dbh->prepare("DELETE FROM Shorteners WHERE Permanent = ? AND LastAccessed < ?");
+    die "Couldn't prepare query: " . $dbh->errstr unless $sth;
+    $rows = $sth->execute( 0, $date );
+    die "Couldn't execute query: " . $dbh->errstr unless defined $rows;
+}
+else {
+    my $sth = $dbh->prepare("DELETE FROM Shorteners WHERE Permanent = ?");
+    die "Couldn't prepare query: " . $dbh->errstr unless $sth;
+    $rows = $sth->execute(0);
+    die "Couldn't execute query: " . $dbh->errstr unless defined $rows;
+}
+
+# $rows could be 0E0, here we want to show it 0
+$RT::Logger->info(sprintf "Successfully deleted %d shorteners", $rows);
+
+__END__
+
+=head1 NAME
+
+rt-clean-shorteners - clean old RT shorteners
+
+=head1 SYNOPSIS
+
+     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 shorteners from DB.
+
+=head1 OPTIONS
+
+=over 4
+
+=item older
+
+Date interval in the C<< <NUM>[<unit>] >> format. Default unit is D(ays),
+H(our), M(onth) and Y(ear) are also supported.
+
+For example: C<rt-clean-shorteners --older 1M> would delete all shorteners
+that haven't been accessed for 1 month.
+
+=item verbose
+
+print additional info to STDOUT
+
+=back

commit 351a99153c8a8d964afc9bb3a3035d517b51726a
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 99605c3043..4ad3a12acc 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -2046,6 +2046,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 9a32a95a5d..6dccf3bf6d 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 5b6fd80bae..7d6d56155d 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 b4a9d1a059..6bbc79bbca 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 356070b26ae696156b1131a12a57f4a5855365e5
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 7049a82137..2157d7e87a 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -80,18 +80,16 @@ 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' );
 
 done_testing;

commit 8a04c19c0cd3997151b1798be287e856b1da71a3
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 6576ec8a78..5e296114a8 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -2048,6 +2048,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 57fbae685d..8dd3f19781 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -569,3 +569,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 5f6c3d85fb..14c61e8aa7 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -811,3 +811,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 bc8b456ecd..680be475b4 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -598,3 +598,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 6c368b7909..29f2af7802 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -590,3 +590,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.3/acl.Pg b/etc/upgrade/5.0.3/acl.Pg
new file mode 100644
index 0000000000..26dae5217f
--- /dev/null
+++ b/etc/upgrade/5.0.3/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.3/schema.Oracle b/etc/upgrade/5.0.3/schema.Oracle
new file mode 100644
index 0000000000..2c688b4441
--- /dev/null
+++ b/etc/upgrade/5.0.3/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.3/schema.Pg b/etc/upgrade/5.0.3/schema.Pg
new file mode 100644
index 0000000000..a1279a5e53
--- /dev/null
+++ b/etc/upgrade/5.0.3/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.3/schema.SQLite b/etc/upgrade/5.0.3/schema.SQLite
new file mode 100644
index 0000000000..3542116d0e
--- /dev/null
+++ b/etc/upgrade/5.0.3/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.3/schema.mysql b/etc/upgrade/5.0.3/schema.mysql
new file mode 100644
index 0000000000..c396d49f66
--- /dev/null
+++ b/etc/upgrade/5.0.3/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 bc980da00b..a9bcf5bf5c 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 6286872212..3e0e32d7fb 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 e006cec5dd..99605c3043 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
 
@@ -687,6 +693,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 );
 
@@ -1500,6 +1508,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 = (
@@ -1999,6 +2010,45 @@ sub ClearMasonCache {
     }
 }
 
+=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/;
@@ -5300,6 +5350,81 @@ 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 = ( exists $query_args{$field} ? delete $query_args{$field} : $fallback->{$field} ) // next;
+
+        if ( $field eq 'ResultPage' && $value eq RT->Config->Get('WebPath') . '/Search/Results.html' ) {
+            undef $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 ( ShortenQuery(%short_args), %query_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 2c34ea85a1..7b0a2c5519 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 3b6a3368cb..bd56830dee 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/ShowSearch b/share/html/Elements/ShowSearch
index 95eca046e2..2da24ab4f8 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 58%
copy from share/html/NoAuth/rss/dhandler
copy to share/html/Helpers/Permalink
index ed66eb4edc..3ce59e2946 100644
--- a/share/html/NoAuth/rss/dhandler
+++ b/share/html/Helpers/Permalink
@@ -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 570fbfd7d8..602fbb7a52 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 ed66eb4edc..90f735c01e 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 a9c2a6215f..9a32a95a5d 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 27737ec2b4..b4a9d1a059 100644
--- a/share/html/Search/Results.html
+++ b/share/html/Search/Results.html
@@ -76,7 +76,10 @@
     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 => $ARGS{sc}
+        ? [qw(sc Page SavedSearchId SavedChartSearchId)]
+        : [qw(Query Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId Class ObjectType ExtraQueryParams), @ExtraQueryParams],
 &>
 % }
 % $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
diff --git a/share/static/js/util.js b/share/static/js/util.js
index 5c0c7fc18f..6c04d261e5 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -862,6 +862,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 37f438eeb9649fa93efaa25744fc945ed26e03a7
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 ce0931f35a..e006cec5dd 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -144,6 +144,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 c7a7a512afdea64dec95b923a03cbc49f2350896
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 846e6f9da8..23f12b90f4 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 821f9860096cad488861921fce4883074efddebe
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 6adc8356ab..678ab74674 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 ac412807b4545cf6696d44caf8404f0547e5edb2
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 6d42200acb..2c34ea85a1 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