[Rt-commit] rt branch, 5.0/self-service-homepage-dashboard, created. rt-5.0.0-19-g7bc0b9c0b9

Dianne Skoll dianne at bestpractical.com
Fri Oct 2 11:14:45 EDT 2020


The branch, 5.0/self-service-homepage-dashboard has been created
        at  7bc0b9c0b9b1254fa3b8e19f4e39f6c7a00d551a (commit)

- Log -----------------------------------------------------------------
commit 81c9702b0ccdf96bb2e0a25b22d07b8fdb299acb
Author: Jim Brandt <jbrandt at bestpractical.com>
Date:   Thu Sep 3 15:23:54 2020 -0400

    Add callback for modifying the group query

diff --git a/share/html/SelfService/Elements/MyGroupRequests b/share/html/SelfService/Elements/MyGroupRequests
index 702f0d4a11..70fa2456c6 100644
--- a/share/html/SelfService/Elements/MyGroupRequests
+++ b/share/html/SelfService/Elements/MyGroupRequests
@@ -107,6 +107,10 @@ if ($status) {
 }
 
 my $Format = RT->Config->Get('DefaultSelfServiceSearchResultFormat');
+
+$m->callback( CallbackName => 'ModifyQuery', ARGSRef => \%ARGS, Query => \$Query, Page => \$Page,
+    Rows => \$Rows, Order => \@Order, OrderBy => \@OrderBy, Format => \$Format );
+
 </%INIT>
 <%ARGS>
 $title => undef

commit de95100478aba36fe90feef3f5a0cbaefe123906
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Sep 1 22:55:27 2020 +0800

    Refactor code so we can add history endpoints to new classes more easily
    
    As possible values of $class are already limited in previous code, we
    can be sure that $package is always valid.

diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Transactions.pm
index 719a4e754a..8ecd83de85 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Transactions.pm
@@ -67,22 +67,8 @@ sub dispatch_rules {
             my ($match, $req) = @_;
             my ($class, $id) = ($match->pos(1), $match->pos(2));
 
-            my $record;
-            if ($class eq 'ticket') {
-                $record = RT::Ticket->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'asset') {
-                $record = RT::Asset->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'group') {
-                $record = RT::Group->new($req->env->{"rt.current_user"});
-            }
+            my $package = 'RT::' . ucfirst $class;
+            my $record = $package->new( $req->env->{"rt.current_user"} );
 
             $record->Load($id);
             return { collection => $record->Transactions };

commit e3ca9f738163632b0febe6c13ed57946fe8d01c7
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Sep 2 06:07:20 2020 +0800

    Avoid permission check to get CF type in CustomFieldValueIsEmpty
    
    This is initially to fix uninitialized warnings in RT::Article::Create,
    when people set a custom field they don't have right to see, in which
    case $cf->Type is undef.

diff --git a/lib/RT/Record.pm b/lib/RT/Record.pm
index b3cc0787e4..a4ed13fc44 100644
--- a/lib/RT/Record.pm
+++ b/lib/RT/Record.pm
@@ -2173,7 +2173,7 @@ sub CustomFieldValueIsEmpty {
            : $self->LoadCustomFieldByIdentifier( $args{'Field'} );
 
     if ($cf) {
-        if ( $cf->Type =~ /^Date(?:Time)?$/ ) {
+        if ( $cf->__Value('Type') =~ /^Date(?:Time)?$/ ) {
             my $DateObj = RT::Date->new( $self->CurrentUser );
             $DateObj->Set(
                 Format => 'unknown',

commit 6889750dcda61d2fb1861a85e750567667d72d44
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Thu Sep 3 00:25:34 2020 +0800

    Avoid permission check to get CF CanonicalizeClass
    
    Similar to 68b6a66f7 that gets CF Type without permission check, we need
    it for CanonicalizeClass too, otherwise canonicalization wouldn't work
    for users that don't have global SeeCustomField right(e.g. at article
    class level).

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index a9169ec24f..ae1ac0bd19 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -1838,9 +1838,8 @@ sub _CanonicalizeValueWithCanonicalizer {
     my $self = shift;
     my $args = shift;
 
-    return 1 if !$self->CanonicalizeClass;
+    my $class = $self->__Value('CanonicalizeClass') or return 1;
 
-    my $class = $self->CanonicalizeClass;
     $class->require or die "Can't load $class: $@";
     my $canonicalizer = $class->new($self->CurrentUser);
 

commit e3b184f752f41093485342a5ce303748e8707eb0
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Sep 1 23:04:28 2020 +0800

    Add basic article/class endpoints to REST2
    
    It supports CRUD and also basic searches.

diff --git a/lib/RT/REST2.pm b/lib/RT/REST2.pm
index 0f3fc7024b..1079f95cd6 100644
--- a/lib/RT/REST2.pm
+++ b/lib/RT/REST2.pm
@@ -638,6 +638,51 @@ Below are some examples using the endpoints above.
     DELETE /catalog/:name
         disable catalog
 
+=head3 Articles
+
+    GET /articles?query=<JSON>
+    POST /articles
+        search for articles using L</JSON searches> syntax
+
+    POST /article
+        create an article; provide JSON content
+
+    GET /article/:id
+        retrieve an article
+
+    PUT /article/:id
+        update an article's metadata; provide JSON content
+
+    DELETE /article/:id
+        set status to deleted
+
+    GET /article/:id/history
+        retrieve list of transactions for article
+
+=head3 Classes
+
+    GET /classes/all
+        retrieve list of all classes you can see
+
+    GET /classes?query=<JSON>
+    POST /classes
+        search for classes using L</JSON searches> syntax
+
+    POST /class
+        create a class; provide JSON content
+
+    GET /class/:id
+    GET /class/:name
+        retrieve a class by numeric id or name
+
+    PUT /class/:id
+    PUT /class/:name
+        update a class's metadata; provide JSON content
+
+    DELETE /class/:id
+    DELETE /class/:name
+        disable class
+
 =head3 Users
 
     GET /users?query=<JSON>
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Article.pm
similarity index 59%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Article.pm
index 8ecd83de85..eacfa0d75b 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Article.pm
@@ -46,52 +46,68 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Article;
 use strict;
 use warnings;
 
 use Moose;
 use namespace::autoclean;
 
-extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::QueryByJSON';
+extends 'RT::REST2::Resource::Record';
+with (
+    'RT::REST2::Resource::Record::Readable',
+    'RT::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } },
+    'RT::REST2::Resource::Record::Deletable',
+    'RT::REST2::Resource::Record::Writable'
+        => { -alias => { create_record => '_create_record' } },
+);
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
+        regex => qr{^/article/?$},
+        block => sub { { record_class => 'RT::Article' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
+        regex => qr{^/article/(\d+)/?$},
+        block => sub { { record_class => 'RT::Article', record_id => shift->pos(1) } },
+    )
+}
 
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
+sub create_record {
+    my $self = shift;
+    my $data = shift;
 
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
+    return (\400, "Invalid Class") if !$data->{Class};
 
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
+    my $class = RT::Class->new(RT->SystemUser);
+    $class->Load($data->{Class});
 
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    )
+    return (\400, "Invalid Class") if !$class->Id;
+
+    return ( \403, $self->record->loc("Permission Denied") )
+        unless $self->record->CurrentUser->HasRight(
+        Right  => 'CreateArticle',
+        Object => $class,
+        )
+        and $class->Disabled != 1;
+
+    my ($ok, $txn, $msg) = $self->_create_record($data);
+    return ($ok, $msg);
+}
+
+sub forbidden {
+    my $self = shift;
+    return 0 unless $self->record->id;
+    return !$self->record->CurrentUserHasRight('ShowArticle');
+}
+
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links(@_);
+    push @$links, $self->_transaction_history_link;
+
+    return $links;
 }
 
 __PACKAGE__->meta->make_immutable;
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Articles.pm
similarity index 64%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Articles.pm
index 8ecd83de85..2cdee8fe4c 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Articles.pm
@@ -46,7 +46,7 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Articles;
 use strict;
 use warnings;
 
@@ -58,39 +58,8 @@ with 'RT::REST2::Resource::Collection::QueryByJSON';
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
-    ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
+        regex => qr{^/articles/?$},
+        block => sub { { collection_class => 'RT::Articles' } },
     )
 }
 
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Class.pm
similarity index 56%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Class.pm
index 8ecd83de85..15f4adb650 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Class.pm
@@ -46,54 +46,82 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Class;
 use strict;
 use warnings;
 
 use Moose;
 use namespace::autoclean;
+use RT::REST2::Util qw(expand_uid);
 
-extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::QueryByJSON';
+extends 'RT::REST2::Resource::Record';
+with (
+    'RT::REST2::Resource::Record::Readable',
+    'RT::REST2::Resource::Record::Hypermedia'
+        => { -alias => { hypermedia_links => '_default_hypermedia_links' } },
+    'RT::REST2::Resource::Record::DeletableByDisabling',
+    'RT::REST2::Resource::Record::Writable',
+);
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
+        regex => qr{^/class/?$},
+        block => sub { { record_class => 'RT::Class' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
+        regex => qr{^/class/(\d+)/?$},
+        block => sub { { record_class => 'RT::Class', record_id => shift->pos(1) } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
+        regex => qr{^/class/([^/]+)/?$},
         block => sub {
             my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
+            my $class = RT::Class->new($req->env->{"rt.current_user"});
+            $class->Load($match->pos(1));
+            return { record => $class };
+        },
+    ),
+}
 
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
+sub hypermedia_links {
+    my $self = shift;
+    my $links = $self->_default_hypermedia_links(@_);
+    my $class = $self->record;
 
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    )
+    push @$links, {
+        ref  => 'create',
+        type => 'article',
+        _url => RT::REST2->base_uri . '/article?Class=' . $class->Id,
+    } if $class->CurrentUserHasRight('CreateArticle');
+
+    return $links;
 }
 
+around 'serialize' => sub {
+    my $orig = shift;
+    my $self = shift;
+    my $data = $self->$orig(@_);
+
+    # Load Article Custom Fields for this class
+    if ( my $article_cfs = $self->record->ArticleCustomFields ) {
+        my @values;
+        while (my $cf = $article_cfs->Next) {
+            my $entry = expand_uid($cf->UID);
+            my $content = {
+                %$entry,
+                ref      => 'customfield',
+                name     => $cf->Name,
+            };
+
+            push @values, $content;
+        }
+
+        $data->{ArticleCustomFields} = \@values;
+    }
+
+    return $data;
+};
+
 __PACKAGE__->meta->make_immutable;
 
 1;
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Classes.pm
similarity index 68%
copy from lib/RT/REST2/Resource/Transactions.pm
copy to lib/RT/REST2/Resource/Classes.pm
index 8ecd83de85..7f2b4a3210 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Classes.pm
@@ -46,7 +46,7 @@
 #
 # END BPS TAGGED BLOCK }}}
 
-package RT::REST2::Resource::Transactions;
+package RT::REST2::Resource::Classes;
 use strict;
 use warnings;
 
@@ -58,40 +58,18 @@ with 'RT::REST2::Resource::Collection::QueryByJSON';
 
 sub dispatch_rules {
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/transactions/?$},
-        block => sub { { collection_class => 'RT::Transactions' } },
+        regex => qr{^/classes/?$},
+        block => sub { { collection_class => 'RT::Classes' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
+        regex => qr{^/classes/all/?$},
         block => sub {
             my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $package = 'RT::' . ucfirst $class;
-            my $record = $package->new( $req->env->{"rt.current_user"} );
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
+            my $classes = RT::Classes->new($req->env->{"rt.current_user"});
+            $classes->UnLimit;
+            return { collection => $classes };
         },
     ),
-    Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(queue|user)/([^/]+)/history/?$},
-        block => sub {
-            my ($match, $req) = @_;
-            my ($class, $id) = ($match->pos(1), $match->pos(2));
-
-            my $record;
-            if ($class eq 'queue') {
-                $record = RT::Queue->new($req->env->{"rt.current_user"});
-            }
-            elsif ($class eq 'user') {
-                $record = RT::User->new($req->env->{"rt.current_user"});
-            }
-
-            $record->Load($id);
-            return { collection => $record->Transactions };
-        },
-    )
 }
 
 __PACKAGE__->meta->make_immutable;
