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

Implements

OnInit

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Constructor

constructor()

Methods

Async confirmAndMergeRecords
confirmAndMergeRecords()
Returns : Promise<boolean>
hasValue
hasValue(value: any)

helper method to check whether a value is empty or has a valid value.

Parameters :
Name Type Optional
value any No
Returns : boolean

Properties

Readonly accountSection
Type : unknown
Default value : viewChild(MergeAccountSectionComponent)
entitiesToMerge
Type : E[]
entityConstructor
Type : EntityConstructor
fieldsToMerge
Type : unknown
Default value : signal<FormFieldConfig[]>([])
filteredFieldsToMerge
Type : unknown
Default value : computed(() => { if (!this.showOnlyDifferences()) return this.fieldsToMerge(); return this.fieldsToMerge().filter( (field) => !this.valuesAreIdentical( this.entitiesToMerge[0][field.id], this.entitiesToMerge[1][field.id], ), ); })
hasDiscardedFileOrPhoto
Type : boolean
Default value : false

whether the entitiesToMerge contain some file attachments that would be lost during a merge

mergedEntity
Type : E
mergeForm
Type : EntityForm<E>
showOnlyDifferences
Type : unknown
Default value : signal(false)
import { EntityForm } from "#src/app/core/common-components/entity-form/entity-form";
import { EntityFieldEditComponent } from "#src/app/core/entity/entity-field-edit/entity-field-edit.component";
import {
  ChangeDetectorRef,
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  inject,
  OnInit,
  signal,
  viewChild,
} from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import {
  MAT_DIALOG_DATA,
  MatDialogActions,
  MatDialogClose,
  MatDialogContent,
  MatDialogRef,
} from "@angular/material/dialog";
import { MatError, MatFormFieldModule } from "@angular/material/form-field";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import { ConfirmationDialogService } from "app/core/common-components/confirmation-dialog/confirmation-dialog.service";
import { FormFieldConfig } from "app/core/common-components/entity-form/FormConfig";
import { EntityFormService } from "app/core/common-components/entity-form/entity-form.service";
import { Entity, EntityConstructor } from "app/core/entity/model/entity";
import { MergeFieldsComponent } from "./merge-fields/merge-fields.component";
import { WarningNotOptimizedForSmallScreenComponent } from "#src/app/core/common-components/warning-not-optimized-for-small-screen/warning-not-optimized-for-small-screen.component";
import { MergeAccountSectionComponent } from "./merge-account-section/merge-account-section.component";

@Component({
  selector: "app-bulk-merge-records",
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    MatDialogActions,
    MatDialogContent,
    MatButtonModule,
    EntityFieldEditComponent,
    ReactiveFormsModule,
    MatError,
    MatDialogClose,
    MergeFieldsComponent,
    WarningNotOptimizedForSmallScreenComponent,
    MatFormFieldModule,
    MergeAccountSectionComponent,
    MatSlideToggleModule,
  ],
  templateUrl: "./bulk-merge-records.component.html",
  styleUrls: ["./bulk-merge-records.component.scss"],
})
export class BulkMergeRecordsComponent<E extends Entity> implements OnInit {
  private readonly dialogRef =
    inject<MatDialogRef<BulkMergeRecordsComponent<E>>>(MatDialogRef);
  private readonly confirmationDialog = inject(ConfirmationDialogService);
  private readonly entityFormService = inject(EntityFormService);
  private readonly cdr = inject(ChangeDetectorRef);
  private readonly destroyRef = inject(DestroyRef);

  readonly accountSection = viewChild(MergeAccountSectionComponent);

  entityConstructor: EntityConstructor;
  entitiesToMerge: E[];
  mergedEntity: E;
  fieldsToMerge = signal<FormFieldConfig[]>([]);
  mergeForm: EntityForm<E>;

  showOnlyDifferences = signal(false);

  filteredFieldsToMerge = computed(() => {
    if (!this.showOnlyDifferences()) return this.fieldsToMerge();
    return this.fieldsToMerge().filter(
      (field) =>
        !this.valuesAreIdentical(
          this.entitiesToMerge[0][field.id],
          this.entitiesToMerge[1][field.id],
        ),
    );
  });

  /** whether the entitiesToMerge contain some file attachments that would be lost during a merge */
  hasDiscardedFileOrPhoto: boolean = false;

  constructor() {
    const data = inject<{
      entityConstructor: EntityConstructor;
      entitiesToMerge: E[];
    }>(MAT_DIALOG_DATA);

    this.entityConstructor = data.entityConstructor;
    this.entitiesToMerge = data.entitiesToMerge;
    // Use the primary entity's values as the base so form validators (e.g. uniqueness)
    // treat existing values as the "default" and don't incorrectly flag them as duplicates.
    this.mergedEntity = this.entitiesToMerge[0].copy() as E;
  }

  async ngOnInit(): Promise<void> {
    this.initFieldsToMerge();
    this.mergeForm = await this.entityFormService.createEntityForm(
      this.fieldsToMerge(),
      this.mergedEntity,
      this.destroyRef,
      false,
      true,
      false,
    );
    this.cdr.detectChanges();
  }

