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

Implements

OnInit

Metadata

Relationships

Used by

No results matching.

Index

Properties
Methods

Constructor

constructor()

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
search
search()
Returns : void

Properties

Readonly dataSource
Type : unknown
Default value : new MatTableDataSource<DuplicatePair>()

Paging is left to the table's data source and ListPaginatorComponent, which also clamps to the last page as the list shrinks whenever a duplicate is resolved.

Readonly displayedColumns
Type : []
Default value : ["record", "possibleDuplicate", "actions"]
Readonly hideUnmatchableField
Type : unknown
Default value : () => {...}

fields whose values could never match, see isMatchableField

Readonly isLoading
Type : unknown
Default value : computed(() => this.state().status === "loading")
Readonly pairs
Type : unknown
Default value : computed<DuplicatePair[]>(() => { const state = this.state(); return state.status === "ready" ? state.pairs : []; })

read-only: the pairs are maintained by the analysis, not set from here

Readonly searched
Type : unknown
Default value : computed(() => this.state().status === "ready")
selectedEntityType
Type : unknown
Default value : signal<string>("")
selectedFields
Type : unknown
Default value : signal<string[]>([])
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  OnInit,
  signal,
} from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { catchError, map, of, startWith, switchMap } from "rxjs";
import { FormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatTableDataSource, 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 { ListPaginatorComponent } from "#src/app/core/common-components/entities-table/list-paginator/list-paginator.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,
  isMatchableField,
} from "../duplicate-detection.service";
import { EntityConstructor } from "#src/app/core/entity/model/entity";
import { EntitySchemaField } from "#src/app/core/entity/schema/entity-schema-field";
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,
    MatProgressBarModule,
    ListPaginatorComponent,
  ],
})
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[]>([]);

  /** the search to run, or null while none has been started */
  private readonly request = signal<{
    ctor: EntityConstructor;
    fields: string[];
  } | null>(null);

  /**
   * The running analysis. It stays subscribed while a search is active, so the list
   * reflects merges and any other change to the data without searching again;
   * `switchMap` ends the previous analysis whenever a new search starts.
   */
  private readonly state = toSignal(
    toObservable(this.request).pipe(
      switchMap((request) =>
        request
          ? this.duplicateDetectionService
              .watchDuplicates(request.ctor, request.fields)
              .pipe(
                map((pairs) => ({ status: "ready", pairs }) as const),
                startWith({ status: "loading" } as const),
                catchError((e) => {
                  this.alertService.addDanger(
                    $localize`Could not search for duplicates: ${e instanceof Error ? e.message : e}`,
                  );
                  return of({ status: "idle" } as const);
                }),
              )
          : of({ status: "idle" } as const),
      ),
    ),
    { initialValue: { status: "idle" } as const },
  );

  /** read-only: the pairs are maintained by the analysis, not set from here */
  readonly pairs = computed<DuplicatePair[]>(() => {
    const state = this.state();
    return state.status === "ready" ? state.pairs : [];
  });
  readonly isLoading = computed(() => this.state().status === "loading");
  readonly searched = computed(() => this.state().status === "ready");

  /**
   * Paging is left to the table's data source and {@link ListPaginatorComponent},
   * which also clamps to the last page as the list shrinks whenever a duplicate is resolved.
   */
  readonly dataSource = new MatTableDataSource<DuplicatePair>();

  readonly displayedColumns = ["record", "possibleDuplicate", "actions"];

  constructor() {
    effect(() => (this.dataSource.data = this.pairs()));
  }

  /** fields whose values could never match, see {@link isMatchableField} */
  readonly hideUnmatchableField = (field: EntitySchemaField) =>
    !isMatchableField(field);

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

  clear() {
    this.request.set(null);
    this.selectedFields.set([]);
  }

  search() {
    const type = this.selectedEntityType();
    const fields = [...this.selectedFields()];
    if (!type || !fields.length) {
      this.request.set(null);
      return;
    }

    // A new search is a new list, so it starts at the first page: the data source
    // only ever clamps the page index downwards, never resets it.
    this.dataSource.paginator?.firstPage();
    this.request.set({ ctor: this.entityRegistry.get(type), fields });
  }

  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;
    }

    // the merged record's update and the discarded one's removal reach the running
    // analysis on their own, so the list updates without searching again
    await this.bulkMergeService.executeAction([
      pair.record,
      pair.possibleDuplicate,
    ]);
  }
}
<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()"
      [hideOption]="hideUnmatchableField"
    ></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]="dataSource"
      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>

    <app-list-paginator
      [dataSource]="dataSource"
      [idForSavingPagination]="'review-duplicates'"
    ></app-list-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 ""