diff --git a/lib/RT/REST2/Resource/Record/Writable.pm b/lib/RT/REST2/Resource/Record/Writable.pm
index 3ff69bdda0..8fdcb4bd57 100644
--- a/lib/RT/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/REST2/Resource/Record/Writable.pm
@@ -410,7 +410,7 @@ sub create_record {
     # if a record class handles CFs in ->Create, use it (so it doesn't generate
     # spurious transactions and interfere with default values, etc). Otherwise,
     # add OCFVs after ->Create
-    if ($record->isa('RT::Ticket') || $record->isa('RT::Asset')) {
+    if ($record->isa('RT::Ticket') || $record->isa('RT::Asset') || $record->isa('RT::Article') ) {
         if ($cfs) {
             while (my ($id, $value) = each(%$cfs)) {
                 delete $cfs->{$id};
diff --git a/lib/RT/REST2/Resource/Transactions.pm b/lib/RT/REST2/Resource/Transactions.pm
index 8ecd83de85..8b7737b90c 100644
--- a/lib/RT/REST2/Resource/Transactions.pm
+++ b/lib/RT/REST2/Resource/Transactions.pm
@@ -62,7 +62,7 @@ sub dispatch_rules {
         block => sub { { collection_class => 'RT::Transactions' } },
     ),
     Path::Dispatcher::Rule::Regex->new(
-        regex => qr{^/(ticket|queue|asset|user|group)/(\d+)/history/?$},
+        regex => qr{^/(ticket|queue|asset|user|group|article)/(\d+)/history/?$},
         block => sub {
             my ($match, $req) = @_;
             my ($class, $id) = ($match->pos(1), $match->pos(2));

commit b40af0d7a5a2a288407fa938b38d1551da323ee1
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Sep 2 06:07:39 2020 +0800

    Add REST2 tests for articles/classes
    
    They are based on asset/catalog tests, with necessary changes to test
    articles/classes instead.

diff --git a/t/rest2/article-customfields.t b/t/rest2/article-customfields.t
new file mode 100644
index 0000000000..03d90d11ea
--- /dev/null
+++ b/t/rest2/article-customfields.t
@@ -0,0 +1,389 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+use Test::Deep;
+
+my $mech = RT::Test::REST2->mech;
+
+my $auth           = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user           = RT::Test::REST2->user;
+
+my $class = RT::Class->new( RT->SystemUser );
+$class->Load('General');
+$class->Create( Name => 'General' ) if !$class->Id;
+ok( $class->Id, "General class" );
+
+my $single_cf = RT::CustomField->new( RT->SystemUser );
+my ( $ok, $msg ) = $single_cf->Load('Content');
+ok( $ok, $msg );
+my $single_cf_id = $single_cf->Id;
+
+( $ok, $msg ) = $single_cf->AddToObject($class);
+ok( $ok, $msg );
+
+my $multi_cf = RT::CustomField->new( RT->SystemUser );
+( $ok, $msg )
+    = $multi_cf->Create( Name => 'Multi', Type => 'FreeformMultiple',
+    LookupType => RT::Article->CustomFieldLookupType );
+ok( $ok, $msg );
+my $multi_cf_id = $multi_cf->Id;
+
+( $ok, $msg ) = $multi_cf->AddToObject($class);
+ok( $ok, $msg );
+
+# Article Creation with no ModifyCustomField
+my ( $article_url, $article_id );
+{
+    my $payload = {
+        Name         => 'Article creation using REST',
+        Class        => 'General',
+        CustomFields => { $single_cf_id => 'Hello world!', },
+    };
+
+    # Rights Test - No CreateArticle
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+    my $content = $mech->json_response;
+    is( $content->{message}, 'Permission Denied', "can't create Article with custom fields you can't set" );
+
+    # Rights Test - With CreateArticle
+    $user->PrincipalObj->GrantRight( Right => 'CreateArticle' );
+
+    $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 400 );
+
+    delete $payload->{CustomFields};
+
+    $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+}
+
+# Article Display
+{
+    # Rights Test - No ShowArticle
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+}
+
+# Rights Test - With ShowArticle but no SeeCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'ShowArticle' );
+
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+    is_deeply( $content->{'CustomFields'}, [], 'Article custom field not present' );
+    is_deeply( [ grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} } ], [], 'No CF hypermedia' );
+}
+
+my $no_article_cf_values = bag(
+    { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => [] },
+    { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => [] },
+);
+
+# Rights Test - With ShowArticle and SeeCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'SeeCustomField' );
+
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+    cmp_deeply( $content->{CustomFields}, $no_article_cf_values, 'No article custom field values' );
+
+    cmp_deeply(
+        [ grep { $_->{ref} eq 'customfield' } @{ $content->{'_hyperlinks'} } ],
+        [   {   ref  => 'customfield',
+                id   => $single_cf_id,
+                name => 'Content',
+                type => 'customfield',
+                _url => re(qr[$rest_base_path/customfield/$single_cf_id$]),
+            },
+            {   ref  => 'customfield',
+                id   => $multi_cf_id,
+                name => 'Multi',
+                type => 'customfield',
+                _url => re(qr[$rest_base_path/customfield/$multi_cf_id$]),
+            }
+        ],
+        'Two CF hypermedia',
+    );
+
+    my ($single_url)
+        = map { $_->{_url} }
+        grep { $_->{ref} eq 'customfield' && $_->{id} == $single_cf_id } @{ $content->{'_hyperlinks'} };
+    my ($multi_url)
+        = map { $_->{_url} }
+        grep { $_->{ref} eq 'customfield' && $_->{id} == $multi_cf_id } @{ $content->{'_hyperlinks'} };
+
+    $res = $mech->get( $single_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    cmp_deeply(
+        $mech->json_response,
+        superhashof(
+            {   id         => $single_cf_id,
+                Disabled   => 0,
+                LookupType => RT::Article->CustomFieldLookupType,
+                MaxValues  => 1,
+                Name       => 'Content',
+                Type       => 'Text',
+            }
+        ),
+        'single cf'
+    );
+
+    $res = $mech->get( $multi_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    cmp_deeply(
+        $mech->json_response,
+        superhashof(
+            {   id         => $multi_cf_id,
+                Disabled   => 0,
+                LookupType => RT::Article->CustomFieldLookupType,
+                MaxValues  => 0,
+                Name       => 'Multi',
+                Type       => 'Freeform',
+            }
+        ),
+        'multi cf'
+    );
+}
+
+# Article Update without ModifyCustomField
+{
+    my $payload = {
+        Name         => 'Article update using REST',
+        CustomFields => { $single_cf_id => 'Modified CF', },
+    };
+
+    # Rights Test - No ModifyArticle
+    my $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+TODO: {
+        local $TODO = "RT ->Update isn't introspectable";
+        is( $res->code, 403 );
+    }
+    is_deeply(
+        $mech->json_response,
+        [   'Article Article creation using REST: Permission Denied',
+            'Could not add new custom field value: Permission Denied'
+        ]
+    );
+
+    $user->PrincipalObj->GrantRight( Right => 'ModifyArticle' );
+
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Article Article update using REST: Name changed from "Article creation using REST" to "Article update using REST"',
+            'Could not add new custom field value: Permission Denied'
+        ]
+    );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name}, 'Article update using REST' );
+    cmp_deeply( $content->{CustomFields}, $no_article_cf_values, 'No update to CF' );
+}
+
+# Article Update with ModifyCustomField
+{
+    $user->PrincipalObj->GrantRight( Right => 'ModifyCustomField' );
+    my $payload = {
+        Name         => 'More updates using REST',
+        CustomFields => { $single_cf_id => 'Modified CF', },
+    };
+    my $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Article More updates using REST: Name changed from "Article update using REST" to "More updates using REST"',
+            'Content Modified CF added'
+        ]
+    );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $modified_article_cf_values = bag(
+        { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => ['Modified CF'] },
+        { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => [] },
+    );
+
+    my $content = $mech->json_response;
+    is( $content->{Name}, 'More updates using REST' );
+    cmp_deeply( $content->{CustomFields}, $modified_article_cf_values, 'New CF value' );
+
+    # make sure changing the CF doesn't add a second OCFV
+    $payload->{CustomFields}{$single_cf_id} = 'Modified Again';
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply( $mech->json_response, ['Content Modified CF changed to Modified Again'] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $modified_article_cf_values = bag(
+        {   name   => 'Content',
+            id     => $single_cf_id,
+            type   => 'customfield',
+            _url   => ignore(),
+            values => ['Modified Again']
+        },
+        { name => 'Multi', id => $multi_cf_id, type => 'customfield', _url => ignore(), values => [] },
+    );
+
+    $content = $mech->json_response;
+    cmp_deeply( $content->{CustomFields}, $modified_article_cf_values, 'New CF value' );
+
+    # stop changing the CF, change something else, make sure CF sticks around
+    delete $payload->{CustomFields}{$single_cf_id};
+    $payload->{Name} = 'No CF change';
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply( $mech->json_response,
+        ['Article No CF change: Name changed from "More updates using REST" to "No CF change"'] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $content = $mech->json_response;
+    cmp_deeply( $content->{CustomFields}, $modified_article_cf_values, 'Same CF value' );
+}
+
+# Article Creation with ModifyCustomField
+{
+    my $payload = {
+        Name         => 'Article creation using REST',
+        Class        => 'General',
+        CustomFields => { $single_cf_id => 'Hello world!', },
+    };
+
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+}
+
+# Rights Test - With ShowArticle and SeeCustomField
+{
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $article_cf_values = bag(
+        { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => ['Hello world!'] },
+        { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => [] },
+    );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+    cmp_deeply( $content->{'CustomFields'}, $article_cf_values, 'Article custom field' );
+}
+
+# Article Creation for multi-value CF
+my $i = 1;
+for my $value ( 'scalar', ['array reference'], [ 'multiple', 'values' ], ) {
+    my $payload = {
+        Name         => 'Multi-value CF ' . $i,
+        Class        => 'General',
+        CustomFields => { $multi_cf_id => $value, },
+    };
+
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Multi-value CF ' . $i );
+
+    my $output            = ref($value) ? $value : [$value];    # scalar input comes out as array reference
+    my $article_cf_values = bag(
+        { name => 'Content', id => $single_cf_id, type => 'customfield', _url => ignore(), values => [] },
+        { name => 'Multi',   id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => $output },
+    );
+
+    cmp_deeply( $content->{'CustomFields'}, $article_cf_values, 'Article custom field' );
+    $i++;
+}
+
+{
+
+    sub modify_multi_ok {
+        local $Test::Builder::Level = $Test::Builder::Level + 1;
+        my $input    = shift;
+        my $messages = shift;
+        my $output   = shift;
+        my $name     = shift;
+
+        my $payload = { CustomFields => { $multi_cf_id => $input, }, };
+        my $res     = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+        is( $res->code, 200 );
+        is_deeply( $mech->json_response, $messages );
+
+        $res = $mech->get( $article_url, 'Authorization' => $auth, );
+        is( $res->code, 200 );
+
+        my $content = $mech->json_response;
+        my $values;
+        for my $cf ( @{ $content->{CustomFields} } ) {
+            next unless $cf->{id} == $multi_cf_id;
+
+            $values = [ sort @{ $cf->{values} } ];
+        }
+        cmp_deeply( $values, $output, $name || 'New CF value' );
+    }
+
+    # starting point: ['multiple', 'values'],
+    modify_multi_ok( [ 'multiple', 'values' ], [], [ 'multiple', 'values' ], 'no change' );
+    modify_multi_ok(
+        [ 'multiple', 'values', 'new' ],
+        ['new added as a value for Multi'],
+        [ 'multiple', 'new', 'values' ],
+        'added "new"'
+    );
+    modify_multi_ok(
+        [ 'multiple', 'new' ],
+        ['values is no longer a value for custom field Multi'],
+        [ 'multiple', 'new' ],
+        'removed "values"'
+    );
+    modify_multi_ok(
+        'replace all',
+        [   'replace all added as a value for Multi',
+            'multiple is no longer a value for custom field Multi',
+            'new is no longer a value for custom field Multi'
+        ],
+        ['replace all'],
+        'replaced all values'
+    );
+    modify_multi_ok( [], ['replace all is no longer a value for custom field Multi'], [], 'removed all values' );
+
+    modify_multi_ok(
+        [ 'foo',                            'foo', 'bar' ],
+        [ 'foo added as a value for Multi', undef, 'bar added as a value for Multi' ],
+        [ 'bar',                            'foo' ],
+        'multiple values with the same name'
+    );
+    modify_multi_ok( [ 'foo', 'bar' ], [], [ 'bar', 'foo' ], 'multiple values with the same name' );
+    modify_multi_ok( ['bar'], ['foo is no longer a value for custom field Multi'],
+        ['bar'], 'multiple values with the same name' );
+    modify_multi_ok( [ 'bar', 'bar', 'bar' ], [ undef, undef ], ['bar'], 'multiple values with the same name' );
+}
+
+done_testing;
diff --git a/t/rest2/articles.t b/t/rest2/articles.t
new file mode 100644
index 0000000000..db6ccd8538
--- /dev/null
+++ b/t/rest2/articles.t
@@ -0,0 +1,184 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+use Test::Deep;
+
+my $mech = RT::Test::REST2->mech;
+
+my $auth           = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user           = RT::Test::REST2->user;
+
+# Empty DB
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/articles",
+        [ { field => 'id', operator => '>', value => 0 } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code,                    200 );
+    is( $mech->json_response->{count}, 0 );
+}
+
+# Missing Class
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/article",
+        { Name => 'Article creation using REST', },
+        'Authorization' => $auth,
+    );
+    is( $res->code,                      400 );
+    is( $mech->json_response->{message}, 'Invalid Class' );
+}
+
+# Article Creation
+my ( $article_url, $article_id );
+{
+    my $payload = {
+        Name    => 'Article creation using REST',
+        Summary => 'Article summary',
+        Class   => 'General',
+    };
+
+    # Rights Test - No CreateArticle
+    my $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+
+    # Rights Test - With CreateArticle
+    $user->PrincipalObj->GrantRight( Right => 'CreateArticle' );
+    $res = $mech->post_json( "$rest_base_path/article", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $article_url = $res->header('location') );
+    ok( ($article_id) = $article_url =~ qr[/article/(\d+)] );
+}
+
+# Article Display
+{
+    # Rights Test - No ShowArticle
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 403 );
+}
+
+# Rights Test - With ShowArticle
+{
+    $user->PrincipalObj->GrantRight( Right => 'ShowArticle' );
+
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},   $article_id );
+    is( $content->{Name}, 'Article creation using REST' );
+
+    ok( exists $content->{$_} ) for qw(Creator Created LastUpdated LastUpdatedBy Name Summary);
+
+    my $links = $content->{_hyperlinks};
+    is( scalar @$links, 2 );
+
+    is( $links->[0]{ref},  'self' );
+    is( $links->[0]{id},   1 );
+    is( $links->[0]{type}, 'article' );
+    like( $links->[0]{_url}, qr[$rest_base_path/article/$article_id$] );
+
+    is( $links->[1]{ref}, 'history' );
+    like( $links->[1]{_url}, qr[$rest_base_path/article/$article_id/history$] );
+
+    my $class = $content->{Class};
+    is( $class->{id},   1 );
+    is( $class->{type}, 'class' );
+    like( $class->{_url}, qr{$rest_base_path/class/1$} );
+
+    my $creator = $content->{Creator};
+    is( $creator->{id},   'test' );
+    is( $creator->{type}, 'user' );
+    like( $creator->{_url}, qr{$rest_base_path/user/test$} );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'test' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/test$} );
+}
+
+# Article Search
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/articles",
+        [ { field => 'id', operator => '>', value => 0 } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $article = $content->{items}->[0];
+    is( $article->{type}, 'article' );
+    is( $article->{id},   1 );
+    like( $article->{_url}, qr{$rest_base_path/article/1$} );
+}
+
+# Article Update
+{
+    my $payload = { Name => 'Article update using REST', };
+
+    # Rights Test - No ModifyArticle
+    my $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+TODO: {
+        local $TODO = "RT ->Update isn't introspectable";
+        is( $res->code, 403 );
+    }
+    is_deeply( $mech->json_response, ['Article Article creation using REST: Permission Denied'] );
+
+    $user->PrincipalObj->GrantRight( Right => 'ModifyArticle' );
+
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Article Article update using REST: Name changed from "Article creation using REST" to "Article update using REST"'
+        ]
+    );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name}, 'Article update using REST' );
+
+    # update again with no changes
+    $res = $mech->put_json( $article_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply( $mech->json_response, [] );
+
+    $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $content = $mech->json_response;
+    is( $content->{Name}, 'Article update using REST' );
+}
+
+# Transactions
+{
+    my $res = $mech->get( $article_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    $res = $mech->get( $mech->url_for_hypermedia('history'), 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             2 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             2 );
+    is( scalar @{ $content->{items} }, 2 );
+
+    for my $txn ( @{ $content->{items} } ) {
+        is( $txn->{type}, 'transaction' );
+        like( $txn->{_url}, qr{$rest_base_path/transaction/\d+$} );
+    }
+}
+
+done_testing;
diff --git a/t/rest2/classes.t b/t/rest2/classes.t
new file mode 100644
index 0000000000..56f4b9ffc2
--- /dev/null
+++ b/t/rest2/classes.t
@@ -0,0 +1,210 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+
+my $mech           = RT::Test::REST2->mech;
+my $auth           = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user           = RT::Test::REST2->user;
+
+$user->PrincipalObj->GrantRight( Right => 'SuperUser' );
+
+my $class_url;
+
+# search Name = General
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/classes",
+        [ { field => 'Name', value => 'General' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $class = $content->{items}->[0];
+    is( $class->{type}, 'class' );
+    is( $class->{id},   1 );
+    like( $class->{_url}, qr{$rest_base_path/class/1$} );
+    $class_url = $class->{_url};
+}
+
+# Class display
+{
+    my $res = $mech->get( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},          1 );
+    is( $content->{Name},        'General' );
+    is( $content->{Description}, 'The default class' );
+    is( $content->{Disabled},    0 );
+
+    ok( exists $content->{$_}, "got $_" ) for qw(LastUpdated Created);
+
+    my $links = $content->{_hyperlinks};
+    is( scalar @$links, 2 );
+
+    is( $links->[0]{ref},  'self' );
+    is( $links->[0]{id},   1 );
+    is( $links->[0]{type}, 'class' );
+    like( $links->[0]{_url}, qr[$rest_base_path/class/1$] );
+
+    is( $links->[1]{ref},  'create' );
+    is( $links->[1]{type}, 'article' );
+    like( $links->[1]{_url}, qr[$rest_base_path/article\?Class=1$] );
+
+    my $creator = $content->{Creator};
+    is( $creator->{id},   'RT_System' );
+    is( $creator->{type}, 'user' );
+    like( $creator->{_url}, qr{$rest_base_path/user/RT_System$} );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'RT_System' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/RT_System$} );
+}
+
+# Class update
+{
+    my $payload = {
+        Name        => 'Servers',
+        Description => 'gotta serve em all',
+    };
+
+    my $res = $mech->put_json( $class_url, $payload, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+    is_deeply(
+        $mech->json_response,
+        [   'Class General: Description changed from "The default class" to "gotta serve em all"',
+            'Class Servers: Name changed from "General" to "Servers"'
+        ]
+    );
+
+    $res = $mech->get( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name},        'Servers' );
+    is( $content->{Description}, 'gotta serve em all' );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'test' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/test$} );
+}
+
+# search Name = Servers
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/classes",
+        [ { field => 'Name', value => 'Servers' } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $class = $content->{items}->[0];
+    is( $class->{type}, 'class' );
+    is( $class->{id},   1 );
+    like( $class->{_url}, qr{$rest_base_path/class/1$} );
+}
+
+# Class delete
+{
+    my $res = $mech->delete( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 204 );
+
+    my $class = RT::Class->new( RT->SystemUser );
+    $class->Load(1);
+    is( $class->Id, 1, '"deleted" class still in the database' );
+    ok( $class->Disabled, '"deleted" class disabled' );
+
+    $res = $mech->get( $class_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{Name},     'Servers' );
+    is( $content->{Disabled}, 1 );
+}
+
+# Class create
+my ( $laptops_url, $laptops_id );
+{
+    my $payload = { Name => 'Laptops', };
+
+    my $res = $mech->post_json( "$rest_base_path/class", $payload, 'Authorization' => $auth, );
+    is( $res->code, 201 );
+    ok( $laptops_url = $res->header('location') );
+    ok( ($laptops_id) = $laptops_url =~ qr[/class/(\d+)] );
+}
+
+# Class display
+{
+    my $res = $mech->get( $laptops_url, 'Authorization' => $auth, );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{id},       $laptops_id );
+    is( $content->{Name},     'Laptops' );
+    is( $content->{Disabled}, 0 );
+
+    ok( exists $content->{$_}, "got $_" ) for qw(LastUpdated Created);
+
+    my $links = $content->{_hyperlinks};
+    is( scalar @$links, 2 );
+
+    is( $links->[0]{ref},  'self' );
+    is( $links->[0]{id},   $laptops_id );
+    is( $links->[0]{type}, 'class' );
+    like( $links->[0]{_url}, qr[$rest_base_path/class/$laptops_id$] );
+
+    is( $links->[1]{ref},  'create' );
+    is( $links->[1]{type}, 'article' );
+    like( $links->[1]{_url}, qr[$rest_base_path/article\?Class=$laptops_id$] );
+
+    my $creator = $content->{Creator};
+    is( $creator->{id},   'test' );
+    is( $creator->{type}, 'user' );
+    like( $creator->{_url}, qr{$rest_base_path/user/test$} );
+
+    my $updated_by = $content->{LastUpdatedBy};
+    is( $updated_by->{id},   'test' );
+    is( $updated_by->{type}, 'user' );
+    like( $updated_by->{_url}, qr{$rest_base_path/user/test$} );
+}
+
+# id > 0 (finds new Laptops class but not disabled Servers class)
+{
+    my $res = $mech->post_json(
+        "$rest_base_path/classes",
+        [ { field => 'id', operator => '>', value => 0 } ],
+        'Authorization' => $auth,
+    );
+    is( $res->code, 200 );
+
+    my $content = $mech->json_response;
+    is( $content->{count},             1 );
+    is( $content->{page},              1 );
+    is( $content->{per_page},          20 );
+    is( $content->{total},             1 );
+    is( scalar @{ $content->{items} }, 1 );
+
+    my $class = $content->{items}->[0];
+    is( $class->{type}, 'class' );
+    is( $class->{id},   $laptops_id );
+    like( $class->{_url}, qr{$rest_base_path/class/$laptops_id$} );
+}
+
+done_testing;

