src/app/features/de-duplication/bulk-merge-records/merge-account-section/merge-account-section.component.ts
| changeDetection | ChangeDetectionStrategy.OnPush |
| host | { |
| selector | app-merge-account-section |
| standalone | true |
| imports |
MatError
MatRadioModule
MatExpansionModule
MatCheckboxModule
MatFormFieldModule
MatSelectModule
|
| styleUrls | ./merge-account-section.component.scss |
| templateUrl | ./merge-account-section.component.html |
| styleUrl | ./merge-account-section.component.scss |
ReactiveFormsModule
MatError
MatRadioModule
MatExpansionModule
MatCheckboxModule
MatFormFieldModule
MatSelectModule
OnInit
Properties |
|
Methods |
Inputs |
| entitiesToMerge | |
Type : Entity[]
|
|
| Required : true | |
| entityConstructor | |
Type : EntityConstructor
|
|
| Required : true | |
| formatAccountStatus | ||||||
formatAccountStatus(account: UserAccount | null | undefined)
|
||||||
|
Parameters :
Returns :
string
|
| formatRoles | ||||||
formatRoles(account: UserAccount | null | undefined)
|
||||||
|
Parameters :
Returns :
string
|
| formatSelectedAccountStatus |
formatSelectedAccountStatus()
|
|
Returns :
string
|
| getAccountRoles | ||||||
getAccountRoles(index: number)
|
||||||
|
Parameters :
Returns :
Role[]
|
| hasAccountEmail | ||||||
hasAccountEmail(index: number)
|
||||||
|
Parameters :
Returns :
boolean
|
| hasSelectableAccount | ||||||
hasSelectableAccount(index: number)
|
||||||
|
Parameters :
Returns :
boolean
|
| isAccountRolesSelected | ||||||
isAccountRolesSelected(index: number)
|
||||||
|
Parameters :
Returns :
boolean
|
| selectAccountToKeep | ||||||
selectAccountToKeep(index: number | string)
|
||||||
|
Parameters :
Returns :
void
|
| toggleAccountRoles |
toggleAccountRoles(index: number, checked: boolean)
|
|
Returns :
void
|
| Async validateAndGetDecision |
validateAndGetDecision()
|
|
Returns :
Promise<unknown | AccountMergeDecision>
|
| Readonly accountLoadError |
Type : unknown
|
Default value : signal<boolean>(false)
|
| Readonly availableRoles |
Type : unknown
|
Default value : signal<Role[]>([])
|
| Readonly deleteSecondaryAccount |
Type : unknown
|
Default value : signal<boolean>(true)
|
| Readonly entityAccounts |
Type : unknown
|
Default value : signal<(UserAccount | null)[]>([null, null])
|
| Readonly hasAnyUserAccount |
Type : unknown
|
Default value : computed(() =>
this.entityAccounts().some((a) => a != null),
)
|
| Readonly primaryIndex |
Type : unknown
|
Default value : signal<number>(0)
|
|
Index of the entity whose ID will be retained after merge. |
| Readonly selectedAccountIndex |
Type : unknown
|
Default value : signal<number | null>(null)
|
| Readonly selectedAccountIndexValue |
Type : unknown
|
Default value : computed(() => {
const selectedIndex = this.selectedAccountIndex();
return selectedIndex == null ? null : String(selectedIndex);
})
|
| Readonly selectedRolesControl |
Type : unknown
|
Default value : signal<FormControl<Role[]>>(
new FormControl<Role[]>([], { nonNullable: true }),
)
|
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
OnInit,
signal,
} from "@angular/core";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatExpansionModule } from "@angular/material/expansion";
import { MatError, MatFormFieldModule } from "@angular/material/form-field";
import { MatRadioModule } from "@angular/material/radio";
import { MatSelectModule } from "@angular/material/select";
import { YesNoCancelButtons } from "app/core/common-components/confirmation-dialog/confirmation-dialog/confirmation-dialog.component";
import { ConfirmationDialogService } from "app/core/common-components/confirmation-dialog/confirmation-dialog.service";
import { Entity, EntityConstructor } from "app/core/entity/model/entity";
import { UserAdminService } from "app/core/user/user-admin-service/user-admin.service";
import {
Role,
UserAccount,
} from "app/core/user/user-admin-service/user-account";
import { catchError, lastValueFrom, of } from "rxjs";
export interface AccountMergeDecision {
accountUpdate: {
accountId: string;
update: Partial<UserAccount>;
} | null;
deleteSecondaryAccount: boolean;
}
@Component({
selector: "app-merge-account-section",
changeDetection: ChangeDetectionStrategy.OnPush,
host: { style: "display: contents" },
imports: [
ReactiveFormsModule,
MatError,
MatRadioModule,
MatExpansionModule,
MatCheckboxModule,
MatFormFieldModule,
MatSelectModule,
],
templateUrl: "./merge-account-section.component.html",
styleUrl: "./merge-account-section.component.scss",
})
export class MergeAccountSectionComponent implements OnInit {
private readonly userAdminService = inject(UserAdminService);
private readonly confirmationDialog = inject(ConfirmationDialogService);
entitiesToMerge = input.required<Entity[]>();
entityConstructor = input.required<EntityConstructor>();
readonly entityAccounts = signal<(UserAccount | null)[]>([null, null]);
readonly accountLoadError = signal<boolean>(false);
/** Index of the entity whose ID will be retained after merge. */
readonly primaryIndex = signal<number>(0);
readonly selectedAccountIndex = signal<number | null>(null);
readonly availableRoles = signal<Role[]>([]);
readonly selectedRolesControl = signal<FormControl<Role[]>>(
new FormControl<Role[]>([], { nonNullable: true }),
);
readonly deleteSecondaryAccount = signal<boolean>(true);
readonly hasAnyUserAccount = computed(() =>
this.entityAccounts().some((a) => a != null),
);
readonly selectedAccountIndexValue = computed(() => {
const selectedIndex = this.selectedAccountIndex();
return selectedIndex == null ? null : String(selectedIndex);
});
ngOnInit(): Promise<void> {
return this.initializeAccountData();
}
private async initializeAccountData(): Promise<void> {
await this.loadAccounts();
if (!this.hasAnyUserAccount()) return;
await this.loadAvailableRoles();
this.setPrimaryIndex(this.primaryIndex());
}
private async loadAccounts(): Promise<void> {
if (!this.entityConstructor().enableUserAccounts) return;
const results = await Promise.all(
this.entitiesToMerge().map((e) => this.fetchUserAccount(e)),
);
this.entityAccounts.set(results.map((r) => r.account));
this.accountLoadError.set(results.some((r) => r.error));
if (!results[0].account && results[1]?.account) {
this.primaryIndex.set(1);
} else {
this.primaryIndex.set(0);
}
}
private async fetchUserAccount(
entity: Entity,
): Promise<{ account: UserAccount | null; error: boolean }> {
try {
const account = await lastValueFrom(
this.userAdminService.getUser(entity.getId()).pipe(
catchError((err) => {
if (err?.status === 404) return of(null);
throw err;
}),
),
);
return { account, error: false };
} catch {
return { account: null, error: true };
}
}
private async loadAvailableRoles(): Promise<void> {
try {
this.availableRoles.set(
await lastValueFrom(this.userAdminService.getAllRoles()),
);
} catch {
this.availableRoles.set(
this.uniqueRoles(
this.entityAccounts().flatMap((account) => account?.roles ?? []),
),
);
}
}
hasSelectableAccount(index: number): boolean {
return this.entityAccounts()[index] != null;
}
hasAccountEmail(index: number): boolean {
return !!this.entityAccounts()[index]?.email;
}
selectAccountToKeep(index: number | string): void {
const parsedIndex = Number(index);
if (
!Number.isInteger(parsedIndex) ||
!this.hasSelectableAccount(parsedIndex)
) {
return;
}
this.setPrimaryIndex(parsedIndex);
}
private setPrimaryIndex(index: number): void {
this.primaryIndex.set(index);
this.selectedAccountIndex.set(index);
this.resetSelectedRolesForIndex(index);
}
private resetSelectedRolesForIndex(index: number): void {
const roles = this.getAccountRoles(index);
const control = this.selectedRolesControl();
control.setValue(roles);
control.markAsPristine();
}
formatRoles(account: UserAccount | null | undefined): string {
if (!account?.roles?.length) return "-";
return account.roles.map((r) => r.name).join(", ");
}
formatAccountStatus(account: UserAccount | null | undefined): string {
if (!account) return "-";
return account.enabled ? $localize`Enabled` : $localize`Disabled`;
}
formatSelectedAccountStatus(): string {
const selectedIndex = this.selectedAccountIndex();
if (selectedIndex == null) return "-";
return this.formatAccountStatus(this.entityAccounts()[selectedIndex]);
}
isAccountRolesSelected(index: number): boolean {
const selectedRoles = this.selectedRolesControl().value;
const selectedRoleIds = new Set(selectedRoles.map((r) => r.id));
const accountRoles = this.getAccountRoles(index);
if (!accountRoles.length) return false;
return accountRoles.every((role) => selectedRoleIds.has(role.id));
}
toggleAccountRoles(index: number, checked: boolean): void {
const selectedRoles = this.selectedRolesControl().value;
const accountRoles = this.getAccountRoles(index);
if (!accountRoles.length) return;
const selectedMap = new Map(selectedRoles.map((r) => [r.id, r]));
if (checked) {
accountRoles.forEach((role) => selectedMap.set(role.id, role));
} else {
accountRoles.forEach((role) => selectedMap.delete(role.id));
}
this.selectedRolesControl().setValue(Array.from(selectedMap.values()));
this.selectedRolesControl().markAsDirty();
}
getAccountRoles(index: number): Role[] {
return this.uniqueRoles(
(this.entityAccounts()[index]?.roles ?? []).map(
(role) =>
this.availableRoles().find(
(availableRole) => availableRole.id === role.id,
) ?? role,
),
);
}
private uniqueRoles(roles: Role[]): Role[] {
const roleMap = new Map(roles.map((role) => [role.id, role]));
return Array.from(roleMap.values());
}
private buildAccountUpdate(): {
accountId: string;
update: Partial<UserAccount>;
} | null {
const selectedIndex = this.selectedAccountIndex();
if (selectedIndex == null) return null;
const selectedAccount = this.entityAccounts()[selectedIndex];
if (!selectedAccount?.id) return null;
const control = this.selectedRolesControl();
if (!control.dirty) return null;
const selectedRoles = control.value ?? [];
const existingRoles = selectedAccount.roles ?? [];
const selectedIds = new Set(selectedRoles.map((r) => r.id));
const existingIds = new Set(existingRoles.map((r) => r.id));
const rolesChanged =
selectedIds.size !== existingIds.size ||
[...selectedIds].some((id) => !existingIds.has(id));
if (!rolesChanged) return null;
return {
accountId: selectedAccount.id,
update: { roles: selectedRoles },
};
}
async validateAndGetDecision(): Promise<false | AccountMergeDecision> {
this.deleteSecondaryAccount.set(true);
if (
this.accountLoadError() &&
this.entityConstructor().enableUserAccounts
) {
const confirmed = await this.confirmationDialog.getConfirmation(
$localize`:merge account load error title:Warning! User account status unknown`,
$localize`:merge account load error:User account information could not be loaded (you may be offline or lack account_manager permissions). Proceeding may leave an orphaned user account.`,
);
if (!confirmed) return false;
}
const accountsFound = this.entityAccounts().filter((a) => a != null);
if (accountsFound.length === 1) {
const confirmed = await this.confirmationDialog.getConfirmation(
$localize`:merge account warning title:Warning! User account(s) found`,
$localize`:merge account warning one account:One of the records has a linked user account. This account will remain linked as a login for the merged record.`,
);
if (!confirmed) return false;
}
if (accountsFound.length === 2) {
const selectedIndex = this.selectedAccountIndex() ?? this.primaryIndex();
const secondaryIndex = selectedIndex === 0 ? 1 : 0;
const selectedEmail = this.entityAccounts()[selectedIndex]?.email ?? "-";
const secondaryEmail =
this.entityAccounts()[secondaryIndex]?.email ?? "-";
const result = await this.confirmationDialog.getConfirmation(
$localize`:merge account warning title:Warning! User account(s) found`,
$localize`:merge account warning both accounts:Both records have a linked user account. You have selected the account for ${selectedEmail}:selectedEmail: to remain linked to the merged record.\nDo you want to delete the second account ${secondaryEmail}:secondaryEmail: (which will not have a linked record after this merge)?`,
YesNoCancelButtons,
);
if (result === undefined) {
return false;
}
this.deleteSecondaryAccount.set(result === true);
}
return {
accountUpdate: this.buildAccountUpdate(),
deleteSecondaryAccount: this.deleteSecondaryAccount(),
};
}
}
@if (
entityConstructor().enableUserAccounts &&
(hasAnyUserAccount() || accountLoadError())
) {
<div class="account-panel-wrapper">
<mat-expansion-panel [expanded]="hasAnyUserAccount()">
<mat-expansion-panel-header>
<mat-panel-title i18n>Linked User Accounts</mat-panel-title>
<mat-panel-description i18n>
Login account fields (different from entity profile fields)
</mat-panel-description>
</mat-expansion-panel-header>
@if (accountLoadError()) {
<mat-error class="account-load-error" i18n>
User account information could not be loaded. You may be offline or
lack account_manager permissions. This merge will proceed without
changes to user accounts.
</mat-error>
}
@if (hasAnyUserAccount()) {
<div class="account-grid">
<div class="label-cell">
<strong i18n>Account To Keep</strong>
</div>
<mat-radio-group
class="account-select-radio-group"
[value]="selectedAccountIndexValue()"
(change)="selectAccountToKeep($event.value)"
>
<div class="account-select-cell">
@if (hasSelectableAccount(0) && hasAccountEmail(0)) {
<mat-radio-button value="0">
{{ entityAccounts()[0]?.email }}
</mat-radio-button>
} @else {
<span class="aligned-text">-</span>
}
</div>
<div class="account-select-cell">
@if (hasSelectableAccount(1) && hasAccountEmail(1)) {
<mat-radio-button value="1">
{{ entityAccounts()[1]?.email }}
</mat-radio-button>
} @else {
<span class="aligned-text">-</span>
}
</div>
</mat-radio-group>
<div class="preview-cell account-preview">
{{
selectedAccountIndex() === null
? "-"
: (entityAccounts()[selectedAccountIndex() ?? 0]?.email ?? "-")
}}
</div>
<div class="label-cell">
<strong i18n>Account Roles</strong>
</div>
<div class="account-select-cell">
@if (getAccountRoles(0).length > 0) {
<mat-checkbox
[checked]="isAccountRolesSelected(0)"
(change)="toggleAccountRoles(0, $event.checked)"
>
{{ formatRoles(entityAccounts()[0]) }}
</mat-checkbox>
} @else {
<span class="aligned-text">-</span>
}
</div>
<div class="account-select-cell">
@if (getAccountRoles(1).length > 0) {
<mat-checkbox
[checked]="isAccountRolesSelected(1)"
(change)="toggleAccountRoles(1, $event.checked)"
>
{{ formatRoles(entityAccounts()[1]) }}
</mat-checkbox>
} @else {
<span class="aligned-text">-</span>
}
</div>
<div class="preview-cell preview-field-cell">
<mat-form-field class="full-width">
<mat-select [formControl]="selectedRolesControl()" multiple>
<mat-select-trigger>
@for (role of selectedRolesControl().value; track role.id) {
<span>{{ role.name }} </span>
}
</mat-select-trigger>
@for (role of availableRoles(); track role.id) {
<mat-option [value]="role">{{ role.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="label-cell">
<strong i18n>Account Status</strong>
</div>
<div class="account-select-cell">
<span class="aligned-text">{{
formatAccountStatus(entityAccounts()[0])
}}</span>
</div>
<div class="account-select-cell">
<span class="aligned-text">{{
formatAccountStatus(entityAccounts()[1])
}}</span>
</div>
<div class="preview-cell account-preview">
{{ formatSelectedAccountStatus() }}
</div>
</div>
}
</mat-expansion-panel>
</div>
}
./merge-account-section.component.scss
.account-panel-wrapper {
grid-column: 1 / -1;
}
mat-error.account-load-error {
display: block;
margin-bottom: 0.75rem;
}
.account-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 1rem;
row-gap: 0.5rem;
align-items: start;
}
.label-cell {
padding: 0.5rem 0;
}
mat-radio-group.account-select-radio-group {
grid-column: span 2;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.account-select-cell {
display: flex;
align-items: center;
min-height: 56px;
}
.preview-cell {
min-width: 0;
min-height: 56px;
display: flex;
align-items: center;
}
.account-preview {
font-weight: 500;
}
.full-width {
width: 100%;
}
.preview-field-cell {
align-items: stretch;
}
.aligned-text {
padding-left: 2.85rem;
}