[Rt-commit] rt branch 5.0/asset-custom-roles created. rt-5.0.3-135-g2de7eb5899

BPS Git Server git at git.bestpractical.com
Tue Oct 4 22:18:04 UTC 2022


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

The branch, 5.0/asset-custom-roles has been created
        at  2de7eb5899688715886db11f33b6d3d823585c89 (commit)

- Log -----------------------------------------------------------------
commit 2de7eb5899688715886db11f33b6d3d823585c89
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 15:49:29 2017 +0000

    Add API and web tests for interacting with custom roles on assets

diff --git a/t/customroles/assets.t b/t/customroles/assets.t
new file mode 100644
index 0000000000..314041f908
--- /dev/null
+++ b/t/customroles/assets.t
@@ -0,0 +1,330 @@
+use strict;
+use warnings;
+
+use RT::Test::Assets tests => undef;
+
+my $general = create_catalog( Name => 'General' );
+my $inbox = create_catalog( Name => 'Inbox' );
+my $specs = create_catalog( Name => 'Specs' );
+my $development = create_catalog( Name => 'Development' );
+
+my $engineer = RT::CustomRole->new(RT->SystemUser);
+my $sales = RT::CustomRole->new(RT->SystemUser);
+my $unapplied = RT::CustomRole->new(RT->SystemUser);
+
+my $linus = RT::Test->load_or_create_user( EmailAddress => 'linus at example.com' );
+my $blake = RT::Test->load_or_create_user( EmailAddress => 'blake at example.com' );
+my $williamson = RT::Test->load_or_create_user( EmailAddress => 'williamson at example.com' );
+my $moss = RT::Test->load_or_create_user( EmailAddress => 'moss at example.com' );
+my $ricky = RT::Test->load_or_create_user( EmailAddress => 'ricky.roma at example.com' );
+
+my $team = RT::Test->load_or_create_group(
+    'Team',
+    Members => [$blake, $williamson, $moss, $ricky],
+);
+
+sub txn_messages_like {
+    local $Test::Builder::Level = $Test::Builder::Level + 1;
+
+    my $a = shift;
+    my $re = shift;
+
+    my $txns = $a->Transactions;
+    $txns->Limit(FIELD => 'Type', VALUE => 'SetWatcher');
+    $txns->Limit(FIELD => 'Type', VALUE => 'AddWatcher');
+    $txns->Limit(FIELD => 'Type', VALUE => 'DelWatcher');
+
+    is($txns->Count, scalar(@$re), 'expected number of transactions');
+
+    while (my $txn = $txns->Next) {
+        like($txn->BriefDescription, (shift(@$re) || qr/(?!)/));
+    }
+}
+
+diag 'setup' if $ENV{'TEST_VERBOSE'};
+{
+    ok( RT::Test->add_rights( { Principal => 'Privileged', Right => [ qw(CreateAsset ShowAsset ModifyAsset ShowCatalog) ] } ));
+
+    my ($ok, $msg) = $engineer->Create(
+        Name       => 'Engineer-' . $$,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 1,
+    );
+    ok($ok, "created Engineer role: $msg");
+
+    ($ok, $msg) = $sales->Create(
+        Name       => 'Sales-' . $$,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 0,
+    );
+    ok($ok, "created Sales role: $msg");
+
+    ($ok, $msg) = $unapplied->Create(
+        Name       => 'Unapplied-' . $$,
+        LookupType => RT::Asset->CustomFieldLookupType,
+        MaxValues  => 0,
+    );
+    ok($ok, "created Unapplied role: $msg");
+
+    ($ok, $msg) = $sales->AddToObject($inbox->id);
+    ok($ok, "added Sales to Inbox: $msg");
+
+    ($ok, $msg) = $sales->AddToObject($specs->id);
+    ok($ok, "added Sales to Specs: $msg");
+
+    ($ok, $msg) = $engineer->AddToObject($specs->id);
+    ok($ok, "added Engineer to Specs: $msg");
+
+    ($ok, $msg) = $engineer->AddToObject($development->id);
+    ok($ok, "added Engineer to Development: $msg");
+}
+
+diag 'create assets in General (no custom roles)' if $ENV{'TEST_VERBOSE'};
+{
+    my $general1 = create_asset(
+        Catalog   => 'General',
+        Name      => 'an asset',
+        Owner     => $williamson->PrincipalId,
+        Contact   => [$blake->EmailAddress],
+    );
+    is($general1->Owner->id, $williamson->id, 'owner is correct');
+    is($general1->RoleAddresses('Contact'), $blake->EmailAddress, 'contacts correct');
+    is($general1->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($general1->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($general1->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $general2 = create_asset(
+        Catalog   => 'General',
+        Name      => 'another asset',
+        Owner     => $linus->PrincipalId,
+        Contact   => [$moss->EmailAddress, $williamson->EmailAddress],
+        HeldBy    => [$blake->EmailAddress],
+    );
+    is($general2->Owner->id, $linus->id, 'owner is correct');
+    is($general2->RoleAddresses('Contact'), (join ', ', sort $moss->EmailAddress, $williamson->EmailAddress), 'contacts correct');
+    is($general2->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby correct');
+    is($general2->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($general2->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $general3 = create_asset(
+        Catalog              => 'General',
+        Name                 => 'oops',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress],
+    );
+    is($general3->Owner->id, $ricky->id, 'owner is correct');
+    is($general3->RoleAddresses('Contact'), '', 'no contacts');
+    is($general3->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($general3->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($general3->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+}
+
+diag 'create assets in Inbox (sales role)' if $ENV{'TEST_VERBOSE'};
+{
+    my $inbox1 = create_asset(
+        Catalog   => 'Inbox',
+        Name      => 'an asset',
+        Owner     => $williamson->PrincipalId,
+        Contact   => [$blake->EmailAddress],
+    );
+    is($inbox1->Owner->id, $williamson->id, 'owner is correct');
+    is($inbox1->RoleAddresses('Contact'), $blake->EmailAddress, 'contacts correct');
+    is($inbox1->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($inbox1->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox1->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $inbox2 = create_asset(
+        Catalog   => 'Inbox',
+        Name      => 'another asset',
+        Owner     => $linus->PrincipalId,
+        Contact   => [$moss->EmailAddress, $williamson->EmailAddress],
+        HeldBy    => [$blake->EmailAddress],
+    );
+    is($inbox2->Owner->id, $linus->id, 'owner is correct');
+    is($inbox2->RoleAddresses('Contact'), (join ', ', sort $moss->EmailAddress, $williamson->EmailAddress), 'contacts correct');
+    is($inbox2->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby correct');
+    is($inbox2->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox2->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $inbox3 = create_asset(
+        Catalog              => 'Inbox',
+        Name                 => 'oops',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress],
+    );
+    is($inbox3->Owner->id, $ricky->id, 'owner is correct');
+    is($inbox3->RoleAddresses('Contact'), '', 'no contacts');
+    is($inbox3->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($inbox3->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox3->RoleAddresses($sales->GroupType), $blake->EmailAddress, 'got sales');
+
+    my $inbox4 = create_asset(
+        Catalog              => 'Inbox',
+        Name                 => 'more',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress, $williamson->EmailAddress],
+    );
+    is($inbox4->Owner->id, $ricky->id, 'owner is correct');
+    is($inbox4->RoleAddresses('Contact'), '', 'no contacts');
+    is($inbox4->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($inbox4->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($inbox4->RoleAddresses($sales->GroupType), (join ', ', sort $blake->EmailAddress, $williamson->EmailAddress), 'got sales');
+}
+
+diag 'create assets in Specs (both roles)' if $ENV{'TEST_VERBOSE'};
+{
+    my $specs1 = create_asset(
+        Catalog   => 'Specs',
+        Name      => 'an asset',
+        Owner     => $williamson->PrincipalId,
+        Contact   => [$blake->EmailAddress],
+    );
+    is($specs1->Owner->id, $williamson->id, 'owner is correct');
+    is($specs1->RoleAddresses('Contact'), $blake->EmailAddress, 'contacts correct');
+    is($specs1->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($specs1->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($specs1->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $specs2 = create_asset(
+        Catalog   => 'Specs',
+        Name      => 'another asset',
+        Owner     => $linus->PrincipalId,
+        Contact   => [$moss->EmailAddress, $williamson->EmailAddress],
+        HeldBy    => [$blake->EmailAddress],
+    );
+    is($specs2->Owner->id, $linus->id, 'owner is correct');
+    is($specs2->RoleAddresses('Contact'), (join ', ', sort $moss->EmailAddress, $williamson->EmailAddress), 'contacts correct');
+    is($specs2->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby correct');
+    is($specs2->RoleAddresses($engineer->GroupType), '', 'no engineer (role not applied to catalog)');
+    is($specs2->RoleAddresses($sales->GroupType), '', 'no sales (role not applied to catalog)');
+
+    my $specs3 = create_asset(
+        Catalog              => 'Specs',
+        Name                 => 'oops',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress],
+    );
+    is($specs3->Owner->id, $ricky->id, 'owner is correct');
+    is($specs3->RoleAddresses('Contact'), '', 'no contacts');
+    is($specs3->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($specs3->RoleAddresses($engineer->GroupType), $linus->EmailAddress, 'got engineer');
+    is($specs3->RoleAddresses($sales->GroupType), $blake->EmailAddress, 'got sales');
+
+    my $specs4 = create_asset(
+        Catalog              => 'Specs',
+        Name                 => 'more',
+        Owner                => $ricky->PrincipalId,
+        $engineer->GroupType => $linus,
+        $sales->GroupType    => [$blake->EmailAddress, $williamson->EmailAddress],
+    );
+    is($specs4->Owner->id, $ricky->id, 'owner is correct');
+    is($specs4->RoleAddresses('Contact'), '', 'no contacts');
+    is($specs4->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($specs4->RoleAddresses($engineer->GroupType), $linus->EmailAddress, 'got engineer');
+    is($specs4->RoleAddresses($sales->GroupType), (join ', ', sort $blake->EmailAddress, $williamson->EmailAddress), 'got sales');
+}
+
+diag 'update asset in Specs' if $ENV{'TEST_VERBOSE'};
+{
+    my $a = create_asset(
+        Catalog => 'Specs',
+        Name    => 'updates',
+    );
+
+    is($a->Owner->id, RT->Nobody->id, 'owner nobody');
+    is($a->RoleAddresses('Contact'), '', 'no contacts');
+    is($a->RoleAddresses('HeldBy'), '', 'no heldby');
+    is($a->RoleAddresses($engineer->GroupType), '', 'no engineer');
+    is($a->RoleAddresses($sales->GroupType), '', 'no sales');
+    is($a->RoleAddresses($unapplied->GroupType), '', 'no unapplied');
+
+    my ($ok, $msg) = $a->AddRoleMember(Type => 'Owner', Principal => $linus->PrincipalObj);
+    ok($ok, "set owner: $msg");
+    is($a->Owner->id, $linus->id, 'owner linus');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => 'Contact', Principal => $ricky->PrincipalObj);
+    ok($ok, "add contact: $msg");
+    is($a->RoleAddresses('Contact'), $ricky->EmailAddress, 'contact ricky');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => 'HeldBy', Principal => $blake->PrincipalObj);
+    ok($ok, "add heldby: $msg");
+    is($a->RoleAddresses('HeldBy'), $blake->EmailAddress, 'heldby blake');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => $ricky->PrincipalObj);
+    ok($ok, "add sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), $ricky->EmailAddress, 'sales ricky');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => $moss->PrincipalObj);
+    ok($ok, "add sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), (join ', ', sort $ricky->EmailAddress, $moss->EmailAddress), 'sales ricky and moss');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => RT->Nobody->PrincipalObj);
+    ok($ok, "add sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), (join ', ', sort $ricky->EmailAddress, $moss->EmailAddress), 'sales ricky and moss');
+
+    ($ok, $msg) = $a->DeleteRoleMember(Type => $sales->GroupType, PrincipalId => $moss->PrincipalId);
+    ok($ok, "remove sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), $ricky->EmailAddress, 'sales ricky');
+
+    ($ok, $msg) = $a->DeleteRoleMember(Type => $sales->GroupType, PrincipalId => $ricky->PrincipalId);
+    ok($ok, "remove sales: $msg");
+    is($a->RoleAddresses($sales->GroupType), '', 'sales empty');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => $linus->PrincipalObj);
+    ok($ok, "add engineer: $msg");
+    is($a->RoleAddresses($engineer->GroupType), $linus->EmailAddress, 'engineer linus');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => $blake->PrincipalObj);
+    ok($ok, "add engineer: $msg");
+    is($a->RoleAddresses($engineer->GroupType), $blake->EmailAddress, 'engineer blake (single-member role so linus gets displaced)');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => RT->Nobody->PrincipalObj);
+    ok($ok, "add engineer: $msg");
+    is($a->RoleAddresses($engineer->GroupType), '', 'engineer nobody (single-member role so blake gets displaced)');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $unapplied->GroupType, Principal => $linus->PrincipalObj);
+    ok(!$ok, "did not add unapplied role member: $msg");
+    like($msg, qr/That role is invalid for this object/);
+    is($a->RoleAddresses($unapplied->GroupType), '', 'no unapplied members');
+
+    txn_messages_like($a, [
+        qr/Owner set to linus\@example\.com/,
+        qr/Contact ricky\.roma\@example\.com added/,
+        qr/Held By blake\@example\.com added/,
+        qr/Sales-$$ ricky\.roma\@example\.com added/,
+        qr/Sales-$$ moss\@example\.com added/,
+        qr/Sales-$$ Nobody in particular added/,
+        qr/Sales-$$ moss\@example\.com deleted/,
+        qr/Sales-$$ ricky\.roma\@example\.com deleted/,
+        qr/Engineer-$$ set to linus\@example\.com/,
+        qr/Engineer-$$ set to blake\@example\.com/,
+        qr/Engineer-$$ set to Nobody in particular/,
+    ]);
+}
+
+diag 'groups can be role members' if $ENV{'TEST_VERBOSE'};
+{
+    my $a = create_asset(
+        Catalog => 'Specs',
+        Name    => 'groups',
+    );
+
+    my ($ok, $msg) = $a->AddRoleMember(Type => $sales->GroupType, Principal => $team->PrincipalObj);
+    ok($ok, "add team: $msg");
+    is($a->RoleAddresses($sales->GroupType), (join ', ', sort $blake->EmailAddress, $ricky->EmailAddress, $moss->EmailAddress, $williamson->EmailAddress), 'sales is all the team members');
+
+    ($ok, $msg) = $a->AddRoleMember(Type => $engineer->GroupType, Principal => $team->PrincipalObj);
+    ok(!$ok, "could not add team: $msg");
+    like($msg, qr/cannot be a group/);
+    is($a->RoleAddresses($engineer->GroupType), '', 'engineer is still nobody');
+
+    txn_messages_like($a, [
+        qr/Sales-$$ group Team added/,
+    ]);
+}
+
+done_testing;
diff --git a/t/customroles/web-assets.t b/t/customroles/web-assets.t
new file mode 100644
index 0000000000..0d236aaf06
--- /dev/null
+++ b/t/customroles/web-assets.t
@@ -0,0 +1,279 @@
+use strict;
+use warnings;
+use RT::Test::Assets tests => undef;
+my ($baseurl, $m) = RT::Test::Assets->started_ok;
+ok $m->login, "Logged in agent";
+
+
+my $catalog = create_catalog( Name => "Software" );
+ok $catalog->id, "Created Catalog";
+
+my $owner = RT::Test->load_or_create_user(Name => 'owner', EmailAddress => 'owner at example.com');
+my $licensee = RT::Test->load_or_create_user(Name => 'licensee at example.com', EmailAddress => 'licensee at example.com', Password => 'password');
+
+my $role;
+my ($asset, $asset2, $asset3);
+
+diag "Create custom role and apply it to General assets";
+{
+    $m->follow_link_ok({ id => "admin-custom-roles-create" }, "Custom Role create link");
+    $m->submit_form_ok({
+        with_fields => {
+            Name        => 'Licensee',
+            Description => 'The person who licensed the software',
+            LookupType  => RT::Asset->CustomFieldLookupType,
+            EntryHint   => 'Make sure user has real name set',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Custom role created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+    ok($id, 'Got role id');
+
+    $role = RT::CustomRole->new(RT->SystemUser);
+    $role->Load($id);
+    is $role->id, $id, "id matches";
+    is $role->Name, "Licensee", "Name matches";
+    is $role->Description, "The person who licensed the software", "Description matches";
+    is $role->LookupType, RT::Asset->CustomFieldLookupType, "LookupType matches";
+    is $role->EntryHint, "Make sure user has real name set", "EntryHint matches";
+
+    ok(!$role->IsAdded($catalog->Id), 'not added to catalog yet');
+
+    $m->follow_link_ok({ id => "page-applies-to" }, "Applies to link");
+    $m->submit_form_ok({
+        with_fields => {
+            ("AddRole-" . $id) => $catalog->Id,
+        },
+        button => 'Update',
+    }, "submitted applies to form");
+    $m->text_contains('Licensee added to queue Software', "Found update message");
+
+    # refresh cache
+    RT::CustomRoles->RegisterRoles;
+
+    ok($role->IsAdded($catalog->Id), 'added to catalog now');
+    is_deeply([sort $catalog->Roles], [sort 'Contact', 'HeldBy', 'Owner', $role->GroupType], '->Roles');
+}
+
+diag "Create asset with custom role";
+{
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id, CatalogChanged => 1 } }, "Picked a catalog");
+    $m->text_contains('Licensee', 'custom role name');
+    $m->content_contains('Make sure user has real name set', 'custom role entry hint');
+
+    $m->submit_form_ok({
+        with_fields => {
+            id               => 'new',
+            Name             => 'Some Software',
+            Owner            => 'owner at example.com',
+            $role->GroupType => 'licensee at example.com',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    $asset = RT::Asset->new( RT->SystemUser );
+    $asset->Load($id);
+    is $asset->id, $id, "id matches";
+    is $asset->Name, "Some Software", "Name matches";
+    is $asset->Owner->EmailAddress, 'owner at example.com', "Owner matches";
+    is $asset->RoleAddresses($role->GroupType), 'licensee at example.com', "Licensee matches";
+}
+
+diag "Grant permissions on Licensee";
+{
+    $m->follow_link_ok({ id => "admin-assets-catalogs-select" }, "Admin assets");
+    $m->follow_link_ok({ text => 'Software' }, "Picked a catalog");
+    $m->follow_link_ok({ id => 'page-group-rights' }, "Group rights");
+
+    $m->text_contains('Licensee', 'role group name');
+
+    my $acl_id = $catalog->RoleGroup($role->GroupType)->Id;
+
+    $m->form_name('ModifyGroupRights');
+    $m->tick("SetRights-" . $acl_id . '-RT::Catalog-' . $catalog->id, 'ShowAsset');
+    $m->tick("SetRights-" . $acl_id . '-RT::Catalog-' . $catalog->id, 'ShowCatalog');
+    $m->submit;
+    $m->text_contains("Granted right 'ShowAsset' to Licensee");
+    $m->text_contains("Granted right 'ShowCatalog' to Licensee");
+
+    RT::Principal::InvalidateACLCache();
+}
+
+diag "Create asset without custom role";
+{
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id, CatalogChanged => 1 } }, "Picked a catalog");
+    $m->text_contains('Licensee', 'custom role name');
+    $m->content_contains('Make sure user has real name set', 'custom role entry hint');
+
+    $m->submit_form_ok({
+        with_fields => {
+            id               => 'new',
+            Name             => 'More Software',
+            Owner            => 'owner at example.com',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    $asset2 = RT::Asset->new( RT->SystemUser );
+    $asset2->Load($id);
+    is $asset2->id, $id, "id matches";
+    is $asset2->Name, "More Software", "Name matches";
+    is $asset2->Owner->EmailAddress, 'owner at example.com', "Owner matches";
+    is $asset2->RoleAddresses($role->GroupType), '', "No Licensee";
+}
+
+diag "Search by custom role";
+{
+    $m->follow_link_ok({ id => "assets-simple_search" }, "Asset simple search link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->Id } }, "Picked a catalog");
+    $m->submit_form_ok({
+        with_fields => {
+            'Role.' . $role->GroupType => 'licensee at example.com',
+        },
+        button => 'SearchAssets',
+    }, "Search by role");
+
+    $m->text_contains('Some Software', 'search hit');
+    $m->text_lacks('More Software', 'search miss');
+
+    $m->submit_form_ok({
+        with_fields => {
+            'Role.' . $role->GroupType => '',
+            '!Role.' . $role->GroupType => 'licensee at example.com',
+        },
+        button => 'SearchAssets',
+    }, "Search by role");
+
+    $m->text_lacks('Some Software', 'search miss');
+    $m->text_contains('More Software', 'search hit');
+}
+
+diag "Search by custom role";
+{
+    $m->follow_link_ok({ id => "assets-search" }, "Asset search link");
+    $m->submit_form_ok({ with_fields => { ValueOfCatalog => $catalog->Id }, button => 'AddClause' }, "Picked a catalog");
+
+    my $form = $m->form_name('BuildQuery');
+    my @watcher_options = ( '', qw/Owner HeldBy Contact CustomRole.{Licensee}/ );
+    is_deeply( [ $form->find_input('WatcherField')->possible_values ], \@watcher_options, 'WatcherField options' );
+
+    $m->submit_form_ok({
+        with_fields => {
+            WatcherField   => 'CustomRole.{Licensee}',
+            ValueOfWatcher => 'licensee at example.com',
+        },
+        button => 'DoSearch',
+    }, "Search by role");
+
+    $m->text_contains('Some Software', 'search hit');
+    $m->text_lacks('More Software', 'search miss');
+
+    $m->follow_link_ok({ id => "assets-search" }, "Asset search link");
+    $m->submit_form_ok({ with_fields => { ValueOfCatalog => $catalog->Id }, button => 'AddClause' }, "Picked a catalog");
+    $m->submit_form_ok({
+        with_fields => {
+            WatcherField   => 'CustomRole.{Licensee}',
+            ValueOfWatcher => 'licensee at example.com',
+            WatcherOp      => 'NOT LIKE',
+        },
+        button => 'DoSearch',
+    }, "Search by role");
+
+    $m->text_lacks('Some Software', 'search miss');
+    $m->text_contains('More Software', 'search hit');
+}
+
+diag "Test permissions on Licensee";
+{
+    $m->logout;
+    $m->login('licensee at example.com', 'password');
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset->Id);
+    $m->text_contains('Some Software', 'asset name shows on page');
+    $m->text_contains('Licensee', 'role name shows on page');
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset2->Id);
+    $m->text_lacks('More Software', 'asset name does not show on page');
+    $m->text_lacks('Licensee', 'role name does not show on page');
+    $m->text_contains("You don't have permission to view this asset.");
+    $m->warning_like( qr/You don't have permission to view this asset/, 'got warning' );
+}
+
+$m->logout;
+$m->login; # log back in as root
+
+diag "Disable role";
+{
+    $m->follow_link_ok({ id => "admin-custom-roles-select" }, "Custom Role select link");
+    $m->follow_link_ok({ text => 'Licensee' }, "Picked a custom role");
+    $m->submit_form_ok({
+        with_fields => {
+            Enabled => 0,
+        },
+    }, "submitted update form");
+    $m->text_contains('Custom role disabled');
+
+    # refresh cache
+    RT::CustomRoles->RegisterRoles;
+
+    $role->Load($role->Id);
+    is $role->Name, "Licensee", "Name matches";
+    ok $role->Disabled, "now disabled";
+
+    my $catalog_id = $catalog->Id;
+    $catalog = RT::Catalog->new( RT->SystemUser );
+    $catalog->Load($catalog_id);
+    is_deeply([sort $catalog->Roles], [sort 'Contact', 'HeldBy', 'Owner'], '->Roles no longer includes Licensee');
+}
+
+diag "Test permissions on Licensee";
+{
+    $m->logout;
+    $m->login('licensee at example.com', 'password');
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset->Id);
+    $m->text_lacks('Some Software', 'asset name does not show on page');
+    $m->text_lacks('Licensee', 'role name does not show on page');
+    $m->text_contains("You don't have permission to view this asset.");
+    $m->warning_like( qr/You don't have permission to view this asset/, 'got warning' );
+
+    $m->get_ok("$baseurl/Asset/Display.html?id=".$asset2->Id);
+    $m->text_lacks('More Software', 'asset name does not show on page');
+    $m->text_lacks('Licensee', 'role name does not show on page');
+    $m->text_contains("You don't have permission to view this asset.");
+    $m->warning_like( qr/You don't have permission to view this asset/, 'got warning' );
+}
+
+$m->logout;
+$m->login; # log back in as root
+
+diag "Create asset with disabled custom role";
+{
+    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
+    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id, CatalogChanged => 1 } }, "Picked a catalog");
+    $m->text_lacks('Licensee', 'custom role name');
+    $m->text_lacks('Make sure user has real name set', 'custom role entry hint');
+
+    $m->submit_form_ok({
+        with_fields => {
+            id               => 'new',
+            Name             => 'All Software',
+            Owner            => 'owner at example.com',
+        },
+    }, "submitted create form");
+    $m->text_like(qr/Asset .* created/, "Found created message");
+    my ($id) = $m->uri =~ /id=(\d+)/;
+
+    $asset3 = RT::Asset->new( RT->SystemUser );
+    $asset3->Load($id);
+    is $asset3->id, $id, "id matches";
+    is $asset3->Name, "All Software", "Name matches";
+    is $asset3->Owner->EmailAddress, 'owner at example.com', "Owner matches";
+    is $asset3->RoleAddresses($role->GroupType), '', "No Licensee";
+}
+
+done_testing;

commit 7087b53b468e346ce718b9376a15a07653a63409
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Oct 5 03:39:17 2022 +0800

    Relax requirements about role names to be unique for each lookup type

diff --git a/lib/RT/CustomRole.pm b/lib/RT/CustomRole.pm
index 6c8b8b47fa..cf22198491 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -104,7 +104,7 @@ sub Create {
     }
 
     {
-        my ($val, $msg) = $self->_ValidateName( $args{'Name'} );
+        my ($val, $msg) = $self->_ValidateName( $args{'Name'}, $args{'LookupType'} );
         return ($val, $msg) unless $val;
     }
 
@@ -261,8 +261,9 @@ a new custom role. Returns undef if there's already a role by that name.
 sub ValidateName {
     my $self = shift;
     my $name = shift;
+    my $type = shift || $self->LookupType || 'RT::Queue-RT::Ticket';
 
-    my ($ok, $msg) = $self->_ValidateName($name);
+    my ($ok, $msg) = $self->_ValidateName($name, $type);
 
     return $ok ? 1 : 0;
 }
@@ -270,6 +271,7 @@ sub ValidateName {
 sub _ValidateName {
     my $self = shift;
     my $name = shift;
+    my $type = shift || $self->LookupType || 'RT::Queue-RT::Ticket';
 
     return (undef, "Role name is required") unless length $name;
 
@@ -305,7 +307,7 @@ sub _ValidateName {
     }
 
     my $temp = RT::CustomRole->new(RT->SystemUser);
-    $temp->LoadByCols(Name => $name);
+    $temp->LoadByCols(Name => $name, LookupType => $type);
 
     if ( $temp->Name && $temp->id != ($self->id||0))  {
         return (undef, $self->loc("Role already exists") );
@@ -314,6 +316,23 @@ sub _ValidateName {
     return (1);
 }
 
+=head2 ValidateLookupType TYPE
+
+Takes a custom role lookup type. Returns true unless there's another role
+with the same name and lookup type.
+
+=cut
+
+sub ValidateLookupType {
+    my $self = shift;
+    my $type = shift;
+    if ( $self->Id && lc $self->LookupType ne lc $type ) {
+        return $self->ValidateName( $self->Name, $type );
+    }
+    return 1;
+}
+
+
 =head2 Delete
 
 Delete this object. You should Disable instead.
diff --git a/sbin/rt-validator.in b/sbin/rt-validator.in
index fa6730547b..ba67577e23 100644
--- a/sbin/rt-validator.in
+++ b/sbin/rt-validator.in
@@ -420,7 +420,7 @@ push @CHECKS, 'User Defined Group Name uniqueness' => sub {
 push @CHECKS, 'Custom Role Name uniqueness' => sub {
     return check_uniqueness(
         'CustomRoles',
-        columns         => ['Name'],
+        columns         => ['Name', 'LookupType'],
         action          => sub {
             return unless prompt(
                 'Rename', "Found a custom role with a non-unique Name."
diff --git a/share/html/Admin/CustomRoles/Modify.html b/share/html/Admin/CustomRoles/Modify.html
index f6a73baa3d..35b14b7fb9 100644
--- a/share/html/Admin/CustomRoles/Modify.html
+++ b/share/html/Admin/CustomRoles/Modify.html
@@ -133,7 +133,7 @@ $EnabledChecked = 'checked="checked"';
 
 unless ($Create) {
     if ( defined $id && $id eq 'new' ) {
-        my ($val, $msg) = $RoleObj->Create( Name => $Name );
+        my ($val, $msg) = $RoleObj->Create( Name => $Name, LookupType => $LookupType );
         if (!$val) {
             $Create = 1; # Create failed, so bring us back to step 1
             push @results, $msg;

commit 6b1c6fa00da2257814f619cd4b85d1d0499121b3
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sun Oct 2 19:11:18 2022 +0800

    Support custom roles for asset searches

diff --git a/lib/RT/Assets.pm b/lib/RT/Assets.pm
index 046a834e9e..ca7c355be1 100644
--- a/lib/RT/Assets.pm
+++ b/lib/RT/Assets.pm
@@ -89,6 +89,7 @@ our %FIELD_METADATA = (
     HeldByGroup      => [ 'MEMBERSHIPFIELD' => 'HeldBy', ], #loc_left_pair
     Contact          => [ 'WATCHERFIELD' => 'Contact', ], #loc_left_pair
     ContactGroup     => [ 'MEMBERSHIPFIELD' => 'Contact', ], #loc_left_pair
+    CustomRole       => [ 'WATCHERFIELD' ], # loc_left_pair
 
     CustomFieldValue => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair
     CustomField      => [ 'CUSTOMFIELD' => 'Asset' ], #loc_left_pair
@@ -1217,6 +1218,47 @@ sub _StringLimit {
     );
 }
 
+=head2 _CustomRoleDecipher
+
+Try and turn a custom role descriptor (e.g. C<CustomRole.{Engineer}>) into
+(role, column, original name).
+
+=cut
+
+sub _CustomRoleDecipher {
+    my ( $self, $string ) = @_;
+
+    # $column could be core fields like "EmailAddress" or CFs like
+    # "CustomField.{Department}", the CF format is used in OrderByCols.
+    my ( $field, $column ) = ( $string =~ /^\{(.+?)\}(?:\.(.+))?$/ );
+
+    my $role;
+
+    if ( $field =~ /\D/ ) {
+        my $roles = RT::CustomRoles->new( $self->CurrentUser );
+        $roles->LimitToLookupType( RT::Asset->CustomFieldLookupType );
+        $roles->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
+
+        # in case there are multiple matches, bail out as we
+        # don't know which one to use
+        $role = $roles->First;
+        if ($role) {
+            if ( $roles->Next ) {
+                RT->Logger->error(
+                    "Ambiguous custom role named '$field' in AssetSQL; skipping. Perhaps specify __CustomRole.{id}__ instead."
+                );
+                $role = undef;
+            }
+        }
+    }
+    else {
+        $role = RT::CustomRole->new( $self->CurrentUser );
+        $role->Load($field);
+    }
+
+    return ( $role, $column, $field );
+}
+
 =head2 _WatcherLimit
 
 Handle watcher limits.  (Requestor, CC, etc..)
@@ -1238,18 +1280,25 @@ sub _WatcherLimit {
     my $meta = $FIELD_METADATA{ $field };
     my $type = $meta->[1] || '';
     my $class = $meta->[2] || 'Asset';
+    my $column = $rest{SUBKEY};
+
+    if ($field eq 'CustomRole') {
+        my ($role, $col, $original_name) = $self->_CustomRoleDecipher( $column );
+        $column = $col || 'id';
+        $type = $role ? $role->GroupType : $original_name;
+    }
 
     # Bail if the subfield is not allowed
-    if (    $rest{SUBKEY}
-        and not grep { $_ eq $rest{SUBKEY} } @{$SEARCHABLE_SUBFIELDS{'User'}})
+    if (    $column
+        and not grep { $_ eq $column } @{$SEARCHABLE_SUBFIELDS{'User'}})
     {
-        die "Invalid watcher subfield: '$rest{SUBKEY}'";
+        die "Invalid watcher subfield: '$column'";
     }
 
     $self->RoleLimit(
         TYPE      => $type,
         CLASS     => "RT::$class",
-        FIELD     => $rest{SUBKEY},
+        FIELD     => $column,
         OPERATOR  => $op,
         VALUE     => $value,
         SUBCLAUSE => "assetsql",
diff --git a/share/html/Asset/Search/Bulk.html b/share/html/Asset/Search/Bulk.html
index f1331b3af2..43b716296d 100644
--- a/share/html/Asset/Search/Bulk.html
+++ b/share/html/Asset/Search/Bulk.html
@@ -124,29 +124,29 @@
   </&>
 
   <&| /Widgets/TitleBox, title => loc("People"), class => "asset-people asset-bulk-people", title_class => "inverse" &>
-% for my $rname ( $asset->Roles( ACLOnly => 0 ) ) {
-% my $role = $asset->Role( $rname );
-% if ( $role->{'Single'} ) {
+% for my $rname ( $asset->Roles( ACLOnly => 0, Single => 1 ), map { $_->GroupType } @{ $single_roles->ItemsArrayRef } ) {
 % my $input = "SetRoleMember-$rname";
     <div class="form-row">
       <div class="col-6">
-        <&| /Elements/LabeledValue, Label => loc($rname) &>
+        <&| /Elements/LabeledValue, Label => RT::Asset->LabelForRole($rname) &>
           <input class="form-control" type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" />
         </&>
       </div>
     </div>
-% } else {
+% }
+
+% for my $rname ( $asset->Roles( ACLOnly => 0, Single => 0 ), map { $_->GroupType } @{ $multi_roles->ItemsArrayRef } ) {
 % my $input = "AddRoleMember-$rname";
     <div class="form-row">
       <div class="col-6">
-        <&| /Elements/LabeledValue, Label => loc("Add [_1]", loc($rname)) &>
+        <&| /Elements/LabeledValue, Label => loc("Add [_1]", RT::Asset->LabelForRole($rname)) &>
           <input class="form-control" type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" />
         </&>
       </div>
 
 % $input = "RemoveRoleMember-$rname";
       <div class="col-6">
-        <&| /Elements/LabeledValue, Label => loc("Remove [_1]", loc($rname)) &>
+        <&| /Elements/LabeledValue, Label => loc("Remove [_1]", RT::Asset->LabelForRole($rname)) &>
             <input class="form-control" type="text" value="<% $ARGS{ $input } || '' %>" name="<% $input %>" id="<% $input %>" data-autocomplete="Users" data-autocomplete-return="Name" />
 
             <div class="custom-control custom-checkbox">
@@ -157,7 +157,6 @@
       </div>
     </div>
 % }
-% }
 % my $people_cfs = $cfs->Clone;
 % $people_cfs->LimitToGrouping( 'RT::Asset' => 'People');
 % if ( $people_cfs->Count ) {
@@ -229,6 +228,9 @@ delete $ARGS{$_} foreach grep { $ARGS{$_} =~ /^$/ } keys %ARGS;
 $DECODED_ARGS->{'UpdateAssetAll'} = 1 unless @UpdateAsset;
 
 my $cfs;
+my $single_roles = RT::CustomRoles->new( $session{CurrentUser} );
+my $multi_roles  = RT::CustomRoles->new( $session{CurrentUser} );
+
 if ( $ARGS{Query} ) {
     $cfs = RT::CustomFields->new( $session{'CurrentUser'} );
     $cfs->LimitToLookupType( RT::Asset->CustomFieldLookupType );
@@ -252,9 +254,26 @@ if ( $ARGS{Query} ) {
         }
     }
     $cfs->LimitToGlobalOrObjectId(@ids);
+
+    if ( @ids ) {
+        $single_roles->LimitToObjectId($_) for @ids;
+        $multi_roles->LimitToObjectId($_) for @ids;
+    }
 }
 else {
     $cfs = $catalog_obj->AssetCustomFields;
+    $single_roles->LimitToObjectId( $catalog_obj->Id );
+    $multi_roles->LimitToObjectId( $catalog_obj->Id );
+}
+
+if ( $single_roles->_isLimited ) {
+    $single_roles->LimitToLookupType( RT::Asset->CustomFieldLookupType );
+    $single_roles->LimitToSingleValue;
+}
+
+if ( $multi_roles->_isLimited ) {
+    $multi_roles->LimitToLookupType( RT::Asset->CustomFieldLookupType );
+    $multi_roles->LimitToMultipleValue;
 }
 
 if ( $ARGS{'CreateLinkedTicket'} ){
diff --git a/share/html/Search/Elements/BuildFormatString b/share/html/Search/Elements/BuildFormatString
index 6211f8369c..12e630d857 100644
--- a/share/html/Search/Elements/BuildFormatString
+++ b/share/html/Search/Elements/BuildFormatString
@@ -144,6 +144,19 @@ elsif ( $Class eq 'RT::Assets' ) {
         push @fields, "CustomFieldView.{" . $CustomField->Name . "}";
     }
 
+    my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'} );
+    foreach my $id ( keys %catalogs ) {
+
+        # Gotta load up the $catalog object, since catalogs get stored by name now.
+        my $catalog = RT::Catalog->new( $session{'CurrentUser'} );
+        $catalog->Load($id);
+        next unless $catalog->Id;
+        $CustomRoles->LimitToObjectId( $catalog->Id );
+    }
+    $CustomRoles->LimitToLookupType( RT::Asset->CustomFieldLookupType ) if $CustomRoles->_isLimited;
+    while ( my $role = $CustomRoles->Next ) {
+        push @fields, 'CustomRole.{' . $role->Name . '}';
+    }
 }
 else {
     $Format ||= RT->Config->Get('DefaultSearchResultFormat');
diff --git a/share/html/Search/Elements/PickBasics b/share/html/Search/Elements/PickBasics
index 4e134b1e81..22ff9d67a0 100644
--- a/share/html/Search/Elements/PickBasics
+++ b/share/html/Search/Elements/PickBasics
@@ -275,7 +275,7 @@ elsif ( $Class eq 'RT::Assets' ) {
             Field => {
                 Type => 'component',
                 Path => 'SelectPersonType',
-                Arguments => { Default => 'Owner', Class => 'RT::Assets' },
+                Arguments => { Default => 'Owner', Class => 'RT::Assets', Catalogs => \%catalogs },
             },
             Op => {
                 Type => 'component',
diff --git a/share/html/Search/Elements/PickCustomRoles b/share/html/Search/Elements/PickCustomRoles
index b24956e6fb..ab86e39f04 100644
--- a/share/html/Search/Elements/PickCustomRoles
+++ b/share/html/Search/Elements/PickCustomRoles
@@ -47,20 +47,35 @@
 %# END BPS TAGGED BLOCK }}}
 <%ARGS>
 %queues => ()
+%catalogs => ()
 </%ARGS>
 <%INIT>
 RT->Deprecated( Message => '/Search/Elements/PickCustomRoles is obsolete', Remove => '5.2' );
 
 my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
-foreach my $id (keys %queues) {
-    # Gotta load up the $queue object, since queues get stored by name now.
-    my $queue = RT::Queue->new($session{'CurrentUser'});
-    $queue->Load($id);
-    next unless $queue->Id;
-    $CustomRoles->LimitToObjectId($queue->Id);
+if ( %queues ) {
+    foreach my $id (keys %queues) {
+        # Gotta load up the $queue object, since queues get stored by name now.
+        my $queue = RT::Queue->new($session{'CurrentUser'});
+        $queue->Load($id);
+        next unless $queue->Id;
+        $CustomRoles->LimitToObjectId($queue->Id);
+    }
+    # If there are no referenced queues, do not limit LookupType to return 0 custom roles.
+    $CustomRoles->LimitToLookupType( RT::Ticket->CustomFieldLookupType ) if $CustomRoles->_isLimited;
 }
-# If there are no referenced queues, do not limit LookupType to return 0 custom roles.
-$CustomRoles->LimitToLookupType( RT::Ticket->CustomFieldLookupType ) if $CustomRoles->_isLimited;
+elsif ( %catalogs ) {
+    foreach my $id (keys %catalogs) {
+        # Gotta load up the $catalog object, since catalogs get stored by name now.
+        my $catalog = RT::Catalog->new($session{'CurrentUser'});
+        $catalog->Load($id);
+        next unless $catalog->Id;
+        $CustomRoles->LimitToObjectId($catalog->Id);
+    }
+    # If there are no referenced catalogs, do not limit LookupType to return 0 custom roles.
+    $CustomRoles->LimitToLookupType( RT::Asset->CustomFieldLookupType ) if $CustomRoles->_isLimited;
+}
+
 $m->callback(
     CallbackName => 'MassageCustomRoles',
     CustomRoles  => $CustomRoles,
diff --git a/share/html/Search/Elements/SelectPersonType b/share/html/Search/Elements/SelectPersonType
index 942a3a408a..9e029f78c6 100644
--- a/share/html/Search/Elements/SelectPersonType
+++ b/share/html/Search/Elements/SelectPersonType
@@ -83,10 +83,18 @@
 
 <%INIT>
 my ( @types, @subtypes );
+my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
+
 if ( $Class eq 'RT::Assets' ) {
     @types = qw(Owner HeldBy Contact);
     @subtypes = @{ $RT::Assets::SEARCHABLE_SUBFIELDS{'User'} };
 
+    foreach my $id (keys %Catalogs) {
+        my $catalog = RT::Catalog->new($session{'CurrentUser'});
+        $catalog->Load($id);
+        next unless $catalog->Id;
+        $CustomRoles->LimitToObjectId($catalog->Id);
+    }
 }
 else {
    if ($Role) {
@@ -106,27 +114,26 @@ else {
    else {
       @types = qw(Requestor Cc AdminCc Watcher Owner QueueCc QueueAdminCc QueueWatcher);
 
-      my $CustomRoles = RT::CustomRoles->new( $session{'CurrentUser'});
       foreach my $id (keys %Queues) {
           my $queue = RT::Queue->new($session{'CurrentUser'});
           $queue->Load($id);
           next unless $queue->Id;
           $CustomRoles->LimitToObjectId($queue->Id);
       }
-
-      # If there are no referenced queues/catalogs, do not limit LookupType to return 0 custom roles.
-      $CustomRoles->LimitToLookupType( $Class->RecordClass->CustomFieldLookupType ) if $CustomRoles->_isLimited;
-
-      $m->callback(
-          CallbackName => 'MassageCustomRoles',
-          CustomRoles  => $CustomRoles,
-      );
-      push @types, map { [ "CustomRole.{" . $_->Name . "}", $_->Name ] } @{ $CustomRoles->ItemsArrayRef };
    }
 
     @subtypes = @{ $RT::Tickets::SEARCHABLE_SUBFIELDS{'User'} };
 }
 
+# If there are no referenced queues/catalogs, do not limit LookupType to return 0 custom roles.
+$CustomRoles->LimitToLookupType( $Class->RecordClass->CustomFieldLookupType ) if $CustomRoles->_isLimited;
+
+$m->callback(
+    CallbackName => 'MassageCustomRoles',
+    CustomRoles  => $CustomRoles,
+);
+push @types, map { [ "CustomRole.{" . $_->Name . "}", $_->Name ] } @{ $CustomRoles->ItemsArrayRef };
+
 $m->callback(Types => \@types, Subtypes => \@subtypes);
 
 </%INIT>
@@ -140,4 +147,5 @@ $Name => 'WatcherType'
 $Role => undef
 @Roles => ()
 %Queues => ()
+%Catalogs => ()
 </%ARGS>

commit 620cb106651c2c3b38bdab4483f0093d85247a69
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri May 21 04:01:39 2021 +0800

    Show single custom role's name in the result message of adding members
    
    This is for asset custom roles, tickets don't have this issue as the
    result message was customized in RT::Ticket already.

diff --git a/lib/RT/Group.pm b/lib/RT/Group.pm
index 7f9854a204..9fc202d001 100644
--- a/lib/RT/Group.pm
+++ b/lib/RT/Group.pm
@@ -1074,7 +1074,7 @@ sub _AddMember {
     }
 
     return (1, $self->loc("[_1] set to [_2]",
-                          $self->loc($self->Name), $new_member_obj->Object->Name) )
+                          $self->Label, $new_member_obj->Object->Name) )
         if $self->SingleMemberRoleGroup;
 
     return ( 1, $self->loc("Member added: [_1]", $new_member_obj->Object->Name) );

commit 24843b8a4c22554a6700fe9ce8ad516074092579
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Thu May 20 05:13:52 2021 +0800

    Clear old data when registering the whole custom roles
    
    RegisterRoles is used to refresh custom roles in
    RT::Interface::Web::MaybeRebuildCustomRolesCache, but previously it just
    updated enabled ones and wrongly ignored disabled/deleted ones.
    
    This commit updates the logic to remove existing custom roles first and
    then fill new data from scratch, which fixes the issue.

diff --git a/lib/RT/CustomRoles.pm b/lib/RT/CustomRoles.pm
index 7fdecf856c..f9f01db2fa 100644
--- a/lib/RT/CustomRoles.pm
+++ b/lib/RT/CustomRoles.pm
@@ -98,6 +98,11 @@ subsystem, suitable for system startup.
 sub RegisterRoles {
     my $class = shift;
 
+    for my $type ( keys %RT::Record::Role::Roles::ROLES ) {
+        %{ $RT::Record::Role::Roles::ROLES{$type} } = map { $_ => $RT::Record::Role::Roles::ROLES{$type}{$_} }
+            grep { !/^RT::CustomRole-/ } keys %{$RT::Record::Role::Roles::ROLES{$type}};
+    }
+
     my $roles = $class->new(RT->SystemUser);
     $roles->UnLimit;
 
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 885a0e3bcf..8255f2b992 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -317,7 +317,7 @@ sub Roles {
 }
 
 {
-    my %ROLES;
+    our %ROLES;
     sub _ROLES {
         my $class = ref($_[0]) || $_[0];
         return $ROLES{$class} ||= {};

commit aa2c79327e84916dba2d93eabbd06ef0ce532315
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed May 17 18:45:06 2017 +0000

    Exclude asset custom roles from ticket search
    
    This covers both search builder and bulk update.

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 1278f3b000..183932117f 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -1112,6 +1112,7 @@ sub _CustomRoleDecipher {
 
     if ( $field =~ /\D/ ) {
         my $roles = RT::CustomRoles->new( $self->CurrentUser );
+        $roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
         $roles->Limit( FIELD => 'Name', VALUE => $field, CASESENSITIVE => 0 );
 
         # custom roles are named uniquely, but just in case there are
diff --git a/share/html/Elements/ColumnMap b/share/html/Elements/ColumnMap
index 8a5b89fcb1..da1f866efe 100644
--- a/share/html/Elements/ColumnMap
+++ b/share/html/Elements/ColumnMap
@@ -294,7 +294,12 @@ $WCOLUMN_MAP = $COLUMN_MAP = {
             my $role_obj = $m->notes($key);
             if (!$role_obj) {
                 $role_obj = RT::CustomRole->new($_[0]->CurrentUser);
-                $role_obj->Load($role_name);
+                if ($role_name =~ /^\d+$/) {
+                    $role_obj->Load($role_name);
+                }
+                else {
+                    $role_obj->LoadByCols(Name => $role_name, LookupType => $_[0]->CustomFieldLookupType);
+                }
 
                 RT->Logger->notice("Unable to load custom role $role_name")
                     unless $role_obj->Id;
diff --git a/share/html/Search/Bulk.html b/share/html/Search/Bulk.html
index 1b9a2855f0..d038d587f5 100644
--- a/share/html/Search/Bulk.html
+++ b/share/html/Search/Bulk.html
@@ -154,6 +154,7 @@
       </&>
 
 % my $single_roles = RT::CustomRoles->new($session{CurrentUser});
+% $single_roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
 % $single_roles->LimitToSingleValue;
 % $single_roles->LimitToObjectId($_) for keys %$seen_queues;
 % while (my $role = $single_roles->Next) {
@@ -163,6 +164,7 @@
 % }
 
 % my $multi_roles = RT::CustomRoles->new($session{CurrentUser});
+% $multi_roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
 % $multi_roles->LimitToMultipleValue;
 % $multi_roles->LimitToObjectId($_) for keys %$seen_queues;
 % while (my $role = $multi_roles->Next) {
diff --git a/share/html/Search/Elements/BuildFormatString b/share/html/Search/Elements/BuildFormatString
index 15f0351c18..6211f8369c 100644
--- a/share/html/Search/Elements/BuildFormatString
+++ b/share/html/Search/Elements/BuildFormatString
@@ -211,6 +211,7 @@ else {
         next unless $queue->Id;
         $CustomRoles->LimitToObjectId( $queue->Id );
     }
+    $CustomRoles->LimitToLookupType(RT::Ticket->CustomFieldLookupType) if $CustomRoles->_isLimited;
 
     my @user_fields = qw/id Name EmailAddress Organization RealName City Country/;
     my $user_cfs    = RT::CustomFields->new( $session{CurrentUser} );
diff --git a/share/html/Search/Elements/PickCustomRoles b/share/html/Search/Elements/PickCustomRoles
index b1acbad2f3..b24956e6fb 100644
--- a/share/html/Search/Elements/PickCustomRoles
+++ b/share/html/Search/Elements/PickCustomRoles
@@ -59,6 +59,8 @@ foreach my $id (keys %queues) {
     next unless $queue->Id;
     $CustomRoles->LimitToObjectId($queue->Id);
 }
+# If there are no referenced queues, do not limit LookupType to return 0 custom roles.
+$CustomRoles->LimitToLookupType( RT::Ticket->CustomFieldLookupType ) if $CustomRoles->_isLimited;
 $m->callback(
     CallbackName => 'MassageCustomRoles',
     CustomRoles  => $CustomRoles,
diff --git a/share/html/Search/Elements/SelectPersonType b/share/html/Search/Elements/SelectPersonType
index 57e4c8a209..942a3a408a 100644
--- a/share/html/Search/Elements/SelectPersonType
+++ b/share/html/Search/Elements/SelectPersonType
@@ -113,6 +113,10 @@ else {
           next unless $queue->Id;
           $CustomRoles->LimitToObjectId($queue->Id);
       }
+
+      # If there are no referenced queues/catalogs, do not limit LookupType to return 0 custom roles.
+      $CustomRoles->LimitToLookupType( $Class->RecordClass->CustomFieldLookupType ) if $CustomRoles->_isLimited;
+
       $m->callback(
           CallbackName => 'MassageCustomRoles',
           CustomRoles  => $CustomRoles,

commit 7debe76dbcbfbd282707f6e8c053121b484ba319
Author: Jim Brandt <jbrandt at bestpractical.com>
Date:   Wed May 3 14:05:01 2017 -0400

    Add lookup type to custom role admin page listing

diff --git a/share/html/Admin/CustomRoles/index.html b/share/html/Admin/CustomRoles/index.html
index c366cfbab7..f308d6b4af 100644
--- a/share/html/Admin/CustomRoles/index.html
+++ b/share/html/Admin/CustomRoles/index.html
@@ -92,11 +92,12 @@
   <em><&|/l&>No custom roles matching search criteria found.</&></em>
 % } else {
   <& /Elements/CollectionList,
-    OrderBy => 'Name',
-    Order => 'ASC',
+    OrderBy => 'LookupType|Name',
+    Order => 'ASC|ASC',
     Rows  => $Rows,
     %ARGS,
     Format => $Format,
+    DisplayFormat => ($Type? '' : '__FriendlyLookupType__,'). $Format,
     Collection => $roles,
     AllowSorting => 1,
     PassArguments => [qw(
@@ -110,6 +111,7 @@ my $title = loc("Select a Custom Role");
 
 my $roles = RT::CustomRoles->new($session{'CurrentUser'});
 $roles->FindAllRows if $FindDisabled;
+$roles->LimitToLookupType( $Type ) if $Type;
 
 if ( defined $SearchString && length $SearchString ) {
     $roles->Limit(
@@ -128,6 +130,7 @@ my $Rows = RT->Config->Get('AdminSearchResultRows')->{'CustomRoles'} || 50;
 
 </%INIT>
 <%ARGS>
+$Type => ''
 $FindDisabled => 0
 $Format       => undef
 
diff --git a/share/html/Elements/RT__CustomRole/ColumnMap b/share/html/Elements/RT__CustomRole/ColumnMap
index 758b24bd89..2c2f7ba6e2 100644
--- a/share/html/Elements/RT__CustomRole/ColumnMap
+++ b/share/html/Elements/RT__CustomRole/ColumnMap
@@ -63,7 +63,16 @@ my $COLUMN_MAP = {
             title     => $c, attribute => $c,
             value     => sub { return $_[0]->$c() },
         } }
-        qw(Name Description EntryHint)
+        qw(Name Description LookupType EntryHint)
+    ),
+
+    map(
+        { my $c = $_; my $short = $c; $short =~ s/^Friendly//;
+          $c => {
+            title     => $short, attribute => $short,
+            value     => sub { return $_[0]->$c() },
+        } }
+        qw(FriendlyLookupType FriendlyType FriendlyPattern)
     ),
 
     MaxValues => {

commit b9b050ebe5e14476d35b59a633e596a040f0d24e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Apr 19 21:04:13 2017 +0000

    Add custom roles to assets

diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm
index d42ae0e3db..bc0f282d48 100644
--- a/lib/RT/Asset.pm
+++ b/lib/RT/Asset.pm
@@ -101,6 +101,48 @@ for my $role ('Owner', 'HeldBy', 'Contact') {
     );
 }
 
+RT::CustomRole->RegisterLookupType(
+    CustomFieldLookupType() => {
+        FriendlyName => 'Assets',
+        CreateGroupPredicate => sub {
+            my ($object, $role) = @_;
+            if ($object->isa('RT::Catalog')) {
+                # In case catalog level custom role groups got deleted
+                # somehow.  Allow to re-create them like default ones.
+                return $role->IsAdded($object->id);
+            }
+            elsif ($object->isa('RT::Asset')) {
+                # see if the role has been applied to the asset's catalog
+                # need to walk around ACLs
+                return $role->IsAdded($object->__Value('Catalog'));
+            }
+
+            return 0;
+        },
+        AppliesToObjectPredicate => sub {
+            my ($object, $role) = @_;
+            return 0 unless $object->CurrentUserHasRight('ShowCatalog');
+
+            # custom roles apply to catalogs, so canonicalize an asset
+            # into its catalog
+            if ($object->isa('RT::Asset')) {
+                $object = $object->CatalogObj;
+            }
+
+            if ($object->isa('RT::Catalog')) {
+                return $role->IsAdded($object->Id);
+            }
+
+            return 0;
+        },
+        Subgroup => {
+            Domain => 'RT::Asset-Role',
+            Table  => 'Assets',
+            Parent => 'Catalog',
+        },
+    }
+);
+
 =head1 DESCRIPTION
 
 An Asset is a small record object upon which zero to many custom fields are
@@ -262,7 +304,7 @@ sub Create {
     }
 
     my $roles = {};
-    my @errors = $self->_ResolveRoles( $roles, %args );
+    my @errors = $catalog->_ResolveRoles( $roles, %args );
     return (0, @errors) if @errors;
 
     RT->DatabaseHandle->BeginTransaction();
diff --git a/lib/RT/CustomRole.pm b/lib/RT/CustomRole.pm
index 077e8bc052..6c8b8b47fa 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -278,13 +278,30 @@ sub _ValidateName {
         return ($ok, $self->loc("'[_1]' is not a valid name.", $name));
     }
 
-    # These roles are builtin, so avoid any potential confusion
-    if ($name =~ m{^( cc
+    if ( $type eq 'RT::Queue-RT::Ticket' ) {
+        # These roles are ticket builtin, so avoid any potential confusion
+        if (
+            $name =~ m{^( cc
                     | admin[ ]?cc
                     | requestors?
                     | owner
-                    ) $}xi) {
-        return (undef, $self->loc("Role already exists") );
+                    ) $}xi
+            )
+        {
+            return ( undef, $self->loc("Role already exists") );
+        }
+    }
+    else {
+        # These roles are asset builtin, so avoid any potential confusion
+        if (
+            $name =~ m{^( heldby
+                    | contacts?
+                    | owner
+                    ) $}xi
+            )
+        {
+            return ( undef, $self->loc("Role already exists") );
+        }
     }
 
     my $temp = RT::CustomRole->new(RT->SystemUser);
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 6e827ffe28..51c6d2fca8 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4360,11 +4360,16 @@ sub ProcessAssetRoleMembers {
         elsif ($arg =~ /^SetRoleMember-(.+)$/) {
             my $role = $1;
             my $group = $object->RoleGroup($role);
+            if ( !$group->id ) {
+                $group = $object->_CreateRoleGroup($role);
+            }
             next unless $group->id and $group->SingleMemberRoleGroup;
-            next if $ARGS{$arg} eq $group->UserMembersObj->First->Name;
+            my $original_user = $group->UserMembersObj->First || RT->Nobody;
+            $ARGS{$arg} ||= 'Nobody';
+            next if $ARGS{$arg} eq $original_user->Name;
             my ($ok, $msg) = $object->AddRoleMember(
                 Type => $role,
-                User => $ARGS{$arg} || 'Nobody',
+                User => $ARGS{$arg},
             );
             push @results, $msg;
         }
diff --git a/lib/RT/Principal.pm b/lib/RT/Principal.pm
index 27200c3c0f..48ab66e13f 100644
--- a/lib/RT/Principal.pm
+++ b/lib/RT/Principal.pm
@@ -447,7 +447,7 @@ sub HasRights {
             if ( $custom_role->id && !$custom_role->Disabled ) {
                 my $added;
                 for my $object ( @{ $args{'EquivObjects'} } ) {
-                    next unless $object->isa('RT::Queue');
+                    next unless $object->isa('RT::Queue') || $object->isa('RT::Catalog');
                     if ( $custom_role->IsAdded( $object->id ) ) {
                         $added = 1;
                         last;
@@ -699,7 +699,7 @@ sub RolesWithRight {
             if ( $custom_role->id && !$custom_role->Disabled ) {
                 my $added;
                 for my $object ( @{ $args{'EquivObjects'} } ) {
-                    next unless $object->isa('RT::Queue');
+                    next unless $object->isa('RT::Queue') || $object->isa('RT::Catalog');
                     if ( $custom_role->IsAdded( $object->id ) ) {
                         $added = 1;
                         last;
diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index 5e61149cce..885a0e3bcf 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -308,9 +308,9 @@ sub Roles {
              map { [ $_, $self->_ROLES->{$_} ] }
             keys %{ $self->_ROLES };
 
-    # Cache at ticket/queue object level mainly to reduce calls of
-    # custom role's AppliesToObjectPredicate for performance.
-    if ( ref($self) =~ /RT::(?:Ticket|Queue)/ ) {
+    # Cache at object level mainly to reduce calls of custom role's
+    # AppliesToObjectPredicate for performance.
+    if ( ref($self) =~ /RT::(?:Ticket|Queue|Asset|Catalog)/ ) {
         $self->{_Roles}{$key} = \@roles;
     }
     return @roles;
diff --git a/share/html/Asset/Create.html b/share/html/Asset/Create.html
index f5e91da737..c71526cc61 100644
--- a/share/html/Asset/Create.html
+++ b/share/html/Asset/Create.html
@@ -65,7 +65,7 @@
 
       <div class="col-6">
         <&| /Widgets/TitleBox, title => loc("People"), class => "asset-people", title_class => "inverse" &>
-            <& Elements/EditPeople, %ARGS, AssetObj => $asset &>
+            <& Elements/EditPeople, %ARGS, AssetObj => $asset, CatalogObj => $catalog &>
         </&>
       </div>
     </div>
@@ -156,7 +156,7 @@ if ($id eq "new") {
             ProcessLinksForCreate( ARGSRef => \%ARGS ),
             map {
                 $_ => $ARGS{$_}
-            } $asset->Roles,
+            } $catalog->Roles,
         );
 
         # Handle basic fields
diff --git a/share/html/Asset/Elements/AssetSearchPeople b/share/html/Asset/Elements/AssetSearchPeople
index fece788d36..32022d0b1a 100644
--- a/share/html/Asset/Elements/AssetSearchPeople
+++ b/share/html/Asset/Elements/AssetSearchPeople
@@ -46,10 +46,10 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <&| /Widgets/TitleBox, class => "asset-search-people", title => loc('People') &>
-% for my $role (RT::Asset->Roles) {
+% for my $role ($CatalogObj->Roles) {
   <div class="asset-role-<% CSSClass($role) %> form-row">
     <div class="label col-2">
-      <% loc($role) %>
+      <% RT::Asset->LabelForRole($role) %>
     </div>
     <div class="value col-4">
       <input class="form-control" type="text" id="Role.<% $role %>" name="Role.<% $role %>"
diff --git a/share/html/Asset/Elements/EditCatalogPeople b/share/html/Asset/Elements/EditCatalogPeople
index 08c7e00bc7..d52663c439 100644
--- a/share/html/Asset/Elements/EditCatalogPeople
+++ b/share/html/Asset/Elements/EditCatalogPeople
@@ -52,8 +52,8 @@ $Object
 </%init>
 % for my $role ($Object->Roles( ACLOnly => 0 )) {
   <div class="role-<% CSSClass($role) %> role">
-    <h3><% loc($role) %></h3>
-    <& EditRoleMembers, Group => $Object->RoleGroup($role) &>
+    <h3><% $Object->LabelForRole($role) %></h3>
+    <& EditRoleMembers, Object => $Object, Role => $role &>
   </div>
 % }
 
diff --git a/share/html/Asset/Elements/EditPeople b/share/html/Asset/Elements/EditPeople
index 8db9a7f1f9..79120caa6b 100644
--- a/share/html/Asset/Elements/EditPeople
+++ b/share/html/Asset/Elements/EditPeople
@@ -45,12 +45,19 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-% for my $role ( $AssetObj->Roles ) {
-  <&| /Elements/LabeledValue, Label => loc($role), Class => "asset-people-".CSSClass($role) &>
-    <& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 1, ($AssetObj->Role($role)->{Single} ? () : (AutocompleteType => 'Principals', AutocompleteMultiple => 1)) &>
+% for my $role ( $object->Roles ) {
+% my $custom_role = $object->CustomRoleObj($role);
+% my $hint = $custom_role ? $custom_role->EntryHint : '';
+  <&| /Elements/LabeledValue, Label => $object->LabelForRole($role), Class => "asset-people-".CSSClass($role), LabelTooltip => $hint &>
+    <& /Elements/EmailInput, Name => $role, Size => undef, Default => $ARGS{$role}, Autocomplete => 1, ($object->Role($role)->{Single} ? () : (AutocompleteType => 'Principals', AutocompleteMultiple => 1)) &>
   </&>
 % }
 
+<%init>
+my $object = $AssetObj->Id ? $AssetObj : $CatalogObj;
+</%init>
+
 <%args>
 $AssetObj
+$CatalogObj
 </%args>
diff --git a/share/html/Asset/Elements/EditRoleMembers b/share/html/Asset/Elements/EditRoleMembers
index a99b66e322..e0b109529b 100644
--- a/share/html/Asset/Elements/EditRoleMembers
+++ b/share/html/Asset/Elements/EditRoleMembers
@@ -46,17 +46,20 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%args>
-$Group       => undef
+$Object
+$Role
 $Recursively => 0
 </%args>
 <%init>
+my $Group = $Object->RoleGroup($Role);
 my $field_name = "RemoveRoleMember-" . $Group->Name;
 </%init>
 <ul class="role-members list-group list-group-compact">
 % my $Users = $Group->UserMembersObj( Recursively => $Recursively );
-% if ($Group->SingleMemberRoleGroup) {
+% if ($Object->Role($Role)->{Single}) {
+% my $user = $Users->First || RT->Nobody;
   <li class="list-group-item">
-    <input class="form-control selectpicker" type="text" value="<% $Users->First->Name %>" name="SetRoleMember-<% $Group->Name %>" id="SetRoleMember-<% $Group->Name %>" data-autocomplete="Users" data-autocomplete-return="Name" />
+    <input class="form-control selectpicker" type="text" value="<% $user->Name %>" name="SetRoleMember-<% $Group->Name %>" id="SetRoleMember-<% $Group->Name %>" data-autocomplete="Users" data-autocomplete-return="Name" />
   </li>
 % } else {
 % while ( my $user = $Users->Next ) {
diff --git a/share/html/Asset/Elements/SelectRoleType b/share/html/Asset/Elements/SelectRoleType
index 17f296d813..953232f483 100644
--- a/share/html/Asset/Elements/SelectRoleType
+++ b/share/html/Asset/Elements/SelectRoleType
@@ -55,6 +55,6 @@ $AllowNull  => 0
   <option value=""></option>
 % }
 % for my $role ($Object->Roles( ACLOnly => 0, Single => 0 )) {
-  <option value="<% $role %>"><% loc($role) %></option>
+  <option value="<% $role %>"><% $Object->LabelForRole($role) %></option>
 % }
 </select>
diff --git a/share/html/Asset/Elements/ShowPeople b/share/html/Asset/Elements/ShowPeople
index 6b35fdf2e6..fb505decae 100644
--- a/share/html/Asset/Elements/ShowPeople
+++ b/share/html/Asset/Elements/ShowPeople
@@ -54,14 +54,14 @@ my $CatalogObj = $AssetObj->CatalogObj;
 % for my $role ($AssetObj->Roles) {
   <div class="form-row">
     <div class="label col-3">
-      <% loc($role) %>:
+      <% $AssetObj->LabelForRole($role) %>:
     </div>
     <div class="value col-9">
       <div class="user-accordion accordion">
 % if ($AssetObj->Role($role)->{Single}) {
 %      my $users = $AssetObj->RoleGroup($role)->UserMembersObj(Recursively => 0);
 %      $users->FindAllRows;
-%      my $user = $users->Next;
+%      my $user = $users->Next || RT->Nobody;
 %     if ( $user->id != RT->Nobody->id ) {
         <& ShowRoleMembers, Group => $AssetObj->RoleGroup($role), Role => $role &>
 %     } else {
diff --git a/share/html/Asset/Elements/ShowRoleMembers b/share/html/Asset/Elements/ShowRoleMembers
index e050b9117e..ab134f2d1a 100644
--- a/share/html/Asset/Elements/ShowRoleMembers
+++ b/share/html/Asset/Elements/ShowRoleMembers
@@ -51,7 +51,7 @@
 %     next if $user->id == RT->Nobody->id;
 
 <div class="accordion-item">
-  <span class="accordion-title collapsed toggle" data-toggle="collapse" data-target="#<% $Role %>-user-<% $user->id %>" aria-expanded="false" aria-controls="<% $Role %>-user-<% $user->id %>" id="<% $Role %>-user-<% $user->id %>-title" >
+  <span class="accordion-title collapsed toggle" data-toggle="collapse" data-target="[id='<% $Role %>-user-<% $user->id %>']" aria-expanded="false" aria-controls="<% $Role %>-user-<% $user->id %>" id="<% $Role %>-user-<% $user->id %>-title" >
 
 % if ($Title) {
     <& /Elements/ShowUser, User => $user, Link => 1 &>

commit 58d9b85761d428a14f06d1d6a1e3809eef8f1be7
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 16:49:27 2017 +0000

    Add support for LookupType to custom roles
    
    This allows custom roles to be reused for any object class, not just tickets
    and queues.

diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index 324f790d19..7b7ac07475 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -520,6 +520,7 @@ CREATE TABLE CustomRoles (
         Description     VARCHAR2(255),
         MaxValues       NUMBER(11,0) DEFAULT 0 NOT NULL,
         EntryHint       VARCHAR2(255),
+        LookupType      VARCHAR2(255),
         Creator         NUMBER(11,0) DEFAULT 0 NOT NULL,
         Created         DATE,
         LastUpdatedBy   NUMBER(11,0) DEFAULT 0 NOT NULL,
diff --git a/etc/schema.Pg b/etc/schema.Pg
index 9f34ec4b0a..0c81859312 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -753,6 +753,7 @@ CREATE TABLE CustomRoles (
   Description varchar(255) NULL  ,
   MaxValues integer NOT NULL DEFAULT 0  ,
   EntryHint varchar(255) NULL  ,
+  LookupType varchar(255) NOT NULL  ,
 
   Creator integer NOT NULL DEFAULT 0  ,
   Created TIMESTAMP NULL  ,
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index d2e455f9e5..2811ed2c4c 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -549,6 +549,7 @@ CREATE TABLE CustomRoles (
   Description varchar(255) collate NOCASE NULL  ,
   MaxValues integer,
   EntryHint varchar(255) collate NOCASE NULL  ,
+  LookupType varchar(255) collate NOCASE NOT NULL,
 
   Creator integer NOT NULL DEFAULT 0  ,
   Created DATETIME NULL  ,
diff --git a/etc/schema.mysql b/etc/schema.mysql
index f773ffd472..15868174dc 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -538,6 +538,7 @@ CREATE TABLE CustomRoles (
   Description varchar(255) NULL  ,
   MaxValues integer,
   EntryHint varchar(255) NULL  ,
+  LookupType varchar(255) CHARACTER SET ascii NOT NULL,
 
   Creator integer NOT NULL DEFAULT 0  ,
   Created DATETIME NULL  ,
diff --git a/etc/upgrade/5.0.4/schema.Oracle b/etc/upgrade/5.0.4/schema.Oracle
new file mode 100644
index 0000000000..300bf8d8b6
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.Oracle
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD LookupType VARCHAR2(255);
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/etc/upgrade/5.0.4/schema.Pg b/etc/upgrade/5.0.4/schema.Pg
new file mode 100644
index 0000000000..671d871f45
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.Pg
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD COLUMN LookupType VARCHAR(255);
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/etc/upgrade/5.0.4/schema.SQLite b/etc/upgrade/5.0.4/schema.SQLite
new file mode 100644
index 0000000000..ec766a33cd
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.SQLite
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD COLUMN LookupType VARCHAR(255) collate NOCASE;
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/etc/upgrade/5.0.4/schema.mysql b/etc/upgrade/5.0.4/schema.mysql
new file mode 100644
index 0000000000..850f200953
--- /dev/null
+++ b/etc/upgrade/5.0.4/schema.mysql
@@ -0,0 +1,2 @@
+ALTER TABLE CustomRoles ADD COLUMN LookupType varchar(255) CHARACTER SET ascii;
+UPDATE CustomRoles SET LookupType='RT::Queue-RT::Ticket';
diff --git a/lib/RT/CustomRole.pm b/lib/RT/CustomRole.pm
index fa4ec0f747..077e8bc052 100644
--- a/lib/RT/CustomRole.pm
+++ b/lib/RT/CustomRole.pm
@@ -55,6 +55,9 @@ use base 'RT::Record';
 use RT::CustomRoles;
 use RT::ObjectCustomRole;
 
+use Role::Basic 'with';
+with "RT::Record::Role::LookupType";
+
 =head1 NAME
 
 RT::CustomRole - user-defined role groups
@@ -79,6 +82,7 @@ Create takes a hash of values and creates a row in the database:
   varchar(255) 'Description'.
   int(11) 'MaxValues'.
   varchar(255) 'EntryHint'.
+  varchar(255) 'LookupType'.
   smallint(6) 'Disabled'.
 
 =cut
@@ -90,6 +94,7 @@ sub Create {
         Description => '',
         MaxValues   => 0,
         EntryHint   => '',
+        LookupType  => '',
         Disabled    => 0,
         @_,
     );
@@ -106,6 +111,9 @@ sub Create {
     $args{'Disabled'} ||= 0;
     $args{'MaxValues'} = int $args{'MaxValues'};
 
+    # backwards compatibility; used to be the only possibility
+    $args{'LookupType'} ||= 'RT::Queue-RT::Ticket';
+
     $RT::Handle->BeginTransaction;
 
     my ($ok, $msg) = $self->SUPER::Create(
@@ -113,6 +121,7 @@ sub Create {
         Description => $args{'Description'},
         MaxValues   => $args{'MaxValues'},
         EntryHint   => $args{'EntryHint'},
+        LookupType  => $args{'LookupType'},
         Disabled    => $args{'Disabled'},
     );
     unless ($ok) {
@@ -152,9 +161,9 @@ sub _RegisterAsRole {
     my $self = shift;
     my $id = $self->Id;
 
-    RT::Ticket->RegisterRole(
+    $self->ObjectTypeFromLookupType->RegisterRole(
         Name                 => $self->GroupType,
-        EquivClasses         => ['RT::Queue'],
+        EquivClasses         => [$self->RecordClassFromLookupType],
         Single               => $self->SingleValue,
         UserDefined          => 1,
 
@@ -171,17 +180,10 @@ sub _RegisterAsRole {
             my $role = RT::CustomRole->new(RT->SystemUser);
             $role->Load($id);
 
-            if ($object->isa('RT::Queue')) {
-                # In case queue level custom role groups got deleted
-                # somehow.  Allow to re-create them like default ones.
-                return $role->IsAdded($object->id);
-            }
-            elsif ($object->isa('RT::Ticket')) {
-                # see if the role has been applied to the ticket's queue
-                # need to walk around ACLs because of the common case of
-                # (e.g. Everyone) having the CreateTicket right but not
-                # ShowTicket
-                return $role->IsAdded($object->__Value('Queue'));
+            if ( $role->Id ) {
+                if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'CreateGroupPredicate')) {
+                    return $predicate->($object, $role);
+                }
             }
 
             return 0;
@@ -205,16 +207,10 @@ sub _RegisterAsRole {
             my $role = RT::CustomRole->new(RT->SystemUser);
             $role->Load($id);
 
-            if ( $object->isa('RT::Ticket') || $object->isa('RT::Queue') ) {
-                return 0 unless $object->CurrentUserHasRight('SeeQueue');
-
-                # custom roles apply to queues, so canonicalize a ticket
-                # into its queue
-                if ( $object->isa('RT::Ticket') ) {
-                    $object = $object->QueueObj;
+            if ( $role->Id ) {
+                if (my $predicate = $role->LookupTypeRegistration($role->LookupType, 'AppliesToObjectPredicate')) {
+                    return $predicate->($object, $role);
                 }
-
-                return $role->IsAdded( $object->Id );
             }
 
             return 0;
@@ -235,7 +231,7 @@ sub _RegisterAsRole {
 sub _UnregisterAsRole {
     my $self = shift;
 
-    RT::Ticket->UnregisterRole($self->GroupType);
+    $self->ObjectTypeFromLookupType->UnregisterRole($self->GroupType);
 }
 
 =head2 Load ID/NAME
@@ -375,7 +371,7 @@ sub NotAddedTo {
 
 =head2 AddToObject
 
-Adds (applies) this custom role to the provided queue (ObjectId).
+Adds (applies) this custom role to the provided object (ObjectId).
 
 Accepts a param hash of:
 
@@ -383,7 +379,7 @@ Accepts a param hash of:
 
 =item C<ObjectId>
 
-Queue name or id.
+Object id of the class corresponding with L</LookupType>.
 
 =item C<SortOrder>
 
@@ -400,26 +396,30 @@ sub AddToObject {
     my $self = shift;
     my %args = @_%2? (ObjectId => @_) : (@_);
 
-    my $queue = RT::Queue->new( $self->CurrentUser );
-    $queue->Load( $args{'ObjectId'} );
-    return (0, $self->loc('Invalid queue'))
-        unless $queue->id;
+    my $class = $self->RecordClassFromLookupType;
+    my $object = $class->new( $self->CurrentUser );
+    $object->Load( $args{'ObjectId'} );
+    unless ($object->id) {
+        RT->Logger->warn("Unable to load $class '$args{'ObjectId'}' for custom role " . $self->Id);
+        return (0, $self->loc('Unable to load [_1]', $args{'ObjectId'}))
+    }
 
-    $args{'ObjectId'} = $queue->id;
+    $args{'ObjectId'} = $object->id;
 
     return ( 0, $self->loc('Permission Denied') )
-        unless $queue->CurrentUserHasRight('AdminCustomRoles');
+        unless $object->CurrentUserHasRight('AdminCustomRoles');
+
     my $rec = RT::ObjectCustomRole->new( $self->CurrentUser );
     my ( $status, $add ) = $rec->Add( %args, CustomRole => $self );
     my $msg;
-    $msg = $self->loc("[_1] added to queue [_2]", $self->Name, $queue->Name) if $status;
+    $msg = $self->loc("[_1] added to queue [_2]", $self->Name, $object->Name) if $status;
 
     return ( $add, $msg );
 }
 
 =head2 RemoveFromObject
 
-Removes this custom role from the provided queue (ObjectId).
+Removes this custom role from the provided object (ObjectId).
 
 Accepts a param hash of:
 
@@ -427,7 +427,7 @@ Accepts a param hash of:
 
 =item C<ObjectId>
 
-Queue name or id.
+Object id of the class corresponding with L</LookupType>.
 
 =back
 
@@ -440,19 +440,25 @@ sub RemoveFromObject {
     my $self = shift;
     my %args = @_%2? (ObjectId => @_) : (@_);
 
-    my $queue = RT::Queue->new( $self->CurrentUser );
-    $queue->Load( $args{'ObjectId'} );
-    return (0, $self->loc('Invalid queue id'))
-        unless $queue->id;
+    my $class = $self->RecordClassFromLookupType;
+    my $object = $class->new( $self->CurrentUser );
+    $object->Load( $args{'ObjectId'} );
+    unless ($object->id) {
+        RT->Logger->warn("Unable to load $class '$args{'ObjectId'}' for custom role " . $self->Id);
+        return (0, $self->loc('Unable to load [_1]', $args{'ObjectId'}))
+    }
+
+    $args{'ObjectId'} = $object->id;
 
     return ( 0, $self->loc('Permission Denied') )
-        unless $queue->CurrentUserHasRight('AdminCustomRoles');
+        unless $object->CurrentUserHasRight('AdminCustomRoles');
+
     my $rec = RT::ObjectCustomRole->new( $self->CurrentUser );
     $rec->LoadByCols( CustomRole => $self->id, ObjectId => $args{'ObjectId'} );
     return (0, $self->loc('Custom role is not added') ) unless $rec->id;
     my ( $status, $delete ) = $rec->Delete;
     my $msg;
-    $msg = $self->loc("[_1] removed from queue [_2]", $self->Name, $queue->Name) if $status;
+    $msg = $self->loc("[_1] removed from queue [_2]", $self->Name, $object->Name) if $status;
 
     return ( $delete, $msg );
 }
@@ -561,6 +567,39 @@ sub SetMaxValues {
     return ($ok, $msg);
 }
 
+=head2 LookupType
+
+Returns the current value of LookupType.
+(In the database, LookupType is stored as varchar(255).)
+
+=head2 SetLookupType VALUE
+
+
+Set LookupType to VALUE.
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, LookupType will be stored as a varchar(255).)
+
+=cut
+
+sub SetLookupType {
+    my $self = shift;
+    my $lookup = shift;
+    if ( $lookup ne $self->LookupType ) {
+        # Okay... We need to invalidate our existing relationships
+        RT::ObjectCustomRole->new($self->CurrentUser)->DeleteAll( CustomRole => $self );
+    }
+
+    $self->_UnregisterAsRole;
+
+    my ($ok, $msg) = $self->_Set(Field => 'LookupType', Value => $lookup);
+
+    # update EquivClasses declaration
+    $self->_RegisterAsRole;
+    RT->System->CustomRoleCacheNeedsUpdate(1);
+
+    return ($ok, $msg);
+}
+
 =head2 EntryHint
 
 Returns the current value of EntryHint.
@@ -615,62 +654,65 @@ Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
 
 =cut
 
-sub _SetGroupsDisabledForQueue {
+sub _SetGroupsDisabledForObject {
     my $self = shift;
     my $value = shift;
-    my $queue = shift;
+    my $object = shift;
 
-    # set disabled on the queue group
-    my $queue_group = RT::Group->new($self->CurrentUser);
-    $queue_group->LoadRoleGroup(
+    # set disabled on the object group
+    my $object_group = RT::Group->new($self->CurrentUser);
+    $object_group->LoadRoleGroup(
         Name   => $self->GroupType,
-        Object => $queue,
+        Object => $object,
     );
 
-    if (!$queue_group->Id) {
+    if (!$object_group->Id) {
         $RT::Handle->Rollback;
-        $RT::Logger->error("Couldn't find role group for " . $self->GroupType . " on queue " . $queue->Id);
+        $RT::Logger->error("Couldn't find role group for " . $self->GroupType . " on " . ref($object) . " #" . $object->Id);
         return(undef);
     }
 
-    my ($ok, $msg) = $queue_group->SetDisabled($value);
+    my ($ok, $msg) = $object_group->SetDisabled($value);
     unless ($ok) {
         $RT::Handle->Rollback;
         $RT::Logger->error("Couldn't SetDisabled($value) on role group: $msg");
         return(undef);
     }
 
-    # disable each existant ticket group
-    my $ticket_groups = RT::Groups->new($self->CurrentUser);
+    my $subgroup_config = $self->LookupTypeRegistration($self->LookupType, 'Subgroup');
+    if ($subgroup_config) {
+        # disable each existant ticket group
+        my $groups = RT::Groups->new($self->CurrentUser);
 
-    if ($value) {
-        $ticket_groups->LimitToEnabled;
-    }
-    else {
-        $ticket_groups->LimitToDeleted;
-    }
-
-    $ticket_groups->Limit(FIELD => 'Domain', OPERATOR => 'LIKE', VALUE => "RT::Ticket-Role", CASESENSITIVE => 0 );
-    $ticket_groups->Limit(FIELD => 'Name', OPERATOR => '=', VALUE => $self->GroupType, CASESENSITIVE => 0);
-
-    my $tickets = $ticket_groups->Join(
-        ALIAS1 => 'main',
-        FIELD1 => 'Instance',
-        TABLE2 => 'Tickets',
-        FIELD2 => 'Id',
-    );
-    $ticket_groups->Limit(
-        ALIAS => $tickets,
-        FIELD => 'Queue',
-        VALUE => $queue->Id,
-    );
+        if ($value) {
+            $groups->LimitToEnabled;
+        }
+        else {
+            $groups->LimitToDeleted;
+        }
 
-    while (my $ticket_group = $ticket_groups->Next) {
-        my ($ok, $msg) = $ticket_group->SetDisabled($value);
-        unless ($ok) {
-            $RT::Handle->Rollback;
-            $RT::Logger->error("Couldn't SetDisabled($value) ticket role group: $msg");
-            return(undef);
+        $groups->Limit(FIELD => 'Domain', OPERATOR => 'LIKE', VALUE => $subgroup_config->{Domain}, CASESENSITIVE => 0 );
+        $groups->Limit(FIELD => 'Name', OPERATOR => '=', VALUE => $self->GroupType, CASESENSITIVE => 0);
+
+        my $objects = $groups->Join(
+            ALIAS1 => 'main',
+            FIELD1 => 'Instance',
+            TABLE2 => $subgroup_config->{Table},
+            FIELD2 => 'Id',
+        );
+        $groups->Limit(
+            ALIAS => $objects,
+            FIELD => $subgroup_config->{Parent},
+            VALUE => $object->Id,
+        );
+
+        while (my $group = $groups->Next) {
+            my ($ok, $msg) = $group->SetDisabled($value);
+            unless ($ok) {
+                $RT::Handle->Rollback;
+                $RT::Logger->error("Couldn't SetDisabled($value) role group: $msg");
+                return(undef);
+            }
         }
     }
 }
@@ -753,6 +795,8 @@ sub _CoreAccessible {
         {read => 1, write => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => ''},
         EntryHint =>
         {read => 1, write => 1, sql_type => 12, length => 255,  is_blob => 0,  is_numeric => 0,  type => 'varchar(255)', default => ''},
+        LookupType =>
+        {read => 1, write => 1, sql_type => 12, length => 255,  is_blob => 0,  is_numeric => 0,  type => 'varchar(255)', default => ''},
         Creator =>
         {read => 1, auto => 1, sql_type => 4, length => 11,  is_blob => 0,  is_numeric => 1,  type => 'int(11)', default => '0'},
         Created =>
diff --git a/lib/RT/CustomRoles.pm b/lib/RT/CustomRoles.pm
index f49a9f9e4d..7fdecf856c 100644
--- a/lib/RT/CustomRoles.pm
+++ b/lib/RT/CustomRoles.pm
@@ -156,6 +156,19 @@ sub LimitToMultipleValue {
     );
 }
 
+=head2 LimitToLookupType
+
+Takes LookupType and limits collection.
+
+=cut
+
+sub LimitToLookupType  {
+    my $self = shift;
+    my $lookup = shift;
+
+    $self->Limit( FIELD => 'LookupType', VALUE => "$lookup" );
+}
+
 =head2 ApplySortOrder
 
 Sort custom roles according to the order provided by the object custom roles.
diff --git a/lib/RT/ObjectCustomRole.pm b/lib/RT/ObjectCustomRole.pm
index c66161044f..554004134b 100644
--- a/lib/RT/ObjectCustomRole.pm
+++ b/lib/RT/ObjectCustomRole.pm
@@ -57,11 +57,11 @@ use RT::ObjectCustomRoles;
 
 =head1 NAME
 
-RT::ObjectCustomRole - record representing addition of a custom role to a queue
+RT::ObjectCustomRole - record representing addition of a custom role to an object
 
 =head1 DESCRIPTION
 
-This record is created if you want to add a custom role to a queue.
+This record is created if you want to add a custom role to an object.
 
 Inherits methods from L<RT::Record::AddAndSort>.
 
@@ -79,12 +79,16 @@ sub Table {'ObjectCustomRoles'}
 
 =head2 ObjectCollectionClass
 
-Returns class name of collection of records custom roles can be added to.
-Now it's only L<RT::Queue>, so 'RT::Queues' is returned.
+Returns class name of collection of records this custom role can be added to
+by consulting the custom role's C<LookupType>.
 
 =cut
 
-sub ObjectCollectionClass {'RT::Queues'}
+sub ObjectCollectionClass {
+    my $self = shift;
+    my %args = (@_);
+    return $args{'CustomRole'}->CollectionClassFromLookupType;
+}
 
 =head2 CustomRoleObj
 
@@ -100,22 +104,30 @@ sub CustomRoleObj {
     return $obj;
 }
 
-=head2 QueueObj
+=head2 Object
 
-Returns the L<RT::Queue> object which this ObjectCustomRole is added to
+Returns the object which this ObjectCustomRole is added to
 
 =cut
 
+sub Object {
+    my $self = shift;
+    my $role = $self->CustomRoleObj;
+    my $class = $role->RecordClassFromLookupType;
+    my $object = $class->new($self->CurrentUser);
+    $object->Load($self->ObjectId);
+    return $object;
+}
+
 sub QueueObj {
     my $self = shift;
-    my $queue = RT::Queue->new($self->CurrentUser);
-    $queue->Load($self->ObjectId);
-    return $queue;
+    RT->Deprecated( Instead => "Object", Remove => '5.2' );
+    return $self->Object(@_);
 }
 
 =head2 Add
 
-Adds the custom role to the queue and creates (or re-enables) that queue's role
+Adds the custom role to the object and creates (or re-enables) that object's role
 group.
 
 =cut
@@ -132,15 +144,15 @@ sub Add {
         return(undef);
     }
 
-    my $queue = $self->QueueObj;
+    my $object = $self->Object;
     my $role = $self->CustomRoleObj;
 
     # see if we already have this role group (which can happen if you
-    # add a role to a queue, remove it, then add it back in)
+    # add a role to an object, remove it, then add it back in)
     my $existing = RT::Group->new($self->CurrentUser);
     $existing->LoadRoleGroup(
         Name   => $role->GroupType,
-        Object => $queue,
+        Object => $object,
     );
 
     if ($existing->Id) {
@@ -150,7 +162,7 @@ sub Add {
         my $group = RT::Group->new($self->CurrentUser);
         my ($ok, $msg) = $group->CreateRoleGroup(
             Name   => $role->GroupType,
-            Object => $queue,
+            Object => $object,
         );
 
         unless ($ok) {
@@ -168,7 +180,7 @@ sub Add {
 
 =head2 Delete
 
-Removes the custom role from the queue and disables that queue's role group.
+Removes the custom role from the object and disables that object's role group.
 
 =cut
 
@@ -194,7 +206,7 @@ sub FindDependencies {
     $self->SUPER::FindDependencies($walker, $deps);
 
     $deps->Add( out => $self->CustomRoleObj );
-    $deps->Add( out => $self->QueueObj );
+    $deps->Add( out => $self->Object );
 }
 
 sub Serialize {
diff --git a/lib/RT/ObjectCustomRoles.pm b/lib/RT/ObjectCustomRoles.pm
index 9baabb514b..64352fc94b 100644
--- a/lib/RT/ObjectCustomRoles.pm
+++ b/lib/RT/ObjectCustomRoles.pm
@@ -106,6 +106,25 @@ sub LimitToObjectId {
     );
 }
 
+sub LimitToLookupType {
+    my $self = shift;
+    my $lookup = shift;
+
+    $self->{'_crs_alias'} ||= $self->Join(
+        ALIAS1 => 'main',
+        FIELD1 => 'CustomRole',
+        TABLE2 => 'CustomRoles',
+        FIELD2 => 'id',
+    );
+    $self->Limit(
+        ALIAS    => $self->{'_crs_alias'},
+        FIELD    => 'LookupType',
+        OPERATOR => '=',
+        VALUE    => $lookup,
+    );
+}
+
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index 8aba033035..1f3af9c9d0 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -481,6 +481,7 @@ sub CustomRoles {
     my $roles = RT::CustomRoles->new( $self->CurrentUser );
     if ( $self->CurrentUserHasRight('SeeQueue') ) {
         $roles->LimitToObjectId( $self->Id );
+        $roles->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
         $roles->ApplySortOrder;
     }
     else {
@@ -1086,6 +1087,7 @@ sub FindDependencies {
     # Object Custom Roles
     $objs = RT::ObjectCustomRoles->new( $self->CurrentUser );
     $objs->LimitToObjectId($self->Id);
+    $objs->LimitToLookupType(RT::Ticket->CustomFieldLookupType);
     $deps->Add( in => $objs );
 }
 
diff --git a/lib/RT/Record/Role/LookupType.pm b/lib/RT/Record/Role/LookupType.pm
index de53291252..4f9fa2ba1a 100644
--- a/lib/RT/Record/Role/LookupType.pm
+++ b/lib/RT/Record/Role/LookupType.pm
@@ -161,7 +161,8 @@ sub LookupTypeRegistration {
     my $option = shift
         or return %{ $REGISTRY{$class}{$path}};
 
-    return $REGISTRY{$class}{$path}{$option};
+    my $ret = $REGISTRY{$class}{$path}{$option};
+    return $ret;
 }
 
 =head2 FriendlyLookupType
diff --git a/lib/RT/Ticket.pm b/lib/RT/Ticket.pm
index d63881f79e..cc7fc7d6c8 100644
--- a/lib/RT/Ticket.pm
+++ b/lib/RT/Ticket.pm
@@ -114,6 +114,50 @@ for my $role (sort keys %ROLES) {
     );
 }
 
+RT::CustomRole->RegisterLookupType(
+    CustomFieldLookupType() => {
+        FriendlyName => 'Tickets',
+        CreateGroupPredicate => sub {
+            my ($object, $role) = @_;
+            if ($object->isa('RT::Queue')) {
+                # In case queue level custom role groups got deleted
+                # somehow.  Allow to re-create them like default ones.
+                return $role->IsAdded($object->id);
+            }
+            elsif ($object->isa('RT::Ticket')) {
+                # see if the role has been applied to the ticket's queue
+                # need to walk around ACLs because of the common case of
+                # (e.g. Everyone) having the CreateTicket right but not
+                # ShowTicket
+                return $role->IsAdded($object->__Value('Queue'));
+            }
+
+            return 0;
+        },
+        AppliesToObjectPredicate => sub {
+            my ($object, $role) = @_;
+            return 0 unless $object->CurrentUserHasRight('SeeQueue');
+
+            # custom roles apply to queues, so canonicalize a ticket
+            # into its queue
+            if ($object->isa('RT::Ticket')) {
+                $object = $object->QueueObj;
+            }
+
+            if ($object->isa('RT::Queue')) {
+                return $role->IsAdded($object->Id);
+            }
+
+            return 0;
+        },
+        Subgroup => {
+            Domain => 'RT::Ticket-Role',
+            Table  => 'Tickets',
+            Parent => 'Queue',
+        },
+    }
+);
+
 our %MERGE_CACHE = (
     effective => {},
     merged => {},
diff --git a/share/html/Admin/CustomRoles/Modify.html b/share/html/Admin/CustomRoles/Modify.html
index 115a64588d..f6a73baa3d 100644
--- a/share/html/Admin/CustomRoles/Modify.html
+++ b/share/html/Admin/CustomRoles/Modify.html
@@ -64,6 +64,14 @@
     <input class="form-control" type="text" name="Description" value="<% $Create ? "" : $RoleObj->Description || $Description || '' %>" size="60" />
 </&>
 
+<&| /Elements/LabeledValue, Label => loc('Applies To') &>
+    <& /Admin/Elements/SelectLookupType,
+        Name    => "LookupType",
+        Object  => $RoleObj,
+        Default => $RoleObj->LookupType || $LookupType,
+    &>
+</&>
+
 <&| /Elements/LabeledValue, Label => loc("Entry Hint") &>
     <input class="form-control" type="text" name="EntryHint" value="<% $Create ? "" : $RoleObj->EntryHint || $EntryHint || '' %>" size="60" />
 </&>
@@ -140,7 +148,7 @@ unless ($Create) {
 
 if ( $RoleObj->Id ) {
     $title = loc('Configuration for role [_1]', $RoleObj->Name );
-    my @attribs = qw(Description Name EntryHint Disabled);
+    my @attribs = qw(Description Name EntryHint LookupType Disabled);
 
     # we just created the role
     if (!$id || $id eq 'new') {
@@ -198,4 +206,5 @@ $SetEnabled => undef
 $SetMultiple => undef
 $Multiple => undef
 $Enabled => undef
+$LookupType => RT::Ticket->CustomFieldLookupType
 </%ARGS>
diff --git a/share/html/Admin/CustomRoles/Objects.html b/share/html/Admin/CustomRoles/Objects.html
index c8bc2f7a27..d3e954b6d0 100644
--- a/share/html/Admin/CustomRoles/Objects.html
+++ b/share/html/Admin/CustomRoles/Objects.html
@@ -56,8 +56,8 @@
 <h2><&|/l&>Selected objects</&></h2>
 
 <& /Elements/CollectionList,
-    OrderBy => 'id',
-    Order => 'ASC',
+    OrderBy => $class->isa('RT::Queue') ? ['SortOrder', 'Name'] : 'id',
+    Order => $class->isa('RT::Queue') ? ['ASC', 'ASC'] : 'ASC',
     %ARGS,
     Collection => $added,
     Rows => 0,
@@ -74,8 +74,8 @@
 <h2><&|/l&>Unselected objects</&></h2>
 
 <& /Elements/CollectionList,
-    OrderBy => 'Name',
-    Order   => 'ASC',
+    OrderBy => $class->isa('RT::Queue') ? ['SortOrder', 'Name'] : 'id',
+    Order => $class->isa('RT::Queue') ? ['ASC', 'ASC'] : 'ASC',
     %ARGS,
     Collection    => $not_added,
     Rows          => $rows,
@@ -102,6 +102,8 @@ my $role = RT::CustomRole->new( $session{'CurrentUser'} );
 $role->Load($id) or Abort(loc("Could not load custom role #[_1]", $id));
 $id = $role->id;
 
+my $class = $role->RecordClassFromLookupType;
+
 if ($role->Disabled) {
     Abort(loc("Cannot modify objects of disabled custom role #[_1]", $id));
 }
@@ -132,8 +134,12 @@ if ( $Update ) {
 my $added = $role->AddedTo;
 my $not_added = $role->NotAddedTo;
 
-my $format = RT->Config->Get('AdminSearchResultFormat')->{'Queues'};
-my $rows = RT->Config->Get('AdminSearchResultRows')->{'Queues'} || 50;
+my $collection_class = ref($added);
+$collection_class =~ s/^RT:://;
+
+my $format = RT->Config->Get('AdminSearchResultFormat')->{$collection_class}
+    || '__id__,__Name__';
+my $rows = RT->Config->Get('AdminSearchResultRows')->{$collection_class} || 50;
 
 my $title = loc('Modify associated objects for [_1]', $role->Name);
 

commit 63ab5907f1ac7ce1743be9a2f12e2817c9b65b67
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 16:30:57 2017 +0000

    Allow RegisterLookupType to provide options besides just FriendlyName
    
    We are going to add new options including "CreateGroupPredicate",
    "AppliesToObjectPredicate" and "Subgroup".

diff --git a/lib/RT/Record/Role/LookupType.pm b/lib/RT/Record/Role/LookupType.pm
index 3655969aef..de53291252 100644
--- a/lib/RT/Record/Role/LookupType.pm
+++ b/lib/RT/Record/Role/LookupType.pm
@@ -85,10 +85,22 @@ with 'RT::Record::Role';
 
 =head1 PROVIDES
 
-=head2 RegisterLookupType LOOKUPTYPE FRIENDLYNAME
+=head2 RegisterLookupType LOOKUPTYPE OPTIONS
 
 Tell RT that a certain object accepts records of this role via a lookup
-type and provide a friendly name for them.
+type. I<OPTIONS> is a hash reference for which the following keys are
+used:
+
+=over 4
+
+=item FriendlyName
+
+The string to display in the UI to users for this lookup type
+
+=back
+
+For backwards compatibility, I<OPTIONS> may also be a string which is
+interpreted as specifying the I<FriendlyName>.
 
 Examples:
 
@@ -107,11 +119,15 @@ my %REGISTRY = ();
 sub RegisterLookupType {
     my $class = shift;
     my $path = shift;
-    my $friendly_name = shift;
+    my $options = shift;
 
     die "RegisterLookupType is a class method" if blessed($class);
 
-    $REGISTRY{$class}{$path} = $friendly_name;
+    $options = {
+        FriendlyName => $options,
+    } if !ref($options);
+
+    $REGISTRY{$class}{$path} = $options;
 }
 
 =head2 LookupTypes
@@ -126,6 +142,28 @@ sub LookupTypes {
     return sort keys %{ $REGISTRY{ $class } };
 }
 
+=head2 LookupTypeRegistration [PATH] [OPTION]
+
+Returns the arguments of calls to L</RegisterLookupType>. With no arguments, returns a hash of hashes,
+where the first-level key is the path (corresponding with L<RT::Record/CustomFieldLookupType>) and
+the second-level hash is the option names. If path and option are provided, it looks up in that
+nested hash structure to provide the desired information.
+
+=cut
+
+sub LookupTypeRegistration {
+    my $self = shift;
+    my $class = blessed($self) || $self;
+
+    my $path = shift
+        or return %{ $REGISTRY{$class}};
+
+    my $option = shift
+        or return %{ $REGISTRY{$class}{$path}};
+
+    return $REGISTRY{$class}{$path}{$option};
+}
+
 =head2 FriendlyLookupType
 
 Returns a localized description of the LookupType of this record
@@ -138,8 +176,9 @@ sub FriendlyLookupType {
 
     my $class = blessed($self) || $self;
 
-    return ($self->loc( $REGISTRY{$class}{$lookup} ))
-        if defined $REGISTRY{$class}{$lookup};
+    if (my $friendly = $self->LookupTypeRegistration($lookup, 'FriendlyName')) {
+        return $self->loc($friendly);
+    }
 
     my @types = map { s/^RT::// ? $self->loc($_) : $_ }
       grep { defined and length }

commit c314d6128280d60520dcf068a333b1077b4409fb
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 16:26:26 2017 +0000

    Factor out a LookupType role from CustomFields
    
    This will be added to CustomRoles to support custom roles on assets
    and other record types.
    
    This generalizes and deprecates /Admin/Elements/SelectCustomFieldLookupType in
    favor of a new /Admin/Elements/SelectLookupType. That way we can use it on the
    CustomRole Modify page

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index c0112ae7da..0db2f68c66 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -57,7 +57,8 @@ use Scalar::Util 'blessed';
 use base 'RT::Record';
 
 use Role::Basic 'with';
-with "RT::Record::Role::Rights";
+with "RT::Record::Role::Rights",
+     "RT::Record::Role::LookupType";
 
 sub Table {'CustomFields'}
 
@@ -218,7 +219,6 @@ our %FieldTypes = (
 
 
 my %BUILTIN_GROUPINGS;
-my %FRIENDLY_LOOKUP_TYPES = ();
 
 __PACKAGE__->RegisterLookupType( 'RT::Queue-RT::Ticket' => "Tickets", );    #loc
 __PACKAGE__->RegisterLookupType( 'RT::Queue-RT::Ticket-RT::Transaction' => "Ticket Transactions", ); #loc
@@ -1414,120 +1414,6 @@ sub SetLookupType {
     return $self->_Set(Field => 'LookupType', Value =>$lookup);
 }
 
-=head2 LookupTypes
-
-Returns an array of LookupTypes available
-
-=cut
-
-
-sub LookupTypes {
-    my $self = shift;
-    return sort keys %FRIENDLY_LOOKUP_TYPES;
-}
-
-=head2 FriendlyLookupType
-
-Returns a localized description of the type of this custom field
-
-=cut
-
-sub FriendlyLookupType {
-    my $self = shift;
-    my $lookup = shift || $self->LookupType;
-
-    return ($self->loc( $FRIENDLY_LOOKUP_TYPES{$lookup} ))
-        if defined $FRIENDLY_LOOKUP_TYPES{$lookup};
-
-    my @types = map { s/^RT::// ? $self->loc($_) : $_ }
-      grep { defined and length }
-      split( /-/, $lookup )
-      or return;
-
-    state $LocStrings = [
-        "[_1] objects",            # loc
-        "[_1]'s [_2] objects",        # loc
-        "[_1]'s [_2]'s [_3] objects",   # loc
-    ];
-    return ( $self->loc( $LocStrings->[$#types], @types ) );
-}
-
-=head1 RecordClassFromLookupType
-
-Returns the type of Object referred to by ObjectCustomFields' ObjectId column
-
-Optionally takes a LookupType to use instead of using the value on the loaded
-record.  In this case, the method may be called on the class instead of an
-object.
-
-=cut
-
-sub RecordClassFromLookupType {
-    my $self = shift;
-    my $type = shift || $self->LookupType;
-    my ($class) = ($type =~ /^([^-]+)/);
-    unless ( $class ) {
-        if (blessed($self) and $self->LookupType eq $type) {
-            $RT::Logger->error(
-                "Custom Field #". $self->id
-                ." has incorrect LookupType '$type'"
-            );
-        } else {
-            RT->Logger->error("Invalid LookupType passed as argument: $type");
-        }
-        return undef;
-    }
-    return $class;
-}
-
-=head1 ObjectTypeFromLookupType
-
-Returns the ObjectType used in ObjectCustomFieldValues rows for this CF
-
-Optionally takes a LookupType to use instead of using the value on the loaded
-record.  In this case, the method may be called on the class instead of an
-object.
-
-=cut
-
-sub ObjectTypeFromLookupType {
-    my $self = shift;
-    my $type = shift || $self->LookupType;
-    my ($class) = ($type =~ /([^-]+)$/);
-    unless ( $class ) {
-        if (blessed($self) and $self->LookupType eq $type) {
-            $RT::Logger->error(
-                "Custom Field #". $self->id
-                ." has incorrect LookupType '$type'"
-            );
-        } else {
-            RT->Logger->error("Invalid LookupType passed as argument: $type");
-        }
-        return undef;
-    }
-    return $class;
-}
-
-sub CollectionClassFromLookupType {
-    my $self = shift;
-    my $record_class = shift || $self->RecordClassFromLookupType;
-
-    return undef unless $record_class;
-
-    my $collection_class;
-    if ( UNIVERSAL::can($record_class.'Collection', 'new') ) {
-        $collection_class = $record_class.'Collection';
-    } elsif ( UNIVERSAL::can($record_class.'es', 'new') ) {
-        $collection_class = $record_class.'es';
-    } elsif ( UNIVERSAL::can($record_class.'s', 'new') ) {
-        $collection_class = $record_class.'s';
-    } else {
-        $RT::Logger->error("Can not find a collection class for record class '$record_class'");
-        return undef;
-    }
-    return $collection_class;
-}
-
 =head2 Groupings Object|Class Name, Queue Name|Catalog Name
 
 Returns a (sorted and lowercased) list of the groupings in which this custom
@@ -1640,20 +1526,6 @@ sub RegisterBuiltInGroupings {
     $BUILTIN_GROUPINGS{''} = { map { %$_ } values %BUILTIN_GROUPINGS  };
 }
 
-=head1 IsOnlyGlobal
-
-Certain custom fields (users, groups) should only be added globally;
-codify that set here for reference.
-
-=cut
-
-sub IsOnlyGlobal {
-    my $self = shift;
-
-    return ($self->LookupType =~ /^RT::(?:Group|User)/io);
-
-}
-
 =head1 AddedTo
 
 Returns collection with objects this custom field is added to.
@@ -2141,31 +2013,6 @@ sub CurrentUserCanSee {
     return 0;
 }
 
-=head2 RegisterLookupType LOOKUPTYPE FRIENDLYNAME
-
-Tell RT that a certain object accepts custom fields via a lookup type and
-provide a friendly name for such CFs.
-
-Examples:
-
-    'RT::Queue-RT::Ticket'                 => "Tickets",                # loc
-    'RT::Queue-RT::Ticket-RT::Transaction' => "Ticket Transactions",    # loc
-    'RT::User'                             => "Users",                  # loc
-    'RT::Group'                            => "Groups",                 # loc
-    'RT::Queue'                            => "Queues",                 # loc
-
-This is a class method. 
-
-=cut
-
-sub RegisterLookupType {
-    my $self = shift;
-    my $path = shift;
-    my $friendly_name = shift;
-
-    $FRIENDLY_LOOKUP_TYPES{$path} = $friendly_name;
-}
-
 =head2 IncludeContentForValue [VALUE] (and SetIncludeContentForValue)
 
 Gets or sets the  C<IncludeContentForValue> for this custom field. RT
diff --git a/lib/RT/Record/Role/LookupType.pm b/lib/RT/Record/Role/LookupType.pm
new file mode 100644
index 0000000000..3655969aef
--- /dev/null
+++ b/lib/RT/Record/Role/LookupType.pm
@@ -0,0 +1,250 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2018 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 }}}
+
+package RT::Record::Role::LookupType;
+
+use strict;
+use warnings;
+use 5.010;
+
+use Role::Basic;
+use Scalar::Util qw(blessed);
+
+=head1 NAME
+
+RT::Record::Role::LookupType - Common methods for records which have a LookupType
+
+=head1 DESCRIPTION
+
+Certain records, like custom fields, can be applied to different types of
+records (tickets, transactions, groups, users, etc). This role implements
+such I<LookupType> concerns.
+
+This role does not manage concerns relating to specifying which records
+of a class (as in L<RT::ObjectCustomField>).
+
+=head1 REQUIRES
+
+=head2 L<RT::Record::Role>
+
+=head2 LookupType
+
+A C<LookupType> method which returns this record's lookup type is required.
+Currently unenforced at compile-time due to poor interactions with
+L<DBIx::SearchBuilder::Record/AUTOLOAD>.  You'll hit run-time errors if
+this method isn't available in consuming classes, however.
+
+=cut
+
+with 'RT::Record::Role';
+
+=head1 PROVIDES
+
+=head2 RegisterLookupType LOOKUPTYPE FRIENDLYNAME
+
+Tell RT that a certain object accepts records of this role via a lookup
+type and provide a friendly name for them.
+
+Examples:
+
+    'RT::Queue-RT::Ticket'                 => "Tickets",                # loc
+    'RT::Queue-RT::Ticket-RT::Transaction' => "Ticket Transactions",    # loc
+    'RT::User'                             => "Users",                  # loc
+    'RT::Group'                            => "Groups",                 # loc
+    'RT::Queue'                            => "Queues",                 # loc
+
+This is a class method.
+
+=cut
+
+my %REGISTRY = ();
+
+sub RegisterLookupType {
+    my $class = shift;
+    my $path = shift;
+    my $friendly_name = shift;
+
+    die "RegisterLookupType is a class method" if blessed($class);
+
+    $REGISTRY{$class}{$path} = $friendly_name;
+}
+
+=head2 LookupTypes
+
+Returns an array of LookupTypes available for this record or class
+
+=cut
+
+sub LookupTypes {
+    my $self = shift;
+    my $class = blessed($self) || $self;
+    return sort keys %{ $REGISTRY{ $class } };
+}
+
+=head2 FriendlyLookupType
+
+Returns a localized description of the LookupType of this record
+
+=cut
+
+sub FriendlyLookupType {
+    my $self = shift;
+    my $lookup = shift || $self->LookupType;
+
+    my $class = blessed($self) || $self;
+
+    return ($self->loc( $REGISTRY{$class}{$lookup} ))
+        if defined $REGISTRY{$class}{$lookup};
+
+    my @types = map { s/^RT::// ? $self->loc($_) : $_ }
+      grep { defined and length }
+      split( /-/, $lookup )
+      or return;
+
+    state $LocStrings = [
+        "[_1] objects",            # loc
+        "[_1]'s [_2] objects",        # loc
+        "[_1]'s [_2]'s [_3] objects",   # loc
+    ];
+    return ( $self->loc( $LocStrings->[$#types], @types ) );
+}
+
+=head1 RecordClassFromLookupType
+
+Returns the type of Object referred to by ObjectCustomFields' ObjectId column.
+(The first part of the LookupType, e.g. the C<RT::Queue> of
+C<RT::Queue-RT::Ticket-RT::Transaction>)
+
+Optionally takes a LookupType to use instead of using the value on the loaded
+record.  In this case, the method may be called on the class instead of an
+object.
+
+=cut
+
+sub RecordClassFromLookupType {
+    my $self = shift;
+    my $type = shift || $self->LookupType;
+    my ($class) = ($type =~ /^([^-]+)/);
+    unless ( $class ) {
+        if (blessed($self) and $self->LookupType eq $type) {
+            $RT::Logger->error(
+                blessed($self) . " #". $self->id
+                ." has incorrect LookupType '$type'"
+            );
+        } else {
+            RT->Logger->error("Invalid LookupType passed as argument: $type");
+        }
+        return undef;
+    }
+    return $class;
+}
+
+=head1 ObjectTypeFromLookupType
+
+Returns the ObjectType for this record. (The last part of the LookupType,
+e.g. the C<RT::Transaction> of C<RT::Queue-RT::Ticket-RT::Transaction>)
+
+Optionally takes a LookupType to use instead of using the value on the loaded
+record.  In this case, the method may be called on the class instead of an
+object.
+
+=cut
+
+sub ObjectTypeFromLookupType {
+    my $self = shift;
+    my $type = shift || $self->LookupType;
+    my ($class) = ($type =~ /([^-]+)$/);
+    unless ( $class ) {
+        if (blessed($self) and $self->LookupType eq $type) {
+            $RT::Logger->error(
+                blessed($self) . " #". $self->id
+                ." has incorrect LookupType '$type'"
+            );
+        } else {
+            RT->Logger->error("Invalid LookupType passed as argument: $type");
+        }
+        return undef;
+    }
+    return $class;
+}
+
+sub CollectionClassFromLookupType {
+    my $self = shift;
+
+    my $record_class = shift || $self->RecordClassFromLookupType;
+    return undef unless $record_class;
+
+    my $collection_class;
+    if ( UNIVERSAL::can($record_class.'Collection', 'new') ) {
+        $collection_class = $record_class.'Collection';
+    } elsif ( UNIVERSAL::can($record_class.'es', 'new') ) {
+        $collection_class = $record_class.'es';
+    } elsif ( UNIVERSAL::can($record_class.'s', 'new') ) {
+        $collection_class = $record_class.'s';
+    } else {
+        $RT::Logger->error("Can not find a collection class for record class '$record_class'");
+        return undef;
+    }
+    return $collection_class;
+}
+
+=head1 IsOnlyGlobal
+
+Certain record types (users, groups) should only be added globally;
+codify that set here for reference.
+
+=cut
+
+sub IsOnlyGlobal {
+    my $self = shift;
+
+    return ($self->LookupType =~ /^RT::(?:Group|User)/io);
+
+}
+
+1;
diff --git a/share/html/Admin/CustomFields/Modify.html b/share/html/Admin/CustomFields/Modify.html
index 2cfc5be13e..daef955a53 100644
--- a/share/html/Admin/CustomFields/Modify.html
+++ b/share/html/Admin/CustomFields/Modify.html
@@ -96,8 +96,10 @@
 % }
 
 <&| /Elements/LabeledValue, Label => loc("Applies to") &>
-    <& /Admin/Elements/SelectCustomFieldLookupType,
+    <& /Admin/Elements/SelectLookupType,
+      Class => 'RT::CustomField',
       Name => "LookupType",
+      Object => $CustomFieldObj,
       Default => $CustomFieldObj->LookupType || $LookupType,
     &>
 </&>
diff --git a/share/html/Admin/Elements/SelectCustomFieldLookupType b/share/html/Admin/Elements/SelectCustomFieldLookupType
index f43d543829..dfc4506513 100644
--- a/share/html/Admin/Elements/SelectCustomFieldLookupType
+++ b/share/html/Admin/Elements/SelectCustomFieldLookupType
@@ -45,16 +45,11 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<select class="form-control selectpicker" name="<%$Name%>">
-%for my $option ($cf->LookupTypes) {
-<option value="<%$option%>"<%defined ($Default) && ($option eq $Default) && qq[ selected="selected"] |n%>><% $cf->FriendlyLookupType($option) %></option>
-%}
-</select>
-<%INIT>
-my $cf = RT::CustomField->new($session{'CurrentUser'});
+<& SelectLookupType, %ARGS, Class => 'RT::CustomField' &>
 
+<%INIT>
+RT->Deprecated(
+    Remove => '5.2',
+    Instead => 'SelectLookupType',
+);
 </%INIT>
-<%ARGS>
-$Default=> ''
-$Name => 'LookupType'
-</%ARGS>
diff --git a/share/html/Admin/Elements/SelectCustomFieldLookupType b/share/html/Admin/Elements/SelectLookupType
similarity index 90%
copy from share/html/Admin/Elements/SelectCustomFieldLookupType
copy to share/html/Admin/Elements/SelectLookupType
index f43d543829..7b470555cd 100644
--- a/share/html/Admin/Elements/SelectCustomFieldLookupType
+++ b/share/html/Admin/Elements/SelectLookupType
@@ -46,15 +46,16 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <select class="form-control selectpicker" name="<%$Name%>">
-%for my $option ($cf->LookupTypes) {
-<option value="<%$option%>"<%defined ($Default) && ($option eq $Default) && qq[ selected="selected"] |n%>><% $cf->FriendlyLookupType($option) %></option>
+%for my $option ($Object->LookupTypes) {
+<option value="<%$option%>"<%defined ($Default) && ($option eq $Default) && qq[ selected="selected"] |n%>><% $Object->FriendlyLookupType($option) %></option>
 %}
 </select>
 <%INIT>
-my $cf = RT::CustomField->new($session{'CurrentUser'});
-
+$Object ||= $Class->new($session{'CurrentUser'});
 </%INIT>
 <%ARGS>
-$Default=> ''
+$Default => ''
 $Name => 'LookupType'
+$Object => undef
+$Class => ''
 </%ARGS>

commit 5a752d9b39231a1f46c563035bff835b9ed0d11d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Apr 13 15:48:07 2017 +0000

    Add RT::Asset->RoleAddresses
    
    This mirrors RT::Ticket->RoleAddresses

diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm
index e313db5d63..d42ae0e3db 100644
--- a/lib/RT/Asset.pm
+++ b/lib/RT/Asset.pm
@@ -528,6 +528,23 @@ sub RoleGroup {
     }
 }
 
+=head2 RoleAddresses
+
+Takes a role name and returns a string of all the email addresses for
+users in that role
+
+=cut
+
+sub RoleAddresses {
+    my $self = shift;
+    my $role = shift;
+
+    if ( $self->CurrentUserCanSee ) {
+        return $self->RoleGroup($role)->MemberEmailAddressesAsString;
+    }
+    return undef;
+}
+
 =head1 INTERNAL METHODS
 
 Public methods, but you shouldn't need to call these unless you're

commit 8757d54ce23f38513d49c6258637b665ba57ccc6
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Apr 18 14:32:30 2017 +0000

    Add CustomRoleObj method for loading by GroupType
    
    With this we can easily go from the output of ->Roles to an RT::CustomRole
    object.

diff --git a/lib/RT/Record/Role/Roles.pm b/lib/RT/Record/Role/Roles.pm
index ffcfa5240d..5e61149cce 100644
--- a/lib/RT/Record/Role/Roles.pm
+++ b/lib/RT/Record/Role/Roles.pm
@@ -851,4 +851,25 @@ you see faster create times.
 
 =cut
 
+=head2 CustomRoleObj
+
+Returns the L<RT::CustomRole> object for this role if and only if it's
+backed by a custom role. If it's a core role (e.g. Ticket Requestors),
+returns C<undef>.
+
+=cut
+
+sub CustomRoleObj {
+    my $self = shift;
+    my $name = shift;
+
+    if (my ($id) = $name =~ /^RT::CustomRole-(\d+)$/) {
+        my $role = RT::CustomRole->new($self->CurrentUser);
+        $role->Load($id);
+        return $role;
+    }
+
+    return undef;
+}
+
 1;

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


hooks/post-receive
-- 
rt


More information about the rt-commit mailing list