commit 9f6f07fdf8961ada7170530049cee9fedaa81157
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Sep 5 04:23:56 2020 +0800

    Search group itself for SelfService "My group's tickets"
    
    Previously we used "WatcherGroup", which was actually to search tickets
    whose roles have any user members of the group in question, instead of
    the group itself. E.g. for group "security" that has "alice" as a
    member.
    
        Ticket 1:
            Requestor: alice
    
        Ticket 2:
            Requestor: security
    
    "WatcherGroup" gets both tickets(alice is also requestor of ticket 2 via
    "security"), while "Watcher" only gets ticket 2, which is what we really
    want.

diff --git a/share/html/SelfService/Elements/MyGroupRequests b/share/html/SelfService/Elements/MyGroupRequests
index 70fa2456c6..01c180cd28 100644
--- a/share/html/SelfService/Elements/MyGroupRequests
+++ b/share/html/SelfService/Elements/MyGroupRequests
@@ -85,12 +85,12 @@ if ( $groups_obj->Count ){
     # Confirm we got a group. Count can report available groups, but
     # if the current user doesn't have SeeGroup, it won't be loaded.
     if ( $group ){
-        $Query = "(( WatcherGroup = " . $group->Id . " )";
+        $Query = "(( Watcher = " . $group->Id . " )";
     }
 
     # Handle multiple groups
     while ( $group = $groups_obj->Next ){
-        $Query .= " OR ( WatcherGroup = " . $group->Id . " )";
+        $Query .= " OR ( Watcher = " . $group->Id . " )";
     }
 
     $Query .= ")" if $Query;

commit bc4128710a90e505b87efdcb77b605dc3ec9f4b3
Author: Dianne Skoll <dianne at bestpractical.com>
Date:   Fri Oct 2 11:06:15 2020 -0400

    Add ability to serve a custom dashboard as the SelfService home page.

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index a7301b3436..945ba235c6 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -2948,6 +2948,41 @@ on self service pages.
 
 Set($SelfServiceShowGroupTickets, 0);
 
+=item C<$SelfServiceUseDashboard>
+
+C<$SelfServiceUseDashboard> is a flag indicating whether or not to use
+a dashboard for the Self Service home page.  If it is set to false,
+then the normal Open Tickets / Closed Tickets menu is shown rather
+than a dashboard.
+
+=cut
+
+Set($SelfServiceUseDashboard, 0);
+
+=item C<$SelfServicePageComponents>
+
+C<$SelfServicePageComponents> is an arrayref of allowed components on
+the SelfService page, if you have set $SelfServiceUseDashboard to true.
+If this is not set at all, then $HomepageComponents is used instead.
+
+=cut
+
+Set(
+    $SelfServicePageComponents,
+    [
+        qw(QuickCreate QueueList MyAdminQueues MySupportQueues MyReminders RefreshHomepage Dashboards SavedSearches FindUser MyAssets FindAsset FindGroup SelfServiceTopArticles ) # loc_qw
+    ]
+);
+
+=item C<$SelfServiceArticleClass>
+
+C<$SelfServiceArticleClass> limits the articles shown to self-service
+users to the specified class.
+
+=cut
+
+Set($SelfServiceArticleClass, "SelfService");
+
 =back
 
 =head2 Articles
diff --git a/lib/RT/Attribute.pm b/lib/RT/Attribute.pm
index 7b609aa363..ac03f87b6b 100644
--- a/lib/RT/Attribute.pm
+++ b/lib/RT/Attribute.pm
@@ -672,7 +672,7 @@ sub FindDependencies {
         }
     }
     # dashboards have dependencies on all the searches and dashboards they use
