From cac46d52c4c485b37bd9c9f1d031350cad681d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Mon, 19 Jan 2026 13:28:40 +0100 Subject: [PATCH 1/3] #EX-211: Add infinite scroll pagination to data-table.components --- frontend/Exence/package-lock.json | 14 +++ frontend/Exence/package.json | 1 + .../dashboard/dashboard.component.html | 3 + .../private/dashboard/dashboard.component.ts | 37 ++++++-- .../transaction.service.ts | 91 +++++++++++++++++-- ...transactions-and-categories.component.html | 5 +- .../transactions-and-categories.component.ts | 20 +++- .../data-table/data-table.component.html | 17 ++-- .../shared/data-table/data-table.component.ts | 49 +++++----- .../src/styles/components/paginator.scss | 28 ------ frontend/Exence/src/styles/styles.scss | 1 - 11 files changed, 183 insertions(+), 83 deletions(-) delete mode 100644 frontend/Exence/src/styles/components/paginator.scss diff --git a/frontend/Exence/package-lock.json b/frontend/Exence/package-lock.json index 89c9e2f..88e79bc 100644 --- a/frontend/Exence/package-lock.json +++ b/frontend/Exence/package-lock.json @@ -26,6 +26,7 @@ "jwt-decode": "^4.0.0", "ng2-charts": "^8.0.0", "ngx-cookie-service": "^21.1.0", + "ngx-infinite-scroll": "^21.0.0", "or": "^0.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -9083,6 +9084,19 @@ "@angular/core": "^21.0.0" } }, + "node_modules/ngx-infinite-scroll": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-21.0.0.tgz", + "integrity": "sha512-bm1hCB7aoO9zyiNZBBOLHx9t+cwk/tLiFG5eQB8pWiNup6I2eQiHoT5B2gqlJjj4GfuER5phy5AIWl/B7YiwSQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=21.0.0 <22.0.0", + "@angular/core": ">=21.0.0 <22.0.0" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "dev": true, diff --git a/frontend/Exence/package.json b/frontend/Exence/package.json index ca685d3..fa8ef7d 100644 --- a/frontend/Exence/package.json +++ b/frontend/Exence/package.json @@ -32,6 +32,7 @@ "jwt-decode": "^4.0.0", "ng2-charts": "^8.0.0", "ngx-cookie-service": "^21.1.0", + "ngx-infinite-scroll": "^21.0.0", "or": "^0.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", diff --git a/frontend/Exence/src/app/private/dashboard/dashboard.component.html b/frontend/Exence/src/app/private/dashboard/dashboard.component.html index 1768232..421eb8e 100644 --- a/frontend/Exence/src/app/private/dashboard/dashboard.component.html +++ b/frontend/Exence/src/app/private/dashboard/dashboard.component.html @@ -88,6 +88,7 @@ title="Expenses" [type]="transactionTypes.EXPENSE" (dataChangedEvent)="onDataChanged()" + (onScroll)="onScroll($event, transactionTypes.EXPENSE)" /> } @else { @@ -106,6 +108,7 @@ [data]="{ transactions, categories }" title="Transactions" (dataChangedEvent)="onDataChanged()" + (onScroll)="onScroll($event)" /> diff --git a/frontend/Exence/src/app/private/dashboard/dashboard.component.ts b/frontend/Exence/src/app/private/dashboard/dashboard.component.ts index 43cde93..ea98468 100644 --- a/frontend/Exence/src/app/private/dashboard/dashboard.component.ts +++ b/frontend/Exence/src/app/private/dashboard/dashboard.component.ts @@ -48,7 +48,6 @@ export class DashboardComponent implements OnInit { readonly display = inject(DisplaySizeService); readonly navigation = inject(NavigationService); - transactionTypes = TransactionType; dateIntervals = DateInterval; @@ -65,23 +64,25 @@ export class DashboardComponent implements OnInit { topCategories?: CategorySummaryResponse[]; topCategory?: CategorySummaryResponse; + loading = false; + async ngOnInit(): Promise { await this.initialize(); } async initialize(): Promise { return Promise.all([ - this.transactionService.list(), + this.getTransactions(), + this.getTransactions(0, TransactionType.INCOME), + this.getTransactions(0, TransactionType.EXPENSE), this.categoryService.list(), this.categoryService.listTop4(), - this.transactionService.incomes(), - this.transactionService.expenses(), this.transactionService.totals(), - ]).then(([transactions, categories, top4, incomes, expenses, totals]) => { + ]).then(([transactions, incomes, expenses, categories, top4, totals]) => { this.transactions = transactions; - this.categories = categories; this.incomes = incomes; this.expenses = expenses; + this.categories = categories; this.totals = totals; this.topCategories = top4; @@ -90,7 +91,7 @@ export class DashboardComponent implements OnInit { }); } - public async openCreateTransactionDialog(transactionType: TransactionType): Promise { + async openCreateTransactionDialog(transactionType: TransactionType): Promise { const result = await this.dialog.openNonModal( CreateTransactionDialogComponent, { type: transactionType } ); @@ -102,4 +103,26 @@ export class DashboardComponent implements OnInit { async onDataChanged(): Promise { await this.initialize(); } + + async getTransactions(pageIndex = 0, type?: TransactionType): Promise> { + switch (type) { + case TransactionType.INCOME: + return this.transactionService.listIncomes(pageIndex); + case TransactionType.EXPENSE: + return this.transactionService.listExpenses(pageIndex); + default: + return this.transactionService.list(pageIndex); + } + } + + async onScroll(pageIndex: number, type?: TransactionType): Promise { + if (!this.loading && !this.transactions.last) { + this.loading = true; + try { + this.transactions = await this.getTransactions(pageIndex, type); + } finally { + this.loading = false; + } + } + } } diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts b/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts index 6a60eeb..b00491a 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts @@ -1,10 +1,11 @@ import { inject, Injectable } from '@angular/core'; -import { lastValueFrom } from 'rxjs'; +import { lastValueFrom, tap } from 'rxjs'; import { PagedResponse } from '../../data-model/modules/common/PagedResponse'; import { RecurringTransactionsResponse } from '../../data-model/modules/transaction/RecurringTransactionsResponse'; import { Transaction } from '../../data-model/modules/transaction/Transaction'; import { TransactionTotalsResponse } from '../../data-model/modules/transaction/TransactionTotalsResponse'; import { HttpService } from '../../shared/http/http.service'; +import { TransactionType } from '../../data-model/modules/transaction/TransactionType'; @Injectable({ providedIn: 'root', @@ -13,25 +14,60 @@ export class TransactionService { private readonly http = inject(HttpService); private baseUrl = '/api/transactions'; + + private transactions?: PagedResponse; + private transactionsOutOfSync = false; + + private incomes?: PagedResponse; + private incomesOutOfSync = false; + + private expenses?: PagedResponse; + private expensesOutOfSync = false; public get(id: number): Promise { return lastValueFrom(this.http.get(`${this.baseUrl}/${id}`)); } - public list(): Promise> { - return lastValueFrom(this.http.get>(this.baseUrl)); + public async list(pageIndex?: number): Promise> { + if (this.transactions && !this.transactionsOutOfSync && pageIndex === this.transactions.page) { + return Promise.resolve(this.transactions); + } + await lastValueFrom(this.http.get>(this.baseUrl, { page: (pageIndex ?? 0).toString() }) + .pipe(tap(transactions => { + this.transactions = this.getDataToCache(this.transactions, transactions, this.transactionsOutOfSync, pageIndex); + this.transactionsOutOfSync = false; + }))); + return this.transactions!; } public listRecurrings(): Promise { return lastValueFrom(this.http.get(`${this.baseUrl}/recurring`)); } - public incomes(): Promise> { - return lastValueFrom(this.http.get>(`${this.baseUrl}/income`)); + public async listIncomes(pageIndex?: number): Promise> { + if (this.incomes && !this.incomesOutOfSync && pageIndex === this.incomes.page) { + return Promise.resolve(this.incomes); + } + await lastValueFrom(this.http.get>(`${this.baseUrl}/income`, { page: (pageIndex ?? 0).toString() }) + .pipe(tap(incomes => { + this.incomes = this.getDataToCache(this.incomes, incomes, this.incomesOutOfSync, pageIndex); + this.incomesOutOfSync = false; + })) + ); + return this.incomes!; } - public expenses(): Promise> { - return lastValueFrom(this.http.get>(`${this.baseUrl}/expense`)); + public async listExpenses(pageIndex?: number): Promise> { + if (this.expenses && !this.expensesOutOfSync && pageIndex === this.expenses.page) { + return Promise.resolve(this.expenses); + } + await lastValueFrom(this.http.get>(`${this.baseUrl}/expense`, { page: (pageIndex ?? 0).toString() }) + .pipe(tap(expenses => { + this.expenses = this.getDataToCache(this.expenses, expenses, this.expensesOutOfSync, pageIndex); + this.expensesOutOfSync = false; + })) + ); + return this.expenses!; } public totals(): Promise { @@ -39,14 +75,51 @@ export class TransactionService { } public create(request: Transaction): Promise { - return lastValueFrom(this.http.post(this.baseUrl, request)); + return lastValueFrom(this.http.post(this.baseUrl, request).pipe( + tap(() => { + if (request.type === TransactionType.INCOME) { + this.incomesOutOfSync = true; + } else { + this.expensesOutOfSync = true; + } + this.transactionsOutOfSync = true; + }) + )); } public update(request: Transaction): Promise { - return lastValueFrom(this.http.put(`${this.baseUrl}/${request.id}`, request)); + return lastValueFrom(this.http.put(`${this.baseUrl}/${request.id}`, request).pipe( + tap(() => { + if (request.type === TransactionType.INCOME) { + this.incomesOutOfSync = true; + } else { + this.expensesOutOfSync = true; + } + this.transactionsOutOfSync = true; + }) + )); } + // somehow detect what type was removed public delete(id: number): Promise { return lastValueFrom(this.http.delete(`${this.baseUrl}/${id}`)); } + + private getDataToCache(oldCached: PagedResponse | undefined, dataToAddToCache: PagedResponse, isOutOfSync: boolean, pageIndex?: number): PagedResponse { + // either content, or pageIndex changed + if (oldCached && (isOutOfSync || pageIndex !== oldCached.page)) { + const clone: PagedResponse = { ...oldCached }; + const content = [...clone.content.sort((a, b) => b.date.localeCompare(a.date)), ...dataToAddToCache.content]; + const numberOfElements = clone.numberOfElements + dataToAddToCache.numberOfElements; + + const result = { + ...dataToAddToCache, + content, + numberOfElements + }; + return result; + } else { + return dataToAddToCache; + } + } } diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html index ef72480..54b46bf 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html +++ b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html @@ -70,7 +70,6 @@ matIcon="money_off" class="flex-grow-1" nonExpandable - paginationDisabled title="Recurring Incomes" [type]="transactionTypes.INCOME" [isRecurring]="true" @@ -84,7 +83,6 @@ matIcon="money_off" class="flex-grow-1" nonExpandable - paginationDisabled title="Recurring Expenses" [type]="transactionTypes.EXPENSE" [isRecurring]="true" @@ -99,7 +97,6 @@ matIcon="money_off" class="flex-grow-1" nonExpandable - paginationDisabled title="Recurrings" [isRecurring]="true" (dataChangedEvent)="onDataChanged()" @@ -135,6 +132,7 @@ [class.overflow-hidden]="display.isMd()" title="Transactions" (dataChangedEvent)="onDataChanged()" + (onScroll)="onScroll($event)" /> @@ -164,7 +162,6 @@ title="Categories" type="category" (dataChangedEvent)="onDataChanged()" - paginationDisabled /> diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts index 8caddd5..50a99fa 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts @@ -43,6 +43,7 @@ export class TransactionsAndCategoriesComponent implements OnInit { categories: Category[] = []; selectedIndex = 0; + loading = false; transactionTypes = TransactionType; @@ -66,19 +67,34 @@ export class TransactionsAndCategoriesComponent implements OnInit { }); } - public async openCreateTransactionDialog(): Promise { + async openCreateTransactionDialog(): Promise { const result = await this.dialog.openNonModal(CreateTransactionDialogComponent, undefined); if (!result) return; await this.initialize(); // to trigger data refresh in all tables (e.g. if a recurring transaction was created) } - public async openCreateCategoryDialog(): Promise { + async openCreateCategoryDialog(): Promise { const result = await this.dialog.openNonModal(CreateCategoryDialogComponent, undefined); if (!result) return; this.categories = (await this.categoryService.list()); } + async getTransactions(pageIndex: number): Promise> { + return this.transactionService.list(pageIndex); + } + async onDataChanged(): Promise { await this.initialize(); } + + async onScroll(pageIndex: number): Promise { + if (!this.loading && !this.transactions.last) { + this.loading = true; + try { + this.transactions = await this.getTransactions(pageIndex); + } finally { + this.loading = false; + } + } + } } diff --git a/frontend/Exence/src/app/shared/data-table/data-table.component.html b/frontend/Exence/src/app/shared/data-table/data-table.component.html index 045c01e..d41c2c9 100644 --- a/frontend/Exence/src/app/shared/data-table/data-table.component.html +++ b/frontend/Exence/src/app/shared/data-table/data-table.component.html @@ -19,7 +19,13 @@

