mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
12 changed files with 328 additions and 151 deletions
@ -0,0 +1,44 @@ |
|||||
|
<mat-form-field |
||||
|
appearance="outline" |
||||
|
class="w-100" |
||||
|
[class.without-hint]="!hasHint()" |
||||
|
> |
||||
|
<mat-label>{{ label() }}</mat-label> |
||||
|
<mat-select |
||||
|
[errorStateMatcher]="errorStateMatcher" |
||||
|
[formControl]="control" |
||||
|
(closed)="onPanelClosed()" |
||||
|
> |
||||
|
<mat-select-trigger> |
||||
|
<div class="d-flex"> |
||||
|
@if (selectedAccount()) { |
||||
|
<gf-entity-logo |
||||
|
class="mr-1" |
||||
|
[hasPlaceholder]="true" |
||||
|
[tooltip]="selectedAccount()?.platform?.name ?? ''" |
||||
|
[url]="selectedAccount()?.platform?.url ?? ''" |
||||
|
/> |
||||
|
} |
||||
|
<span>{{ selectedAccount()?.name }}</span> |
||||
|
</div> |
||||
|
</mat-select-trigger> |
||||
|
|
||||
|
@if (hasNullOption()) { |
||||
|
<mat-option [value]="null" /> |
||||
|
} |
||||
|
|
||||
|
@for (account of accounts(); track account.id) { |
||||
|
<mat-option [value]="account.id"> |
||||
|
<div class="d-flex"> |
||||
|
<gf-entity-logo |
||||
|
class="mr-1" |
||||
|
[hasPlaceholder]="true" |
||||
|
[tooltip]="account.platform?.name ?? ''" |
||||
|
[url]="account.platform?.url ?? ''" |
||||
|
/> |
||||
|
<span>{{ account.name }}</span> |
||||
|
</div> |
||||
|
</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
@ -0,0 +1,131 @@ |
|||||
|
import { AccountWithPlatform } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { ANIMATION_MODULE_TYPE, importProvidersFrom } from '@angular/core'; |
||||
|
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; |
||||
|
import '@angular/localize/init'; |
||||
|
import { |
||||
|
applicationConfig, |
||||
|
Meta, |
||||
|
moduleMetadata, |
||||
|
StoryObj |
||||
|
} from '@storybook/angular'; |
||||
|
|
||||
|
import { EntityLogoImageSourceService } from '../entity-logo/entity-logo-image-source.service'; |
||||
|
import { EntityLogoImageSourceServiceMock } from '../mocks/entity-logo-image-source.service.mock'; |
||||
|
import { GfAccountSelectorComponent } from './account-selector.component'; |
||||
|
|
||||
|
const ACCOUNTS: AccountWithPlatform[] = [ |
||||
|
{ |
||||
|
comment: null, |
||||
|
createdAt: new Date('2024-01-01'), |
||||
|
currency: 'USD', |
||||
|
id: '3ef7e6d9-4598-4eb2-b0e8-00e61cfc0ea6', |
||||
|
name: 'Coinbase Account', |
||||
|
platform: { |
||||
|
id: '9da3a8a7-4795-43e3-a6db-ccb914189737', |
||||
|
name: 'Coinbase', |
||||
|
url: 'https://coinbase.com' |
||||
|
}, |
||||
|
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', |
||||
|
updatedAt: new Date('2024-01-01'), |
||||
|
userId: '081aa387-487d-4438-83a4-3060eb2a016e' |
||||
|
}, |
||||
|
{ |
||||
|
comment: null, |
||||
|
createdAt: new Date('2024-01-01'), |
||||
|
currency: 'CHF', |
||||
|
id: '9da3a8a7-4795-43e3-a6db-ccb914189737', |
||||
|
name: 'Ghostfolio Account', |
||||
|
platform: { |
||||
|
id: 'f3e9d0e5-1e0f-4a3c-8b12-1f2a3b4c5d6e', |
||||
|
name: 'Ghostfolio', |
||||
|
url: 'https://ghostfol.io' |
||||
|
}, |
||||
|
platformId: 'f3e9d0e5-1e0f-4a3c-8b12-1f2a3b4c5d6e', |
||||
|
updatedAt: new Date('2024-01-01'), |
||||
|
userId: '081aa387-487d-4438-83a4-3060eb2a016e' |
||||
|
}, |
||||
|
{ |
||||
|
comment: null, |
||||
|
createdAt: new Date('2024-01-01'), |
||||
|
currency: 'EUR', |
||||
|
id: 'd191b2d5-9d5a-4c5f-9d55-6f0e5f6a7b8c', |
||||
|
name: 'Savings Account', |
||||
|
platformId: null, |
||||
|
updatedAt: new Date('2024-01-01'), |
||||
|
userId: '081aa387-487d-4438-83a4-3060eb2a016e' |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
type AccountSelectorStory = GfAccountSelectorComponent & { |
||||
|
value: string | null; |
||||
|
}; |
||||
|
|
||||
|
const meta: Meta<AccountSelectorStory> = { |
||||
|
title: 'Account Selector', |
||||
|
component: GfAccountSelectorComponent, |
||||
|
decorators: [ |
||||
|
applicationConfig({ |
||||
|
providers: [ |
||||
|
importProvidersFrom(CommonModule), |
||||
|
{ |
||||
|
provide: ANIMATION_MODULE_TYPE, |
||||
|
useValue: 'NoopAnimations' |
||||
|
}, |
||||
|
{ |
||||
|
provide: EntityLogoImageSourceService, |
||||
|
useValue: new EntityLogoImageSourceServiceMock() |
||||
|
} |
||||
|
] |
||||
|
}), |
||||
|
moduleMetadata({ |
||||
|
imports: [GfAccountSelectorComponent, ReactiveFormsModule] |
||||
|
}) |
||||
|
], |
||||
|
render: ({ accounts, hasHint, hasNullOption, label, value }) => { |
||||
|
return { |
||||
|
props: { |
||||
|
accounts, |
||||
|
hasHint, |
||||
|
hasNullOption, |
||||
|
label, |
||||
|
formGroup: new FormGroup({ |
||||
|
account: new FormControl(value) |
||||
|
}) |
||||
|
}, |
||||
|
template: ` |
||||
|
<form [formGroup]="formGroup"> |
||||
|
<gf-account-selector |
||||
|
formControlName="account" |
||||
|
[accounts]="accounts" |
||||
|
[hasHint]="hasHint" |
||||
|
[hasNullOption]="hasNullOption" |
||||
|
[label]="label" |
||||
|
/> |
||||
|
</form> |
||||
|
` |
||||
|
}; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export default meta; |
||||
|
|
||||
|
type Story = StoryObj<AccountSelectorStory>; |
||||
|
|
||||
|
export const Default: Story = { |
||||
|
args: { |
||||
|
accounts: ACCOUNTS, |
||||
|
label: 'Account', |
||||
|
value: '9da3a8a7-4795-43e3-a6db-ccb914189737' |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export const WithNullOption: Story = { |
||||
|
args: { |
||||
|
accounts: ACCOUNTS, |
||||
|
hasNullOption: true, |
||||
|
label: 'Account', |
||||
|
value: null |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,109 @@ |
|||||
|
import { AccountWithPlatform } from '@ghostfolio/common/types'; |
||||
|
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; |
||||
|
|
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
computed, |
||||
|
inject, |
||||
|
input, |
||||
|
signal |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { |
||||
|
ControlValueAccessor, |
||||
|
FormControl, |
||||
|
NgControl, |
||||
|
ReactiveFormsModule |
||||
|
} from '@angular/forms'; |
||||
|
import { ErrorStateMatcher } from '@angular/material/core'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatSelectModule } from '@angular/material/select'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'd-block' }, |
||||
|
imports: [ |
||||
|
GfEntityLogoComponent, |
||||
|
MatFormFieldModule, |
||||
|
MatSelectModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-account-selector', |
||||
|
templateUrl: './account-selector.component.html' |
||||
|
}) |
||||
|
export class GfAccountSelectorComponent implements ControlValueAccessor { |
||||
|
public readonly accounts = input.required<AccountWithPlatform[]>(); |
||||
|
public readonly control = new FormControl<string | null>(null); |
||||
|
public readonly errorStateMatcher: ErrorStateMatcher = { |
||||
|
isErrorState: () => { |
||||
|
const control = this.ngControl?.control; |
||||
|
|
||||
|
return !!(control?.invalid && (control.dirty || control.touched)); |
||||
|
} |
||||
|
}; |
||||
|
public readonly hasHint = input(true); |
||||
|
public readonly hasNullOption = input(false); |
||||
|
public readonly label = input.required<string>(); |
||||
|
public readonly selectedAccount = computed(() => { |
||||
|
const selectedAccountId = this.selectedAccountId(); |
||||
|
|
||||
|
return this.accounts().find(({ id }) => { |
||||
|
return id === selectedAccountId; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
private readonly ngControl = inject(NgControl, { |
||||
|
optional: true, |
||||
|
self: true |
||||
|
}); |
||||
|
private readonly selectedAccountId = signal<string | null>(null); |
||||
|
|
||||
|
public constructor() { |
||||
|
// Register as the value accessor manually, because injecting NgControl
|
||||
|
// and providing NG_VALUE_ACCESSOR at the same time creates a cycle
|
||||
|
if (this.ngControl) { |
||||
|
this.ngControl.valueAccessor = this; |
||||
|
} |
||||
|
|
||||
|
this.control.valueChanges |
||||
|
.pipe(takeUntilDestroyed()) |
||||
|
.subscribe((accountId) => { |
||||
|
this.selectedAccountId.set(accountId); |
||||
|
this.onChange(accountId); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onPanelClosed() { |
||||
|
this.onTouched(); |
||||
|
} |
||||
|
|
||||
|
public registerOnChange(fn: (accountId: string | null) => void) { |
||||
|
this.onChange = fn; |
||||
|
} |
||||
|
|
||||
|
public registerOnTouched(fn: () => void) { |
||||
|
this.onTouched = fn; |
||||
|
} |
||||
|
|
||||
|
public setDisabledState(isDisabled: boolean) { |
||||
|
if (isDisabled) { |
||||
|
this.control.disable({ emitEvent: false }); |
||||
|
} else { |
||||
|
this.control.enable({ emitEvent: false }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public writeValue(accountId: string | null) { |
||||
|
this.control.setValue(accountId ?? null, { emitEvent: false }); |
||||
|
this.selectedAccountId.set(accountId ?? null); |
||||
|
} |
||||
|
|
||||
|
private onChange: (accountId: string | null) => void = () => { |
||||
|
// ControlValueAccessor onChange callback
|
||||
|
}; |
||||
|
|
||||
|
private onTouched = (): void => { |
||||
|
// ControlValueAccessor onTouched callback
|
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './account-selector.component'; |
||||
Loading…
Reference in new issue