Browse Source

Merge remote-tracking branch 'origin/main' into feature/extend-holdings-endpoint-for-cash

pull/5650/head
KenTandrian 3 weeks ago
parent
commit
5a30a70c62
  1. 7
      CHANGELOG.md
  2. 59
      apps/api/src/app/access/access.controller.ts
  3. 19
      apps/api/src/app/access/access.service.ts
  4. 19
      apps/api/src/app/access/update-access.dto.ts
  5. 2
      apps/client/src/app/app-routing.module.ts
  6. 12
      apps/client/src/app/components/access-table/access-table.component.html
  7. 7
      apps/client/src/app/components/access-table/access-table.component.ts
  8. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss
  9. 51
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  10. 77
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  11. 20
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  12. 42
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  13. 1
      apps/client/src/app/components/user-account-access/user-account-access.html
  14. 15
      apps/client/src/app/pages/auth/auth-page-routing.module.ts
  15. 5
      apps/client/src/app/pages/auth/auth-page.component.ts
  16. 11
      apps/client/src/app/pages/auth/auth-page.module.ts
  17. 18
      apps/client/src/app/pages/auth/auth-page.routes.ts
  18. 5
      apps/client/src/app/services/data.service.ts
  19. 3
      libs/common/src/lib/permissions.ts
  20. 471
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts

7
CHANGELOG.md

@ -9,7 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support to edit a granted access (experimental)
- Added support for a date range query parameter in the data gathering endpoint
- Added a _Storybook_ story for the activities table component
### Changed
- Improved the spacing around the buttons in the holding detail dialog
- Refactored the auth page to standalone
## 2.206.0 - 2025-10-04

59
apps/api/src/app/access/access.controller.ts

@ -14,6 +14,7 @@ import {
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -23,6 +24,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto';
@Controller('access')
export class AccessController {
@ -39,7 +41,7 @@ export class AccessController {
include: {
granteeUser: true
},
orderBy: { granteeUserId: 'asc' },
orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id }
});
@ -103,9 +105,12 @@ export class AccessController {
@HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id });
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!access || access.userId !== this.request.user.id) {
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -116,4 +121,52 @@ export class AccessController {
id
});
}
@HasPermission(permissions.updateAccess)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAccess(
@Body() data: UpdateAccessDto,
@Param('id') id: string
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
},
where: { id }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

19
apps/api/src/app/access/access.service.ts

@ -20,14 +20,14 @@ export class AccessService {
}
public async accesses(params: {
cursor?: Prisma.AccessWhereUniqueInput;
include?: Prisma.AccessInclude;
orderBy?: Prisma.Enumerable<Prisma.AccessOrderByWithRelationInput>;
skip?: number;
take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params;
const { cursor, include, orderBy, skip, take, where } = params;
return this.prismaService.access.findMany({
cursor,
@ -52,4 +52,17 @@ export class AccessService {
where
});
}
public async updateAccess({
data,
where
}: {
data: Prisma.AccessUpdateInput;
where: Prisma.AccessWhereUniqueInput;
}): Promise<Access> {
return this.prismaService.access.update({
data,
where
});
}
}

19
apps/api/src/app/access/update-access.dto.ts

@ -0,0 +1,19 @@
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsUUID()
granteeUserId?: string;
@IsString()
id: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional()
permissions?: AccessPermission[];
}

2
apps/client/src/app/app-routing.module.ts

@ -42,7 +42,7 @@ const routes: Routes = [
{
path: internalRoutes.auth.path,
loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule),
import('./pages/auth/auth-page.routes').then((m) => m.routes),
title: internalRoutes.auth.title
},
{

12
apps/client/src/app/components/access-table/access-table.component.html

@ -65,6 +65,14 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
@if (user?.settings?.isExperimentalFeatures) {
<button mat-menu-item (click)="onUpdateAccess(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</button>
}
@if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyUrlToClipboard(element.id)">
<span class="align-items-center d-flex">
@ -72,6 +80,10 @@
<span i18n>Copy link to clipboard</span>
</span>
</button>
}
@if (
user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC'
) {
<hr class="my-0" />
}
<button mat-menu-item (click)="onDeleteAccess(element.id)">