@if (!emptyTableData) { -
+
@if (!emptyTransactionTable) { } - } @else { } diff --git a/frontend/Exence/src/app/shared/data-table/data-table.component.ts b/frontend/Exence/src/app/shared/data-table/data-table.component.ts index bd8aaf3..5ebe47b 100644 --- a/frontend/Exence/src/app/shared/data-table/data-table.component.ts +++ b/frontend/Exence/src/app/shared/data-table/data-table.component.ts @@ -9,10 +9,10 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; -import { MatPaginatorModule } from '@angular/material/paginator'; import { MatSelectModule } from '@angular/material/select'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { InfiniteScrollDirective } from 'ngx-infinite-scroll'; import { Category } from '../../data-model/modules/category/Category'; import { PagedResponse } from '../../data-model/modules/common/PagedResponse'; import { Transaction } from '../../data-model/modules/transaction/Transaction'; @@ -27,9 +27,9 @@ import { ButtonComponent } from '../button/button.component'; import { DialogService } from '../dialog/dialog.service'; import { DisplaySizeService } from '../display-size.service'; import { SnackbarService } from '../snackbar/snackbar.service'; +import { StopPropagationDirective } from '../stop-propagation.directive'; import { SvgIcons } from '../svg-icons/svg-icons'; import { ValidatorComponent } from '../validator/validator.component'; -import { StopPropagationDirective } from "../stop-propagation.directive"; export interface DataTableModel { transactions?: PagedResponse; @@ -39,24 +39,24 @@ export interface DataTableModel { @Component({ selector: 'ex-data-table', imports: [ - MatCardModule, - MatTableModule, - MatIconModule, - MatButtonModule, - CommonModule, - MatTooltipModule, - MatFormFieldModule, - FormsModule, - ReactiveFormsModule, - MatInputModule, - MatPaginatorModule, - MatMenuModule, - MatCheckboxModule, - MatSelectModule, - ButtonComponent, - ValidatorComponent, - StopPropagationDirective, -], + MatCardModule, + MatTableModule, + MatIconModule, + MatButtonModule, + CommonModule, + MatTooltipModule, + MatFormFieldModule, + FormsModule, + ReactiveFormsModule, + MatInputModule, + MatMenuModule, + MatCheckboxModule, + MatSelectModule, + ButtonComponent, + ValidatorComponent, + StopPropagationDirective, + InfiniteScrollDirective, + ], templateUrl: './data-table.component.html', styleUrl: './data-table.component.scss', // TODO remove deprecated angular animations @@ -90,9 +90,9 @@ export class DataTableComponent extends BaseComponent { type = input(); isRecurring = input(); nonExpandable = input(false, { transform: booleanAttribute }); - paginationDisabled = input(false, { transform: booleanAttribute }); dataChangedEvent = output(); + onScroll = output(); displayedColumns = ['title', 'date', 'amount', 'category', 'actions']; displayedCategoryColumns = ['name', 'emoji', 'actions']; @@ -104,7 +104,7 @@ export class DataTableComponent extends BaseComponent { transactionDataSource?: MatTableDataSource; categoryDataSource?: MatTableDataSource; pageSize?: number; - pageIndex?: number; + pageIndex = 0; pageLength?: number; pageSizeOptions = [5, 10, 25, 100]; @@ -266,6 +266,11 @@ export class DataTableComponent extends BaseComponent { } } + getNextPage(): number { + this.pageIndex++; + return this.pageIndex; + } + private mapToTransactionModel(transaction: Transaction, categories: Category[]): TransactionModel { const category = categories.find(c => c.id === transaction.categoryId); return { diff --git a/frontend/Exence/src/styles/components/paginator.scss b/frontend/Exence/src/styles/components/paginator.scss deleted file mode 100644 index 3230b30..0000000 --- a/frontend/Exence/src/styles/components/paginator.scss +++ /dev/null @@ -1,28 +0,0 @@ -mat-paginator.mat-mdc-paginator { - --mat-paginator-container-background-color: var(--app-card-color); - - .mat-mdc-paginator-page-size-label { - white-space: nowrap; - margin: 0 0.5rem; - } - - .mat-mdc-paginator-page-size-select { - margin: 0; - - mat-select.mat-mdc-select .mat-mdc-select-trigger { - gap: 0.5rem; - } - - .mat-mdc-form-field-infix { - display: flex; - align-items: center; - justify-content: center; - min-height: 0; - padding: 0.5rem 0; - } - - .mat-mdc-text-field-wrapper { - align-items: center; - } - } -} \ No newline at end of file diff --git a/frontend/Exence/src/styles/styles.scss b/frontend/Exence/src/styles/styles.scss index 5aedb74..9e9a703 100644 --- a/frontend/Exence/src/styles/styles.scss +++ b/frontend/Exence/src/styles/styles.scss @@ -9,7 +9,6 @@ @import './components/form-field.scss'; @import './components/progress.scss'; @import './components/tooltip.scss'; -@import './components/paginator.scss'; @import './components/tab.scss'; @import './components/emoji-picker.scss'; @import './components/dialog.scss'; From 3eddce8be07348b6ddeba637cc2a979c26ed66e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Mon, 19 Jan 2026 13:50:11 +0100 Subject: [PATCH 2/3] Handle delete, and fix outOfSync logic --- .../transaction.service.ts | 15 ++++++++++++--- .../shared/data-table/data-table.component.html | 2 +- .../app/shared/data-table/data-table.component.ts | 4 ++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts b/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts index b00491a..56c6f0b 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts @@ -101,13 +101,22 @@ export class TransactionService { } // somehow detect what type was removed - public delete(id: number): Promise { - return lastValueFrom(this.http.delete(`${this.baseUrl}/${id}`)); + public delete(id: number, type?: TransactionType): Promise { + return lastValueFrom(this.http.delete(`${this.baseUrl}/${id}`).pipe( + tap(() => { + if (type === TransactionType.INCOME) { + this.incomesOutOfSync = true; + } else if (type === TransactionType.EXPENSE) { + this.expensesOutOfSync = true; + } + this.transactionsOutOfSync = true; + }) + )); } private getDataToCache(oldCached: PagedResponse | undefined, dataToAddToCache: PagedResponse, isOutOfSync: boolean, pageIndex?: number): PagedResponse { // either content, or pageIndex changed - if (oldCached && (isOutOfSync || pageIndex !== oldCached.page)) { + if (oldCached && !isOutOfSync && pageIndex !== oldCached.page) { const clone: PagedResponse = { ...oldCached }; const content = [...clone.content.sort((a, b) => b.date.localeCompare(a.date)), ...dataToAddToCache.content]; const numberOfElements = clone.numberOfElements + dataToAddToCache.numberOfElements; diff --git a/frontend/Exence/src/app/shared/data-table/data-table.component.html b/frontend/Exence/src/app/shared/data-table/data-table.component.html index d41c2c9..36a3832 100644 --- a/frontend/Exence/src/app/shared/data-table/data-table.component.html +++ b/frontend/Exence/src/app/shared/data-table/data-table.component.html @@ -188,7 +188,7 @@