[Rt-commit] rt branch 5.0/search-url-shortener created. rt-5.0.2-19-gbb7c6019a3

BPS Git Server git at git.bestpractical.com
Thu Sep 23 20:03:42 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  bb7c6019a3ce6f35d4780ff15aef18fc5870eff7 (commit)

- Log -----------------------------------------------------------------
commit bb7c6019a3ce6f35d4780ff15aef18fc5870eff7
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 8d3cd8ae4c2c7f4818ac64573ab9d16e4c92b88c
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 dd687f9725..d47e57c37d 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -1355,6 +1355,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 08c01d96b4641ade7151329a50101ea409950be1
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..1851e936be
--- /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' },
+    { text => 'Permalink',    url => '/Search/Edit.html?sc=84e839cc' },
+    { text => 'Show Results', url => '/Search/Results.html?sc=84e839cc' },
+    { text => 'Permalink',    url => '/Search/Results.html?sc=84e839cc' },
+    { text => 'Bulk Update',  url => '/Search/Bulk.html?sc=84e839cc' },
+    { text => 'Permalink',    url => '/Search/Bulk.html?sc=84e839cc' },
+    { text => 'Chart',        url => '/Search/Chart.html?sc=84e839cc' },
+
+    # Chart page has new code which contains chart arguments.
+    { text => 'Permalink', 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 666e618f969785a7dfe1e324e6eb664dca463b2b
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 d3b5fb816ef33dbe211af87ac52c86df6f18498c
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 827da8a728..9908487b89 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -2042,6 +2042,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 5b0ae7cc6e4c6b4863d804944ef3dff45c6c02f6
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 8508e599b1ba0026f6c2f03618752e7580ac115d
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/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..827da8a728 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,7 @@ use JSON qw();
 use Plack::Util;
 use HTTP::Status qw();
 use Regexp::Common;
+use RT::Shortener;
 
 =head2 SquishedCSS $style
 
@@ -687,6 +689,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 +1504,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 +2006,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 +5346,85 @@ sub GetDashboards {
     return \%dashboards;
 }
 
+sub QueryString {
+    my %args = @_;
+    my $u    = URI->new();
+    $u->query_form(map { $_ => $args{$_} } sort keys %args);
+    return $u->query;
+}
+
+our @SHORTENER_FIELDS
+    = qw/Class ObjectType Query Format RowsPerPage Order OrderBy ExtraQueryParams ResultPage
+         Width Height ChartStyle GroupBy ChartFunction StackedGroupBy/;
+
+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_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..dd687f9725 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 (qw/Width Height ChartStyle GroupBy ChartFunction StackedGroupBy/) {
+                $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,6 +697,27 @@ 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',
+                    title      => loc('Permalink'),
+                    class      => 'permalink',
+                    attributes => { 'data-code' => $short_query{sc}, 'data-url' => "$request_path?sc=$short_query{sc}" },
+                    path       => "$request_path?sc=$short_query{sc}",
+                );
+            }
+        }
+
         $current_search_menu->child( edit_search =>
             title => loc('Edit Search'), path => "/Search/Build.html" . ( ($has_query) ? $args : '' ) );
         if ( $current_user->HasRight( Right => 'ShowSearchAdvanced', Object => RT->System ) ) {
@@ -734,20 +757,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 +781,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