src/app/features/de-duplication/bulk-merge-records/merge-account-section/merge-account-section.component.ts

Implements

OnInit

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs

Inputs

entitiesToMerge
Type : Entity[]
Required :  true
entityConstructor
Type : EntityConstructor
Required :  true

Methods

formatAccountStatus
formatAccountStatus(account: UserAccount | null | undefined)
Parameters :
Name Type Optional
account UserAccount | null | undefined No
Returns : string
formatRoles
formatRoles(account: UserAccount | null | undefined)
Parameters :
Name Type Optional
account UserAccount | null | undefined No
Returns : string
formatSelectedAccountStatus
formatSelectedAccountStatus()
Returns : string
getAccountRoles
getAccountRoles(index: number)
Parameters :
Name Type Optional
index number No
Returns : Role[]
hasAccountEmail
hasAccountEmail(index: number)
Parameters :
Name Type Optional
index number No
Returns : boolean
hasSelectableAccount
hasSelectableAccount(index: number)
Parameters :
Name Type Optional
index number No
Returns : boolean
isAccountRolesSelected
isAccountRolesSelected(index: number)
Parameters :
Name Type Optional
index number No
Returns : boolean
selectAccountToKeep
selectAccountToKeep(index: number | string)
Parameters :
Name Type Optional
index number | string No
Returns : void
toggleAccountRoles
toggleAccountRoles(index: number, checked: boolean)
Parameters :
Name Type Optional
index number No
checked boolean No
Returns : void
Async validateAndGetDecision
validateAndGetDecision()

Properties

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;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""