7
apps/client/src/app/components/access-table/access-table.component.ts

@ -23,6 +23,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import {
copyOutline,
createOutline,
ellipsisHorizontal,
linkOutline,
lockClosedOutline,
@ -53,6 +54,7 @@ export class GfAccessTableComponent implements OnChanges {
@Input() user: User;
@Output() accessDeleted = new EventEmitter<string>();
@Output() accessToUpdate = new EventEmitter<string>();
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
@ -65,6 +67,7 @@ export class GfAccessTableComponent implements OnChanges {
) {
addIcons({
copyOutline,
createOutline,
ellipsisHorizontal,
linkOutline,
lockClosedOutline,
@ -112,4 +115,8 @@ export class GfAccessTableComponent implements OnChanges {
title: $localize`Do you really want to revoke this granted access?`
});
}
public onUpdateAccess(aId: string) {
this.accessToUpdate.emit(aId);
}
}

4
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.scss

@ -8,5 +8,9 @@
aspect-ratio: 16 / 9;
margin: 0 -0.5rem;
}
.button-container {
gap: 0.5rem;
}
}
}

51
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -424,33 +424,34 @@
(dataSource?.data.length > 0 &&
data.hasPermissionToReportDataGlitch === true)
) {
<hr />
<div class="row">
<div class="col">
<hr />
@if (data.hasPermissionToAccessAdminControl) {
<a
class="mr-2"
mat-stroked-button
[queryParams]="{
assetProfileDialog: true,
dataSource: SymbolProfile?.dataSource,
symbol: SymbolProfile?.symbol
}"
[routerLink]="routerLinkAdminControlMarketData"
(click)="onClose()"
><ion-icon class="mr-1" name="create-outline"></ion-icon
><span i18n>Manage Asset Profile</span>...</a
>
}
@if (
dataSource?.data.length > 0 &&
data.hasPermissionToReportDataGlitch === true
) {
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span>...</a
>
}
<div class="button-container d-flex flex-wrap">
@if (data.hasPermissionToAccessAdminControl) {
<a
mat-stroked-button
[queryParams]="{
assetProfileDialog: true,
dataSource: SymbolProfile?.dataSource,
symbol: SymbolProfile?.symbol
}"
[routerLink]="routerLinkAdminControlMarketData"
(click)="onClose()"
><ion-icon class="mr-1" name="create-outline"></ion-icon
><span i18n>Manage Asset Profile</span>...</a
>
}
@if (
dataSource?.data.length > 0 &&
data.hasPermissionToReportDataGlitch === true
) {
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span>...</a
>
}
</div>
</div>
</div>
}

