Methods
|
onEntityTypeChange
|
onEntityTypeChange(type: string)
|
|
|
Parameters :
| Name |
Type |
Optional |
| type |
string
|
No
|
|
|
onPageChange
|
onPageChange(event: PageEvent)
|
|
|
Parameters :
| Name |
Type |
Optional |
| event |
PageEvent
|
No
|
|
|
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>
}
.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 with directive