Added paginated submissions and support for generalized pagination.

This commit is contained in:
Andrew Lalis 2023-03-25 13:23:16 +01:00
parent bb5cf53908
commit f36a1d8912
7 changed files with 130 additions and 13 deletions

View File

@ -54,7 +54,8 @@ public class SecurityConfig {
"/auth/users/*/access", "/auth/users/*/access",
"/auth/users/*/followers", "/auth/users/*/followers",
"/auth/users/*/following", "/auth/users/*/following",
"/users/*/recent-submissions" "/users/*/recent-submissions",
"/users/*/submissions"
).permitAll() ).permitAll()
.requestMatchers(// Allow the following POST endpoints to be public. .requestMatchers(// Allow the following POST endpoints to be public.
HttpMethod.POST, HttpMethod.POST,

View File

@ -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.dto.SubmissionResponse;
import nl.andrewlalis.gymboard_api.domains.api.service.submission.UserSubmissionService; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -20,4 +22,9 @@ public class UserSubmissionsController {
public List<SubmissionResponse> getRecentSubmissions(@PathVariable String userId) { public List<SubmissionResponse> getRecentSubmissions(@PathVariable String userId) {
return submissionService.getRecentSubmissions(userId); return submissionService.getRecentSubmissions(userId);
} }
@GetMapping(path = "/users/{userId}/submissions")
public Page<SubmissionResponse> getSubmissions(@PathVariable String userId, Pageable pageable) {
return submissionService.getSubmissions(userId, pageable);
}
} }

View File

@ -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.model.User;
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService; import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
import nl.andrewlalis.gymboard_api.util.PredicateBuilder; import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -14,6 +16,12 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List; 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 @Service
public class UserSubmissionService { public class UserSubmissionService {
private final UserRepository userRepository; private final UserRepository userRepository;
@ -28,8 +36,7 @@ public class UserSubmissionService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<SubmissionResponse> getRecentSubmissions(String userId) { public List<SubmissionResponse> getRecentSubmissions(String userId) {
User user = userRepository.findById(userId) User user = findByIdOrThrow(userId, userRepository);
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
userAccessService.enforceUserAccess(user); userAccessService.enforceUserAccess(user);
return submissionRepository.findAll((root, query, criteriaBuilder) -> { return submissionRepository.findAll((root, query, criteriaBuilder) -> {
@ -43,4 +50,16 @@ public class UserSubmissionService {
return pb.build(); return pb.build();
}, PageRequest.of(0, 5)).map(SubmissionResponse::new).toList(); }, PageRequest.of(0, 5)).map(SubmissionResponse::new).toList();
} }
@Transactional(readOnly = true)
public Page<SubmissionResponse> 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);
}
} }

View File

@ -2,3 +2,53 @@ export interface GeoPoint {
latitude: number; latitude: number;
longitude: number; longitude: number;
} }
export interface Page<Type> {
content: Array<Type>;
empty: boolean;
first: boolean;
last: boolean;
number: number;
totalElements: number;
totalPages: number;
}
export interface PaginationOptions {
page: number;
size: number;
sort?: Array<PaginationSort> | PaginationSort;
}
export function defaultPaginationOptions(): PaginationOptions {
return { page: 0, size: 10 };
}
export function toQueryParams(options: PaginationOptions): Record<string, any> {
const params: Record<string, any> = {
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'
}

View File

@ -1,12 +1,21 @@
import {api} from 'src/api/main'; import {api} from 'src/api/main';
import {AuthStoreType} from 'stores/auth-store'; import {AuthStoreType} from 'stores/auth-store';
import {ExerciseSubmission, parseSubmission} from 'src/api/main/submission'; import {ExerciseSubmission, parseSubmission} from 'src/api/main/submission';
import {defaultPaginationOptions, Page, PaginationOptions, toQueryParams} from 'src/api/main/models';
class UsersModule { class UsersModule {
public async getRecentSubmissions(userId: string, authStore: AuthStoreType): Promise<Array<ExerciseSubmission>> { public async getRecentSubmissions(userId: string, authStore: AuthStoreType): Promise<Array<ExerciseSubmission>> {
const response = await api.get(`/users/${userId}/recent-submissions`, authStore.axiosConfig); const response = await api.get(`/users/${userId}/recent-submissions`, authStore.axiosConfig);
return response.data.map(parseSubmission); return response.data.map(parseSubmission);
} }
public async getSubmissions(userId: string, authStore: AuthStoreType, paginationOptions: PaginationOptions = defaultPaginationOptions()): Promise<Page<ExerciseSubmission>> {
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; export default UsersModule;

View File

@ -1,14 +1,19 @@
<template> <template>
<div> <div>
<div v-if="recentSubmissions.length > 0"> <div v-if="loadedSubmissions.length > 0">
<q-list separator> <q-list separator>
<ExerciseSubmissionListItem <ExerciseSubmissionListItem
v-for="sub in recentSubmissions" v-for="sub in loadedSubmissions"
:submission="sub" :submission="sub"
:key="sub.id" :key="sub.id"
:show-name="false" :show-name="false"
/> />
</q-list> </q-list>
<div class="text-center">
<q-btn id="loadMoreButton" v-if="lastSubmissionsPage && !lastSubmissionsPage.last" @click="loadNextPage(true)">
Load more
</q-btn>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -17,11 +22,12 @@
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import {useQuasar} from 'quasar'; import {useQuasar} from 'quasar';
import {useAuthStore} from 'stores/auth-store'; 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 {ExerciseSubmission} from 'src/api/main/submission';
import api from 'src/api/main'; import api from 'src/api/main';
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue'; import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
import {showApiErrorToast} from 'src/utils'; import {showApiErrorToast} from 'src/utils';
import {Page, PaginationOptions, PaginationSortDir} from 'src/api/main/models';
interface Props { interface Props {
userId: string; userId: string;
@ -32,14 +38,38 @@ const i18n = useI18n();
const quasar = useQuasar(); const quasar = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const recentSubmissions: Ref<ExerciseSubmission[]> = ref([]); const lastSubmissionsPage: Ref<Page<ExerciseSubmission> | undefined> = ref();
const loadedSubmissions: Ref<ExerciseSubmission[]> = ref([]);
const paginationOptions: PaginationOptions = {page: 0, size: 10};
onMounted(async () => { onMounted(async () => {
try { resetPagination();
recentSubmissions.value = await api.users.getRecentSubmissions(props.userId, authStore); await loadNextPage(false);
} catch (error: any) {
showApiErrorToast(i18n, quasar);
}
}); });
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 };
}
</script> </script>
<style scoped> <style scoped>

View File

@ -7,6 +7,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { User } from 'src/api/main/auth'; import { User } from 'src/api/main/auth';
import {AxiosRequestConfig} from "axios";
interface AuthState { interface AuthState {
user: User | null; user: User | null;
@ -19,7 +20,7 @@ export const useAuthStore = defineStore('authStore', {
}, },
getters: { getters: {
loggedIn: (state) => state.user !== null && state.token !== null, loggedIn: (state) => state.user !== null && state.token !== null,
axiosConfig(state) { axiosConfig(state): AxiosRequestConfig {
if (this.token !== null) { if (this.token !== null) {
return { return {
headers: { Authorization: 'Bearer ' + state.token }, headers: { Authorization: 'Bearer ' + state.token },