From f36a1d891278c8a7e5f3a66d79b69390fbea00d1 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Sat, 25 Mar 2023 13:23:16 +0100 Subject: [PATCH] Added paginated submissions and support for generalized pagination. --- .../gymboard_api/config/SecurityConfig.java | 3 +- .../controller/UserSubmissionsController.java | 7 +++ .../submission/UserSubmissionService.java | 23 ++++++++- gymboard-app/src/api/main/models.ts | 50 +++++++++++++++++++ gymboard-app/src/api/main/users.ts | 9 ++++ .../src/pages/user/UserSubmissionsPage.vue | 48 ++++++++++++++---- gymboard-app/src/stores/auth-store.ts | 3 +- 7 files changed, 130 insertions(+), 13 deletions(-) diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java index 77add9f..5fe13f7 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -54,7 +54,8 @@ public class SecurityConfig { "/auth/users/*/access", "/auth/users/*/followers", "/auth/users/*/following", - "/users/*/recent-submissions" + "/users/*/recent-submissions", + "/users/*/submissions" ).permitAll() .requestMatchers(// Allow the following POST endpoints to be public. HttpMethod.POST, diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/UserSubmissionsController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/UserSubmissionsController.java index bbd140b..2729a5d 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/UserSubmissionsController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/UserSubmissionsController.java @@ -2,6 +2,8 @@ package nl.andrewlalis.gymboard_api.domains.api.controller; import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.service.submission.UserSubmissionService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; @@ -20,4 +22,9 @@ public class UserSubmissionsController { public List getRecentSubmissions(@PathVariable String userId) { return submissionService.getRecentSubmissions(userId); } + + @GetMapping(path = "/users/{userId}/submissions") + public Page getSubmissions(@PathVariable String userId, Pageable pageable) { + return submissionService.getSubmissions(userId, pageable); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java index d051ed5..4b0fd76 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/UserSubmissionService.java @@ -6,7 +6,9 @@ import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.model.User; import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService; import nl.andrewlalis.gymboard_api.util.PredicateBuilder; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +16,12 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; +import static nl.andrewlalis.gymboard_api.util.DataUtils.findByIdOrThrow; + +/** + * Service for dealing with user submissions; primarily fetching them in a + * variety of ways. + */ @Service public class UserSubmissionService { private final UserRepository userRepository; @@ -28,8 +36,7 @@ public class UserSubmissionService { @Transactional(readOnly = true) public List getRecentSubmissions(String userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + User user = findByIdOrThrow(userId, userRepository); userAccessService.enforceUserAccess(user); return submissionRepository.findAll((root, query, criteriaBuilder) -> { @@ -43,4 +50,16 @@ public class UserSubmissionService { return pb.build(); }, PageRequest.of(0, 5)).map(SubmissionResponse::new).toList(); } + + @Transactional(readOnly = true) + public Page getSubmissions(String userId, Pageable pageable) { + User user = findByIdOrThrow(userId, userRepository); + userAccessService.enforceUserAccess(user); + + return submissionRepository.findAll((root, query, criteriaBuilder) -> { + PredicateBuilder pb = PredicateBuilder.and(criteriaBuilder); + pb.with(criteriaBuilder.equal(root.get("user"), user)); + return pb.build(); + }, pageable).map(SubmissionResponse::new); + } } diff --git a/gymboard-app/src/api/main/models.ts b/gymboard-app/src/api/main/models.ts index d922f0d..812ef79 100644 --- a/gymboard-app/src/api/main/models.ts +++ b/gymboard-app/src/api/main/models.ts @@ -2,3 +2,53 @@ export interface GeoPoint { latitude: number; longitude: number; } + +export interface Page { + content: Array; + empty: boolean; + first: boolean; + last: boolean; + number: number; + totalElements: number; + totalPages: number; +} + +export interface PaginationOptions { + page: number; + size: number; + sort?: Array | PaginationSort; +} + +export function defaultPaginationOptions(): PaginationOptions { + return { page: 0, size: 10 }; +} + +export function toQueryParams(options: PaginationOptions): Record { + const params: Record = { + page: options.page, + size: options.size + }; + if (options.sort) { + if (Array.isArray(options.sort)) { + params.sort = options.sort.map(s => s.propertyName + ',' + s.sortDir); + } else { + params.sort = options.sort.propertyName + ',' + options.sort.sortDir; + } + } + return params; +} + +export class PaginationSort { + public readonly propertyName: string; + public readonly sortDir: PaginationSortDir; + + constructor(propertyName: string, sortDir: PaginationSortDir = PaginationSortDir.ASC) { + this.propertyName = propertyName; + this.sortDir = sortDir; + } +} + +export enum PaginationSortDir { + ASC = 'asc', + DESC = 'desc' +} diff --git a/gymboard-app/src/api/main/users.ts b/gymboard-app/src/api/main/users.ts index d757610..fca5d10 100644 --- a/gymboard-app/src/api/main/users.ts +++ b/gymboard-app/src/api/main/users.ts @@ -1,12 +1,21 @@ import {api} from 'src/api/main'; import {AuthStoreType} from 'stores/auth-store'; import {ExerciseSubmission, parseSubmission} from 'src/api/main/submission'; +import {defaultPaginationOptions, Page, PaginationOptions, toQueryParams} from 'src/api/main/models'; class UsersModule { public async getRecentSubmissions(userId: string, authStore: AuthStoreType): Promise> { const response = await api.get(`/users/${userId}/recent-submissions`, authStore.axiosConfig); return response.data.map(parseSubmission); } + + public async getSubmissions(userId: string, authStore: AuthStoreType, paginationOptions: PaginationOptions = defaultPaginationOptions()): Promise> { + const config = structuredClone(authStore.axiosConfig); + config.params = toQueryParams(paginationOptions); + const response = await api.get(`/users/${userId}/submissions`, {...toQueryParams(paginationOptions), ...authStore.axiosConfig}); + response.data.content = response.data.content.map(parseSubmission); + return response.data; + } } export default UsersModule; diff --git a/gymboard-app/src/pages/user/UserSubmissionsPage.vue b/gymboard-app/src/pages/user/UserSubmissionsPage.vue index 34811e3..e57bad8 100644 --- a/gymboard-app/src/pages/user/UserSubmissionsPage.vue +++ b/gymboard-app/src/pages/user/UserSubmissionsPage.vue @@ -1,14 +1,19 @@ @@ -17,11 +22,12 @@ import {useI18n} from 'vue-i18n'; import {useQuasar} from 'quasar'; import {useAuthStore} from 'stores/auth-store'; -import {onMounted, ref, Ref} from 'vue'; +import {nextTick, onMounted, ref, Ref} from 'vue'; import {ExerciseSubmission} from 'src/api/main/submission'; import api from 'src/api/main'; import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue'; import {showApiErrorToast} from 'src/utils'; +import {Page, PaginationOptions, PaginationSortDir} from 'src/api/main/models'; interface Props { userId: string; @@ -32,14 +38,38 @@ const i18n = useI18n(); const quasar = useQuasar(); const authStore = useAuthStore(); -const recentSubmissions: Ref = ref([]); +const lastSubmissionsPage: Ref | undefined> = ref(); +const loadedSubmissions: Ref = ref([]); +const paginationOptions: PaginationOptions = {page: 0, size: 10}; onMounted(async () => { - try { - recentSubmissions.value = await api.users.getRecentSubmissions(props.userId, authStore); - } catch (error: any) { - showApiErrorToast(i18n, quasar); - } + resetPagination(); + await loadNextPage(false); }); + +async function loadNextPage(scroll: boolean) { + try { + lastSubmissionsPage.value = await api.users.getSubmissions(props.userId, authStore, paginationOptions); + loadedSubmissions.value.push(...lastSubmissionsPage.value.content); + paginationOptions.page++; + await nextTick(); + const button = document.getElementById('loadMoreButton'); + if (scroll && button) { + button.scrollIntoView({ behavior: 'smooth' }); + } + } catch (error: any) { + if (error.response) { + showApiErrorToast(i18n, quasar); + } else { + console.log(error); + } + } +} + +function resetPagination() { + paginationOptions.page = 0; + paginationOptions.size = 10; + paginationOptions.sort = { propertyName: 'performedAt', sortDir: PaginationSortDir.DESC }; +}