src/app/features/de-duplication/review-duplicates/review-duplicates.component.ts

Implements

OnInit

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Methods

clear
clear()
Returns : void
Async mergeRecords
mergeRecords(pair: DuplicatePair)
Parameters :
Name Type Optional
pair DuplicatePair No
Returns : any
onEntityTypeChange
onEntityTypeChange(type: string)
Parameters :
Name Type Optional
type string No
Returns : void
onPageChange
onPageChange(event: PageEvent)
Parameters :
Name Type Optional
event PageEvent No
Returns : void
Async search
search()
Returns : any

Properties

Readonly displayedColumns
Type : []
Default value : ["record", "possibleDuplicate", "actions"]
isLoading
Type : unknown
Default value : signal(false)
pageIndex
Type : unknown
Default value : signal(0)
pageSize
Type : unknown
Default value : signal(5)
paginatedPairs
Type : unknown
Default value : computed(() => { const start = this.pageIndex() * this.pageSize(); return this.pairs().slice(start, start + this.pageSize()); })
pairs
Type : unknown
Default value : signal<DuplicatePair[]>([])
searched
Type : unknown
Default value : signal(false)
selectedEntityType
Type : unknown
Default value : signal<string>("")
selectedFields
Type : unknown
Default value : signal<string[]>([])
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  OnInit,
  signal,
} from "@angular/core";
import { FormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatPaginatorModule, PageEvent } from "@angular/material/paginator";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatTableModule } from "@angular/material/table";
import { ActivatedRoute } from "@angular/router";
import { AlertService } from "#src/app/core/alerts/alert.service";
import { EntityBlockComponent } from "#src/app/core/basic-datatypes/entity/entity-block/entity-block.component";
import { ViewTitleComponent } from "#src/app/core/common-components/view-title/view-title.component";
import { EntityFieldSelectComponent } from "#src/app/core/entity/entity-field-select/entity-field-select.component";
import { EntityRegistry } from "#src/app/core/entity/database-entity.decorator";
import { EntityTypeSelectComponent } from "#src/app/core/entity/entity-type-select/entity-type-select.component";
import { EntityAbility } from "#src/app/core/permissions/ability/entity-ability";
import { DisableEntityOperationDirective } from "#src/app/core/permissions/permission-directive/disable-entity-operation.directive";
import { RouteTarget } from "../../../route-target";
import {
  DuplicateDetectionService,
  DuplicatePair,
} from "../duplicate-detection.service";
import { BulkMergeService } from "../bulk-merge-service";

@RouteTarget("ReviewDuplicates")
@Component({
  selector: "app-review-duplicates",
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: "./review-duplicates.component.html",
  styleUrls: ["./review-duplicates.component.scss"],
  imports: [
    ViewTitleComponent,
    EntityTypeSelectComponent,
    EntityFieldSelectComponent,
    EntityBlockComponent,
    DisableEntityOperationDirective,
    FormsModule,
    MatFormFieldModule,
    MatButtonModule,
    MatTableModule,
    MatPaginatorModule,
    MatProgressBarModule,
  ],
})
export class ReviewDuplicatesComponent implements OnInit {
  private readonly entityRegistry = inject(EntityRegistry);
  private readonly duplicateDetectionService = inject(
    DuplicateDetectionService,
  );
  private readonly bulkMergeService = inject(BulkMergeService);
  private readonly route = inject(ActivatedRoute);
  private readonly alertService = inject(AlertService);
  private readonly ability = inject(EntityAbility);

  ngOnInit() {
    const entityType = this.route.snapshot.queryParamMap.get("entityType");
    if (entityType) {
      this.selectedEntityType.set(entityType);
    }
  }

  selectedEntityType = signal<string>("");
  selectedFields = signal<string[]>([]);
  isLoading = signal(false);
  searched = signal(false);
  pairs = signal<DuplicatePair[]>([]);

  pageSize = signal(5);
  pageIndex = signal(0);

  readonly displayedColumns = ["record", "possibleDuplicate", "actions"];
  private searchRequestId = 0;

  paginatedPairs = computed(() => {
    const start = this.pageIndex() * this.pageSize();
    return this.pairs().slice(start, start + this.pageSize());
  });

  onEntityTypeChange(type: string) {
    this.selectedEntityType.set(type);
    this.clear();
  }

  clear() {
    this.searchRequestId++;
    this.pairs.set([]);
    this.selectedFields.set([]);
    this.searched.set(false);
    this.isLoading.set(false);
    this.pageIndex.set(0);
  }

  async search() {
    const requestId = ++this.searchRequestId;
    const type = this.selectedEntityType();
    const fields = [...this.selectedFields()];
    if (!type || !fields.length) {
      this.pairs.set([]);
      this.searched.set(false);
      this.pageIndex.set(0);
      this.isLoading.set(false);
      return;
    }

    this.isLoading.set(true);
    this.pageIndex.set(0);
    this.pairs.set([]);
    this.searched.set(false);
    try {
      const ctor = this.entityRegistry.get(type);
      const result = await this.duplicateDetectionService.findDuplicates(
        ctor,
        fields,
      );
      if (requestId !== this.searchRequestId) return;
      this.pairs.set(result);
      this.searched.set(true);
    } catch (e) {
      if (requestId !== this.searchRequestId) return;
      this.alertService.addDanger(
        $localize`Could not search for duplicates: ${e instanceof Error ? e.message : e}`,
      );
    } finally {
      if (requestId === this.searchRequestId) {
        this.isLoading.set(false);
      }
    }
  }