77
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,4 +1,5 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
@ -8,7 +9,8 @@ import {
ChangeDetectorRef,
Component,
Inject,
OnDestroy
OnDestroy,
OnInit
} from '@angular/core';
import {
FormBuilder,
@ -47,8 +49,11 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html'
})
export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
export class GfCreateOrUpdateAccessDialogComponent
implements OnDestroy, OnInit
{
public accessForm: FormGroup;
public mode: 'create' | 'update';
private unsubscribeSubject = new Subject<void>();
@ -59,14 +64,24 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
private dataService: DataService,
private formBuilder: FormBuilder,
private notificationService: NotificationService
) {}
) {
this.mode = this.data.access?.id ? 'update' : 'create';
}
public ngOnInit() {
const isPublic = this.data.access.type === 'PUBLIC';
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
granteeUserId: [
this.data.access.grantee,
isPublic ? null : Validators.required
],
permissions: [this.data.access.permissions[0], Validators.required],
type: [this.data.access.type, Validators.required],
granteeUserId: [this.data.access.grantee, Validators.required]
type: [
{ disabled: this.mode === 'update', value: this.data.access.type },
Validators.required
]
});
this.accessForm.get('type').valueChanges.subscribe((accessType) => {
@ -77,6 +92,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
granteeUserIdControl.setValidators(Validators.required);
} else {
granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]);
}
@ -91,6 +107,19 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
}
public async onSubmit() {
if (this.mode === 'create') {
await this.createAccess();
} else {
await this.updateAccess();
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private async createAccess() {
const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
@ -126,8 +155,40 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnDestroy {
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
private async updateAccess() {
const access: UpdateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
id: this.data.access.id,
permissions: [this.accessForm.get('permissions').value]
};
try {
await validateObjectForForm({
classDto: UpdateAccessDto,
form: this.accessForm,
object: access
});
this.dataService
.putAccess(access)
.pipe(
catchError(({ status }) => {
if (status.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({
title: $localize`Oops! Could not update access.`
});
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close(access);
});
} catch (error) {
console.error(error);
}
}
}

20
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -4,7 +4,13 @@
(keyup.enter)="accessForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Grant access</h1>
<h1 mat-dialog-title>
@if (mode === 'create') {
<span i18n>Grant access</span>
} @else {
<span i18n>Edit access</span>
}
</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
@ -66,9 +72,17 @@
color="primary"
mat-flat-button
type="submit"
[disabled]="!(accessForm.dirty && accessForm.valid)"
[disabled]="
mode === 'create'
? !(accessForm.dirty && accessForm.valid)
: !accessForm.valid
"
>
<ng-container i18n>Save</ng-container>
@if (mode === 'create') {
<ng-container i18n>Save</ng-container>
} @else {
<ng-container i18n>Update</ng-container>
}
</button>
</div>
</form>

42
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -115,6 +115,8 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccessDialog();
} else if (params['editDialog'] && params['accessId']) {
this.openUpdateAccessDialog(params['accessId']);
}
});
@ -173,6 +175,12 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
});
}
public onUpdateAccess(aId: string) {
this.router.navigate([], {
queryParams: { accessId: aId, editDialog: true }
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
@ -200,6 +208,40 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
});
}
private openUpdateAccessDialog(accessId: string) {
const access = this.accessesGive?.find(({ id }) => {
return id === accessId;
});
if (!access) {
console.log('Could not find access.');
return;
}
const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialogComponent, {
data: {
access: {
alias: access.alias,
id: access.id,
grantee: access.grantee === 'Public' ? null : access.grantee,
permissions: access.permissions,
type: access.type
}
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.update();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() {
this.accessesGet = this.user.access.map(({ alias, id, permissions }) => {
return {

1
apps/client/src/app/components/user-account-access/user-account-access.html

@ -64,6 +64,7 @@
[showActions]="hasPermissionToDeleteAccess"
[user]="user"
(accessDeleted)="onDeleteAccess($event)"
(accessToUpdate)="onUpdateAccess($event)"
/>
@if (hasPermissionToCreateAccess) {
<div class="fab-container">

15
apps/client/src/app/pages/auth/auth-page-routing.module.ts

@ -1,15 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthPageComponent } from './auth-page.component';
const routes: Routes = [
{ component: AuthPageComponent, path: '' },
{ component: AuthPageComponent, path: ':jwt' }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AuthPageRoutingModule {}

5
apps/client/src/app/pages/auth/auth-page.component.ts

@ -11,11 +11,10 @@ import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-auth-page',
templateUrl: './auth-page.html',
styleUrls: ['./auth-page.scss'],
standalone: false
templateUrl: './auth-page.html'
})
export class AuthPageComponent implements OnDestroy, OnInit {
export class GfAuthPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(

11
apps/client/src/app/pages/auth/auth-page.module.ts

@ -1,11 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { AuthPageRoutingModule } from './auth-page-routing.module';
import { AuthPageComponent } from './auth-page.component';
@NgModule({
declarations: [AuthPageComponent],
imports: [AuthPageRoutingModule, CommonModule]
})
export class AuthPageModule {}

18
apps/client/src/app/pages/auth/auth-page.routes.ts

@ -0,0 +1,18 @@
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfAuthPageComponent } from './auth-page.component';
export const routes: Routes = [
{
component: GfAuthPageComponent,
path: '',
title: internalRoutes.auth.title
},
{
component: GfAuthPageComponent,
path: ':jwt',
title: internalRoutes.auth.title
}
];

5
apps/client/src/app/services/data.service.ts

@ -1,4 +1,5 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
@ -792,6 +793,10 @@ export class DataService {
return this.http.post('/api/v1/watchlist', watchlistItem);
}
public putAccess(aAccess: UpdateAccessDto) {
return this.http.put<Access>(`/api/v1/access/${aAccess.id}`, aAccess);
}
public putAccount(aAccount: UpdateAccountDto) {
return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount);
}

3
libs/common/src/lib/permissions.ts

@ -49,6 +49,7 @@ export const permissions = {
syncDemoUserAccount: 'syncDemoUserAccount',
toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount',
updateAccess: 'updateAccess',
updateAuthDevice: 'updateAuthDevice',
updateMarketData: 'updateMarketData',
updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile',
@ -93,6 +94,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readTags,
permissions.readWatchlist,
permissions.updateAccount,
permissions.updateAccess,
permissions.updateAuthDevice,
permissions.updateMarketData,
permissions.updateMarketDataOfOwnAssetProfile,
@ -133,6 +135,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readMarketDataOfOwnAssetProfile,
permissions.readWatchlist,
permissions.updateAccount,
permissions.updateAccess,
permissions.updateAuthDevice,
permissions.updateMarketDataOfOwnAssetProfile,
permissions.updateOrder,

471
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -0,0 +1,471 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NotificationService } from 'apps/client/src/app/core/notification/notification.service';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfActivityTypeComponent } from '../activity-type/activity-type.component';
import { GfEntityLogoComponent } from '../entity-logo';
import { GfNoTransactionsInfoComponent } from '../no-transactions-info/no-transactions-info.component';
import { GfValueComponent } from '../value';
import { GfActivitiesTableComponent } from './activities-table.component';
const activities: Activity[] = [
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2025-04-09T13:47:33.133Z'),
currency: 'USD',
date: new Date('2025-04-09T13:45:45.504Z'),
fee: 1,
id: 'a76968ff-80a4-4453-81ed-c3627dea3919',
isDraft: false,
quantity: 115,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 103.543,
updatedAt: new Date('2025-05-31T18:43:01.840Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 1,
unitPriceInAssetProfileCurrency: 103.543,
value: 11907.445,
valueInBaseCurrency: 11907.445
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-08-07T13:40:39.103Z'),
currency: 'USD',
date: new Date('2024-08-07T13:38:06.289Z'),
fee: 2.97,
id: '0c2f4fbf-6edc-4adc-8f83-abf8148500ec',
isDraft: false,
quantity: 105,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 110.24,
updatedAt: new Date('2025-05-31T18:46:14.175Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 2.97,
feeInBaseCurrency: 2.97,
unitPriceInAssetProfileCurrency: 110.24,
value: 11575.2,
valueInBaseCurrency: 11575.2
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
date: new Date('2024-03-12T15:14:38.597Z'),
fee: 45.29,
id: 'bfc92677-faf4-4d4f-9762-e0ec056525c2',
isDraft: false,
quantity: 167,
symbolProfileId: '888d4123-db9a-42f3-9775-01b1ae6f9092',
type: 'BUY',
unitPrice: 41.0596,
updatedAt: new Date('2025-05-31T18:49:54.064Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
comment: null,
countries: [],
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
cusip: '463918102',
dataSource: 'YAHOO',
figi: 'BBG01KYQ6PV3',
figiComposite: 'BBG01KYQ6PV3',
figiShareClass: 'BBG01KYQ6QS5',
holdings: [],
id: '888d4123-db9a-42f3-9775-01b1ae6f9092',
isActive: true,
isin: 'CA4639181029',
name: 'iShares Bitcoin Trust',
updatedAt: new Date('2025-09-29T03:14:07.742Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'IBIT',
symbolMapping: {},
url: 'https://www.ishares.com',
userId: null,
activitiesCount: 6,
dateOfFirstActivity: new Date('2024-01-01T08:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 45.29,
feeInBaseCurrency: 45.29,
unitPriceInAssetProfileCurrency: 41.0596,
value: 6856.9532,
valueInBaseCurrency: 6856.9532
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-02-23T15:53:46.907Z'),
currency: 'USD',
date: new Date('2024-02-23T15:53:15.745Z'),
fee: 3,
id: '7c9ceb54-acb1-4850-bfb1-adb41c29fd6a',
isDraft: false,
quantity: 81,
symbolProfileId: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
type: 'BUY',
unitPrice: 67.995,
updatedAt: new Date('2025-05-31T18:48:48.209Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
comment: 'No data',
countries: [],
createdAt: new Date('2022-04-13T20:05:47.301Z'),
currency: 'USD',
cusip: '92206C565',
dataSource: 'YAHOO',
figi: 'BBG00LWSF7T3',
figiComposite: 'BBG00LWSF7T3',
figiShareClass: 'BBG00LWSF8K0',
holdings: [],
id: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
isActive: true,
isin: 'US92206C5655',
name: 'Vanguard Total World Bond ETF',
updatedAt: new Date('2025-10-02T06:02:56.314Z'),
sectors: [],
symbol: 'BNDW',
symbolMapping: {},
url: 'https://vanguard.com',
userId: null,
activitiesCount: 38,
dateOfFirstActivity: new Date('2022-04-13T20:05:48.742Z')
},
tags: [],
feeInAssetProfileCurrency: 3,
feeInBaseCurrency: 3,
unitPriceInAssetProfileCurrency: 67.995,
value: 5507.595,
valueInBaseCurrency: 5507.595
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2023-01-11T14:35:22.325Z'),
currency: 'USD',
date: new Date('2023-01-11T14:34:55.174Z'),
fee: 7.38,
id: '3fe87b3f-78de-407a-bc02-4189b221051f',
isDraft: false,
quantity: 55,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 89.48,
updatedAt: new Date('2025-05-31T18:46:44.616Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 7.38,
feeInBaseCurrency: 7.38,
unitPriceInAssetProfileCurrency: 89.48,
value: 4921.4,
valueInBaseCurrency: 4921.4
}
];
const dataSource = new MatTableDataSource<Activity>(activities);
export default {
title: 'Activities Table',
component: GfActivitiesTableComponent,
decorators: [
moduleMetadata({
imports: [
CommonModule,
GfActivityTypeComponent,
GfEntityLogoComponent,
GfNoTransactionsInfoComponent,
GfSymbolPipe,
GfValueComponent,
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
MatTooltipModule,
NgxSkeletonLoaderModule,
RouterModule.forChild([])
],
providers: [NotificationService]
})
]
} as Meta<GfActivitiesTableComponent>;
type Story = StoryObj<GfActivitiesTableComponent>;
export const Loading: Story = {
args: {
baseCurrency: 'USD',
dataSource: undefined,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 0
}
};
export const Default: Story = {
args: {
baseCurrency: 'USD',
dataSource,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: activities.length
}
};
export const Pagination: Story = {
args: {
baseCurrency: 'USD',
dataSource: new MatTableDataSource<Activity>(
Array.from({ length: 50 }).map((_, i) => ({
...(activities[i % activities.length] as Activity),
date: new Date(2025, 5, (i % 28) + 1),
id: `${i}`
}))
),
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 50
}
};
Loading…
Cancel
Save