Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2acd306
feat(frontend): add ReturnUrlHelper
skynet2 Apr 19, 2026
47e4b04
feat(frontend): add TableQueryStateHelper
skynet2 Apr 19, 2026
9babb00
fix(frontend): handle menu-mode filters and validate JSON in TableQue…
skynet2 Apr 19, 2026
1ed5f1b
feat(accounts): honor returnUrl on save/cancel in upsert
skynet2 Apr 19, 2026
1fe6335
fix(accounts): set type=button on upsert action buttons
skynet2 Apr 19, 2026
9e3e9c6
feat(tags): honor returnUrl on save/cancel in upsert
skynet2 Apr 19, 2026
92ed92a
Merge branch 'master' into table-improvements
skynet2 Apr 19, 2026
3bbef32
feat(categories): honor returnUrl on save/cancel in upsert
skynet2 Apr 19, 2026
2bb9e0f
feat(currencies): honor returnUrl on save/cancel in upsert
skynet2 Apr 19, 2026
7ce85b6
feat(rules): honor returnUrl on save/cancel in rules + schedules upsert
skynet2 Apr 19, 2026
1f3b314
feat(transactions): honor returnUrl on save/delete in upsert + editor
skynet2 Apr 19, 2026
9e159bf
feat(accounts): pass returnUrl from list to edit/new
skynet2 Apr 19, 2026
ab06198
feat(frontend): pass returnUrl from list pages to edit/new
skynet2 Apr 19, 2026
f297938
feat(transactions-table): pass returnUrl from rows to edit/new/clone
skynet2 Apr 19, 2026
51d5335
feat(accounts): sync table filters/sort to URL query string
skynet2 Apr 19, 2026
65a66f6
feat(frontend): sync filters/sort to URL for tags/categories/currenci…
skynet2 Apr 19, 2026
cf6f669
feat(frontend): sync sort/global filter to URL for service-tokens list
skynet2 Apr 19, 2026
436f229
feat(transactions-table): sync lazy-table state to URL, preserve aliases
skynet2 Apr 19, 2026
779a590
refactor(frontend): persist table state via localStorage + per-tab se…
skynet2 Apr 19, 2026
be3585c
feat(frontend): restore table state only on returnUrl round-trip (?re…
skynet2 Apr 19, 2026
b7a0476
refactor(frontend): centralize navigateAfterSave + restore Enter-to-s…
skynet2 Apr 19, 2026
a4f74dc
fix(transactions-table): URL filters win over storage/preset + takeUn…
skynet2 Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions frontend/src/app/pages/accounts/accounts-list.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
[resizableColumns]="true" columnResizeMode="expand"
[paginator]="false"
[globalFilterFields]="['account.name', 'account.number', 'account.currency']"
(onFilter)="syncStateToUrl()"
(onSort)="syncStateToUrl()"
responsiveLayout="scroll">
<ng-template #caption>
<div class="flex justify-between items-center flex-column sm:flex-row gap-2">
Expand All @@ -54,7 +56,7 @@
pTooltip="Refresh table" tooltipPosition="top"
(click)="refreshTable()" />
<p-button label="Create new account" class="p-button-link"
(click)="this.router.navigate(['/accounts', 'new'])" />
(click)="this.router.navigate(['/accounts', 'new'], { queryParams: { returnUrl: currentReturnUrl } })" />
</div>
</div>
</ng-template>
Expand Down Expand Up @@ -215,8 +217,8 @@
</td>
<td>
<div class="flex gap-2">
<a [href]="'/accounts/edit/' + accountItem.account.id"
[routerLink]="['/', 'accounts', 'edit', accountItem.account.id]">
<a [routerLink]="['/', 'accounts', 'edit', accountItem.account.id]"
[queryParams]="{ returnUrl: currentReturnUrl }">
<p-button icon="pi pi-pencil"
pTooltip="Edit account" tooltipPosition="top" placeholder="Top"
severity="secondary" [rounded]="true" />
Expand Down
63 changes: 60 additions & 3 deletions frontend/src/app/pages/accounts/accounts-list.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
import { AfterViewInit, Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
import { Table, TableModule } from 'primeng/table';
import { FormsModule } from '@angular/forms';
import { InputText, InputTextModule } from 'primeng/inputtext';
Expand Down Expand Up @@ -34,6 +34,10 @@ import { combineLatest, skip } from 'rxjs';
import { Tag } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/v1/tag_pb';
import { TagsService } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/tags/v1/tags_pb';
import { FancyTagComponent } from '../../shared/components/fancy-tag/fancy-tag.component';
import { ReturnUrlHelper } from '../../shared/helpers/return-url.helper';
import { TableQueryStateHelper } from '../../shared/helpers/table-query-state.helper';
import { TableStatePersistence } from '../../shared/helpers/table-state-persistence.helper';
import { TabSessionService } from '../../shared/services/tab-session.service';

@Component({
selector: 'app-account-list',
Expand Down Expand Up @@ -62,7 +66,7 @@ import { FancyTagComponent } from '../../shared/components/fancy-tag/fancy-tag.c
}
`
})
export class AccountsListComponent implements OnInit {
export class AccountsListComponent implements OnInit, AfterViewInit {
@ViewChild('dt1', { static: false }) table!: Table;

statuses: any[] = [];
Expand Down Expand Up @@ -93,13 +97,15 @@ export class AccountsListComponent implements OnInit {
@ViewChild('filter') filter!: ElementRef;
public analyticsMap: { [accountId: number]: GetDebitsAndCreditsSummaryResponse_SummaryItem } = {};
public serverConfig: GetConfigurationResponse = create(GetConfigurationResponseSchema, {});
public initialGlobalFilter: string = '';

constructor(
@Inject(TRANSPORT_TOKEN) private transport: Transport,
private messageService: MessageService,
public router: Router,
route: ActivatedRoute,
private selectedDateService: SelectedDateService
private selectedDateService: SelectedDateService,
private tabSession: TabSessionService
) {
this.accountService = createClient(AccountsService, this.transport);
this.analyticsService = createClient(AnalyticsService, this.transport);
Expand All @@ -113,12 +119,62 @@ export class AccountsListComponent implements OnInit {
}
}
}

if (route.snapshot.queryParamMap.get('restore') === '1') {
const stored = TableStatePersistence.read(this.stateKey, this.tabSession.id);
if (stored) {
if (stored.filters) this.filters = { ...this.filters, ...(stored.filters as { [s: string]: FilterMetadata }) };
if (stored.sort && stored.sort.length > 0) this.multiSortMeta = stored.sort;
if (stored.global) this.initialGlobalFilter = stored.global;
const tagIds = stored.extra?.['tagIds'];
if (Array.isArray(tagIds)) this.selectedTagIds = tagIds as number[];
}
TableStatePersistence.clear(this.stateKey, this.tabSession.id);
this.router.navigate([], { relativeTo: route, queryParams: { restore: null }, queryParamsHandling: 'merge', replaceUrl: true });
Comment thread
skynet2 marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing navigation via this.router.navigate inside a component constructor is generally discouraged in Angular. The component is not yet fully initialized, which can lead to race conditions or unexpected behavior with the router's lifecycle. It is recommended to move this logic to ngOnInit.

}

const queryState = TableQueryStateHelper.decode(route.snapshot.queryParams);
if (queryState.filters) {
this.filters = { ...this.filters, ...(queryState.filters as { [s: string]: FilterMetadata }) };
}
if (queryState.sort && queryState.sort.length > 0) {
this.multiSortMeta = queryState.sort;
}
if (queryState.global) {
this.initialGlobalFilter = queryState.global;
}
Comment thread
skynet2 marked this conversation as resolved.
}
Comment on lines +123 to +146
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for restoring table state from localStorage and query parameters is duplicated across multiple list components (Accounts, Categories, Currencies, etc.). This increases maintenance overhead and the risk of inconsistent behavior. Consider refactoring this into a shared helper method or a base class that handles state restoration consistently across all list pages.


private readonly stateKey = 'accounts';

ngAfterViewInit() {
if (this.initialGlobalFilter && this.table) {
if (this.filter?.nativeElement) {
this.filter.nativeElement.value = this.initialGlobalFilter;
}
this.table.filterGlobal(this.initialGlobalFilter, 'contains');
}
}
Comment on lines +150 to +157
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling this.table.filterGlobal inside ngAfterViewInit can trigger an ExpressionChangedAfterItHasBeenCheckedError because it modifies the table's state after it has been checked by Angular's change detection. Wrapping the call in setTimeout or using a microtask is a safer approach to ensure it runs in a subsequent cycle.

    ngAfterViewInit() {
        if (this.initialGlobalFilter && this.table) {
            setTimeout(() => {
                if (this.filter?.nativeElement) {
                    this.filter.nativeElement.value = this.initialGlobalFilter;
                }
                this.table.filterGlobal(this.initialGlobalFilter, 'contains');
            });
        }
    }


syncStateToUrl(): void {
if (!this.table) return;
const globalVal = (this.table.filters as any)?.['global']?.value;
TableStatePersistence.write(this.stateKey, this.tabSession.id, {
filters: this.table.filters as { [f: string]: FilterMetadata | FilterMetadata[] },
sort: this.table.multiSortMeta ?? [],
global: typeof globalVal === 'string' ? globalVal : undefined,
extra: { tagIds: this.selectedTagIds },
});
}

getAccountUrl(account: ListAccountsResponse_AccountItem): string {
return this.router.createUrlTree(['/', 'accounts', account.account!.id.toString()]).toString();
}

public get currentReturnUrl(): string {
return ReturnUrlHelper.build(this.router);
}

async ngOnInit() {
await this.loadTags();
await this.loadConfig();
Expand Down Expand Up @@ -153,6 +209,7 @@ export class AccountsListComponent implements OnInit {
}

async onTagFilterChange() {
this.syncStateToUrl();
await this.loadAccounts();
await this.loadAnalytics();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@

<div class="flex mt-8">
<div class="card flex flex-wrap gap-6 w-full justify-end">
<p-button [label]="isEdit ? 'Update' : 'Create' " [fluid]="false" (click)="submit()"></p-button>
<p-button label="Cancel" severity="secondary" type="button" [fluid]="false" (click)="cancel()"></p-button>
<p-button [label]="isEdit ? 'Update' : 'Create' " type="submit" [fluid]="false"></p-button>
</div>
</div>
</form>
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/app/pages/accounts/accounts-upsert.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AccountsService, CreateAccountRequestSchema, UpdateAccountRequestSchema
import { ActivatedRoute, Router } from '@angular/router';
import { Message } from 'primeng/message';
import { Checkbox } from 'primeng/checkbox';
import { ReturnUrlHelper } from '../../shared/helpers/return-url.helper';
import { MultiSelectModule } from 'primeng/multiselect';
import { Tag } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/v1/tag_pb';
import { TagsService } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/tags/v1/tags_pb';
Expand Down Expand Up @@ -137,6 +138,10 @@ export class AccountsUpsertComponent implements OnInit {
this.isFormReady = true;
}

async cancel(): Promise<void> {
await ReturnUrlHelper.navigateAfterSave(this.router, this.routeSnapshot, ['/', 'accounts']);
}

get name() {
return this.form.get('name')!;
}
Expand Down Expand Up @@ -188,7 +193,7 @@ export class AccountsUpsertComponent implements OnInit {
);

this.messageService.add({ severity: 'info', detail: 'Account updated' });
await this.router.navigate(['/', 'accounts', response.account!.id.toString()]);
await ReturnUrlHelper.navigateAfterSave(this.router, this.routeSnapshot,['/', 'accounts', response.account!.id.toString()]);
} catch (e: any) {
this.messageService.add({ severity: 'error', detail: ErrorHelper.getMessage(e) });
return;
Expand All @@ -213,7 +218,7 @@ export class AccountsUpsertComponent implements OnInit {
);

this.messageService.add({ severity: 'info', detail: 'New account created' });
await this.router.navigate(['/', 'accounts', response.account!.id.toString()]);
await ReturnUrlHelper.navigateAfterSave(this.router, this.routeSnapshot,['/', 'accounts', response.account!.id.toString()]);
} catch (e: any) {
this.messageService.add({ severity: 'error', detail: ErrorHelper.getMessage(e) });
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
[filters]="filters"
[paginator]="false"
[globalFilterFields]="['id', 'name']"
(onFilter)="syncStateToUrl()"
(onSort)="syncStateToUrl()"
responsiveLayout="scroll"
[multiSortMeta]="multiSortMeta">
<ng-template #caption>
Expand All @@ -27,7 +29,7 @@
</p-iconfield>

<p-button label="Create new category" class="p-button-link"
(click)="this.router.navigate(['/categories', 'new'])" />
(click)="this.router.navigate(['/categories', 'new'], { queryParams: { returnUrl: currentReturnUrl } })" />
</div>
</ng-template>
<ng-template #header>
Expand Down Expand Up @@ -81,7 +83,7 @@
</td>
<td>
<p-button icon="pi pi-pencil"
(onClick)="this.router.navigate(['/', 'categories', 'edit', accountItem.id])"
(onClick)="this.router.navigate(['/', 'categories', 'edit', accountItem.id], { queryParams: { returnUrl: currentReturnUrl } })"
severity="secondary" [rounded]="true" />
</td>
</tr>
Expand Down
61 changes: 58 additions & 3 deletions frontend/src/app/pages/categories/categories-list.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
import { AfterViewInit, Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
import { Table, TableModule } from 'primeng/table';
import { FormsModule } from '@angular/forms';
import { InputText } from 'primeng/inputtext';
Expand All @@ -23,6 +23,10 @@ import { create } from '@bufbuild/protobuf';
import { ListTagsResponse_TagItem, TagsService } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/tags/v1/tags_pb';
import { CategoriesService } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/categories/v1/categories_pb';
import { Category } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/v1/category_pb';
import { ReturnUrlHelper } from '../../shared/helpers/return-url.helper';
import { TableQueryStateHelper } from '../../shared/helpers/table-query-state.helper';
import { TableStatePersistence } from '../../shared/helpers/table-state-persistence.helper';
import { TabSessionService } from '../../shared/services/tab-session.service';

@Component({
selector: 'app-categories-list',
Expand All @@ -34,7 +38,9 @@ import { Category } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/v1/cate
}
`
})
export class CategoriesListComponent implements OnInit {
export class CategoriesListComponent implements OnInit, AfterViewInit {
@ViewChild('dt1', { static: false }) table!: Table;

statuses: any[] = [];

loading: boolean = false;
Expand All @@ -51,12 +57,16 @@ export class CategoriesListComponent implements OnInit {
];

@ViewChild('filter') filter!: ElementRef;
public initialGlobalFilter: string = '';

private readonly stateKey = 'categories';

constructor(
@Inject(TRANSPORT_TOKEN) private transport: Transport,
private messageService: MessageService,
public router: Router,
route: ActivatedRoute
route: ActivatedRoute,
private tabSession: TabSessionService
) {
this.categoriesService = createClient(CategoriesService, this.transport);

Expand All @@ -67,12 +77,57 @@ export class CategoriesListComponent implements OnInit {
}
}
}

if (route.snapshot.queryParamMap.get('restore') === '1') {
const stored = TableStatePersistence.read(this.stateKey, this.tabSession.id);
if (stored) {
if (stored.filters) this.filters = { ...this.filters, ...(stored.filters as { [s: string]: FilterMetadata }) };
if (stored.sort && stored.sort.length > 0) this.multiSortMeta = stored.sort;
if (stored.global) this.initialGlobalFilter = stored.global;
}
TableStatePersistence.clear(this.stateKey, this.tabSession.id);
this.router.navigate([], { relativeTo: route, queryParams: { restore: null }, queryParamsHandling: 'merge', replaceUrl: true });
}

const queryState = TableQueryStateHelper.decode(route.snapshot.queryParams);
if (queryState.filters) {
this.filters = { ...this.filters, ...(queryState.filters as { [s: string]: FilterMetadata }) };
}
if (queryState.sort && queryState.sort.length > 0) {
this.multiSortMeta = queryState.sort;
}
if (queryState.global) {
this.initialGlobalFilter = queryState.global;
}
}

ngAfterViewInit() {
if (this.initialGlobalFilter && this.table) {
if (this.filter?.nativeElement) {
this.filter.nativeElement.value = this.initialGlobalFilter;
}
this.table.filterGlobal(this.initialGlobalFilter, 'contains');
}
}

syncStateToUrl(): void {
if (!this.table) return;
const globalVal = (this.table.filters as any)?.['global']?.value;
TableStatePersistence.write(this.stateKey, this.tabSession.id, {
filters: this.table.filters as { [f: string]: FilterMetadata | FilterMetadata[] },
sort: this.table.multiSortMeta ?? [],
global: typeof globalVal === 'string' ? globalVal : undefined,
});
}

getDetailsUrl(entity: Category): string {
return this.router.createUrlTree(['/', 'categories', entity.id.toString()]).toString();
}

public get currentReturnUrl(): string {
return ReturnUrlHelper.build(this.router);
}

async ngOnInit() {
this.loading = true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@

<div class="flex mt-8">
<div class="card flex flex-wrap gap-6 w-full justify-end">
<p-button *ngIf="!category.id" label="Create" [fluid]="false" (click)="submit()"></p-button>
<p-button *ngIf="category.id" label="Update" [fluid]="false" (click)="submit()"></p-button>
<p-button label="Cancel" severity="secondary" type="button" [fluid]="false" (click)="cancel()"></p-button>
<p-button *ngIf="!category.id" label="Create" type="submit" [fluid]="false"></p-button>
<p-button *ngIf="category.id" label="Update" type="submit" [fluid]="false"></p-button>
</div>
</div>
</form>
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/app/pages/categories/categories-upsert.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Message } from 'primeng/message';
import { Category, CategorySchema } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/v1/category_pb';
import { CategoriesService, CreateCategoryRequestSchema, UpdateCategoryRequestSchema } from '@buf/xskydev_go-money-pb.bufbuild_es/gomoneypb/categories/v1/categories_pb';
import { DefaultCache, ShortLivedCache } from '../../core/services/cache.service';
import { ReturnUrlHelper } from '../../shared/helpers/return-url.helper';

@Component({
selector: 'app-categories-upsert',
Expand All @@ -31,7 +32,7 @@ export class CategoriesUpsertComponent implements OnInit {
constructor(
@Inject(TRANSPORT_TOKEN) private transport: Transport,
private messageService: MessageService,
routeSnapshot: ActivatedRoute,
private routeSnapshot: ActivatedRoute,
private router: Router,
private defaultCache: DefaultCache,
private shortLivedCache: ShortLivedCache
Expand Down Expand Up @@ -84,6 +85,10 @@ export class CategoriesUpsertComponent implements OnInit {
}
}

async cancel(): Promise<void> {
await ReturnUrlHelper.navigateAfterSave(this.router, this.routeSnapshot, ['/', 'categories']);
}

get name() {
return this.form!.get('name')!;
}
Expand All @@ -97,7 +102,7 @@ export class CategoriesUpsertComponent implements OnInit {
);

this.messageService.add({ severity: 'info', detail: 'Category updated' });
await this.router.navigate(['/', 'categories', response.category!.id.toString()]);
await ReturnUrlHelper.navigateAfterSave(this.router, this.routeSnapshot,['/', 'categories', response.category!.id.toString()]);
} catch (e: any) {
this.messageService.add({ severity: 'error', detail: ErrorHelper.getMessage(e) });
return;
Expand All @@ -113,7 +118,7 @@ export class CategoriesUpsertComponent implements OnInit {
);

this.messageService.add({ severity: 'info', detail: 'Category created' });
await this.router.navigate(['/', 'categories', response.category!.id.toString()]);
await ReturnUrlHelper.navigateAfterSave(this.router, this.routeSnapshot,['/', 'categories', response.category!.id.toString()]);
} catch (e: any) {
this.messageService.add({ severity: 'error', detail: ErrorHelper.getMessage(e) });
return;
Expand Down
Loading
Loading