mirror of https://github.com/ghostfolio/ghostfolio
11 changed files with 327 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