  async mergeRecords(pair: DuplicatePair) {
    if (
      this.ability.cannot("update", pair.record) ||
      this.ability.cannot("update", pair.possibleDuplicate) ||
      this.ability.cannot("delete", pair.record) ||
      this.ability.cannot("delete", pair.possibleDuplicate)
    ) {
      this.alertService.addDanger(
        $localize`:Missing permission:Your account does not have the required permission for this action.`,
      );
      return;
    }

    const merged = await this.bulkMergeService.executeAction([
      pair.record,
      pair.possibleDuplicate,
    ]);
    if (merged) {
      const nextPairs = this.pairs().filter(
        (p) =>
          p.record !== pair.record ||
          p.possibleDuplicate !== pair.possibleDuplicate,
      );
      this.pairs.set(nextPairs);

      const maxPageIndex = Math.max(
        Math.ceil(nextPairs.length / this.pageSize()) - 1,
        0,
      );
      if (this.pageIndex() > maxPageIndex) {
        this.pageIndex.set(maxPageIndex);
      }
    }
  }

  onPageChange(event: PageEvent) {
    this.pageSize.set(event.pageSize);
    this.pageIndex.set(event.pageIndex);
  }
}
<app-view-title i18n>Review Possible Duplicates</app-view-title>

<p class="description-text" i18n>
  There may be duplicate registrations in the system. Please review the records
  with a high similarity below.
</p>

<div class="flex-row align-center flex-wrap gap-regular search-controls">
  <mat-form-field>
    <mat-label i18n>Select Record Type</mat-label>
    <app-entity-type-select
      [ngModel]="selectedEntityType()"
      (ngModelChange)="onEntityTypeChange($event)"
    ></app-entity-type-select>
  </mat-form-field>

  <mat-form-field>
    <mat-label i18n>Select fields for unique identifiers</mat-label>
    <app-entity-field-select
      [entityType]="selectedEntityType()"
      [ngModel]="selectedFields()"
      (ngModelChange)="selectedFields.set($event)"
      [multi]="true"
      [disabled]="!selectedEntityType()"
    ></app-entity-field-select>
  </mat-form-field>

  <button
    mat-raised-button
    color="primary"
    (click)="search()"
    [disabled]="
      !selectedEntityType() || !selectedFields().length || isLoading()
    "
    i18n
  >
    Search Duplicates
  </button>

  <button mat-stroked-button (click)="clear()" [disabled]="!searched()" i18n>
    Clear
  </button>
</div>

@if (isLoading()) {
  <mat-progress-bar mode="indeterminate"></mat-progress-bar>
}

@if (searched() && !isLoading() && pairs().length === 0) {
  <p class="no-results-message" i18n>
    No duplicate records found for the selected fields.
  </p>
}

@if (pairs().length > 0) {
  <div class="mat-elevation-z1">
    <table
      mat-table
      [dataSource]="paginatedPairs()"
      class="full-width"
      aria-label="List of possible duplicate records"
      i18n-aria-label
    >
      <ng-container matColumnDef="record">
        <th mat-header-cell *matHeaderCellDef i18n>Record</th>
        <td mat-cell *matCellDef="let pair">
          <app-entity-block [entity]="pair.record"></app-entity-block>
        </td>
      </ng-container>

      <ng-container matColumnDef="possibleDuplicate">
        <th mat-header-cell *matHeaderCellDef i18n>Possible Duplicate</th>
        <td mat-cell *matCellDef="let pair">
          <app-entity-block
            [entity]="pair.possibleDuplicate"
          ></app-entity-block>
        </td>
      </ng-container>

      <ng-container matColumnDef="actions">
        <th mat-header-cell *matHeaderCellDef i18n>Actions</th>
        <td mat-cell *matCellDef="let pair">
          <button
            mat-stroked-button
            (click)="mergeRecords(pair)"
            *appDisabledEntityOperation="{
              entity: pair.record,
              operation: 'update',
            }"
            i18n
          >
            Compare & Merge
          </button>
        </td>
      </ng-container>

      <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
      <tr
        mat-row
        *matRowDef="let pair; columns: displayedColumns"
        class="table-list-item"
      ></tr>
    </table>

    <mat-paginator
      [length]="pairs().length"
      [pageSize]="pageSize()"
      [pageSizeOptions]="[5, 10, 20]"
      (page)="onPageChange($event)"
      showFirstLastButtons
    ></mat-paginator>
  </div>
}

./review-duplicates.component.scss

.description-text {
  max-width: 700px;
}

.search-controls {
  margin-bottom: 16px;

  mat-form-field {
    min-width: 220px;
  }
}

.no-results-message {
  color: rgba(0, 0, 0, 0.54);
  text-align: center;
  padding: 24px 0;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""