From bdc84af96fde53f8386a1761071be64a890125f4 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Wed, 11 Dec 2024 16:59:12 +0100 Subject: [PATCH 1/5] WIP for #336 --- .../repository/InvitationRepository.java | 55 ++++++++++++++++++- .../migration/V42_0__invitations_indexes.sql | 9 +++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 server/src/main/resources/db/mysql/migration/V42_0__invitations_indexes.sql diff --git a/server/src/main/java/access/repository/InvitationRepository.java b/server/src/main/java/access/repository/InvitationRepository.java index 5ce541bb..fc75a222 100644 --- a/server/src/main/java/access/repository/InvitationRepository.java +++ b/server/src/main/java/access/repository/InvitationRepository.java @@ -3,15 +3,21 @@ import access.model.Invitation; import access.model.Role; import access.model.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Map; import java.util.Optional; @Repository -public interface InvitationRepository extends JpaRepository { +public interface InvitationRepository extends JpaRepository, QueryRewriter { @EntityGraph(value = "findByHash", type = EntityGraph.EntityGraphType.LOAD, attributePaths = {"inviter", "roles", "roles.role"}) @@ -23,5 +29,50 @@ public interface InvitationRepository extends JpaRepository { List findByStatusAndRoles_role(Status status, Role role); - List findByRoles_roleIsIn(List roles); + + @Query(value = """ + SELECT i.id, i.email, i.intended_authority,i.created_at, i.expiry_date, + u.name, u.email, + (SELECT GROUP_CONCAT(DISTINCT r.name) FROM roles r WHERE r.id = ir.role_id) as role_names + FROM invitations i INNER JOIN users u ON u.id = i.inviter_id INNER JOIN invitation_roles ir ON ir.invitation_id = i.id + INNER JOIN roles r ON r.id = ir.role_id + WHERE i.status = ?1 + """, + countQuery = "SELECT count(*) FROM invitations WHERE status = ?1", + queryRewriter = InvitationRepository.class, + nativeQuery = true) + Page> searchByStatusPage(Status status, Pageable pageable); + + @Query(value = """ + SELECT i.id, i.email, i.intended_authority,i.created_at, i.expiry_date, + u.name, u.email, + (SELECT GROUP_CONCAT(DISTINCT r.name) FROM roles r WHERE r.id = ir.role_id) as role_names + FROM invitations i INNER JOIN users u ON u.id = i.inviter_id INNER JOIN invitation_roles ir ON ir.invitation_id = i.id + INNER JOIN roles r ON r.id = ir.role_id + WHERE i.status = ?1 AND r.id = ?2 + """, + countQuery = """ + SELECT count(*) FROM invitations + INNER JOIN invitation_roles ir ON ir.invitation_id = i.id + INNER JOIN roles r ON r.id = ir.role_id + WHERE status = ?1 and role_id = ?1 + """, + queryRewriter = InvitationRepository.class, + nativeQuery = true) + Page> searchByStatusAndRolePage(Status status, Long roleId, Pageable pageable); + + @Override + default String rewrite(String query, Sort sort) { + Sort.Order roleNameSort = sort.getOrderFor("role_names"); + if (roleNameSort != null) { + return query.replace("order by i.role_names", "order by role_names"); + } + Sort.Order nameSort = sort.getOrderFor("name"); + if (nameSort != null) { + return query.replace("order by i.name", "order by u.name"); + } + return query; + } + + } diff --git a/server/src/main/resources/db/mysql/migration/V42_0__invitations_indexes.sql b/server/src/main/resources/db/mysql/migration/V42_0__invitations_indexes.sql new file mode 100644 index 00000000..115317bd --- /dev/null +++ b/server/src/main/resources/db/mysql/migration/V42_0__invitations_indexes.sql @@ -0,0 +1,9 @@ +ALTER TABLE `invitations` + ADD INDEX `status_index` (`status`); +ALTER TABLE `invitations` + ADD INDEX `created_at_index` (`created_at`); +ALTER TABLE `invitations` + ADD INDEX `expiry_date_index` (`expiry_date`); +ALTER TABLE `invitations` + ADD FULLTEXT INDEX `email_index` (`email`); + From df0c66a06124ab7e9f97e1344caf3c3d49b70f4b Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Thu, 12 Dec 2024 14:05:14 +0100 Subject: [PATCH 2/5] Integration tests for #336 --- .../java/access/api/InvitationController.java | 61 ++++++- .../repository/InvitationRepository.java | 66 ++++++-- .../access/repository/RoleRepository.java | 10 +- server/src/main/resources/application.yml | 6 +- .../access/api/InvitationControllerTest.java | 153 ++++++++++++++++++ 5 files changed, 269 insertions(+), 27 deletions(-) diff --git a/server/src/main/java/access/api/InvitationController.java b/server/src/main/java/access/api/InvitationController.java index 9ce82a83..cf478464 100644 --- a/server/src/main/java/access/api/InvitationController.java +++ b/server/src/main/java/access/api/InvitationController.java @@ -26,6 +26,10 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -36,6 +40,7 @@ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -261,18 +266,15 @@ public ResponseEntity> accept(@Validated @RequestBody Accept Set manageIdentifiers = provisioning.getRemoteApplications().stream() .map(app -> app.manageId()).collect(Collectors.toSet()); newUserRoles.stream() - .filter(userRole -> userRole.getRole().getApplicationUsages().stream() - .anyMatch(appUsage -> manageIdentifiers.contains(appUsage.getApplication().getManageId()))) - .map(userRole -> userRole.getRole()) - .sorted(Comparator.comparing(Role::getName)) - .findFirst() + .filter(role -> role.getApplicationUsages().stream() + .anyMatch(appUsage -> manageIdentifiers.contains(appUsage.getApplication().getManageId()))) + .min(Comparator.comparing(Role::getName)) .ifPresent(role -> { body.put("userWaitTime", provisioning.getUserWaitTime()); body.put("role", role.getName()); }); }); - return ResponseEntity.status(HttpStatus.CREATED).body(body); } @@ -305,12 +307,59 @@ private void saveOAuth2AuthenticationToken(Authentication authentication, @GetMapping("roles/{roleId}") public ResponseEntity> byRole(@PathVariable("roleId") Long roleId, @Parameter(hidden = true) User user) { LOG.debug(String.format("/roles/%s by user %s", roleId, user.getEduPersonPrincipalName())); + Role role = roleRepository.findById(roleId).orElseThrow(() -> new NotFoundException("Role not found")); UserPermissions.assertRoleAccess(user, role, Authority.INVITER); List invitations = invitationRepository.findByStatusAndRoles_role(Status.OPEN, role); return ResponseEntity.ok(invitations); } + @GetMapping("search") + public ResponseEntity>> search(@Parameter(hidden = true) User user, + @RequestParam(value = "roleId", required = false) Long roleId, + @RequestParam(value = "query", required = false, defaultValue = "") String query, + @RequestParam(value = "pageNumber", required = false, defaultValue = "0") int pageNumber, + @RequestParam(value = "pageSize", required = false, defaultValue = "10") int pageSize, + @RequestParam(value = "sort", required = false, defaultValue = "name") String sort, + @RequestParam(value = "sortDirection", required = false, defaultValue = "ASC") String sortDirection) { + LOG.debug(String.format("/search for invitations %s", user.getEduPersonPrincipalName())); + + Page> invitationsPage; + Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.fromString(sortDirection), sort)); + + if (roleId == null) { + UserPermissions.assertSuperUser(user); + invitationsPage = StringUtils.hasText(query) ? + invitationRepository.searchByStatusPageWithKeyword(Status.OPEN, FullSearchQueryParser.parse(query), pageable) : + invitationRepository.searchByStatusPage(Status.OPEN, pageable); + } else { + Role role = roleRepository.findById(roleId).orElseThrow(() -> new NotFoundException("Role not found")); + UserPermissions.assertRoleAccess(user, role, Authority.INVITER); + invitationsPage = StringUtils.hasText(query) ? + invitationRepository.searchByStatusAndRoleWithKeywordPage(Status.OPEN, role.getId(), FullSearchQueryParser.parse(query), pageable) : + invitationRepository.searchByStatusAndRolePage(Status.OPEN, role.getId(), pageable); + } + if (invitationsPage.getTotalElements() == 0L) { + return ResponseEntity.ok(invitationsPage); + } + List invitationIdentifiers = invitationsPage.getContent().stream().map(m -> (Long) m.get("id")).toList(); + Map>> groupedRoleNames = invitationRepository + .findRoles(invitationIdentifiers) + .stream() + .collect(Collectors.groupingBy(m -> (Long) m.get("id"))); + List> invitations = invitationsPage.getContent() + .stream() + //Must copy to avoid java.lang.UnsupportedOperationException: A TupleBackedMap cannot be modified + .map(invitationMap -> { + Map copy = new HashMap<>(invitationMap); + copy.put("roles", groupedRoleNames.get((Long) invitationMap.get("id"))); + return copy; + }) + .toList(); + return Pagination.of(invitationsPage, invitations); + } + + private void checkEmailEquality(User user, Invitation invitation) { if (invitation.isEnforceEmailEquality() && !invitation.getEmail().equalsIgnoreCase(user.getEmail())) { throw new InvitationEmailMatchingException( diff --git a/server/src/main/java/access/repository/InvitationRepository.java b/server/src/main/java/access/repository/InvitationRepository.java index fc75a222..cf054a50 100644 --- a/server/src/main/java/access/repository/InvitationRepository.java +++ b/server/src/main/java/access/repository/InvitationRepository.java @@ -29,14 +29,11 @@ public interface InvitationRepository extends JpaRepository, Q List findByStatusAndRoles_role(Status status, Role role); - @Query(value = """ SELECT i.id, i.email, i.intended_authority,i.created_at, i.expiry_date, - u.name, u.email, - (SELECT GROUP_CONCAT(DISTINCT r.name) FROM roles r WHERE r.id = ir.role_id) as role_names - FROM invitations i INNER JOIN users u ON u.id = i.inviter_id INNER JOIN invitation_roles ir ON ir.invitation_id = i.id - INNER JOIN roles r ON r.id = ir.role_id - WHERE i.status = ?1 + u.name, u.email as inviter_email + FROM invitations i INNER JOIN users u ON u.id = i.inviter_id + WHERE i.status = ?1 """, countQuery = "SELECT count(*) FROM invitations WHERE status = ?1", queryRewriter = InvitationRepository.class, @@ -45,28 +42,69 @@ public interface InvitationRepository extends JpaRepository, Q @Query(value = """ SELECT i.id, i.email, i.intended_authority,i.created_at, i.expiry_date, - u.name, u.email, - (SELECT GROUP_CONCAT(DISTINCT r.name) FROM roles r WHERE r.id = ir.role_id) as role_names + u.name, u.email as inviter_email + FROM invitations i INNER JOIN users u ON u.id = i.inviter_id + WHERE i.status = ?1 AND + (MATCH(i.email) AGAINST(?2 IN BOOLEAN MODE) + OR MATCH (u.given_name, u.family_name, u.email) against (?2 IN BOOLEAN MODE)) + """, + countQuery = """ + SELECT count(*) FROM invitations i INNER JOIN users u ON u.id = i.inviter_id + WHERE status = ?1 AND + (MATCH(i.email) AGAINST(?2 IN BOOLEAN MODE) + OR MATCH (u.given_name, u.family_name, u.email) against (?2 IN BOOLEAN MODE)) + """, + queryRewriter = InvitationRepository.class, + nativeQuery = true) + Page> searchByStatusPageWithKeyword(Status status, String keyWord, Pageable pageable); + + @Query(value = """ + SELECT i.id, i.email, i.intended_authority,i.created_at, i.expiry_date, + u.name, u.email as inviter_email FROM invitations i INNER JOIN users u ON u.id = i.inviter_id INNER JOIN invitation_roles ir ON ir.invitation_id = i.id INNER JOIN roles r ON r.id = ir.role_id WHERE i.status = ?1 AND r.id = ?2 """, countQuery = """ - SELECT count(*) FROM invitations + SELECT count(*) FROM invitations i INNER JOIN invitation_roles ir ON ir.invitation_id = i.id INNER JOIN roles r ON r.id = ir.role_id - WHERE status = ?1 and role_id = ?1 + WHERE status = ?1 and role_id = ?2 """, queryRewriter = InvitationRepository.class, nativeQuery = true) Page> searchByStatusAndRolePage(Status status, Long roleId, Pageable pageable); + @Query(value = """ + SELECT i.id, i.email, i.intended_authority,i.created_at, i.expiry_date, + u.name, u.email as inviter_email + FROM invitations i INNER JOIN users u ON u.id = i.inviter_id INNER JOIN invitation_roles ir ON ir.invitation_id = i.id + INNER JOIN roles r ON r.id = ir.role_id + WHERE i.status = ?1 AND r.id = ?2 AND + (MATCH(i.email) AGAINST(?3 IN BOOLEAN MODE) + OR MATCH (u.given_name, u.family_name, u.email) against (?3 IN BOOLEAN MODE)) + """, + countQuery = """ + SELECT count(*) FROM invitations i + INNER JOIN invitation_roles ir ON ir.invitation_id = i.id + INNER JOIN roles r ON r.id = ir.role_id + INNER JOIN users u ON u.id = i.inviter_id + WHERE status = ?1 and role_id = ?2 AND + (MATCH(i.email) AGAINST(?3 IN BOOLEAN MODE) + OR MATCH (u.given_name, u.family_name, u.email) against (?3 IN BOOLEAN MODE)) + """, + queryRewriter = InvitationRepository.class, + nativeQuery = true) + Page> searchByStatusAndRoleWithKeywordPage(Status status, Long roleId, String keyWord, Pageable pageable); + + @Query(value = """ + SELECT i.id, r.name FROM roles r INNER JOIN invitation_roles ir ON ir.role_id = r.id + INNER JOIN invitations i ON i.id = ir.invitation_id WHERE i.id IN ?1 + """, nativeQuery = true) + List> findRoles(List invitationIdentifiers); + @Override default String rewrite(String query, Sort sort) { - Sort.Order roleNameSort = sort.getOrderFor("role_names"); - if (roleNameSort != null) { - return query.replace("order by i.role_names", "order by role_names"); - } Sort.Order nameSort = sort.getOrderFor("name"); if (nameSort != null) { return query.replace("order by i.name", "order by u.name"); diff --git a/server/src/main/java/access/repository/RoleRepository.java b/server/src/main/java/access/repository/RoleRepository.java index 6eef3195..323f900f 100644 --- a/server/src/main/java/access/repository/RoleRepository.java +++ b/server/src/main/java/access/repository/RoleRepository.java @@ -48,8 +48,8 @@ SELECT COUNT(r.id) FROM roles r WHERE MATCH (name, description) against (?1 IN B @Query(value = """ - SELECT r.id as id, r.name as name, r.description as description, - (SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id=r.id) as userRoleCount + SELECT r.id AS id, r.name AS name, r.description AS description, + (SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id=r.id) AS userRoleCount FROM roles r WHERE r.organization_guid = ?1 """, countQuery = """ @@ -60,9 +60,9 @@ SELECT COUNT(r.id) FROM roles r WHERE r.organization_guid = ?1 Page> searchByPageAndOrganizationGUID(String organizationGUID, Pageable pageable); @Query(value = """ - SELECT r.id as role_id, a.manage_id as manage_id, a.manage_type as manage_type - FROM applications a INNER JOIN application_usages au on au.application_id = a.id - INNER JOIN roles r on au.role_id = r.id WHERE r.id in ?1 + SELECT r.id AS role_id, a.manage_id AS manage_id, a.manage_type AS manage_type + FROM applications a INNER JOIN application_usages au ON au.application_id = a.id + INNER JOIN roles r ON au.role_id = r.id WHERE r.id IN ?1 """, nativeQuery = true) List> findApplications(List roleIdentifiers); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 16f58ced..26965c89 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -6,7 +6,11 @@ logging: org.mariadb.jdbc.message.server: ERROR org.springframework.security: INFO access: DEBUG +# sql: DEBUG +# org.hibernate.type.descriptor.sql.BasicBinder: TRACE +# org.hibernate.orm.jdbc.bind: TRACE threshold: +# console: TRACE console: WARN server: @@ -53,9 +57,7 @@ spring: properties: hibernate: naming-strategy: org.hibernate.cfg.ImprovedNamingStrategy - format_sql: false open-in-view: false - show-sql: false datasource: driver-class-name: org.mariadb.jdbc.Driver url: jdbc:mysql://localhost/invite?autoReconnect=true&useSSL=false&permitMysqlScheme=true&allowPublicKeyRetrieval=true diff --git a/server/src/test/java/access/api/InvitationControllerTest.java b/server/src/test/java/access/api/InvitationControllerTest.java index 027636df..b465fbf5 100644 --- a/server/src/test/java/access/api/InvitationControllerTest.java +++ b/server/src/test/java/access/api/InvitationControllerTest.java @@ -2,6 +2,7 @@ import access.AbstractTest; import access.AccessCookieFilter; +import access.DefaultPage; import access.exception.NotFoundException; import access.manage.EntityType; import access.model.*; @@ -12,6 +13,7 @@ import io.restassured.http.ContentType; import org.junit.jupiter.api.Test; import org.springframework.data.domain.Example; +import org.springframework.data.domain.Sort; import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriComponentsBuilder; @@ -650,4 +652,155 @@ void resendInviteMailExpirationDate() throws Exception { assertTrue(savedInvitation.getExpiryDate().isAfter(Instant.now().plus(13, ChronoUnit.DAYS))); } + @Test + void invitationsSearchSuperUser() throws Exception { + AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", SUPER_SUB); + + DefaultPage> page = given() + .when() + .filter(accessCookieFilter.cookieFilter()) + .accept(ContentType.JSON) + .header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken()) + .queryParam("pageNumber", 0) + .queryParam("pageSize", 3) + .queryParam("sort", "email") + .queryParam("sortDirection", Sort.Direction.DESC.name()) + .contentType(ContentType.JSON) + .get("/api/v1/invitations/search") + .as(new TypeRef<>() { + }); + + assertEquals(6, page.getTotalElements()); + assertEquals(3, page.getContent().size()); + } + + @Test + void invitationsSearchSuperUserWithKeyword() throws Exception { + AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", SUPER_SUB); + + DefaultPage> page = given() + .when() + .filter(accessCookieFilter.cookieFilter()) + .accept(ContentType.JSON) + .header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken()) + .queryParam("query", "invite") + .queryParam("pageNumber", 0) + .queryParam("pageSize", 3) + .queryParam("sort", "created_at") + .queryParam("sortDirection", Sort.Direction.DESC.name()) + .contentType(ContentType.JSON) + .get("/api/v1/invitations/search") + .as(new TypeRef<>() { + }); + + assertEquals(1, page.getTotalElements()); + List> invitations = page.getContent(); + Map invitation = invitations.getFirst(); + List actual = ((List>) invitation.get("roles")) + .stream() + .map(m -> (String) m.get("name")) + .sorted() + .toList(); + assertEquals(List.of("Calendar", "Mail"), actual); + assertEquals("inviter@new.com", invitation.get("email")); + assertEquals("paul.doe@example.com", invitation.get("inviter_email")); + } + + @Test + void invitationsSearchInviter() throws Exception { + AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", INVITER_SUB); + Role role = roleRepository.findByName("Calendar").get(); + + DefaultPage> page = given() + .when() + .filter(accessCookieFilter.cookieFilter()) + .accept(ContentType.JSON) + .header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken()) + .queryParam("roleId", role.getId()) + .queryParam("pageNumber", 0) + .queryParam("pageSize", 1) + .queryParam("sort", "email") + .queryParam("sortDirection", Sort.Direction.DESC.name()) + .contentType(ContentType.JSON) + .get("/api/v1/invitations/search") + .as(new TypeRef<>() { + }); + + assertEquals(1, page.getTotalElements()); + List> invitations = page.getContent(); + assertEquals(1, invitations.size()); + + Map invitation = invitations.getFirst(); + List actual = ((List>) invitation.get("roles")) + .stream() + .map(m -> (String) m.get("name")) + .sorted() + .toList(); + assertEquals(List.of("Calendar", "Mail"), actual); + } + + @Test + void invitationsSearchInviterWithKeyword() throws Exception { + AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", INVITER_SUB); + Role role = roleRepository.findByName("Calendar").get(); + + DefaultPage> page = given() + .when() + .filter(accessCookieFilter.cookieFilter()) + .accept(ContentType.JSON) + .header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken()) + .queryParam("roleId", role.getId()) + .queryParam("query", "john") + .queryParam("pageNumber", 0) + .queryParam("pageSize", 3) + .queryParam("sort", "name") + .queryParam("sortDirection", Sort.Direction.DESC.name()) + .contentType(ContentType.JSON) + .get("/api/v1/invitations/search") + .as(new TypeRef<>() { + }); + + assertEquals(1, page.getTotalElements()); + } + + @Test + void invitationsSearchInviterWithKeywordNoResults() throws Exception { + AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", INVITER_SUB); + Role role = roleRepository.findByName("Calendar").get(); + + DefaultPage> page = given() + .when() + .filter(accessCookieFilter.cookieFilter()) + .accept(ContentType.JSON) + .header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken()) + .queryParam("roleId", role.getId()) + .queryParam("query", "NOPE") + .queryParam("pageNumber", 0) + .queryParam("pageSize", 3) + .queryParam("sort", "name") + .queryParam("sortDirection", Sort.Direction.DESC.name()) + .contentType(ContentType.JSON) + .get("/api/v1/invitations/search") + .as(new TypeRef<>() { + }); + + assertEquals(0, page.getTotalElements()); + } + + @Test + void invitationsSearchInviterWithKeyword404() throws Exception { + AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", INVITER_SUB); + given() + .when() + .filter(accessCookieFilter.cookieFilter()) + .accept(ContentType.JSON) + .header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken()) + .queryParam("roleId", 999) + .contentType(ContentType.JSON) + .get("/api/v1/invitations/search") + .then() + .statusCode(404); + } + + } \ No newline at end of file From f9d3747fa359196a76e5debc66de382a9a74695e Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Thu, 12 Dec 2024 16:29:30 +0100 Subject: [PATCH 3/5] Client side refactoring for #336 --- client/src/api/index.js | 8 + client/src/locale/en.js | 7 +- client/src/locale/nl.js | 7 +- client/src/tabs/Invitations.js | 215 +++++++----------- client/src/tabs/Invitations.scss | 29 +-- client/src/utils/Authority.js | 4 +- client/src/utils/Pagination.js | 3 + .../repository/InvitationRepository.java | 8 +- .../java/access/seed/PerformanceSeed.java | 7 +- 9 files changed, 124 insertions(+), 164 deletions(-) diff --git a/client/src/api/index.js b/client/src/api/index.js index 0aab4ce5..d4d5d18c 100644 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -147,6 +147,14 @@ export function allInvitations() { return fetchJson(`/api/v1/invitations/all`, {}, {}, false); } +export function searchInvitations(roleId, pagination = {}) { + if (roleId) { + pagination.roleId = roleId; + } + const queryPart = paginationQueryParams(pagination, {}) + return fetchJson(`/api/v1/invitations/search?${queryPart}`, {}, {}, false); +} + //Manage export function allProviders() { return fetchJson("/api/v1/manage/providers"); diff --git a/client/src/locale/en.js b/client/src/locale/en.js index cee68ea7..f39f7765 100644 --- a/client/src/locale/en.js +++ b/client/src/locale/en.js @@ -227,10 +227,7 @@ const en = { delete: "Remove" }, invitations: { - found: "{{count}} {{plural}} found", - foundWithStatus: "{{count}} {{status}} {{plural}}", - singleInvitation: "invitation", - multipleInvitations: "invitations", + title: "Invitations", searchPlaceHolder: "Search for invitation...", noResults: "No invitation where found", inviter: "Invited by", @@ -254,7 +251,7 @@ const en = { roles: "Roles", inviterRoles: "Select the roles for the new invitation", rolesPlaceHolder: "Choose one or more roles", - expiryDate: "Invite valid till", + expiryDate: "Valid till", acceptedAt: "Date accepted", roleExpiryDate: "Role expiry date", roleExpiryDateQuestion: "Set a custom role expiration period", diff --git a/client/src/locale/nl.js b/client/src/locale/nl.js index 5e0d1dff..670b9cb3 100644 --- a/client/src/locale/nl.js +++ b/client/src/locale/nl.js @@ -227,10 +227,7 @@ const nl = { delete: "Verwijder" }, invitations: { - found: "{{count}} {{plural}} gevonden", - foundWithStatus: "{{count}} {{status}} {{plural}}", - singleInvitation: "uitnodiging", - multipleInvitations: "uitnodigingen", + title: "Uitnodigingen", searchPlaceHolder: "Zoek uitnodiging...", noResults: "Geen uitnodigingen gevonden", inviter: "Uitgenodigd door", @@ -254,7 +251,7 @@ const nl = { roles: "Rollen", inviterRoles: "Selecteer de rollen voor de nieuwe uitnodiging", rolesPlaceHolder: "Kies een of meer rollen", - expiryDate: "Uitnodiging geldig tot", + expiryDate: "Geldig tot", acceptedAt: "Datum geaccepteeerd", roleExpiryDate: "Verloopdatum rol", roleExpiryDateQuestion: "Zet een specifieke verloopdatum voor de rol", diff --git a/client/src/tabs/Invitations.js b/client/src/tabs/Invitations.js index de0ca407..5cd96960 100644 --- a/client/src/tabs/Invitations.js +++ b/client/src/tabs/Invitations.js @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from "react"; +import React, {useEffect, useState} from "react"; import I18n from "../locale/I18n"; import "./Invitations.scss"; import {Button, ButtonSize, ButtonType, Checkbox, Chip, Loader, Tooltip} from "@surfnet/sds"; @@ -8,18 +8,17 @@ import {shortDateFromEpoch} from "../utils/Date"; import {chipTypeForUserRole, invitationExpiry} from "../utils/Authority"; import {useNavigate} from "react-router-dom"; -import {allInvitations, deleteInvitation, invitationsByRoleId, resendInvitation} from "../api"; +import {deleteInvitation, resendInvitation, searchInvitations} from "../api"; import ConfirmationDialog from "../components/ConfirmationDialog"; import {useAppStore} from "../stores/AppStore"; import {isEmpty, pseudoGuid} from "../utils/Utils"; import {allowedToDeleteInvitation, AUTHORITIES, INVITATION_STATUS, isUserAllowed} from "../utils/UserRole"; import {UnitHeader} from "../components/UnitHeader"; -import Select from "react-select"; import {ReactComponent as TrashIcon} from "@surfnet/sds/icons/functional-icons/bin.svg"; import {ReactComponent as ResendIcon} from "@surfnet/sds/icons/functional-icons/go-to-other-website.svg"; +import {defaultPagination, pageCount} from "../utils/Pagination"; +import debounce from "lodash.debounce"; -const allValue = "all"; -const mineValue = "mine"; export const Invitations = ({ role, @@ -30,73 +29,72 @@ export const Invitations = ({ }) => { const navigate = useNavigate(); const {user, setFlash} = useAppStore(state => state); - const invitations = useRef(); + const [invitations, setInvitations] = useState([]); const [selectedInvitations, setSelectedInvitations] = useState({}); const [allSelected, setAllSelected] = useState(false); - const [resultAfterSearch, setResultAfterSearch] = useState([]) - const [loading, setLoading] = useState(true); + const [paginationQueryParams, setPaginationQueryParams] = useState(defaultPagination("email")); + const [totalElements, setTotalElements] = useState(0); + const [searching, setSearching] = useState(true); const [confirmation, setConfirmation] = useState({}); const [confirmationOpen, setConfirmationOpen] = useState(false); - const [filterOptions, setFilterOptions] = useState([]); - const [filterValue, setFilterValue] = useState(null); useEffect(() => { - const promise = systemView ? allInvitations() : invitationsByRoleId(role.id); - if (history) { - useAppStore.setState({ - breadcrumbPath: [ - {path: "/inviter", value: I18n.t("tabs.home")}, - {value: I18n.t("tabs.invitations")} - ] - }); - } - promise.then(res => { - res.forEach(invitation => { - invitation.intendedRoles = invitation.roles - .sort((r1, r2) => r1.role.name.localeCompare(r2.role.name)) - .map(role => role.role.name).join(", "); - const now = new Date(); - invitation.status = new Date(invitation.expiryDate * 1000) < now ? INVITATION_STATUS.EXPIRED : invitation.status; - }); - setSelectedInvitations(res - .reduce((acc, invitation) => { - acc[invitation.id] = { - selected: false, - ref: invitation, - allowed: allowedToDeleteInvitation(user, invitation) - }; - return acc; - }, {})); - invitations.current = res; - const newFilterOptions = [{ - label: I18n.t("invitations.statuses.all", {nbr: res.length}), - value: allValue - }]; - const statusOptions = res.reduce((acc, invitation) => { - const option = acc.find(opt => opt.status === invitation.status); - if (option) { - ++option.nbr; - } else { - acc.push({status: invitation.status, nbr: 1}) - } - return acc; - }, []).map(option => ({ - label: `${I18n.t("invitations.statuses." + option.status.toLowerCase())} (${option.nbr})`, - value: option.status - })).concat({ - label: `${I18n.t("invitations.statuses.mine")} (${res.filter(inv => inv.inviter.email === user.email).length})`, - value: mineValue - }).sort((o1, o2) => o1.label.localeCompare(o2.label)); - - setFilterOptions(newFilterOptions.concat(statusOptions)); - setFilterValue(newFilterOptions[0]); + if (history) { + useAppStore.setState({ + breadcrumbPath: [ + {path: "/inviter", value: I18n.t("tabs.home")}, + {value: I18n.t("tabs.invitations")} + ] + }); + } + }, [history]) - setResultAfterSearch(res); - //we need to avoid flickerings - setTimeout(() => setLoading(false), 75); - }) + useEffect(() => { + searchInvitations(systemView ? null : role.id, paginationQueryParams) + .then(page => { + const content = page.content; + content.forEach(invitation => { + invitation.intendedRoles = (invitation.roles || []) + .sort((r1, r2) => r1.name.localeCompare(r2.name)) + .map(role => role.name).join(", "); + const now = new Date(); + invitation.status = new Date(invitation.expiryDate * 1000) < now ? INVITATION_STATUS.EXPIRED : invitation.status; + }); + setInvitations(content); + setSelectedInvitations(content + .reduce((acc, invitation) => { + acc[invitation.id] = { + selected: false, + ref: invitation, + allowed: allowedToDeleteInvitation(user, invitation) + }; + return acc; + }, {})); + setAllSelected(false); + setTotalElements(page.totalElements); + //we need to avoid flickerings + setTimeout(() => setSearching(false), 75); + }) }, - [invitations, user]) // eslint-disable-line react-hooks/exhaustive-deps + [user, paginationQueryParams]) // eslint-disable-line react-hooks/exhaustive-deps + + const search = (query, sorted, reverse, page) => { + if (isEmpty(query) || query.trim().length > 2) { + delayedAutocomplete(query, sorted, reverse, page); + } + }; + + const delayedAutocomplete = debounce((query, sorted, reverse, page) => { + setSearching(true); + //this will trigger a new search + setPaginationQueryParams({ + query: query, + pageNumber: page, + pageSize: pageCount, + sort: sorted, + sortDirection: reverse ? "DESC" : "ASC" + }) + }, 375); const onCheck = invitation => e => { const checked = e.target.checked; @@ -119,8 +117,8 @@ export const Invitations = ({ const invitationIdentifiers = () => { return Object.entries(selectedInvitations) .filter(entry => (entry[1].selected) && entry[1].allowed) - .map(entry => parseInt(entry[0])) - .filter(id => resultAfterSearch.some(res => res.id === id)); + .map(entry => parseInt(entry[0])); + } const showCheckAllHeader = () => { @@ -209,14 +207,6 @@ export const Invitations = ({ } }; - if (loading) { - return - } - - const searchCallback = afterSearch => { - setResultAfterSearch(afterSearch); - } - const actionButtons = () => { if (isEmpty(invitationIdentifiers())) { return null; @@ -234,7 +224,7 @@ export const Invitations = ({ txt={I18n.t("invitations.delete")}/> }/> - {pending && +
}/> -
} + ); } - const filter = () => { - return ( -
-