-    elsif ($self->Name eq 'Dashboard') {
+    elsif ($self->Name eq 'Dashboard' || $self->Name eq 'SelfServiceDashboard') {
         my $content = $self->Content;
         for my $pane (values %{ $content->{Panes} || {} }) {
             for my $component (@$pane) {
diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 400dd5ac22..59ce078f1c 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -1591,6 +1591,9 @@ our %META;
     SearchResultsAutoRedirect => {
         Widget => '/Widgets/Form/Boolean',
     },
+    SelfServiceUseDashboard => {
+        Widget => '/Widgets/Form/Boolean',
+    },
     ShowBccHeader => {
         Widget => '/Widgets/Form/Boolean',
     },
diff --git a/lib/RT/Dashboard/SelfService.pm b/lib/RT/Dashboard/SelfService.pm
new file mode 100644
index 0000000000..e20ffb76fa
--- /dev/null
+++ b/lib/RT/Dashboard/SelfService.pm
@@ -0,0 +1,101 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2020 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::Dashboard::SelfService - dashboard for the Self-Service Home Page
+
+=head1 SYNOPSIS
+
+  See RT::Dashboard
+
+=cut
+
+package RT::Dashboard::SelfService;
+
+use strict;
+use warnings;
+
+use base qw/RT::Dashboard/;
+
+=head2 ObjectName
+
+An object of this class is called "selfservicedashboard"
+
+=cut
+
+sub ObjectName { "selfservicedashboard" } # loc
+
+=head2 PostLoadValidate
+
+Ensure that the ID corresponds to an actual dashboard object, since it's all
+attributes under the hood.
+
+=cut
+
+sub PostLoadValidate {
+    my $self = shift;
+    return (0, "Invalid object type") unless $self->{'Attribute'}->Name eq 'SelfServiceDashboard';
+    return 1;
+}
+
+sub SaveAttribute {
+    my $self   = shift;
+    my $object = shift;
+    my $args   = shift;
+
+    return $object->AddAttribute(
+        'Name'        => 'SelfServiceDashboard',
+        'Description' => $args->{'Name'},
+        'Content'     => {Panes => $args->{'Panes'}},
+    );
+}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index ce7c78f679..de32934bbb 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4718,7 +4718,8 @@ sub UpdateDashboard {
             return ( $ok, $msg ) = $user->SetPreferences( 'HomepageSettings', $data->{panes} );
         }
     } else {
-        my $Dashboard = RT::Dashboard->new( $session{'CurrentUser'} );
+        my $class = $args->{self_service_dashboard} ? 'RT::Dashboard::SelfService' : 'RT::Dashboard';
+        my $Dashboard = $class->new( $session{'CurrentUser'} );
         ( $ok, $msg ) = $Dashboard->LoadById($id);
 
         # report error at the bottom
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 17eac59cb7..659e1bf7b6 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -1229,6 +1229,19 @@ sub _BuildAdminMenu {
         description => loc('Modify the default "RT at a glance" view'),
         path        => '/Admin/Global/MyRT.html',
     );
+
+    if (RT->Config->Get('SelfServiceUseDashboard')) {
+        if ($current_user->HasRight( Right => 'SeeDashboard', Object => RT->System ) ) {
+            my $self_service = $admin_global->child( selfservice_home =>
+                                                     title       => loc('Self-Service Home Page'),
+                                                     description => loc('Edit self-service home page dashboard'),
+                                                     path        => '/Admin/Global/SelfServiceHomePage.html');
+            if ( $request_path =~ m{^/Admin/Global/SelfServiceHomePage} ) {
+                $page->child(content => title => loc('Content'), path => '/Admin/Global/SelfServiceHomePage.html');
+                $page->child(show    => title => loc('Show'), path => '/SelfService');
+            }
+        }
+    }
     $admin_global->child( 'dashboards-in-menu' =>
         title       => loc('Modify Reports menu'),
         description => loc('Customize dashboards in menu'),
@@ -1581,6 +1594,15 @@ sub BuildSelfServiceNav {
 
     my $current_user = $HTML::Mason::Commands::session{CurrentUser};
 
+    if (RT->Config->Get('SelfServiceUseDashboard') && $current_user->HasRight( Right => 'SeeDashboard', Object => RT->System ) ) {
+        if ($request_path =~ m{^/SelfService/index\.html$}) {
+            if ($current_user->HasRight( Right => 'ShowConfigTab',
+                                         Object => RT->System)) {
+                $page->child(content => title => loc('Content'), path => '/Admin/Global/SelfServiceHomePage.html');
+                $page->child(show    => title => loc('Show'), path => '/SelfService');
+            }
+        }
+    }
     my $queues = RT::Queues->new( $current_user );
     $queues->UnLimit;
 
@@ -1600,8 +1622,13 @@ sub BuildSelfServiceNav {
     } elsif ( $queue_id ) {
         $top->child( new => title => loc('New ticket'), path => '/SelfService/Create.html?Queue=' . $queue_id );
     }
-    my $tickets = $top->child( tickets => title => loc('Tickets'), path => '/SelfService/' );
-    $tickets->child( open   => title => loc('Open tickets'),   path => '/SelfService/' );
+
+    my $menu_label = loc('Tickets');
+    if (RT->Config->Get('SelfServiceUseDashboard')) {
+        $menu_label = loc('Self-Service');
+    }
+    my $tickets = $top->child( tickets => title => $menu_label, path => '/SelfService/' );
+    $tickets->child( open   => title => loc('Open tickets'),   path => '/SelfService/Open.html' );
     $tickets->child( closed => title => loc('Closed tickets'), path => '/SelfService/Closed.html' );
 
     $top->child( "assets", title => loc("Assets"), path => "/SelfService/Asset/" )
diff --git a/share/html/SelfService/index.html b/share/html/Admin/Global/SelfServiceHomePage.html
similarity index 55%
copy from share/html/SelfService/index.html
copy to share/html/Admin/Global/SelfServiceHomePage.html
index 78d6b95a7b..3969dd4280 100644
--- a/share/html/SelfService/index.html
+++ b/share/html/Admin/Global/SelfServiceHomePage.html
@@ -45,30 +45,51 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<& /SelfService/Elements/Header, Title => loc('Open tickets') &>
+%#<& /Elements/Header, Title => $title &>
+%#<& /Elements/Tabs &>
+%#<& /Elements/ListActions, actions => \@results &>
 
-% $m->callback(CallbackName => 'BeforeMyRequests', ARGSRef => \%ARGS, Page => $Page);
+<%INIT>
+# If custom self-service page disabled, redirect to /Admin/Global
+if (!RT->Config->Get('SelfServiceUseDashboard')) {
+    RT::Interface::Web::Redirect(RT->Config->Get('WebURL') . 'Admin/Global');
+}
 
-<& /SelfService/Elements/MyRequests,
-    %ARGS,
-    status  => '__Active__',
-    title   => loc('My open tickets'),
-    BaseURL => RT->Config->Get('WebPath') ."/SelfService/?",
-    Page    => $Page, 
-&>
+my $title = loc('Self-Service Home Page');
+my @results;
 
-% $m->callback(CallbackName => 'AfterMyRequests', ARGSRef => \%ARGS, Page => $Page);
+use RT::Dashboard::SelfService;
+my $Dashboard = RT::Dashboard::SelfService->new($session{'CurrentUser'});
 
-<& /SelfService/Elements/MyGroupRequests,
-    %ARGS,
-    status  => '__Active__',
-    title   => loc('My group\'s tickets'),
-    BaseURL => RT->Config->Get('WebPath') ."/SelfService/?",
-    Page    => $Page,
-&>
+my $dashboard_id;
 
-% $m->callback(CallbackName => 'AfterMyGroupRequests', ARGSRef => \%ARGS, Page => $Page);
+# The Self-Service Home Page dashboard is special; its attribute is
+# named "selfservicedashboard" instead of "dashboard".  We just
+# need to get an ID to reuse the rest of the dashboard code.
+my $attr = RT::Attribute->new(RT->SystemUser);
+my ($ok, $msg) = $attr->LoadByNameAndObject(Object => $RT::System,
+                                            Name => 'SelfServiceDashboard');
+if (!$ok) {
+    my $blank_dashboard = {
+        Panes => {
+            body    => [],
+            sidebar => [],
+        }
+    };
+    # Doesn't exist... try creating an empty one
+    ($ok, $msg) = $Dashboard->Save(
+        Privacy => $RT::System,
+        Description => 'Self-Service Home Page Dashboard',
+        Object => $RT::System,
+        Content => $blank_dashboard);
+    unless ($ok) {
+        RT::Logger->error("Unable to create self-service home page dashboard: $msg");
+        Abort(loc("Could not create self-service home page dashboard"));
+    }
+    $dashboard_id = $Dashboard->id;
+} else {
+    $dashboard_id = $attr->Id;
+}
+$m->comp('/Dashboards/Queries.html', id => $dashboard_id, self_service_dashboard => 1, %ARGS);
 
-<%ARGS>
-$Page => 1
-</%ARGS>
+</%INIT>
diff --git a/share/html/Dashboards/Elements/ShowPortlet/component b/share/html/Dashboards/Elements/ShowPortlet/component
index cead432fb2..b3258fe706 100644
--- a/share/html/Dashboards/Elements/ShowPortlet/component
+++ b/share/html/Dashboards/Elements/ShowPortlet/component
@@ -56,7 +56,14 @@ $HasResults
 my $full_path = $Portlet->{path};
 (my $path = $full_path) =~ s{^/Elements/}{};
 
-my $allowed = grep { $_ eq $path } @{RT->Config->Get('HomepageComponents')};
+my $allowed;
+
+if ($m->request_path =~ m{/SelfService}) {
+    $allowed = grep { $_ eq $path } @{RT->Config->Get('SelfServicePageComponents') || RT->Config->Get('HomepageComponents')};
+} else {
+    $allowed = grep { $_ eq $path } @{RT->Config->Get('HomepageComponents')};
+}
+
 </%init>
 % if (!$allowed) {
 %     $m->out( $m->interp->apply_escapes( loc("Invalid portlet [_1]", $path), "h" ) );
diff --git a/share/html/Dashboards/Queries.html b/share/html/Dashboards/Queries.html
index 379e7b1976..b1715bf58f 100644
--- a/share/html/Dashboards/Queries.html
+++ b/share/html/Dashboards/Queries.html
@@ -61,19 +61,38 @@
 <%INIT>
 my @results;
 
-use RT::Dashboard;
-my $Dashboard = RT::Dashboard->new($session{'CurrentUser'});
+# Don't permit someone to supply "self_service_dashboard=1" on the URL line
+if ($m->request_path ne '/Admin/Global/SelfServiceHomePage.html') {
+    $self_service_dashboard = 0;
+}
+
+my $class = $self_service_dashboard ? 'RT::Dashboard::SelfService' : 'RT::Dashboard';
+$class->require;
+my $Dashboard = $class->new($session{'CurrentUser'});
 my ($ok, $msg) = $Dashboard->LoadById($id);
 unless ($ok) {
     RT::Logger->error("Unable to load dashboard with $id: $msg");
     Abort(loc("Could not load dashboard [_1]", $id), Code => HTTP::Status::HTTP_NOT_FOUND);
 }
-my $title = loc("Modify the content of dashboard [_1]", $Dashboard->Name);
+
+my $title;
+
+if ($self_service_dashboard) {
+    $title = loc("Modify the self-service home page");
+} else {
+    $title = loc("Modify the content of dashboard [_1]", $Dashboard->Name);
+}
 
 my @sections;
 my %item_for;
 
-my @components = map { type => "component", name => $_, label => loc($_) }, @{RT->Config->Get('HomepageComponents')};
+my @components;
+
+if ($self_service_dashboard) {
+    @components = map { type => "component", name => $_, label => loc($_) }, @{RT->Config->Get('SelfServicePageComponents') || RT->Config->Get('HomepageComponents')};
+} else {
+    @components = map { type => "component", name => $_, label => loc($_) }, @{RT->Config->Get('HomepageComponents')};
+}
 
 $item_for{ $_->{type} }{ $_->{name} } = $_ for @components;
 
@@ -214,17 +233,32 @@ $m->callback(
 
 if ( $ARGS{UpdateSearches} ) {
     $ARGS{dashboard_id} = $id;
+    $ARGS{self_service_dashboard} = $self_service_dashboard;
     my ($ok, $msg) = UpdateDashboard( \%ARGS, \%item_for );
-    push @results, $ok ? loc('Dashboard updated') : $msg;
+    if ($self_service_dashboard) {
+        push @results, $ok ? loc('Self-service home page updated') : $msg;
+    } else {
+        push @results, $ok ? loc('Dashboard updated') : $msg;
+    }
 
+    my $path;
+    my $args;
+    if ($self_service_dashboard) {
+        $path = '/Admin/Global/SelfServiceHomePage.html';
+        $args = { };
+    } else {
+        $path = '/Dashboards/Queries.html';
+        $args = { id => $id };
+    }
     MaybeRedirectForResults(
         Actions   => \@results,
-        Path      => "/Dashboards/Queries.html",
-        Arguments => { id => $id },
+        Path      => $path,
+        Arguments => $args,
     );
 }
 
 </%INIT>
 <%ARGS>
 $id => '' unless defined $id
+$self_service_dashboard => 0
 </%ARGS>
diff --git a/share/html/Dashboards/Render.html b/share/html/Dashboards/Render.html
index 18aaec7572..7f1c9a2c44 100644
--- a/share/html/Dashboards/Render.html
+++ b/share/html/Dashboards/Render.html
@@ -110,13 +110,20 @@
 my @results;
 my $skip_create = 0;
 
+# Don't permit someone to supply "self_service_dashboard=1" directly
+# on the URL line
+if ($m->request_path ne '/SelfService/index.html') {
+    $self_service_dashboard = 0;
+}
+
 $m->callback(ARGSRef => \%ARGS,
              results => \@results,
              CallbackName => 'Initial',
              skip_create => \$skip_create);
 
-use RT::Dashboard;
-my $Dashboard = RT::Dashboard->new($session{'CurrentUser'});
+my $class = $self_service_dashboard ? 'RT::Dashboard::SelfService' : 'RT::Dashboard';
+$class->require;
+my $Dashboard = $class->new($session{'CurrentUser'});
 my ($ok, $msg) = $Dashboard->LoadById($id);
 unless ($ok) {
     RT::Logger->error("Unable to load dashboard with $id: $msg");
@@ -146,7 +153,12 @@ unless (defined($rows)) {
     $rows = defined($prefs->{'RowsPerPage'}) ? $prefs->{'RowsPerPage'} : 50;
 }
 
-my $title = loc '[_1] Dashboard', $Dashboard->Name;
+my $title;
+if ($self_service_dashboard) {
+    $title = loc('Self-service Dashboard');
+} else {
+    $title = loc '[_1] Dashboard', $Dashboard->Name;
+}
 
 my $show_cb = sub {
     my $pane = shift;
@@ -171,5 +183,6 @@ my $Refresh = $Preview
 $id => undef
 $Preview => 1
 $HasResults => undef
+$self_service_dashboard => 0
 </%ARGS>
 
diff --git a/share/html/SelfService/index.html b/share/html/SelfService/Open.html
similarity index 98%
copy from share/html/SelfService/index.html
copy to share/html/SelfService/Open.html
index 78d6b95a7b..83e9f768a5 100644
--- a/share/html/SelfService/index.html
+++ b/share/html/SelfService/Open.html
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <& /SelfService/Elements/Header, Title => loc('Open tickets') &>
-
+<& /Elements/PageLayout, show_menu => 0 &>
 % $m->callback(CallbackName => 'BeforeMyRequests', ARGSRef => \%ARGS, Page => $Page);
 
 <& /SelfService/Elements/MyRequests,
diff --git a/share/html/SelfService/index.html b/share/html/SelfService/index.html
index 78d6b95a7b..0ddf61c486 100644
--- a/share/html/SelfService/index.html
+++ b/share/html/SelfService/index.html
@@ -45,30 +45,34 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<& /SelfService/Elements/Header, Title => loc('Open tickets') &>
+<%INIT>
+use RT::Dashboard::SelfService;
+if (RT->Config->Get('SelfServiceUseDashboard')) {
+    # Check if we have a self-service dashboard
+    my $Dashboard = RT::Dashboard::SelfService->new($session{'CurrentUser'});
 
-% $m->callback(CallbackName => 'BeforeMyRequests', ARGSRef => \%ARGS, Page => $Page);
+    my $dashboard_id;
 
-<& /SelfService/Elements/MyRequests,
-    %ARGS,
-    status  => '__Active__',
-    title   => loc('My open tickets'),
-    BaseURL => RT->Config->Get('WebPath') ."/SelfService/?",
-    Page    => $Page, 
-&>
+    # The Self-Service Home Page dashboard is special; its attribute is
+    # named "selfservicedashboard" instead of "dashboard".  We just
+    # need to get an ID to reuse the rest of the dashboard code.
+    my $attr = RT::Attribute->new(RT->SystemUser);
+    my ($ok, $msg) = $attr->LoadByNameAndObject(Object => $RT::System,
+                                                Name => 'SelfServiceDashboard');
+    if ($ok && $attr->Id) {
+        # Try to load the dashboard
+        my ($ok, $msg) = $Dashboard->LoadById($attr->Id);
+        if ($ok) {
+            $m->comp('/Dashboards/Render.html', id => $attr->Id, self_service_dashboard => 1);
+            return;
+        }
+    }
+}
 
-% $m->callback(CallbackName => 'AfterMyRequests', ARGSRef => \%ARGS, Page => $Page);
-
-<& /SelfService/Elements/MyGroupRequests,
-    %ARGS,
-    status  => '__Active__',
-    title   => loc('My group\'s tickets'),
-    BaseURL => RT->Config->Get('WebPath') ."/SelfService/?",
-    Page    => $Page,
-&>
-
-% $m->callback(CallbackName => 'AfterMyGroupRequests', ARGSRef => \%ARGS, Page => $Page);
+# Default to old-style "My Open Tickets"
+$m->comp('/SelfService/Open.html', Page => $Page);
 
+</%INIT>
 <%ARGS>
 $Page => 1
 </%ARGS>

commit 7bc0b9c0b9b1254fa3b8e19f4e39f6c7a00d551a
Author: Dianne Skoll <dianne at bestpractical.com>
Date:   Fri Oct 2 11:06:25 2020 -0400

    Add UI for editing and displaying the SortOrder associated with an article.

diff --git a/lib/RT/Article.pm b/lib/RT/Article.pm
index af1e628784..ba7375efca 100644
--- a/lib/RT/Article.pm
+++ b/lib/RT/Article.pm
@@ -777,7 +777,7 @@ sub _CoreAccessible {
         Summary => 
                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
         SortOrder => 
-                {read => 1, write => 1, type => 'int(11)', default => '0'},
+                {read => 1, write => 1, type => 'int(11)', default => '0', is_numeric => 1},
         Class => 
                 {read => 1, write => 1, type => 'int(11)', default => '0'},
         Parent => 
diff --git a/share/html/Articles/Article/Display.html b/share/html/Articles/Article/Display.html
index 6f841d430a..aee001f5c5 100644
--- a/share/html/Articles/Article/Display.html
+++ b/share/html/Articles/Article/Display.html
@@ -58,6 +58,15 @@
     </div>
   </div>
 
+  <div class="form-row">
+    <div class="label col-3">
+      <&|/l&>Sort Order</&>:
+    </div>
+    <div class="value col-9">
+      <span class="current-value"><%$article->SortOrder%></span>
+    </div>
+  </div>
+
   <div class="form-row">
     <div class="label col-3">
       <&|/l&>Class</&>:
diff --git a/share/html/Articles/Article/Edit.html b/share/html/Articles/Article/Edit.html
index b51e0eda55..26aac9da66 100644
--- a/share/html/Articles/Article/Edit.html
+++ b/share/html/Articles/Article/Edit.html
@@ -122,6 +122,14 @@ if ( $ARGS{SetEnabled} ) {
     $ARGS{Disabled} = $ARGS{Enabled} ? 0 : 1;
 }
 
+my $sortorder_ok = 1;
+if ($ARGS{SortOrder}) {
+    if ($ARGS{SortOrder} !~ /^-?\d+$/) {
+        push @results, (0, loc('Sort Order must be an integer'));
+        $sortorder_ok = 0;
+    }
+}
+
 if ( !$id ) {
     $title = loc('Create a new article');
     foreach my $arg ( sort keys %ARGS ) {
@@ -148,25 +156,30 @@ elsif ( $id eq 'new' ) {
           split( /\s+/, $ARGS{'new-RefersTo'} );
     }
 
-    my %cfs = ProcessObjectCustomFieldUpdatesForCreate(
-        ARGSRef         => \%ARGS,
-        ContextObject   => $ClassObj,
-    );
-
-    my $msg;
-    ( $id, $msg ) = $ArticleObj->Create(
-        Summary => $ARGS{'Summary'},
-        Name    => $ARGS{'Name'},
-        Class   => $ARGS{'Class'},
-        Topics  => $ARGS{'Topics'},
-        Disabled => $ARGS{'Disabled'},
-        %create_args,
-        %cfs
-    );
-    push( @results, $msg );
-    if ($id) {
+    my %cfs;
+    if ($sortorder_ok) {
+        %cfs = ProcessObjectCustomFieldUpdatesForCreate(
+            ARGSRef         => \%ARGS,
+            ContextObject   => $ClassObj,
+            );
 
+        my $msg;
+        ( $id, $msg ) = $ArticleObj->Create(
+            Summary => $ARGS{'Summary'},
+            SortOrder => $ARGS{'SortOrder'},
+            Name    => $ARGS{'Name'},
+            Class   => $ARGS{'Class'},
+            Topics  => $ARGS{'Topics'},
+            Disabled => $ARGS{'Disabled'},
+            %create_args,
+            %cfs
+            );
+        push( @results, $msg );
+    } else {
+        $id = 0;
+    }
 
+    if ($id) {
         $ArticleObj->Load($id);
 
         $title = loc( 'Modify article #[_1]', $ArticleObj->Id );
@@ -197,13 +210,15 @@ else {
             Why => loc("Unable to load article") );
     }
 
-    my @attribs = qw(Name Summary Class Disabled);
+    my @attribs = qw(Name Summary Class Disabled SortOrder);
 
-    @results = UpdateRecordObject(
-        AttributesRef => \@attribs,
-        Object        => $ArticleObj,
-        ARGSRef       => \%ARGS
-    );
+    if ($sortorder_ok) {
+        @results = UpdateRecordObject(
+            AttributesRef => \@attribs,
+            Object        => $ArticleObj,
+            ARGSRef       => \%ARGS
+            );
+    }
 
     my @cf_results = ProcessObjectCustomFieldUpdates(
         Object  => $ArticleObj,
diff --git a/share/html/Articles/Article/Elements/EditBasics b/share/html/Articles/Article/Elements/EditBasics
index 73c5a7f5a1..a898a9cbd1 100644
--- a/share/html/Articles/Article/Elements/EditBasics
+++ b/share/html/Articles/Article/Elements/EditBasics
@@ -61,6 +61,14 @@
     <input class="form-control" type="text" name="Summary" value="<%($ARGS{'id'} eq 'new' ? '' : $ArticleObj->Summary) || $ARGS{'Summary'} ||'' |h%>" size="50" />
   </div>
 </div>
+<div class="form-row">
+  <div class="label col-3">
+    <&|/l&>Sort Order</&>:
+  </div>
+  <div class="value col-9">
+    <input class="form-control" type="text" name="SortOrder" value="<%($ARGS{'id'} eq 'new' ? '0' : $ArticleObj->SortOrder) || $ARGS{'SortOrder'} || '0' |h%>" size="50" />
+  </div>
+</div>
 <div class="form-row">
   <div class="label col-3">
     <&|/l&>Class</&>:
diff --git a/share/html/Elements/SelfServiceTopArticles b/share/html/Elements/SelfServiceTopArticles
new file mode 100644
index 0000000000..79c3a50081
--- /dev/null
+++ b/share/html/Elements/SelfServiceTopArticles
@@ -0,0 +1,56 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2020 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+
+<& /Elements/TopArticles,
+   title => $title,
+   display_path => 'SelfService/Article',
+   classname => (RT->Config->Get('SelfServiceArticleClass') || 'SelfService') &>
+
+<%ARGS>
+$title => undef
+</%ARGS>
diff --git a/share/html/Elements/TopArticles b/share/html/Elements/TopArticles
new file mode 100644
index 0000000000..824e7d7cbb
--- /dev/null
+++ b/share/html/Elements/TopArticles
@@ -0,0 +1,98 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2020 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+
+<&| /Widgets/TitleBox, title => $title &>
+
+% while (my $article = $articles->Next) {
+  <div class="form-row">
+    <span class="value col-auto">
+      <a href="<% RT->Config->Get('WebPath') %>/<% $display_path %>/Display.html?id=<%$article->Id%>"><%$article->Name || loc('(no name)')%>: <%$article->Summary%></a>
+    </span>
+  </div>
+% }
+</&>
+
+<%INIT>
+$title = loc('Articles');
+# Figure out which class of articles applies, if a classname was given
+my $class;
+
+if ($classname) {
+    $class = RT::Class->new( $session{'CurrentUser'} );
+    my ($ok, $msg) = $class->LoadByCols( Name => RT->Config->Get('SelfServiceArticleClass') );
+    if (!$ok || !$class->Id) {
+        # Could not find the class... bail?
+        return;
+    }
+}
+
+# Get the articles
+my $articles = RT::Articles->new( $session{'CurrentUser'} );
+
+$articles->RowsPerPage($rows);
+
+if ($class) {
+    $articles->Search(Class   => $class->Id,
+                      OrderBy => ['SortOrder', 'LastUpdated'],
+                      Order   => ['ASC',       'DESC'       ]);
+} else {
+    $articles->Search(OrderBy => ['SortOrder', 'LastUpdated'],
+                      Order   => ['ASC',       'DESC'       ]);
+}
+
+</%INIT>
+
+<%ARGS>
+$title => undef
+$rows => 10
+# Unfortunately, the directory hierarchy under SelfService
+# is just "Article" instead of "Articles/Article", so we have
+# to make to path for displaying an article a parameter.
+$display_path => 'Articles/Article'
+$classname => undef
+</%ARGS>

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


More information about the rt-commit mailing list