From d783ac220e2d1937682b52b701a9e455601d8bb9 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 29 Sep 2026 09:21:56 +0200 Subject: [PATCH 1/3] Sort invite roles list by application display name # If applied, this commit will... Sort the list of roles shown on the invite-roles page by application display name. # Why is this change needed? Prior to this change, the list of invite roles shown under /invite-roles was not sorted. Every request could return the roles in a different order, making the menu feel unpredictable and hard to scan. # How does it address the issue? This change adds a sortByApplicationDisplayName method to InviteRoleList that orders roles alphabetically and case insensitively by their application display name, falling back to the role name when a role has no applications. The InviteRolesController now sorts the list using the current locale before rendering it, matching the approach already used for the my-services overview. # Provide links to any relevant tickets, articles or other resources Closes #343 --- .../Tests/Value/InviteRoleListTest.php | 105 ++++++++++++++++++ .../Profile/Value/InviteRoleList.php | 30 +++++ .../Controller/InviteRolesController.php | 8 +- 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/OpenConext/Profile/Tests/Value/InviteRoleListTest.php diff --git a/src/OpenConext/Profile/Tests/Value/InviteRoleListTest.php b/src/OpenConext/Profile/Tests/Value/InviteRoleListTest.php new file mode 100644 index 000000000..9194815d2 --- /dev/null +++ b/src/OpenConext/Profile/Tests/Value/InviteRoleListTest.php @@ -0,0 +1,105 @@ +buildInviteRole('Role C', 'C-application'), + $this->buildInviteRole('Role A', 'A-application'), + $this->buildInviteRole('Role B', 'B-application'), + ]); + + $list->sortByApplicationDisplayName('en'); + + $sorted = array_values(iterator_to_array($list->getIterator())); + $this->assertCount(3, $sorted); + $this->assertEquals('A-application', $sorted[0]->getApplications()[0]->getName('en')); + $this->assertEquals('B-application', $sorted[1]->getApplications()[0]->getName('en')); + $this->assertEquals('C-application', $sorted[2]->getApplications()[0]->getName('en')); + } + + public function test_it_can_order_by_application_display_name_case_insensitively(): void + { + $list = new InviteRoleList([ + $this->buildInviteRole('Role C', 'C-application'), + $this->buildInviteRole('Role A', 'a-application'), + $this->buildInviteRole('Role B', 'B-application'), + ]); + + $list->sortByApplicationDisplayName('en'); + + $sorted = array_values(iterator_to_array($list->getIterator())); + $this->assertCount(3, $sorted); + $this->assertEquals('a-application', $sorted[0]->getApplications()[0]->getName('en')); + $this->assertEquals('B-application', $sorted[1]->getApplications()[0]->getName('en')); + $this->assertEquals('C-application', $sorted[2]->getApplications()[0]->getName('en')); + } + + public function test_roles_without_applications_are_sorted_by_role_name(): void + { + $list = new InviteRoleList([ + $this->buildInviteRole('Role C', 'C-application'), + $this->buildInviteRoleWithoutApplication('Role A'), + $this->buildInviteRole('Role B', 'B-application'), + ]); + + $list->sortByApplicationDisplayName('en'); + + $sorted = array_values(iterator_to_array($list->getIterator())); + $this->assertCount(3, $sorted); + $this->assertEquals('B-application', $sorted[0]->getApplications()[0]->getName('en')); + $this->assertEquals('C-application', $sorted[1]->getApplications()[0]->getName('en')); + $this->assertEquals('Role A', $sorted[2]->getName()); + } + + public function test_it_can_order_nothing(): void + { + $list = new InviteRoleList([]); + $list->sortByApplicationDisplayName('en'); + $this->assertCount(0, $list); + } + + private function buildInviteRole(string $roleName, string $applicationNameEn): InviteRole + { + return new InviteRole($roleName, $roleName, [ + [ + 'landingPage' => '', + 'nameEn' => $applicationNameEn, + 'nameNl' => $applicationNameEn, + 'organisationEn' => '', + 'organisationNl' => '', + 'logo' => '', + ], + ]); + } + + private function buildInviteRoleWithoutApplication(string $roleName): InviteRole + { + return new InviteRole($roleName, $roleName, []); + } +} diff --git a/src/OpenConext/Profile/Value/InviteRoleList.php b/src/OpenConext/Profile/Value/InviteRoleList.php index 5d5704dab..702443a19 100644 --- a/src/OpenConext/Profile/Value/InviteRoleList.php +++ b/src/OpenConext/Profile/Value/InviteRoleList.php @@ -61,4 +61,34 @@ public function count(): int { return count($this->roles); } + + /** + * Sorts the roles alphabetically (case-insensitively) by the display name of their + * first application, falling back to the role name for roles without applications. + */ + public function sortByApplicationDisplayName( + string $locale, + ): void { + usort( + $this->roles, + fn(InviteRole $a, InviteRole $b): int => strcasecmp( + $this->getSortNameFor($a, $locale), + $this->getSortNameFor($b, $locale), + ), + ); + } + + private function getSortNameFor( + InviteRole $inviteRole, + string $locale, + ): string { + if ($inviteRole->hasApplications()) { + $applicationName = $inviteRole->getApplications()[0]->getName($locale); + if ($applicationName !== '') { + return $applicationName; + } + } + + return $inviteRole->getName(); + } } diff --git a/src/OpenConext/ProfileBundle/Controller/InviteRolesController.php b/src/OpenConext/ProfileBundle/Controller/InviteRolesController.php index 3c1781a33..32d268e03 100644 --- a/src/OpenConext/ProfileBundle/Controller/InviteRolesController.php +++ b/src/OpenConext/ProfileBundle/Controller/InviteRolesController.php @@ -24,6 +24,7 @@ use OpenConext\Profile\Repository\InviteRepositoryInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\User\UserProviderInterface; @@ -44,8 +45,9 @@ public function __construct( methods: ['GET'], schemes: ['https'], )] - public function __invoke(): Response - { + public function __invoke( + Request $request, + ): Response { if (!$this->enabled) { throw $this->createAccessDeniedException(); } @@ -53,6 +55,8 @@ public function __invoke(): Response $user = $this->userProvider->getCurrentUser(); $inviteRoles = $this->inviteRepository->findAllFor($user->getUserIdentifier()); + $inviteRoles->sortByApplicationDisplayName($request->getLocale()); + return $this->render('@OpenConextProfile/InviteRoles/overview.html.twig', ['inviteRoles' => $inviteRoles]); } } From 3a7679b8c5d673be9648c703f8a241c74fc41965 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 29 Sep 2026 09:32:07 +0200 Subject: [PATCH 2/3] Add changelog entry for invite roles sorting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ff8db92..93fe849e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Next release * Upgrade the runtime and build toolchain to PHP 8.5 and Node.js 24. * Refresh the CI Docker base image plus Composer and npm dependencies for the PHP 8.5 build. +* Sort the invite roles overview by application display name #343 # 4.1.4 * Update saml2 library. From 7e08e00e70f96190e560259cd0c511c2d9589176 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 29 Sep 2026 09:55:19 +0200 Subject: [PATCH 3/3] Rename changelog heading to UNRELEASED --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fe849e0..5b5ec2144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Next release +# UNRELEASED * Upgrade the runtime and build toolchain to PHP 8.5 and Node.js 24. * Refresh the CI Docker base image plus Composer and npm dependencies for the PHP 8.5 build. * Sort the invite roles overview by application display name #343