forked from enviPath/enviPy
[Feature] Server pagination implementation (#243)
## Major Changes - Implement a REST style API app in epapi - Currently implements a GET method for all entity types in the browse menu (both package level and global) - Provides paginated results per default with query style filtering for reviewed vs unreviewed. - Provides new paginated templates with thin wrappers per entity types for easier maintainability - Implements e2e tests for the API ## Minor changes - Added more comprehensive gitignore to cover coverage reports and other test/node.js etc. data. - Add additional CI file for API tests that only gets triggered on API relevant changes. ## ⚠️ Currently only works with session-based authentication. Token based will be added in new PR. Co-authored-by: Tim Lorsbach <tim@lorsba.ch> Co-authored-by: jebus <lorsbach@envipath.com> Reviewed-on: enviPath/enviPy#243 Co-authored-by: Tobias O <tobias.olenyi@envipath.com> Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
@ -36,24 +36,17 @@
|
||||
@import "./daisyui-theme.css";
|
||||
|
||||
/* Loading Spinner - Benzene Ring */
|
||||
.loading-spinner {
|
||||
.benzene-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-spinner svg {
|
||||
.benzene-spinner svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.loading-spinner .hexagon,
|
||||
.loading-spinner .double-bonds {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
|
||||
@ -5,31 +5,26 @@
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('paginatedList', (initialItems = [], options = {}) => ({
|
||||
allItems: initialItems,
|
||||
filteredItems: [],
|
||||
Alpine.data('remotePaginatedList', (options = {}) => ({
|
||||
items: [],
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
perPage: options.perPage || 50,
|
||||
searchQuery: '',
|
||||
endpoint: options.endpoint || '',
|
||||
isReviewed: options.isReviewed || false,
|
||||
instanceId: options.instanceId || Math.random().toString(36).substring(2, 9),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
init() {
|
||||
this.filteredItems = this.allItems;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filteredItems.length / this.perPage);
|
||||
if (this.endpoint) {
|
||||
this.fetchPage(1);
|
||||
}
|
||||
},
|
||||
|
||||
get paginatedItems() {
|
||||
const start = (this.currentPage - 1) * this.perPage;
|
||||
const end = start + this.perPage;
|
||||
return this.filteredItems.slice(start, end);
|
||||
},
|
||||
|
||||
get totalItems() {
|
||||
return this.filteredItems.length;
|
||||
return this.items;
|
||||
},
|
||||
|
||||
get showingStart() {
|
||||
@ -38,36 +33,65 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
get showingEnd() {
|
||||
return Math.min(this.currentPage * this.perPage, this.totalItems);
|
||||
if (this.totalItems === 0) return 0;
|
||||
return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems);
|
||||
},
|
||||
|
||||
search(query) {
|
||||
this.searchQuery = query.toLowerCase();
|
||||
if (this.searchQuery === '') {
|
||||
this.filteredItems = this.allItems;
|
||||
} else {
|
||||
this.filteredItems = this.allItems.filter(item =>
|
||||
item.name.toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
async fetchPage(page) {
|
||||
if (!this.endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const url = new URL(this.endpoint, window.location.origin);
|
||||
// Preserve existing query parameters and add pagination params
|
||||
url.searchParams.set('page', page.toString());
|
||||
url.searchParams.set('page_size', this.perPage.toString());
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${this.endpoint} (status ${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.items = data.items || [];
|
||||
this.totalItems = data.total_items || 0;
|
||||
this.totalPages = data.total_pages || 0;
|
||||
this.currentPage = data.page || page;
|
||||
this.perPage = data.page_size || this.perPage;
|
||||
|
||||
// Dispatch event for parent components (e.g., tab count updates)
|
||||
this.$dispatch('items-loaded', { totalItems: this.totalItems });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this.error = `Unable to load ${this.endpoint}. Please try again.`;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.currentPage = 1;
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
this.fetchPage(this.currentPage + 1);
|
||||
}
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.fetchPage(this.currentPage - 1);
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.fetchPage(page);
|
||||
}
|
||||
},
|
||||
|
||||
@ -76,54 +100,43 @@ document.addEventListener('alpine:init', () => {
|
||||
const total = this.totalPages;
|
||||
const current = this.currentPage;
|
||||
|
||||
// Handle empty case
|
||||
if (total === 0) {
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (total <= 7) {
|
||||
// Show all pages if 7 or fewer
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||
}
|
||||
} else {
|
||||
// More than 7 pages - show first, last, and sliding window around current
|
||||
// Always show first page
|
||||
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
|
||||
|
||||
// Determine the start and end of the middle range
|
||||
let rangeStart, rangeEnd;
|
||||
let rangeStart;
|
||||
let rangeEnd;
|
||||
|
||||
if (current <= 4) {
|
||||
// Near the beginning: show pages 2-5
|
||||
rangeStart = 2;
|
||||
rangeEnd = 5;
|
||||
} else if (current >= total - 3) {
|
||||
// Near the end: show last 4 pages before the last page
|
||||
rangeStart = total - 4;
|
||||
rangeEnd = total - 1;
|
||||
} else {
|
||||
// In the middle: show current page and one on each side
|
||||
rangeStart = current - 1;
|
||||
rangeEnd = current + 1;
|
||||
}
|
||||
|
||||
// Add ellipsis before range if there's a gap
|
||||
if (rangeStart > 2) {
|
||||
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
|
||||
}
|
||||
|
||||
// Add pages in the range
|
||||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||
}
|
||||
|
||||
// Add ellipsis after range if there's a gap
|
||||
if (rangeEnd < total - 1) {
|
||||
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user