src/app/features/de-duplication/review-duplicates/review-duplicates.component.ts
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-review-duplicates |
| standalone | true |
| imports |
MatFormFieldModule
MatButtonModule
MatTableModule
MatProgressBarModule
|
| styleUrls | ./review-duplicates.component.scss |
| templateUrl | ./review-duplicates.component.html |
No results matching.
ViewTitleComponent
EntityTypeSelectComponent
EntityFieldSelectComponent
EntityBlockComponent
DisableEntityOperationDirective
FormsModule
MatFormFieldModule
MatButtonModule
MatTableModule
MatProgressBarModule
ListPaginatorComponent
OnInit
Properties |
|
Methods |
constructor()
|
| clear |
clear()
|
|
Returns :
void
|
| Async mergeRecords | ||||||
mergeRecords(pair: DuplicatePair)
|
||||||
|
Parameters :
Returns :
any
|
| onEntityTypeChange | ||||||
onEntityTypeChange(type: string)
|
||||||
|
Parameters :
Returns :
void
|
| search |
search()
|
|
Returns :
void
|
| 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 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;
}