From 52cef0929128597fda0393f2cdb00a927415ced6 Mon Sep 17 00:00:00 2001 From: M1chaCH Date: Sat, 20 Jan 2024 18:11:23 +0100 Subject: [PATCH] good cache in frontend!! & generally cleaned up --- .../app/endpoint/TransactionEndpoint.java | 9 +- .../app/provider/TransactionProvider.java | 107 +++++++----- .../app/service/TransactionService.java | 32 +++- .../framework/dto/PaginationResultDto.java | 21 +++ .../utils/LocalDateDeserializer.java | 9 +- .../app/endpoint/TransactionEndpointTest.java | 29 +++- frontend/angular.json | 3 + frontend/package-lock.json | 13 ++ frontend/package.json | 1 + frontend/src/app/app.component.html | 21 +-- frontend/src/app/app.component.ts | 6 +- frontend/src/app/app.module.ts | 13 +- .../banner-outlet.component.html | 2 + .../banner-outlet.component.scss | 10 ++ .../banner-outlet/banner-outlet.component.ts | 27 +++ .../framework/banner/banner.service.ts | 35 ++++ ...nent.html => dialog-outlet.component.html} | 0 ...nent.scss => dialog-outlet.component.scss} | 0 ...omponent.ts => dialog-outlet.component.ts} | 8 +- .../framework/dialog/dialog.service.ts | 6 +- .../display-error-dialog.component.html | 10 +- .../display-error-dialog.component.ts | 9 +- .../form/date-picker/date-picker.component.ts | 6 +- .../navigation/navigation.component.html | 11 -- .../navigation/navigation.component.scss | 17 -- .../layout/navigation/navigation.component.ts | 18 -- .../tag-selector/tag-selector.component.ts | 25 +-- .../transaction-detail.component.ts | 2 +- .../transaction-filter.component.html | 29 +++- .../transaction-filter.component.ts | 26 +-- .../transaction-import-banner.component.html | 39 +++++ ... transaction-import-banner.component.scss} | 0 .../transaction-import-banner.component.ts | 26 +++ .../transaction-importer.component.html | 20 --- .../transaction-importer.component.ts | 26 --- frontend/src/app/dtos/PaginationResultDto.ts | 5 + .../configuration.page.component.ts | 23 +-- .../pages/home.page/home.page.component.html | 8 +- .../pages/home.page/home.page.component.ts | 22 ++- .../transaction.page.component.html | 7 +- .../transaction.page.component.scss | 1 + .../transaction.page.component.ts | 22 +-- .../src/app/services/EntityCacheService.ts | 67 -------- frontend/src/app/services/api.service.ts | 138 +++++++-------- frontend/src/app/services/auth.service.ts | 160 +++++++++--------- .../app/services/cache/BaseRequestCache.ts | 122 +++++++++++++ .../app/services/cache/PagedRequestCache.ts | 115 +++++++++++++ .../src/app/services/cache/RequestCache.ts | 65 +++++++ frontend/src/app/services/error.service.ts | 2 + frontend/src/app/services/tag.service.ts | 62 ++++--- .../src/app/services/transaction.service.ts | 160 +++++------------- 51 files changed, 971 insertions(+), 624 deletions(-) create mode 100644 backend/src/main/java/ch/michu/tech/swissbudget/framework/dto/PaginationResultDto.java create mode 100644 frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.html create mode 100644 frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.scss create mode 100644 frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.ts create mode 100644 frontend/src/app/components/framework/banner/banner.service.ts rename frontend/src/app/components/framework/dialog/{dialog.component.html => dialog-outlet.component.html} (100%) rename frontend/src/app/components/framework/dialog/{dialog.component.scss => dialog-outlet.component.scss} (100%) rename frontend/src/app/components/framework/dialog/{dialog.component.ts => dialog-outlet.component.ts} (84%) delete mode 100644 frontend/src/app/components/layout/navigation/navigation.component.html delete mode 100644 frontend/src/app/components/layout/navigation/navigation.component.scss delete mode 100644 frontend/src/app/components/layout/navigation/navigation.component.ts create mode 100644 frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.html rename frontend/src/app/components/transactions/transaction-importer/{transaction-importer.component.scss => transaction-import-banner.component.scss} (100%) create mode 100644 frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.ts delete mode 100644 frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.html delete mode 100644 frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.ts create mode 100644 frontend/src/app/dtos/PaginationResultDto.ts delete mode 100644 frontend/src/app/services/EntityCacheService.ts create mode 100644 frontend/src/app/services/cache/BaseRequestCache.ts create mode 100644 frontend/src/app/services/cache/PagedRequestCache.ts create mode 100644 frontend/src/app/services/cache/RequestCache.ts diff --git a/backend/src/main/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpoint.java b/backend/src/main/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpoint.java index 8de981b..f551a4c 100644 --- a/backend/src/main/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpoint.java +++ b/backend/src/main/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpoint.java @@ -12,6 +12,7 @@ import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; @@ -58,17 +59,17 @@ public Response getTransactions( UUID[] tags = new UUID[0]; if (!tagIds.isBlank()) { - tags = ParsingUtils.toUUIDArray(Arrays.stream(tagIds.split(";")).filter(s -> !s.isBlank()).toArray()); + tags = ParsingUtils.toUUIDArray(Arrays.stream(tagIds.split(",")).filter(s -> !s.isBlank()).toArray()); } return Response.status(Status.OK).entity(service.getTransactions(query, tags, from, to, needAttention, page)).build(); } - @GET + @POST @Path("/import") - @Produces(MediaType.APPLICATION_JSON) public Response getImportTransactions() { - return Response.status(Status.OK).entity(service.importTransactions()).build(); + Status status = service.importTransactions() ? Status.OK : Status.NO_CONTENT; + return Response.status(status).build(); } @PUT diff --git a/backend/src/main/java/ch/michu/tech/swissbudget/app/provider/TransactionProvider.java b/backend/src/main/java/ch/michu/tech/swissbudget/app/provider/TransactionProvider.java index ad675a2..1368266 100644 --- a/backend/src/main/java/ch/michu/tech/swissbudget/app/provider/TransactionProvider.java +++ b/backend/src/main/java/ch/michu/tech/swissbudget/app/provider/TransactionProvider.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import lombok.Getter; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jooq.Condition; import org.jooq.DSLContext; @@ -44,8 +45,6 @@ import org.jooq.SelectLimitStep; import org.jooq.impl.UpdatableRecordImpl; -// TODO cache for improved read times -// TODO write tests with demo data @ApplicationScoped public class TransactionProvider implements BaseRecordProvider { @@ -55,11 +54,12 @@ public class TransactionProvider implements BaseRecordProvider conditions = db.select(TRANSACTION.ID) + .from(TRANSACTION) + .where(TRANSACTION.USER_ID.eq(userId)); + conditions = buildTransactionsFilterCondition(conditions, query, tagIds, from, to, needAttention); + return conditions.fetch().size(); + } + + protected SelectConditionStep buildTransactionsFilterCondition( + SelectConditionStep step, + String query, + UUID[] tagIds, + LocalDate from, + LocalDate to, + boolean needAttention + ) { + final LocalDate tomorrow = localDateNow().plusDays(1); + + if (query != null && !query.isBlank()) { + if (!query.startsWith("%")) { + query = "%" + query; + } + if (!query.endsWith("%")) { + query += "%"; + } + + step = step.and(TRANSACTION.ALIAS.likeIgnoreCase(query)) + .or(TRANSACTION.NOTE.likeIgnoreCase(query)) + .or(TRANSACTION.RECEIVER.likeIgnoreCase(query)); + } + + if (needAttention) { + step = step.and(TRANSACTION.NEED_USER_ATTENTION.eq(true)); + } + + if (tagIds != null && tagIds.length > 0) { + Condition tagsCondition = TRANSACTION.TAG_ID.eq(tagIds[0]); + for (int i = 1; i < tagIds.length; i++) { + tagsCondition = tagsCondition.or(TRANSACTION.TAG_ID.eq(tagIds[i])); + } + step = step.and(tagsCondition); + } + + if (from != null && from.isBefore(tomorrow)) { + step = step.and(TRANSACTION.TRANSACTION_DATE.ge(from)); + } + + if (to != null && to.isBefore(tomorrow)) { + step = step.and(TRANSACTION.TRANSACTION_DATE.le(to)); + } + + return step; + } + @LoggedStatement public Optional selectTransaction(DSLContext db, UUID userId, UUID transactionId) { TransactionRecord transaction = db.fetchOne(TRANSACTION, TRANSACTION.USER_ID.eq(userId).and(TRANSACTION.ID.eq(transactionId))); @@ -197,7 +259,6 @@ public List selectTransactionsWithDependenciesWithFilterWithPage boolean needAttention, int page ) { - LocalDate tomorrow = localDateNow().plusDays(1); SelectConditionStep conditions = db .select(TRANSACTION.asterisk(), TAG.asterisk(), KEYWORD.asterisk(), count(TRANSACTION_TAG_DUPLICATE.ID).as(DUPLICATES_COUNT_COLUMN)) @@ -210,41 +271,7 @@ public List selectTransactionsWithDependenciesWithFilterWithPage .on(TRANSACTION.ID.eq(TRANSACTION_TAG_DUPLICATE.TRANSACTION_ID)) .where(TRANSACTION.USER_ID.eq(userId)); - if (query != null && !query.isBlank()) { - if (!query.startsWith("%")) { - query = "%" + query; - } - if (!query.endsWith("%")) { - query += "%"; - } - - conditions = conditions - .and(TRANSACTION.ALIAS.likeIgnoreCase(query) - .or(TRANSACTION.NOTE.likeIgnoreCase(query) - .or(TRANSACTION.RECEIVER.likeIgnoreCase(query)))); - } - - if (needAttention) { - conditions = conditions.and(TRANSACTION.NEED_USER_ATTENTION.eq(true)); - } - - if (tagIds != null && tagIds.length > 0) { - Condition tagsCondition = TRANSACTION.TAG_ID.eq(tagIds[0]); - for (int i = 1; i < tagIds.length; i++) { - tagsCondition = tagsCondition.or(TRANSACTION.TAG_ID.eq(tagIds[i])); - } - conditions = conditions.and(tagsCondition); - } - - if (from != null && from.isBefore(tomorrow)) { - conditions = conditions.and(TRANSACTION.TRANSACTION_DATE.ge(from)); - } - - if (to != null && to.isBefore(tomorrow)) { - conditions = conditions.and(TRANSACTION.TRANSACTION_DATE.le(to)); - } - - SelectLimitStep limitStep = conditions + SelectLimitStep limitStep = buildTransactionsFilterCondition(conditions, query, tagIds, from, to, needAttention) .groupBy(TRANSACTION.ID, TAG.ID, KEYWORD.ID) .orderBy(TRANSACTION.TRANSACTION_DATE.desc()); @@ -388,7 +415,7 @@ public void insertTransactions(DSLContext db, List transactio } @LoggedStatement - public void updateTransactionUserInput(DSLContext db, TransactionRecord transaction) { + public void updateTransactionUserInput(TransactionRecord transaction) { if (transaction.get(TRANSACTION.TAG_ID) == null) { transaction.store(TRANSACTION.ALIAS, TRANSACTION.NOTE); } else if (transaction.get(TRANSACTION.MATCHING_KEYWORD_ID) == null) { diff --git a/backend/src/main/java/ch/michu/tech/swissbudget/app/service/TransactionService.java b/backend/src/main/java/ch/michu/tech/swissbudget/app/service/TransactionService.java index 6b09fda..09f9602 100644 --- a/backend/src/main/java/ch/michu/tech/swissbudget/app/service/TransactionService.java +++ b/backend/src/main/java/ch/michu/tech/swissbudget/app/service/TransactionService.java @@ -4,6 +4,7 @@ import ch.michu.tech.swissbudget.app.provider.TransactionProvider; import ch.michu.tech.swissbudget.app.transaction.TransactionImporter; import ch.michu.tech.swissbudget.framework.data.RequestSupport; +import ch.michu.tech.swissbudget.framework.dto.PaginationResultDto; import ch.michu.tech.swissbudget.framework.error.exception.ResourceNotFoundException; import ch.michu.tech.swissbudget.generated.jooq.tables.records.TransactionRecord; import jakarta.enterprise.context.ApplicationScoped; @@ -27,25 +28,40 @@ public TransactionService(Provider supportProvider, TransactionI this.provider = provider; } - public List getTransactions(String query, UUID[] tagIds, LocalDate from, LocalDate to, boolean needAttention, - int page) { + public PaginationResultDto getTransactions( + String query, UUID[] tagIds, LocalDate from, LocalDate to, boolean needAttention, int page + ) { final RequestSupport support = supportProvider.get(); - return provider.selectTransactionsWithDependenciesWithFilterWithPageAsDto(support.db(), support.getUserIdOrThrow(), - query, tagIds, from, to, needAttention, page); + List data = provider.selectTransactionsWithDependenciesWithFilterWithPageAsDto(support.db(), + support.getUserIdOrThrow(), + query, + tagIds, + from, + to, + needAttention, + page); + long count = provider.countTransactions(support.db(), support.getUserIdOrThrow(), query, tagIds, from, to, needAttention); + + return new PaginationResultDto<>(provider.getPageSize(), count, data); } - public List importTransactions() { + /** + * imports all new transactions for a user. + * + * @return true: if some transactions were imported + */ + public boolean importTransactions() { final RequestSupport support = supportProvider.get(); - return importer.importTransactions(support.db(), support.getUserIdOrThrow()).stream().map(TransactionDto::new).toList(); + return !importer.importTransactions(support.db(), support.getUserIdOrThrow()).isEmpty(); } public void updateTransactionUserInput(TransactionDto toUpdate) { final RequestSupport support = supportProvider.get(); TransactionRecord transaction = provider.selectTransaction(support.db(), support.getUserIdOrThrow(), toUpdate.getId()) - .orElseThrow(() -> new ResourceNotFoundException("transaction", toUpdate.getId())); + .orElseThrow(() -> new ResourceNotFoundException("transaction", toUpdate.getId())); transaction.setAlias(toUpdate.getAlias()); transaction.setNote(toUpdate.getNote()); - provider.updateTransactionUserInput(support.db(), transaction); + provider.updateTransactionUserInput(transaction); } } diff --git a/backend/src/main/java/ch/michu/tech/swissbudget/framework/dto/PaginationResultDto.java b/backend/src/main/java/ch/michu/tech/swissbudget/framework/dto/PaginationResultDto.java new file mode 100644 index 0000000..7e7bd1b --- /dev/null +++ b/backend/src/main/java/ch/michu/tech/swissbudget/framework/dto/PaginationResultDto.java @@ -0,0 +1,21 @@ +package ch.michu.tech.swissbudget.framework.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString +@EqualsAndHashCode +public class PaginationResultDto { + private int pageSize; + private long totalSize; + private List pageData; +} diff --git a/backend/src/main/java/ch/michu/tech/swissbudget/framework/utils/LocalDateDeserializer.java b/backend/src/main/java/ch/michu/tech/swissbudget/framework/utils/LocalDateDeserializer.java index f0a842e..d378c75 100644 --- a/backend/src/main/java/ch/michu/tech/swissbudget/framework/utils/LocalDateDeserializer.java +++ b/backend/src/main/java/ch/michu/tech/swissbudget/framework/utils/LocalDateDeserializer.java @@ -8,18 +8,21 @@ public class LocalDateDeserializer { - public static final String DEFAULT_LOCAL_DATE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + public static final String DEFAULT_LOCAL_DATE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSX"; public static final String FALLBACK_LOCAL_DATE_PATTERN = "yyyy-MM-dd"; private static final Logger LOGGER = Logger.getLogger(LocalDateDeserializer.class.getSimpleName()); + private LocalDateDeserializer() { + } + public static LocalDate parseLocalDate(String dateString) { try { return LocalDate.parse(dateString, DateTimeFormatter.ofPattern(DEFAULT_LOCAL_DATE_PATTERN)); } catch (DateTimeParseException e) { try { LOGGER.log(Level.FINE, - "custom LocalDate deserializer WARN: could not parse date (string:{0} - pattern:{1}), using ISO / fallback {2}", - new Object[]{dateString, DEFAULT_LOCAL_DATE_PATTERN, FALLBACK_LOCAL_DATE_PATTERN}); + "custom LocalDate deserializer WARN: could not parse date (string:{0} - pattern:{1}), using ISO / fallback {2}", + new Object[]{dateString, DEFAULT_LOCAL_DATE_PATTERN, FALLBACK_LOCAL_DATE_PATTERN}); return LocalDate.parse(dateString, DateTimeFormatter.ISO_DATE); } catch (DateTimeParseException e2) { return LocalDate.parse(dateString, DateTimeFormatter.ofPattern(FALLBACK_LOCAL_DATE_PATTERN)); diff --git a/backend/src/test/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpointTest.java b/backend/src/test/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpointTest.java index 13d223b..8ddc463 100644 --- a/backend/src/test/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpointTest.java +++ b/backend/src/test/java/ch/michu/tech/swissbudget/app/endpoint/TransactionEndpointTest.java @@ -35,7 +35,12 @@ void getTransactions_happy() { Response happyResponse = client.createAsRootUser().get(); String result = happyResponse.readEntity(String.class); String expectedTransactionId = data.getGeneratedId("tra_cpm").toString(); - JSONArray items = new JSONArray(result); + + JSONObject data = new JSONObject(result); + assertEquals(3, data.getInt("pageSize")); + assertEquals(11, data.getInt("totalSize")); + + JSONArray items = data.getJSONArray("pageData"); assertEquals(3, items.length()); JSONObject first = items.getJSONObject(0); @@ -43,16 +48,30 @@ void getTransactions_happy() { assertEquals(12.5, first.getDouble("amount")); Response secondPage = client.createAsRootUser().queryParam("page", "2").get(); - JSONArray secondPageItems = new JSONArray(secondPage.readEntity(String.class)); + JSONArray secondPageItems = new JSONObject(secondPage.readEntity(String.class)).getJSONArray("pageData"); assertEquals(3, secondPageItems.length()); String expectedDate = DateBuilder.today().addMonths(-1).firstDayOfMonth().addDays(26).formatted("yyyy-MM-dd"); assertEquals(expectedDate, secondPageItems.getJSONObject(0).getString("transactionDate")); Response lastPage = client.createAsRootUser().queryParam("page", "4").get(); - JSONArray lastPageItems = new JSONArray(lastPage.readEntity(String.class)); + JSONArray lastPageItems = new JSONObject(lastPage.readEntity(String.class)).getJSONArray("pageData"); assertEquals(2, lastPageItems.length()); // in total, there are 11 data rows (11 % 3 = 2) } + @Test + void getTransactions_happyFilter() { + Response queryResponse = client.createAsRootUser().queryParam("query", "auerauftra").get(); + JSONObject result = new JSONObject(queryResponse.readEntity(String.class)); + assertEquals(2, result.getInt("totalSize")); + assertEquals(2, result.getJSONArray("pageData").length()); + + UUID tagId = super.data.getGeneratedId("tag_res"); + Response tagResponse = client.createAsRootUser().queryParam("tagIds", tagId.toString()).get(); + JSONObject tagResult = new JSONObject(tagResponse.readEntity(String.class)); + assertEquals(2, tagResult.getInt("totalSize")); + assertEquals(2, tagResult.getJSONArray("pageData").length()); + } + @Test void putTransaction_happy() { UUID transactionId = data.getGeneratedId("tra_gsg"); @@ -74,7 +93,7 @@ void putTransaction_happy() { assertEquals(originalTransaction.getExpense(), changedTransaction.getExpense()); assertEquals(originalTransaction.getAmount(), changedTransaction.getAmount()); assertEquals(originalTransaction.getTransactionDate().format(DateBuilder.DEFAULT_DATE_FORMATTER), - changedTransaction.getTransactionDate().format(DateBuilder.DEFAULT_DATE_FORMATTER)); + changedTransaction.getTransactionDate().format(DateBuilder.DEFAULT_DATE_FORMATTER)); assertEquals("cool note", changedTransaction.getNote()); assertEquals("test", changedTransaction.getAlias()); @@ -95,7 +114,7 @@ void putTransaction_invalid() { Response unauthorized = client.create().withUserAgent("other-agent").loginUser("bak@test.ch").put(transaction); assertEquals(Status.NOT_FOUND.getStatusCode(), unauthorized.getStatus()); - + transaction.setId(UUID.randomUUID()); Response notFount = client.createAsRootUser().put(transaction); assertEquals(Status.NOT_FOUND.getStatusCode(), notFount.getStatus()); diff --git a/frontend/angular.json b/frontend/angular.json index 3e3f332..251fdfd 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -10,6 +10,9 @@ "style": "scss", "standalone": false, "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true } }, "root": "", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50c5a27..3acb0d2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@angular/platform-browser": "^17.0.0", "@angular/platform-browser-dynamic": "^17.0.0", "@angular/router": "^17.0.0", + "@ngneat/until-destroy": "^10.0.0", "moment": "^2.29.4", "ngx-cookie-service": "^17.0.1", "rxjs": "~7.8.0", @@ -2764,6 +2765,18 @@ "node": ">= 0.4" } }, + "node_modules/@ngneat/until-destroy": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@ngneat/until-destroy/-/until-destroy-10.0.0.tgz", + "integrity": "sha512-xXFAabQ4YVJ82LYxdgUlaKZyR3dSbxqG3woSyaclzxfCgWMEDweCcM/GGYbNiHJa0WwklI98RXHvca+UyCxpeg==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=13", + "rxjs": "^6.4.0 || ^7.0.0" + } + }, "node_modules/@ngtools/webpack": { "version": "17.0.9", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.0.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4d7e154..5485aae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^17.0.0", "@angular/platform-browser-dynamic": "^17.0.0", "@angular/router": "^17.0.0", + "@ngneat/until-destroy": "^10.0.0", "moment": "^2.29.4", "ngx-cookie-service": "^17.0.1", "rxjs": "~7.8.0", diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index e51e774..e52e0b3 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -2,30 +2,27 @@ class="root"> @if (pageState.fullscreen) {
- +
} @else {
-
+
- +
} - - - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 6557b24..0daf97e 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,9 +1,9 @@ import {Component, OnInit} from '@angular/core'; import {NavigationStart, Router} from '@angular/router'; import {fadePageTransition} from './animations'; -import {AuthService} from './services/auth.service'; import {PageStateService} from './services/page-state.service'; import {ThemeService} from './services/theme.service'; +import {TransactionService} from './services/transaction.service'; @Component({ selector: 'app-root', @@ -17,7 +17,7 @@ export class AppComponent implements OnInit { constructor( private router: Router, private theme: ThemeService, - public auth: AuthService, + private transactions: TransactionService, public pageService: PageStateService, ) { this.theme.init(); @@ -28,5 +28,7 @@ export class AppComponent implements OnInit { if (e instanceof NavigationStart) this.currentRoute = e.url; }); + + this.transactions.importTransactionsWhenLogin(); } } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 370fc34..7c8468c 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -6,13 +6,13 @@ import {BrowserModule} from '@angular/platform-browser'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {CookieService} from 'ngx-cookie-service'; import {ContentReplacerDirective} from './animations'; - import {AppRoutingModule} from './app-routing.module'; import {AppComponent} from './app.component'; +import {BannerOutletComponent} from './components/framework/banner/banner-outlet/banner-outlet.component'; import {ConfirmDialogComponent} from './components/framework/dialog/confirm-dialog/confirm-dialog.component'; import {DialogHostDirective} from './components/framework/dialog/dialog-host.directive'; +import {DialogOutletComponent} from './components/framework/dialog/dialog-outlet.component'; import {DialogContentWrapperComponent} from './components/framework/dialog/dialog-wrapper/dialog-content-wrapper.component'; -import {DialogComponent} from './components/framework/dialog/dialog.component'; import {DisplayErrorDialogComponent} from './components/framework/display-error/display-error-dialog.component'; import {ExpansionListToggleComponent} from './components/framework/expansion-panel/expansion-panel-toggle/expansion-list-toggle.component'; import {ExpansionPanelComponent} from './components/framework/expansion-panel/expansion-panel.component'; @@ -33,7 +33,6 @@ import {StepsPanelComponent} from './components/framework/steps-panel/steps-pane import {HelpDialogComponent} from './components/help/help-dialog.component'; import {HeaderComponent} from './components/layout/header/header.component'; import {NavigationTreeComponent} from './components/layout/navigation-tree/navigation-tree.component'; -import {NavigationComponent} from './components/layout/navigation/navigation.component'; import {LoginFormComponent} from './components/login/login-form/login-form.component'; import {WelcomeComponent} from './components/login/welcome/welcome.component'; import {AssignTagDialogComponent} from './components/tags/assign-tag-dialog/assign-tag-dialog.component'; @@ -48,7 +47,7 @@ import {TagComponent} from './components/tags/tag/tag.component'; import {StyledAmountComponent} from './components/transactions/styled-amount/styled-amount.component'; import {TransactionDetailComponent} from './components/transactions/transaction-detail/transaction-detail.component'; import {TransactionFilterComponent} from './components/transactions/transaction-filter/transaction-filter.component'; -import {TransactionImporterComponent} from './components/transactions/transaction-importer/transaction-importer.component'; +import {TransactionImportBannerComponent} from './components/transactions/transaction-importer/transaction-import-banner.component'; import {TransactionPreviewComponent} from './components/transactions/transaction-preview/transaction-preview.component'; import {TransactionComponent} from './components/transactions/transaction/transaction.component'; import {BudgetPageComponent} from './pages/budget.page/budget.page.component'; @@ -75,7 +74,7 @@ import {AuthTokenInterceptor} from './services/auth.service'; TextInputComponent, StepsPanelComponent, PanelStepDirective, - DialogComponent, + DialogOutletComponent, HelpDialogComponent, TextAreaComponent, SelectComponent, @@ -87,7 +86,7 @@ import {AuthTokenInterceptor} from './services/auth.service'; ContentReplacerDirective, TransactionPageComponent, TransactionComponent, - TransactionImporterComponent, + TransactionImportBannerComponent, ExpansionPanelComponent, TransactionDetailComponent, TagIconComponent, @@ -117,9 +116,9 @@ import {AuthTokenInterceptor} from './services/auth.service'; TagColorSelectorComponent, TagIconSelectorComponent, HeaderComponent, - NavigationComponent, WelcomeComponent, NavigationTreeComponent, + BannerOutletComponent, ], imports: [ BrowserModule, diff --git a/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.html b/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.html new file mode 100644 index 0000000..dbf3136 --- /dev/null +++ b/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.scss b/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.scss new file mode 100644 index 0000000..a1923b5 --- /dev/null +++ b/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.scss @@ -0,0 +1,10 @@ +:host { + display: block; + + position: fixed; + right: 0; + bottom: 0; + left: 0; + + z-index: var(--z-index-high); +} \ No newline at end of file diff --git a/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.ts b/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.ts new file mode 100644 index 0000000..a866e2d --- /dev/null +++ b/frontend/src/app/components/framework/banner/banner-outlet/banner-outlet.component.ts @@ -0,0 +1,27 @@ +import {Component, Type, ViewChild, ViewContainerRef} from '@angular/core'; +import {AppBannerComponent, BannerService} from '../banner.service'; + +@Component({ + selector: 'app-banner-outlet', + templateUrl: './banner-outlet.component.html', + styleUrl: './banner-outlet.component.scss', + }) +export class BannerOutletComponent { + + @ViewChild('bannerHost', {static: true, read: ViewContainerRef}) hostContainer!: ViewContainerRef; + + constructor( + service: BannerService, + ) { + service.setOutlet(this); + } + + renderBanner(banner: Type>): AppBannerComponent { + const componentRef = this.hostContainer.createComponent>(banner); + return componentRef.instance; + } + + clearBanner() { + this.hostContainer.clear(); + } +} diff --git a/frontend/src/app/components/framework/banner/banner.service.ts b/frontend/src/app/components/framework/banner/banner.service.ts new file mode 100644 index 0000000..58864ed --- /dev/null +++ b/frontend/src/app/components/framework/banner/banner.service.ts @@ -0,0 +1,35 @@ +import {Injectable, Type} from '@angular/core'; +import {BannerOutletComponent} from './banner-outlet/banner-outlet.component'; + +export interface AppBannerComponent { + data: Data, + onClose?: () => void, +} + +@Injectable({ + providedIn: 'root', + }) +export class BannerService { + + private outlet!: BannerOutletComponent; + private openBanner: AppBannerComponent | undefined; + + constructor() { + } + + public showBanner>(dialog: Type, data?: Data): Component { + this.closeCurrentBanner(); + this.openBanner = this.outlet.renderBanner(dialog); + this.openBanner.data = data; + return this.openBanner as Component; + } + + public closeCurrentBanner() { + this.openBanner?.onClose?.call(undefined); + this.outlet.clearBanner(); + } + + public setOutlet(cmp: BannerOutletComponent) { + this.outlet = cmp; + } +} diff --git a/frontend/src/app/components/framework/dialog/dialog.component.html b/frontend/src/app/components/framework/dialog/dialog-outlet.component.html similarity index 100% rename from frontend/src/app/components/framework/dialog/dialog.component.html rename to frontend/src/app/components/framework/dialog/dialog-outlet.component.html diff --git a/frontend/src/app/components/framework/dialog/dialog.component.scss b/frontend/src/app/components/framework/dialog/dialog-outlet.component.scss similarity index 100% rename from frontend/src/app/components/framework/dialog/dialog.component.scss rename to frontend/src/app/components/framework/dialog/dialog-outlet.component.scss diff --git a/frontend/src/app/components/framework/dialog/dialog.component.ts b/frontend/src/app/components/framework/dialog/dialog-outlet.component.ts similarity index 84% rename from frontend/src/app/components/framework/dialog/dialog.component.ts rename to frontend/src/app/components/framework/dialog/dialog-outlet.component.ts index 46302ab..c309ee3 100644 --- a/frontend/src/app/components/framework/dialog/dialog.component.ts +++ b/frontend/src/app/components/framework/dialog/dialog-outlet.component.ts @@ -3,11 +3,11 @@ import {DialogHostDirective} from './dialog-host.directive'; import {AppDialogOpenItem, DialogService} from './dialog.service'; @Component({ - selector: 'app-dialog', - templateUrl: './dialog.component.html', - styleUrls: ['./dialog.component.scss'], + selector: 'app-dialog-outlet', + templateUrl: './dialog-outlet.component.html', + styleUrls: ['./dialog-outlet.component.scss'], }) -export class DialogComponent { +export class DialogOutletComponent { open: boolean = false; @ViewChild(DialogHostDirective, {static: true}) host!: DialogHostDirective; diff --git a/frontend/src/app/components/framework/dialog/dialog.service.ts b/frontend/src/app/components/framework/dialog/dialog.service.ts index 130955b..e45905a 100644 --- a/frontend/src/app/components/framework/dialog/dialog.service.ts +++ b/frontend/src/app/components/framework/dialog/dialog.service.ts @@ -1,6 +1,6 @@ import {Injectable, TemplateRef, Type} from '@angular/core'; import {WindowScrollService} from '../../../services/window-scroll.service'; -import {DialogComponent} from './dialog.component'; +import {DialogOutletComponent} from './dialog-outlet.component'; @Injectable({ providedIn: 'root', @@ -15,9 +15,9 @@ export class DialogService { ) { } - private _dialogHostComponent!: DialogComponent; + private _dialogHostComponent!: DialogOutletComponent; - set dialogHostComponent(component: DialogComponent) { + set dialogHostComponent(component: DialogOutletComponent) { this._dialogHostComponent = component; } diff --git a/frontend/src/app/components/framework/display-error/display-error-dialog.component.html b/frontend/src/app/components/framework/display-error/display-error-dialog.component.html index e6bd2a0..65d1f60 100644 --- a/frontend/src/app/components/framework/display-error/display-error-dialog.component.html +++ b/frontend/src/app/components/framework/display-error/display-error-dialog.component.html @@ -1,14 +1,14 @@

Actually terribly wrong. This is the error:

-

{{data.status}} {{data.statusText}}

-

{{data.error | translateError}}

+

{{ data.status }} {{ data.statusText }}

+

{{ data.error | translateError }}

-

{{data.error?.errorKey}}

-

{{data.error | translateError}}

+

{{ errorTitle }}

+

{{ data.error | translateError }}

-

{{data.status}} {{data.statusText}}

+

{{ data.status }} {{ data.statusText }}

{ +export class DisplayErrorDialogComponent implements OnInit, AppDialogComponent { @Input() data!: HttpErrorResponse; + errorTitle: string = 'Unknown'; protected readonly location = location; constructor( @@ -18,6 +19,10 @@ export class DisplayErrorDialogComponent implements AppDialogComponent(null, [Validators.min(1), Validators.max(31)]); this.monthControl = new FormControl(null, [Validators.min(1), Validators.max(12)]); this.yearControl = new FormControl(null, [Validators.min(1), Validators.max(9999)]); diff --git a/frontend/src/app/components/layout/navigation/navigation.component.html b/frontend/src/app/components/layout/navigation/navigation.component.html deleted file mode 100644 index 9121c8d..0000000 --- a/frontend/src/app/components/layout/navigation/navigation.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/app/components/layout/navigation/navigation.component.scss b/frontend/src/app/components/layout/navigation/navigation.component.scss deleted file mode 100644 index 8e4c312..0000000 --- a/frontend/src/app/components/layout/navigation/navigation.component.scss +++ /dev/null @@ -1,17 +0,0 @@ -nav { - width: 100%; - height: 100%; - - padding: 12px; - box-sizing: border-box; - transition: opacity 100ms ease-out; -} - -@media (max-width: 650px) { - nav { - position: fixed; - top: var(--header-height); - left: 0; - width: var(--nav-open-width); - } -} \ No newline at end of file diff --git a/frontend/src/app/components/layout/navigation/navigation.component.ts b/frontend/src/app/components/layout/navigation/navigation.component.ts deleted file mode 100644 index e28b75a..0000000 --- a/frontend/src/app/components/layout/navigation/navigation.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {Component} from '@angular/core'; -import {pages} from '../../../app-routing.module'; -import {PageStateService} from '../../../services/page-state.service'; - -@Component({ - selector: 'app-navigation', - templateUrl: './navigation.component.html', - styleUrl: './navigation.component.scss', - }) -export class NavigationComponent { - protected readonly pages = pages; - - constructor( - public pageService: PageStateService, - ) { - } - -} diff --git a/frontend/src/app/components/tags/tag-selector/tag-selector.component.ts b/frontend/src/app/components/tags/tag-selector/tag-selector.component.ts index 27ea7ac..2454691 100644 --- a/frontend/src/app/components/tags/tag-selector/tag-selector.component.ts +++ b/frontend/src/app/components/tags/tag-selector/tag-selector.component.ts @@ -1,13 +1,15 @@ -import {Component, EventEmitter, Input, OnInit, Output} from "@angular/core"; -import {firstValueFrom, map, Observable} from "rxjs"; -import {TagDto} from "../../../dtos/TransactionDtos"; -import {TagService} from "../../../services/tag.service"; +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {UntilDestroy, untilDestroyed} from '@ngneat/until-destroy'; +import {firstValueFrom, map, Observable} from 'rxjs'; +import {TagDto} from '../../../dtos/TransactionDtos'; +import {TagService} from '../../../services/tag.service'; @Component({ - selector: "app-tag-selector", - templateUrl: "./tag-selector.component.html", - styleUrls: ["./tag-selector.component.scss"] -}) + selector: 'app-tag-selector', + templateUrl: './tag-selector.component.html', + styleUrls: ['./tag-selector.component.scss'], + }) +@UntilDestroy() export class TagSelectorComponent implements OnInit { @Input() multiple: boolean = false; @@ -18,14 +20,15 @@ export class TagSelectorComponent implements OnInit { @Output() selectedTagIdsChange = new EventEmitter(); constructor( - private tagService: TagService, + private tagService: TagService, ) { } async ngOnInit() { if (!this.allTags$) - this.allTags$ = this.tagService.get$().pipe( - map(tags => tags?.filter(t => !t.defaultTag)), + this.allTags$ = this.tagService.get().result$.pipe( + untilDestroyed(this), + map(tags => tags?.filter(t => !t.defaultTag)), ); if (!this.selectedTags && !this.selectedTagIds) { diff --git a/frontend/src/app/components/transactions/transaction-detail/transaction-detail.component.ts b/frontend/src/app/components/transactions/transaction-detail/transaction-detail.component.ts index 8cca7c1..24148ea 100644 --- a/frontend/src/app/components/transactions/transaction-detail/transaction-detail.component.ts +++ b/frontend/src/app/components/transactions/transaction-detail/transaction-detail.component.ts @@ -53,7 +53,7 @@ export class TransactionDetailComponent implements OnInit { this.dialogService.openDialog(ResolveTagConflictDialogComponent, this.transaction); } - private saveTransaction(): Observable { + private saveTransaction(): Observable { this.transaction.alias = this.aliasInput.value; this.transaction.note = this.noteInput.value; return this.transactionService.saveTransaction(this.transaction); diff --git a/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.html b/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.html index 4ab3d08..2bd247d 100644 --- a/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.html +++ b/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.html @@ -12,25 +12,40 @@ [multiple]="true">
- +
- +
- + Today - + Week
- +
- + Reset
@@ -39,6 +54,6 @@
priority_high
- Either couldn't assign a tag or found multiple tags and you need to decide. + => Either couldn't assign a tag or found multiple tags and you need to decide. diff --git a/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.ts b/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.ts index a3e09ec..2b4e7b9 100644 --- a/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.ts +++ b/frontend/src/app/components/transactions/transaction-filter/transaction-filter.component.ts @@ -1,9 +1,9 @@ -import {Component, OnInit, ViewChild} from '@angular/core'; +import {Component, Input, OnInit, ViewChild} from '@angular/core'; import {FormControl} from '@angular/forms'; import * as moment from 'moment/moment'; import {BehaviorSubject, debounceTime, filter, merge, of, skip, switchMap} from 'rxjs'; import {ApiService} from '../../../services/api.service'; -import {TransactionService} from '../../../services/transaction.service'; +import {TransactionRequest} from '../../../services/transaction.service'; import {DatePickerComponent} from '../../framework/form/date-picker/date-picker.component'; @Component({ @@ -13,6 +13,7 @@ import {DatePickerComponent} from '../../framework/form/date-picker/date-picker. }) export class TransactionFilterComponent implements OnInit { + @Input() transactionRequest!: TransactionRequest; @ViewChild('fromDatePicker', {static: true}) fromDatePicker!: DatePickerComponent; @ViewChild('toDatePicker', {static: true}) toDatePicker!: DatePickerComponent; queryControl: FormControl; @@ -20,9 +21,7 @@ export class TransactionFilterComponent implements OnInit { toControl: FormControl; needAttentionControl: FormControl; - constructor( - private transactionService: TransactionService, - ) { + constructor() { this.queryControl = new FormControl(''); this._selectedTags = new BehaviorSubject([]); this.fromControl = new FormControl(null); @@ -41,7 +40,7 @@ export class TransactionFilterComponent implements OnInit { } ngOnInit() { - const currentFilter = this.transactionService.currentFilter; + const currentFilter = this.transactionRequest.currentFilter; if (currentFilter !== undefined) { this.queryControl.setValue(currentFilter.query); this.fromControl.setValue(currentFilter.from ?? null); @@ -49,7 +48,7 @@ export class TransactionFilterComponent implements OnInit { this.toControl.setValue(currentFilter.to ?? null); this.toDatePicker.setValue(currentFilter.to ?? null); this.needAttentionControl.setValue(currentFilter.needAttention); - this.selectedTags = currentFilter.tags ?? []; + this.selectedTags = currentFilter.tagIds ?? []; } merge( @@ -62,7 +61,7 @@ export class TransactionFilterComponent implements OnInit { skip(1), debounceTime(500), filter(() => { - const lastValues = this.transactionService.currentFilter; + const lastValues = this.transactionRequest.currentFilter; if (lastValues === undefined) return true; @@ -76,7 +75,7 @@ export class TransactionFilterComponent implements OnInit { || fromMoment?.format(ApiService.API_DATE_FORMAT) !== lastValues.from?.format(ApiService.API_DATE_FORMAT) || toMoment?.format(ApiService.API_DATE_FORMAT) !== lastValues.to?.format(ApiService.API_DATE_FORMAT) || needAttention !== lastValues.needAttention - || JSON.stringify(tagIds) !== JSON.stringify(lastValues.tags); + || JSON.stringify(tagIds) !== JSON.stringify(lastValues.tagIds); }), switchMap(() => { const fromMoment = this.fromControl.valid ? this.fromControl.value ?? undefined : undefined; @@ -84,7 +83,14 @@ export class TransactionFilterComponent implements OnInit { const query = this.queryControl.value; const tagIds = [...this.selectedTags]; - this.transactionService.reloadFilteredTransactions(query, tagIds, fromMoment, toMoment, this.needAttentionControl.value); + console.log('updating', tagIds); + this.transactionRequest.updateFilter({ + from: fromMoment, + to: toMoment, + query: query, + tagIds: tagIds, + needAttention: this.needAttentionControl.value ?? undefined, + }); return of(); }), ).subscribe(); diff --git a/frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.html b/frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.html new file mode 100644 index 0000000..9347d77 --- /dev/null +++ b/frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.html @@ -0,0 +1,39 @@ +
+
+ sync + Importing transactions ... +
+
+ + error + + Importing transactions failed! + +
+
+ + done + + Importing transactions completed + +
+
\ No newline at end of file diff --git a/frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.scss b/frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.scss similarity index 100% rename from frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.scss rename to frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.scss diff --git a/frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.ts b/frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.ts new file mode 100644 index 0000000..c5bb86f --- /dev/null +++ b/frontend/src/app/components/transactions/transaction-importer/transaction-import-banner.component.ts @@ -0,0 +1,26 @@ +import {Component} from '@angular/core'; +import {AppBannerComponent, BannerService} from '../../framework/banner/banner.service'; + +@Component({ + selector: 'app-transaction-import-banner', + templateUrl: './transaction-import-banner.component.html', + styleUrls: ['./transaction-import-banner.component.scss'], + }) +export class TransactionImportBannerComponent implements AppBannerComponent { + data: undefined; + state: 'started' | 'success' | 'error' = 'started'; + + constructor( + public banner: BannerService, + ) { + } + + activateSuccessState() { + this.state = 'success'; + setTimeout(() => this.banner.closeCurrentBanner(), 2500); // hide again + } + + activateErrorState() { + this.state = 'error'; + } +} diff --git a/frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.html b/frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.html deleted file mode 100644 index cdbcc40..0000000 --- a/frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
- sync - Importing transactions ... -
-
- error - Importing transactions failed! - -
-
- done - Importing transactions completed - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.ts b/frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.ts deleted file mode 100644 index a73f955..0000000 --- a/frontend/src/app/components/transactions/transaction-importer/transaction-importer.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {Component, OnInit} from '@angular/core'; -import {TransactionService} from "../../../services/transaction.service"; - -@Component({ - selector: 'app-transaction-importer', - templateUrl: './transaction-importer.component.html', - styleUrls: ['./transaction-importer.component.scss'] -}) -export class TransactionImporterComponent implements OnInit { - state: "started" | "success" | "error" | undefined; - - constructor( - private transactionService: TransactionService, - ) { - } - - ngOnInit(): void { - this.state = "started"; - this.transactionService.importTransactions().then(() => this.activateSuccessState()); - } - - private activateSuccessState() { - this.state = "success"; - setTimeout(() => this.state = undefined, 2500); // hide again - } -} diff --git a/frontend/src/app/dtos/PaginationResultDto.ts b/frontend/src/app/dtos/PaginationResultDto.ts new file mode 100644 index 0000000..251ddf1 --- /dev/null +++ b/frontend/src/app/dtos/PaginationResultDto.ts @@ -0,0 +1,5 @@ +export interface PaginationResultDto { + pageSize: number; + totalSize: number; + pageData: Data[]; +} \ No newline at end of file diff --git a/frontend/src/app/pages/configuration.page/configuration.page.component.ts b/frontend/src/app/pages/configuration.page/configuration.page.component.ts index fb41711..2ff18a8 100644 --- a/frontend/src/app/pages/configuration.page/configuration.page.component.ts +++ b/frontend/src/app/pages/configuration.page/configuration.page.component.ts @@ -1,21 +1,22 @@ -import {Component} from "@angular/core"; -import {Observable} from "rxjs"; -import {APP_ROOT, pages} from "../../app-routing.module"; -import {TagDto} from "../../dtos/TransactionDtos"; -import {TagService} from "../../services/tag.service"; +import {Component} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {Observable} from 'rxjs'; +import {APP_ROOT, pages} from '../../app-routing.module'; +import {TagDto} from '../../dtos/TransactionDtos'; +import {TagService} from '../../services/tag.service'; @Component({ - selector: "app-configuration.page", - templateUrl: "./configuration.page.component.html", - styleUrls: ["./configuration.page.component.scss"] -}) + selector: 'app-configuration.page', + templateUrl: './configuration.page.component.html', + styleUrls: ['./configuration.page.component.scss'], + }) export class ConfigurationPageComponent { tags$: Observable; constructor( - tagService: TagService, + tagService: TagService, ) { - this.tags$ = tagService.get$(); + this.tags$ = tagService.get().result$.pipe(takeUntilDestroyed()); } get helpPage() { diff --git a/frontend/src/app/pages/home.page/home.page.component.html b/frontend/src/app/pages/home.page/home.page.component.html index 22e7870..926c9c8 100644 --- a/frontend/src/app/pages/home.page/home.page.component.html +++ b/frontend/src/app/pages/home.page/home.page.component.html @@ -2,9 +2,11 @@ pageRoute="home" pageTitle="Home">

home.page works!

- +
diff --git a/frontend/src/app/pages/home.page/home.page.component.ts b/frontend/src/app/pages/home.page/home.page.component.ts index fed0716..8e949a3 100644 --- a/frontend/src/app/pages/home.page/home.page.component.ts +++ b/frontend/src/app/pages/home.page/home.page.component.ts @@ -1,19 +1,25 @@ -import {Component} from "@angular/core"; -import {TagService} from "../../services/tag.service"; +import {Component} from '@angular/core'; +import {TagService} from '../../services/tag.service'; +import {TransactionService} from '../../services/transaction.service'; @Component({ - selector: "app-home.page", - templateUrl: "./home.page.component.html", - styleUrls: ["./home.page.component.scss"] -}) + selector: 'app-home.page', + templateUrl: './home.page.component.html', + styleUrls: ['./home.page.component.scss'], + }) export class HomePageComponent { constructor( - private tags: TagService + private tags: TagService, + private transactions: TransactionService, ) { } reset() { - this.tags.invalidateCache(); + this.tags.invalidate(); + } + + resetTransaction() { + this.transactions.invalidate(); } } diff --git a/frontend/src/app/pages/transaction.page/transaction.page.component.html b/frontend/src/app/pages/transaction.page/transaction.page.component.html index f84cfb7..130aa0d 100644 --- a/frontend/src/app/pages/transaction.page/transaction.page.component.html +++ b/frontend/src/app/pages/transaction.page/transaction.page.component.html @@ -3,7 +3,7 @@ pageTitle="Transactions"> - +

Found no transactions / no transactions imported yet!

@@ -13,13 +13,12 @@

{{ key }}

- + [transaction]="transaction"/>
diff --git a/frontend/src/app/pages/transaction.page/transaction.page.component.scss b/frontend/src/app/pages/transaction.page/transaction.page.component.scss index 15ab273..749c4cc 100644 --- a/frontend/src/app/pages/transaction.page/transaction.page.component.scss +++ b/frontend/src/app/pages/transaction.page/transaction.page.component.scss @@ -22,6 +22,7 @@ right: 0; bottom: 0; left: 0; + z-index: var(--z-index-default); cursor: wait; background-color: color-mix(in srgb, var(--michu-tech-background) 80%, var(--michu-tech-foreground)); diff --git a/frontend/src/app/pages/transaction.page/transaction.page.component.ts b/frontend/src/app/pages/transaction.page/transaction.page.component.ts index 2aaf38f..8bec12e 100644 --- a/frontend/src/app/pages/transaction.page/transaction.page.component.ts +++ b/frontend/src/app/pages/transaction.page/transaction.page.component.ts @@ -1,9 +1,9 @@ import {ChangeDetectionStrategy, Component, ElementRef, ViewChild} from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import * as moment from 'moment'; -import {map, Observable, tap} from 'rxjs'; +import {map, Observable} from 'rxjs'; import {TransactionDto} from '../../dtos/TransactionDtos'; -import {TransactionService} from '../../services/transaction.service'; +import {TransactionRequest, TransactionService} from '../../services/transaction.service'; import {WindowScrollService} from '../../services/window-scroll.service'; @Component({ @@ -14,22 +14,24 @@ import {WindowScrollService} from '../../services/window-scroll.service'; }) export class TransactionPageComponent { @ViewChild('loadMore', {static: false}) loadMoreDiv: ElementRef | undefined; + request: TransactionRequest; transactions$: Observable>; - moreTransactionsAvailable: boolean = true; + hasNextPage: boolean = false; - // FIXME 1. filter (so that 0 results) 2. leave page 3. invalidate data 4. return page 5. never stops loading constructor( - private service: TransactionService, + service: TransactionService, scrollService: WindowScrollService, ) { // expects sorted results from backend - this.transactions$ = service.get$().pipe( - map(t => this.mapTransactionsToDates(t)), - tap(() => this.moreTransactionsAvailable = service.hasNextPage()), - ); + this.request = service.get(); + this.transactions$ = this.request.result$.pipe(takeUntilDestroyed(), map(r => this.mapTransactionsToDates(r))); scrollService.scrollChange$.pipe(takeUntilDestroyed()) .subscribe(() => this.checkVisibilityOfLoadMore()); + + this.request.result$ + .pipe(takeUntilDestroyed()) + .subscribe(() => this.hasNextPage = this.request.hasNextPage()); } private checkVisibilityOfLoadMore() { @@ -41,7 +43,7 @@ export class TransactionPageComponent { const visiblePercent = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)) / rect.height * 100; if (visiblePercent > 60) { - this.service.loadNextPage(); + this.request.loadNextPage(); } } diff --git a/frontend/src/app/services/EntityCacheService.ts b/frontend/src/app/services/EntityCacheService.ts deleted file mode 100644 index 218c6e9..0000000 --- a/frontend/src/app/services/EntityCacheService.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {BehaviorSubject, Observable, ReplaySubject, shareReplay, startWith, Subject, Subscription, switchMap} from "rxjs"; -import {logged} from "../operators/logger-operator"; - -export abstract class EntityCacheService { - private readonly source = new BehaviorSubject(undefined); - private readonly resetter = new Subject(); - private data?: Observable; - private subscription?: Subscription; - - protected constructor() { - } - - async init() { - const destination = this.factory(); - this.subscription = this.source.pipe( - logged({ - name: `cache[${this.constructor.name}] - source`, - }) - ).subscribe(destination); - - this.data = this.resetter.asObservable().pipe( - startWith(undefined), - switchMap(() => destination), - logged({ - name: `cache[${this.constructor.name}] - resetter`, - }), - shareReplay(1), - ); - this.source.next(await this.loadData()); - } - - public get$(): Observable { - if (!this.data) - throw new Error("cache service never initialized"); - return this.pipeResult(this.data); - } - - public getCurrent(): T | undefined { - return this.source.getValue(); - } - - public async invalidateCache() { - this.subscription?.unsubscribe(); - this.subscription = this.source.subscribe(this.factory()); - - const newData: T = await this.loadData(); - this.resetter.next(newData); - } - - /** - * called when get$() is executed, can be used to prepare the observable for the user of the service. - * NOTE (is not shared by default) - * @param data the data observable to pipe - * @protected - */ - protected pipeResult(data: Observable): Observable { - return data; - } - - protected abstract loadData(): Promise; - - protected updateData(data: T | undefined) { - this.source.next(data); - } - - private readonly factory = () => new ReplaySubject(); -} \ No newline at end of file diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 62f92c6..01f4b8a 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,108 +1,105 @@ +import {HttpClient, HttpErrorResponse, HttpEvent} from '@angular/common/http'; import {Injectable} from '@angular/core'; -import {HttpClient, HttpErrorResponse} from "@angular/common/http"; -import {catchError, NEVER, Observable} from "rxjs"; -import {environment} from "../../environments/environment"; -import {ErrorService} from "./error.service"; +import {catchError, NEVER, Observable} from 'rxjs'; +import {environment} from '../../environments/environment'; +import {ErrorService} from './error.service'; export const endpoint = { - REGISTER: "/register", - CHECK_MAIL: "/register/mail", - CREATE_MAIL_FOLDER: "/register/mail/folder", - CONTACT: "/contact", - AUTH: "/auth", - MFA: "/auth/mfa", - SUPPORTED_BANK: "/register/bank", - TRANSACTIONS: "/transaction", - IMPORT_TRANSACTIONS: "/transaction/import", - TAG: "/tag", - VALIDATE_NO_KEYWORD: "/tag/validate_no_keyword", - ASSIGN_TAG: "/tag/assign_tag", - RESOLVE_TAG_CONFLICT: "/tag/resolve_conflict", - CHANGE_TAG: "/tag/change_tag", -} + REGISTER: '/register', + CHECK_MAIL: '/register/mail', + CREATE_MAIL_FOLDER: '/register/mail/folder', + CONTACT: '/contact', + AUTH: '/auth', + MFA: '/auth/mfa', + SUPPORTED_BANK: '/register/bank', + TRANSACTIONS: '/transaction', + IMPORT_TRANSACTIONS: '/transaction/import', + TAG: '/tag', + VALIDATE_NO_KEYWORD: '/tag/validate_no_keyword', + ASSIGN_TAG: '/tag/assign_tag', + RESOLVE_TAG_CONFLICT: '/tag/resolve_conflict', + CHANGE_TAG: '/tag/change_tag', +}; @Injectable({ - providedIn: 'root' -}) + providedIn: 'root', + }) export class ApiService { - public static readonly API_DATE_FORMAT = "yyyy-MM-DD"; + public static readonly API_DATE_FORMAT = 'yyyy-MM-DD'; constructor( - private http: HttpClient, - private errorService: ErrorService, + private http: HttpClient, + private errorService: ErrorService, ) { } - public get(endpoint: string, queryParams?: { - key: string, - value: string - }[], showDialogOnError: boolean = false): Observable { + public get(endpoint: string, queryParams?: object, showDialogOnError: boolean = false, httpOptions?: any): Observable { let url: string = `${environment.API_URL}${endpoint}`; if (queryParams) url += this.parseQueryParams(queryParams); - this.logRequest("GET", url); + this.logRequest('GET', url); - const request = this.http.get(url); - return ((request as any) as Observable).pipe( - catchError((err, caught) => this.handleError(err, caught, showDialogOnError)), - ); + return this.http.get(url, httpOptions).pipe( + catchError((err) => this.handleError(err, showDialogOnError)), + ) as Observable; } - public getRaw(url: string): Observable { + public getRaw(url: string, options?: any): Observable> { let fullUrl: string = `${environment.API_URL}${url}`; - this.logRequest("RAW-GET", fullUrl); - return this.http.get(fullUrl); + this.logRequest('RAW-GET', fullUrl); + return this.http.get(fullUrl, options); } - public post(endpoint: string, payload: any, queryParams?: { - key: string, - value: string - }[], showDialogOnError: boolean = false) { + public post(endpoint: string, + payload: any, + queryParams?: ApiQueryParams, + showDialogOnError: boolean = false, + httpOptions?: any, + ): Observable { let url: string = `${environment.API_URL}${endpoint}`; if (queryParams) url += this.parseQueryParams(queryParams); - this.logRequest("POST", url, payload); + this.logRequest('POST', url, payload); - const request = this.http.post(url, payload); - return ((request as any) as Observable).pipe( - catchError((err, caught) => this.handleError(err, caught, showDialogOnError)), - ); + return this.http.post(url, payload, httpOptions).pipe( + catchError((err) => this.handleError(err, showDialogOnError)), + ) as Observable; } - public put(endpoint: string, payload: any, queryParams?: { - key: string, - value: string - }[], showDialogOnError: boolean = false) { + public put(endpoint: string, + payload: any, + queryParams?: ApiQueryParams, + showDialogOnError: boolean = false, + httpOptions?: any, + ): Observable { let url: string = `${environment.API_URL}${endpoint}`; if (queryParams) url += this.parseQueryParams(queryParams); - this.logRequest("PUT", url, payload); + this.logRequest('PUT', url, payload); - const request = this.http.put(url, payload); - return ((request as any) as Observable).pipe( - catchError((err, caught) => this.handleError(err, caught, showDialogOnError)), - ); + return this.http.put(url, payload, httpOptions).pipe( + catchError((err) => this.handleError(err, showDialogOnError)), + ) as Observable; } - public delete(endpoint: string, showDialogOnError: boolean = false): Observable { + public delete(endpoint: string, showDialogOnError: boolean = false, httpOptions?: any): Observable { let url: string = `${environment.API_URL}${endpoint}`; - this.logRequest("DELETE", url); + this.logRequest('DELETE', url); - const request = this.http.delete(url); - return ((request as any) as Observable).pipe( - catchError((err, caught) => this.handleError(err, caught, showDialogOnError)), - ); + return this.http.delete(url, httpOptions).pipe( + catchError((err) => this.handleError(err, showDialogOnError)), + ) as Observable; } - private handleError(err: HttpErrorResponse, _: Observable, showDialogOnError: boolean): Observable { + private handleError(err: HttpErrorResponse, showDialogOnError: boolean): Observable { if (environment.IS_DEV) console.error(err); @@ -113,19 +110,22 @@ export class ApiService { return NEVER; } - private parseQueryParams(params: { key: string, value: string }[]): string { - let url: string = "?"; - for (let i = 0; i < params.length; i++) { - if (i > 0 && i <= params.length - 1) - url += "&"; - const current: { key: string, value: string } = params[i]; - url += `${current.key}=${current.value}`; + private parseQueryParams(params: object): string { + let url: string = '?'; + const keys = Object.keys(params); + const values = Object.values(params); + for (let i = 0; i < keys.length; i++) { + if (i > 0 && i <= keys.length - 1) + url += '&'; + url += `${keys[i]}=${values[i]}`; } return url; } private logRequest(method: string, url: string, payload?: any): void { if (environment.IS_DEV) - console.log(`${method} -> ${url}`, payload ?? ""); + console.log(`${method} -> ${url}`, payload ?? ''); } } + +export type ApiQueryParams = Map; diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 92f6602..d13d3cf 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -1,33 +1,33 @@ -import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from "@angular/common/http"; -import {inject, Injectable} from "@angular/core"; -import {ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree} from "@angular/router"; -import * as moment from "moment"; -import {CookieService} from "ngx-cookie-service"; -import {BehaviorSubject, catchError, concatMap, Observable, of, tap} from "rxjs"; -import {environment} from "../../environments/environment"; -import {pages} from "../app-routing.module"; -import {ErrorDto} from "../dtos/ErrorDto"; -import {MessageDto} from "../dtos/MessageDto"; -import {ApiService, endpoint} from "./api.service"; -import {ErrorService} from "./error.service"; - -export type LoginState = "in" | "out" | "loading"; +import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree} from '@angular/router'; +import * as moment from 'moment'; +import {CookieService} from 'ngx-cookie-service'; +import {BehaviorSubject, catchError, concatMap, first, firstValueFrom, Observable, of, tap} from 'rxjs'; +import {environment} from '../../environments/environment'; +import {pages} from '../app-routing.module'; +import {ErrorDto} from '../dtos/ErrorDto'; +import {MessageDto} from '../dtos/MessageDto'; +import {ApiService, endpoint} from './api.service'; +import {ErrorService} from './error.service'; + +export type LoginState = 'in' | 'out' | 'loading'; @Injectable({ - providedIn: "root", -}) + providedIn: 'root', + }) export class AuthService { - static readonly AUTH_TOKEN = "Auth-Token"; - static readonly MFA_PROCESS_ID = "mfa"; - static readonly USER_ID = "id"; + static readonly AUTH_TOKEN = 'Auth-Token'; + static readonly MFA_PROCESS_ID = 'mfa'; + static readonly USER_ID = 'id'; - private loginSubject: BehaviorSubject = new BehaviorSubject("loading"); + private loginSubject: BehaviorSubject = new BehaviorSubject('loading'); public isLoggedIn$: Observable = this.loginSubject.asObservable(); constructor( - private api: ApiService, - private router: Router, - private tokenService: TokenService, + private api: ApiService, + private router: Router, + private tokenService: TokenService, ) { this.loadLoginState(); } @@ -36,98 +36,104 @@ export class AuthService { this.tokenService.removeToken(); return this.api.post(endpoint.AUTH, { credentials: { - mail, password - }, stay + mail, password, + }, stay, }).pipe( - tap(response => { - const token: string | undefined = (response as MessageDto).message; - if (token) { - this.tokenService.token = token; - this.loginSubject.next("in"); - this.router.navigate([pages.HOME]).then(); - } else { - const newDeviceError: ErrorDto = response as ErrorDto; - if (newDeviceError.errorKey === "AgentNotRegisteredException") { - const processId = newDeviceError.args.processId; - const userId = newDeviceError.args.userId; - localStorage.setItem(AuthService.MFA_PROCESS_ID, processId); - localStorage.setItem(AuthService.USER_ID, userId); - this.router.navigate([pages.LOGIN, pages.login.MFA]).then(); - } + tap(response => { + const token: string | undefined = (response as MessageDto).message; + if (token) { + this.tokenService.token = token; + this.loginSubject.next('in'); + this.router.navigate([pages.HOME]).then(); + } else { + const newDeviceError: ErrorDto = response as ErrorDto; + if (newDeviceError.errorKey === 'AgentNotRegisteredException') { + const processId = newDeviceError.args.processId; + const userId = newDeviceError.args.userId; + localStorage.setItem(AuthService.MFA_PROCESS_ID, processId); + localStorage.setItem(AuthService.USER_ID, userId); + this.router.navigate([pages.LOGIN, pages.login.MFA]).then(); } - }), - concatMap(_ => of("")), - catchError(err => { - this.loginSubject.next("out"); - return of(ErrorService.parseErrorMessage(err.error)); - }), + } + }), + concatMap(_ => of('')), + catchError(err => { + this.loginSubject.next('out'); + return of(ErrorService.parseErrorMessage(err.error)); + }), ); } logout() { - this.loginSubject.next("out"); + this.loginSubject.next('out'); this.tokenService.removeToken(); location.reload(); } validateMfaToken(processId: string, userId: string, code: number): Observable { return this.api.post(endpoint.MFA, { - processId, userId, code + processId, userId, code, }).pipe( - tap(dto => { - this.tokenService.token = dto.message; - localStorage.removeItem(AuthService.MFA_PROCESS_ID); - this.loginSubject.next("in"); - this.router.navigate([pages.HOME]).then(); - }), - concatMap(_ => of(true)), - catchError(_ => of(false)) + tap(dto => { + this.tokenService.token = dto.message; + localStorage.removeItem(AuthService.MFA_PROCESS_ID); + this.loginSubject.next('in'); + this.router.navigate([pages.HOME]).then(); + }), + concatMap(_ => of(true)), + catchError(_ => of(false)), ); } + whenLoggedIn(): Promise { + return firstValueFrom(this.isLoggedIn$.pipe(first(v => v === 'in'))); + } + private loadLoginState(): void { if (this.tokenService.hasToken()) { this.api.getRaw(endpoint.AUTH).subscribe({ - next: _ => this.loginSubject.next("in"), - error: _ => { - this.tokenService.removeToken(); - this.loginSubject.next("out"); - } - }); + next: _ => this.loginSubject.next('in'), + error: _ => { + this.tokenService.removeToken(); + this.loginSubject.next('out'); + }, + }); } else { - this.loginSubject.next("out"); + this.loginSubject.next('out'); } } } @Injectable({ - providedIn: "root", -}) + providedIn: 'root', + }) export class AuthTokenInterceptor implements HttpInterceptor { constructor( - private tokenService: TokenService, + private tokenService: TokenService, ) { } intercept(req: HttpRequest, next: HttpHandler): Observable> { const requestWithHeaders = req.clone({ - setHeaders: {[AuthService.AUTH_TOKEN]: this.tokenService.token}, - }); + setHeaders: {[AuthService.AUTH_TOKEN]: this.tokenService.token}, + }); return next.handle(requestWithHeaders); } } -export const authenticationGuard: CanActivateFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): Observable => { +export const authenticationGuard: CanActivateFn = (_route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot, +): Observable => { const authService = inject(AuthService); const router = inject(Router); return new Observable(obs => { authService.isLoggedIn$.subscribe(loggedIn => { - if (loggedIn === "loading") + if (loggedIn === 'loading') return; - if (loggedIn === "in") + if (loggedIn === 'in') obs.next(true); else obs.next(router.parseUrl(pages.LOGIN)); @@ -136,11 +142,11 @@ export const authenticationGuard: CanActivateFn = (_route: ActivatedRouteSnapsho }; @Injectable({ - providedIn: "root", -}) + providedIn: 'root', + }) export class TokenService { constructor( - private cookie: CookieService, + private cookie: CookieService, ) { } @@ -153,8 +159,8 @@ export class TokenService { } set token(token: string) { - let expires = moment(new Date()).add(300, "days"); - this.cookie.set(AuthService.AUTH_TOKEN, token, expires.toDate(), "/", environment.DOMAIN, true, "Strict"); + let expires = moment(new Date()).add(300, 'days'); + this.cookie.set(AuthService.AUTH_TOKEN, token, expires.toDate(), '/', environment.DOMAIN, true, 'Strict'); this._token = token; } @@ -163,6 +169,6 @@ export class TokenService { } removeToken() { - this.cookie.delete(AuthService.AUTH_TOKEN, "/", environment.DOMAIN, true, "Strict"); + this.cookie.delete(AuthService.AUTH_TOKEN, '/', environment.DOMAIN, true, 'Strict'); } } \ No newline at end of file diff --git a/frontend/src/app/services/cache/BaseRequestCache.ts b/frontend/src/app/services/cache/BaseRequestCache.ts new file mode 100644 index 0000000..5c054e6 --- /dev/null +++ b/frontend/src/app/services/cache/BaseRequestCache.ts @@ -0,0 +1,122 @@ +import {inject} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {ApiService} from '../api.service'; + +export abstract class BaseRequestCache { + protected readonly api: ApiService = inject(ApiService); + + protected readonly consumerCount = new Map(); + protected readonly data = new Map>; + + protected constructor( + protected endpoint: string, + ) { + } + + abstract get(filter?: TFilter): TRequestType; + + getCurrent(filter?: TFilter): TApiResult | undefined { + return this.data.get(stringifyFilter(filter))?.getValue(); + } + + updateAndCopyCache(oldKey: string, newKey: string) { + if (oldKey === newKey) + return; + + const currentSubject = this.data.get(oldKey); + if (!currentSubject) + return; + + const oldValue = currentSubject.getValue(); + const cachedNewValue = this.data.get(newKey)?.getValue(); + + if (cachedNewValue) { + currentSubject.next(cachedNewValue); + this.data.set(oldKey, new BehaviorSubject(oldValue)); + this.data.set(newKey, currentSubject); + this.moveConsumer(oldKey, newKey); + } else { + const newFilter = parseFilter(newKey); + this.loadData(newFilter ?? undefined).subscribe(r => { + this.data.set(newKey, currentSubject); + this.data.set(oldKey, new BehaviorSubject(oldValue)); + currentSubject.next(r); + this.moveConsumer(oldKey, newKey); + }); + } + } + + invalidate() { + const consumeCountEntries = Array.from(this.consumerCount.entries()); + const keysToRefresh = consumeCountEntries.filter(e => e[1] > 0).map(e => e[0]); + const keysToRemove = consumeCountEntries.filter(e => e[1] === 0).map(e => e[0]); + keysToRemove.forEach(k => { + this.data.delete(k); + this.consumerCount.delete(k); + }); + + const filtersToRefresh = keysToRefresh.map(parseFilter) as (TFilter | undefined)[]; + for (let i = 0; i < filtersToRefresh.length; i++) { + const filter = filtersToRefresh[i]; + const key = keysToRefresh[i]; + this.loadData(filter).subscribe(r => this.data.get(key)!.next(r)); + } + } + + protected loadData(filter: object | undefined): Observable { + return this.api.get(this.endpoint, filter ?? undefined, true); + } + + protected subscriptionEnded(key: string) { + const currentCount = this.consumerCount.get(key) ?? 0; + this.consumerCount.set(key, Math.max(0, currentCount - 1)); + } + + private moveConsumer(oldKey: string, newKey: string) { + this.consumerCount.set(oldKey, Math.max((this.consumerCount.get(oldKey) ?? 0) - 1, 0)); + this.consumerCount.set(newKey, (this.consumerCount.get(newKey) ?? 0) + 1); + } +} + +export const DEFAULT_CACHE_KEY = 'empty'; + +export function stringifyFilter(filter?: Filter): string { + if (!filter) + return DEFAULT_CACHE_KEY; + + return JSON.stringify(filter); +} + +export function parseFilter(filter: string): Filter | null { + if (filter === DEFAULT_CACHE_KEY) + return null; + return JSON.parse(filter); +} + +export function mergeFilter(existingFilter: TFilter, newFilter: Partial): TFilter | undefined { + let filter: any = { + ...existingFilter, + ...newFilter, + }; + + // strip current filter (only keep truthy filter properties) + // keep in mind, this will swap false to undefined, so a filter can't be explicitly set to false. + let anyValueDefined: boolean = false; + for (let key of Object.keys(filter)) { + if (Array.isArray(filter[key])) { + if (filter[key].length > 0) + anyValueDefined = true; + else + filter[key] = undefined; + } else { + if (filter[key]) + anyValueDefined = true; + else + filter[key] = undefined; + } + } + + if (!anyValueDefined) filter = undefined; + + return filter; +} diff --git a/frontend/src/app/services/cache/PagedRequestCache.ts b/frontend/src/app/services/cache/PagedRequestCache.ts new file mode 100644 index 0000000..8efb0ee --- /dev/null +++ b/frontend/src/app/services/cache/PagedRequestCache.ts @@ -0,0 +1,115 @@ +import {BehaviorSubject, finalize, firstValueFrom, map, Observable, shareReplay} from 'rxjs'; +import {PaginationResultDto} from '../../dtos/PaginationResultDto'; +import {BaseRequestCache, DEFAULT_CACHE_KEY, mergeFilter, parseFilter, stringifyFilter} from './BaseRequestCache'; + + +export abstract class PagedRequestCache + extends BaseRequestCache, PagedCachedRequest, TFilter> { + private readonly requestPageState: Map = new Map(); + + protected constructor( + endpoint: string, + private resultMapper: (dto: TDto) => TDto = (d) => d, + ) { + super(endpoint); + } + + public override get(filter?: TFilter): PagedCachedRequest { + const key = stringifyFilter(filter); + const currentCount = this.consumerCount.get(key) ?? 0; + this.consumerCount.set(key, currentCount + 1); + + let result: BehaviorSubject | undefined> | undefined = this.data.get(key); + if (!result) { + result = new BehaviorSubject | undefined>(undefined); + + this.loadData(filter).subscribe(r => result!.next(r)); + this.data.set(key, result); + } + + const obs = result.pipe( + map(v => (v?.pageData ?? []).map(v => this.resultMapper(v))), + shareReplay(1), + finalize(() => this.subscriptionEnded(key)), + ); + + return new PagedCachedRequest(obs, this, key); + } + + public async loadNextPage(cacheKey: string): Promise { + const currentRequest: BehaviorSubject | undefined> | undefined = this.data.get(cacheKey); + if (!currentRequest) + return; + + const pageToLoad: number = (this.requestPageState.get(cacheKey) ?? 1) + 1; + const parsedFilter = parseFilter(cacheKey) ?? {}; + const filter = { + ...parsedFilter, + page: pageToLoad, + }; + + const loadedData = await firstValueFrom(this.loadData(filter)); + loadedData.pageData = [...currentRequest.getValue()?.pageData ?? [], ...loadedData.pageData]; + this.requestPageState.set(cacheKey, pageToLoad); + currentRequest.next(loadedData); + } + + override invalidate(): void { + super.invalidate(); + this.requestPageState.clear(); + } + + getCurrentPage(cacheKey: string): number { + return this.requestPageState.get(cacheKey) ?? 1; + } +} + +export class PagedCachedRequest { + protected currentCacheKey: string; + + constructor( + protected data$: Observable, + protected cache: PagedRequestCache, + initialCacheKey: string = DEFAULT_CACHE_KEY, + ) { + this.currentCacheKey = initialCacheKey; + } + + protected _currentFilter: TFilter | undefined; + + get currentFilter() { + return this._currentFilter; + } + + get result$(): Observable { + return this.data$; + } + + loadNextPage(): void { + if (!this.hasNextPage()) { + return; + } + + this.cache.loadNextPage(this.currentCacheKey); + } + + hasNextPage(): boolean { + const currentValue = this.cache.getCurrent(this._currentFilter); + if (!currentValue) { + return false; + } + const currentPage = this.cache.getCurrentPage(this.currentCacheKey); + + const pages = Math.ceil(currentValue.totalSize / currentValue.pageSize); + return currentPage < pages; + } + + updateFilter(filter: Partial): void { + const oldCacheKey = this.currentCacheKey; + + this._currentFilter = mergeFilter(this._currentFilter, filter); + this.currentCacheKey = stringifyFilter(this._currentFilter); + + this.cache.updateAndCopyCache(oldCacheKey, this.currentCacheKey); + } +} \ No newline at end of file diff --git a/frontend/src/app/services/cache/RequestCache.ts b/frontend/src/app/services/cache/RequestCache.ts new file mode 100644 index 0000000..4324d5d --- /dev/null +++ b/frontend/src/app/services/cache/RequestCache.ts @@ -0,0 +1,65 @@ +import {BehaviorSubject, finalize, map, Observable, shareReplay} from 'rxjs'; +import {BaseRequestCache, DEFAULT_CACHE_KEY, mergeFilter, stringifyFilter} from './BaseRequestCache'; + +export abstract class RequestCache + extends BaseRequestCache, TFilter> { + + protected constructor( + endpoint: string, + ) { + super(endpoint); + } + + override get(filter?: TFilter): CachedRequest { + const key = stringifyFilter(filter); + const currentCount = this.consumerCount.get(key) ?? 0; + this.consumerCount.set(key, currentCount + 1); + + let result: BehaviorSubject | undefined = this.data.get(key); + if (!result) { + result = new BehaviorSubject(undefined); + + this.loadData(filter).subscribe(r => result!.next(r)); + this.data.set(key, result); + } + + const obs = result.pipe( + map(v => v ?? []), + shareReplay(1), + finalize(() => this.subscriptionEnded(key)), + ); + + return new CachedRequest(obs, this, key); + } +} + +export class CachedRequest { + protected currentCacheKey: string; + + constructor( + protected data$: Observable, + protected cache: RequestCache, + initialCacheKey: string = DEFAULT_CACHE_KEY, + ) { + this.currentCacheKey = initialCacheKey; + } + + protected _currentFilter: TFilter | undefined; + + get currentFilter() { + return this._currentFilter; + } + + get result$(): Observable { + return this.data$; + } + + updateFilter(filter: Partial): void { + const oldCacheKey = this.currentCacheKey; + + this._currentFilter = mergeFilter(this._currentFilter, filter); + this.currentCacheKey = stringifyFilter(this._currentFilter); + + this.cache.updateAndCopyCache(oldCacheKey, this.currentCacheKey); + } +} \ No newline at end of file diff --git a/frontend/src/app/services/error.service.ts b/frontend/src/app/services/error.service.ts index 6ff9c96..fe254d7 100644 --- a/frontend/src/app/services/error.service.ts +++ b/frontend/src/app/services/error.service.ts @@ -57,6 +57,8 @@ export class ErrorService { return `Please try again later or contact admin. Reason: ${error.args.cause}: ${error.args.message}`; case 'KeywordAlreadyExistsException': return `Keyword '${error.args.keyword}' already exists in tag: '${error.args.tag}'`; + case 'LoginFromNewClientException': + return 'An other client just logged in to this account. If this wasn\'t you, please contact the admin and change your password immediately.'; default: return 'Failed, please contact admin.'; } diff --git a/frontend/src/app/services/tag.service.ts b/frontend/src/app/services/tag.service.ts index c96d8c2..a13b051 100644 --- a/frontend/src/app/services/tag.service.ts +++ b/frontend/src/app/services/tag.service.ts @@ -1,21 +1,19 @@ -import {Injectable} from "@angular/core"; -import {firstValueFrom, Observable, take, tap} from "rxjs"; -import {TagDto} from "../dtos/TransactionDtos"; -import {ApiService, endpoint} from "./api.service"; -import {EntityCacheService} from "./EntityCacheService"; -import {TransactionService} from "./transaction.service"; +import {Injectable} from '@angular/core'; +import {Observable, take, tap} from 'rxjs'; +import {TagDto} from '../dtos/TransactionDtos'; +import {endpoint} from './api.service'; +import {RequestCache} from './cache/RequestCache'; +import {TransactionService} from './transaction.service'; @Injectable({ - providedIn: "root" -}) -export class TagService extends EntityCacheService { + providedIn: 'root', + }) +export class TagService extends RequestCache { constructor( - private api: ApiService, - private transactionService: TransactionService, + private transactionService: TransactionService, ) { - super(); - super.init(); + super(endpoint.TAG); } createTag(tagId: number, icon: string, color: string, name: string, keywords: string[]) { @@ -24,26 +22,19 @@ export class TagService extends EntityCacheService { icon: icon, color: color, name: name, - keywordsToAdd: keywords + keywordsToAdd: keywords, }, undefined, true) - .subscribe(() => this.transactionService.invalidateCache()); + .subscribe(() => this.tagsChanged()); } updateTag(tagId: number, icon: string, color: string, name: string, keywordsToAdd: string[], keywordIdsToDelete: number[]) { this.api.put(endpoint.TAG, {tagId, icon, color, name, keywordsToAdd, keywordIdsToDelete}, undefined, true) - .subscribe(() => this.transactionService.invalidateCache()); + .subscribe(() => this.tagsChanged()); } deleteTag(tagId: string) { this.api.delete(`${endpoint.TAG}/${tagId}`, true) - .subscribe(() => { - const tags = super.getCurrent(); - if (tags) { - tags.splice(tags.findIndex(t => t.id === tagId), 1); - super.updateData(tags); - } - this.transactionService.invalidateCache(); - }); + .subscribe(() => this.tagsChanged()); } /** @@ -54,8 +45,8 @@ export class TagService extends EntityCacheService { */ assignTag(transactionId: string, tagId: string, keyword?: string) { return this.api.put(endpoint.ASSIGN_TAG, {transactionId, tagId, keyword}, undefined, true).pipe( - take(1), - tap(() => this.transactionService.reloadFilteredTransactions()), + take(1), + tap(() => this.tagsChanged()), ); } @@ -66,26 +57,31 @@ export class TagService extends EntityCacheService { */ changeTag(transactionId: string, tagId: string) { return this.api.put(endpoint.CHANGE_TAG, {transactionId, tagId}, undefined, true).pipe( - tap(() => this.transactionService.reloadCurrentFilteredTransitions()), + tap(() => this.tagsChanged()), ); } - resolveConflict(transactionId: string, selectedTagId: string, matchingKeywordId: string, removeUnselectedKeywords: boolean): Observable { + resolveConflict(transactionId: string, + selectedTagId: string, + matchingKeywordId: string, + removeUnselectedKeywords: boolean, + ): Observable { return this.api.put(endpoint.RESOLVE_TAG_CONFLICT, { transactionId, selectedTagId, matchingKeywordId, - removeUnselectedKeywords + removeUnselectedKeywords, }, undefined, true).pipe( - tap(() => this.transactionService.reloadCurrentFilteredTransitions()), + tap(() => this.tagsChanged()), ); } isKeywordInTag(keyword: string): Observable { - return this.api.post(endpoint.VALIDATE_NO_KEYWORD, null, [{key: "keyword", value: keyword}]); + return this.api.post(endpoint.VALIDATE_NO_KEYWORD, null, new Map([['keyword', keyword]])); } - protected override async loadData() { - return firstValueFrom(this.api.get(endpoint.TAG, undefined, true)); + private tagsChanged() { + this.transactionService.invalidate(); + this.invalidate(); } } diff --git a/frontend/src/app/services/transaction.service.ts b/frontend/src/app/services/transaction.service.ts index fdacbbf..418c3f7 100644 --- a/frontend/src/app/services/transaction.service.ts +++ b/frontend/src/app/services/transaction.service.ts @@ -1,145 +1,61 @@ -import {Injectable} from "@angular/core"; -import * as moment from "moment"; -import {firstValueFrom, map, Observable, tap} from "rxjs"; -import {TransactionDto} from "../dtos/TransactionDtos"; -import {ApiService, endpoint} from "./api.service"; -import {EntityCacheService} from "./EntityCacheService"; +import {HttpResponse, HttpStatusCode} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import * as moment from 'moment'; +import {Observable, tap} from 'rxjs'; +import {BannerService} from '../components/framework/banner/banner.service'; +import {TransactionImportBannerComponent} from '../components/transactions/transaction-importer/transaction-import-banner.component'; +import {TransactionDto} from '../dtos/TransactionDtos'; +import {ApiService, endpoint} from './api.service'; +import {AuthService} from './auth.service'; +import {PagedCachedRequest, PagedRequestCache} from './cache/PagedRequestCache'; @Injectable({ - providedIn: "root" -}) -export class TransactionService extends EntityCacheService { - // latest load was entire page -> next page will contain values. - private currentMaxLoadedPage: number = 1; - private pageSize: number = -1; - private latestLoadedSize: number = -1; + providedIn: 'root', + }) +export class TransactionService extends PagedRequestCache { constructor( - private api: ApiService, + private auth: AuthService, + private banner: BannerService, ) { - super(); - super.init(); - } - - private _currentFilter: TransactionFilter; - - public get currentFilter() { - return this._currentFilter; - } - - private set currentFilter(filter: TransactionFilter) { - this._currentFilter = filter; - } - - async importTransactions(): Promise { // fixme fucks shit up if it is the first import, i think fix is that normal query should complete with empty result - this.insertTransactions(await firstValueFrom(this.api.get(endpoint.IMPORT_TRANSACTIONS, [], true))); - } - - loadNextPage() { - if (this.hasNextPage()) { - this.currentMaxLoadedPage++; - const params = this.buildFilterParams( - this.currentFilter?.query, - this.currentFilter?.tags, - this.currentFilter?.from, - this.currentFilter?.to, - this.currentFilter?.needAttention, - this.currentMaxLoadedPage - ); - - this.api.get(endpoint.TRANSACTIONS, params, true) - .subscribe(transactions => { - this.latestLoadedSize = transactions.length; - this.insertTransactions(transactions); - }); - } - } - - reloadCurrentFilteredTransitions() { - this.reloadFilteredTransactions(this.currentFilter?.query, this.currentFilter?.tags, this.currentFilter?.from, this.currentFilter?.to, this.currentFilter?.needAttention); - } - - reloadFilteredTransactions(query?: string, tags?: string[], from?: moment.Moment, to?: moment.Moment, needAttention?: boolean) { - const params = this.buildFilterParams(query, tags, from, to, needAttention, 1); - - this.api.get(endpoint.TRANSACTIONS, params, true) - .subscribe(transactions => { - this.currentFilter = {query, tags, from, to, needAttention}; - this.latestLoadedSize = transactions.length; - super.updateData(transactions); + super(endpoint.TRANSACTIONS, t => { + t.transactionDate = moment(t.transactionDate); // TODO create generic solution for dates (in and out) + return t; }); } - // latest load was entire page -> next page will contain values. - // if the latest load was smaller than the page size, then this had to be the last page. - hasNextPage() { - return this.latestLoadedSize >= this.pageSize; + importTransactionsWhenLogin() { + this.auth.whenLoggedIn().then(() => { + const importBanner = this.banner.showBanner(TransactionImportBannerComponent); + this.api.post>(endpoint.IMPORT_TRANSACTIONS, undefined, undefined, false, {observe: 'response'}) + .subscribe({ + next: (r) => { + importBanner.activateSuccessState(); + if (r && r.status === HttpStatusCode.Ok) { + this.invalidate(); + } + }, + error: () => importBanner.activateErrorState(), + }); + }); } - saveTransaction(transaction: TransactionDto): Observable { + saveTransaction(transaction: TransactionDto): Observable { const transactionDateString = transaction.transactionDate.format(ApiService.API_DATE_FORMAT); // todo maybe generic solution? const payload = { ...transaction, transactionDate: transactionDateString, }; - return this.api.put(endpoint.TRANSACTIONS, payload, [], true); - } - - protected override async loadData() { - const transactions = await firstValueFrom(this.api.get(endpoint.TRANSACTIONS, [], true)); - this.pageSize = transactions.length; - this.latestLoadedSize = transactions.length; - return transactions; - } - - protected override pipeResult(data: Observable): Observable { - return data.pipe( - tap(result => result?.map(t => t.transactionDate = moment(t.transactionDate))), - map(result => this.sortTransactions(result ?? [])), - ); - } - - private insertTransactions(toInsert: TransactionDto[]) { - let currentTransactions = super.getCurrent() ?? []; - currentTransactions = [...currentTransactions, ...toInsert]; - super.updateData(currentTransactions); - } - - private sortTransactions(transactions: TransactionDto[]) { - return transactions.sort((a, b) => { - const momentA = a.transactionDate; - const momentB = b.transactionDate; - if (momentA.isSame(momentB)) - return 0; - if (momentA.isBefore(momentB)) - return 1; - return -1; - }); - } - - private buildFilterParams(query?: string, tags?: string[], from?: moment.Moment, to?: moment.Moment, needAttention?: boolean, page?: number): { - key: string, - value: string - }[] { - this.currentMaxLoadedPage = page ?? 1; - const params: { key: string, value: string }[] = []; - params.push({key: "page", value: `${this.currentMaxLoadedPage}`}); - params.push({key: "query", value: query ?? ""}); - params.push({key: "tagIds", value: tags?.join(";") ?? ""}); - params.push({key: "needAttention", value: `${needAttention ?? false}`}); - if (from) - params.push({key: "from", value: from.format(ApiService.API_DATE_FORMAT)}); - if (to) - params.push({key: "to", value: to.format(ApiService.API_DATE_FORMAT)}); - - return params; + return this.api.put(endpoint.TRANSACTIONS, payload, undefined, true) + .pipe(tap(() => this.invalidate())); } } +export type TransactionRequest = PagedCachedRequest; export type TransactionFilter = { query?: string, - tags?: string[], + tagIds?: string[], from?: moment.Moment, to?: moment.Moment, needAttention?: boolean -} | undefined; \ No newline at end of file +}; \ No newline at end of file