Added paginated submissions and support for generalized pagination.
This commit is contained in:
parent
bb5cf53908
commit
f36a1d8912
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
Loading…
Reference in New Issue