  private initFieldsToMerge(): void {
    const fields: FormFieldConfig[] = [];
    this.entityConstructor.schema.forEach((field, key) => {
      const hasValue = this.entitiesToMerge.some((entity) =>
        this.hasValue(entity[key]),
      );

      const isFileField =
        field.dataType === "photo" || field.dataType === "file";

      if (isFileField && this.entitiesToMerge[1][key] != null) {
        this.hasDiscardedFileOrPhoto = true;
      }

      if (field.label && hasValue && !isFileField && !field.isInternalField) {
        const formField: FormFieldConfig =
          this.entityFormService.extendFormFieldConfig(
            { id: key },
            this.entityConstructor,
          );
        fields.push({
          ...formField,
        });
      }
    });
    this.fieldsToMerge.set(fields);
  }

  private valuesAreIdentical(a: any, b: any): boolean {
    return JSON.stringify(a) === JSON.stringify(b);
  }

  /**
   * helper method to check whether a value is empty or has a valid value.
   */
  hasValue(value: any): boolean {
    return !(
      value === undefined ||
      value === null ||
      value === "" ||
      (Array.isArray(value) && value.length === 0) ||
      value === false
    );
  }

  async confirmAndMergeRecords(): Promise<boolean> {
    this.mergeForm.formGroup.markAllAsTouched();
    if (this.mergeForm.formGroup.invalid) return false;

    const accountDecision =
      await this.accountSection()?.validateAndGetDecision();
    if (accountDecision === false) {
      return false;
    }

    if (this.hasDiscardedFileOrPhoto) {
      const fileIgnoreConfirmed = await this.confirmationDialog.getConfirmation(
        $localize`:Merge confirmation title:Warning! Some file attachments will be lost`,
        $localize`:Merge confirmation dialog with files/photos:"Record B" contains files or images. Merging currently does not support attachments yet. The merged record will only have the attachments from "record A". Files from "record B" will be lost!`,
      );
      if (!fileIgnoreConfirmed) {
        return false;
      }
    }

    if (
      !(await this.confirmationDialog.getConfirmation(
        $localize`:Merge confirmation title:Are you sure you want to merge this?`,
        $localize`:Merge confirmation dialog:Merging of two records will discard the data that is not selected to be merged. This action cannot be undone. Once the two records are merged, there will be only one record left in the system.`,
      ))
    ) {
      return false;
    }

    const primaryIndex = this.accountSection()?.primaryIndex() ?? 0;
    this.dialogRef.close({
      mergedEntity: Object.assign(
        this.entitiesToMerge[primaryIndex].copy(),
        this.mergeForm.formGroup.value,
      ),
      entityAccounts: this.accountSection()?.entityAccounts() ?? [],
      accountUpdate: accountDecision?.accountUpdate ?? null,
      deleteSecondaryAccount: accountDecision?.deleteSecondaryAccount ?? true,
    });
  }
}
<mat-dialog-content>
  <app-warning-not-optimized-for-small-screen />
  @if (mergeForm) {
    <form>
      <div class="merge-grid">
        <div></div>
        <div class="header" i18n>Record A</div>
        <div class="header" i18n>Record B</div>
        <div class="header" i18n>Merge Preview</div>
        @if (entityConstructor.enableUserAccounts) {
          <app-merge-account-section
            [entitiesToMerge]="entitiesToMerge"
            [entityConstructor]="entityConstructor"
          />
        }
        @for (field of filteredFieldsToMerge(); track field.id) {
          <div class="label-cell">
            <strong>{{ field.label }}</strong>
          </div>
          <app-merge-fields
            [field]="field"
            [entities]="entitiesToMerge"
            [control]="mergeForm.formGroup.get(field.id)"
          >
          </app-merge-fields>
          <!-- Merge Preview -->
          <div class="preview-cell">
            <app-entity-field-edit
              [field]="field"
              [entity]="mergedEntity"
              [form]="mergeForm"
            ></app-entity-field-edit>
          </div>
        }
      </div>
    </form>
  }
</mat-dialog-content>

<mat-dialog-actions>
  <button mat-raised-button matDialogClose i18n>Cancel</button>
  <button
    mat-raised-button
    color="accent"
    [disabled]="mergeForm?.formGroup.invalid"
    (click)="confirmAndMergeRecords()"
    i18n
  >
    Merge
  </button>
  <mat-slide-toggle
    [checked]="showOnlyDifferences()"
    (change)="showOnlyDifferences.set($event.checked)"
  >
    <span i18n>Only show fields with differences</span>
  </mat-slide-toggle>
  @if (mergeForm?.formGroup.invalid) {
    <mat-error i18n>
      The data you selected to merge is invalid, please check the fields in the
      "Merge Preview" above.
    </mat-error>
  }
</mat-dialog-actions>

./bulk-merge-records.component.scss

@use "variables/colors";

.merge-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 1rem;
  align-items: start;

  > * {
    min-width: 0;
  }

  .header {
    font-weight: bold;
    background-color: colors.$primary;
    color: white;
    padding: 0.5rem;
    border-bottom: 1px solid #ccc;
    text-align: center;
  }

  app-merge-fields {
    grid-column: 2 / 4;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1rem;
    min-width: 0;
  }

  .label-cell {
    padding: 0.5rem;
  }

  .preview-cell {
    min-width: 0;
    overflow: hidden;

    mat-form-field,
    ::ng-deep .mat-mdc-form-field {
      width: 100%;
    }
  }
}

mat-dialog-actions mat-slide-toggle {
  margin-left: 0.5rem;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""