src/app/features/skill/bulk-link-external-profiles/bulk-link-external-profiles.component.ts
| changeDetection | ChangeDetectionStrategy.OnPush |
||
| selector | app-bulk-link-external-profiles |
||
| standalone | true |
||
| imports |
MatButton
MatDialogClose
MatTable
MatColumnDef
MatHeaderCell
MatCell
MatCellDef
MatHeaderCellDef
MatHeaderRow
MatHeaderRowDef
MatRow
MatRowDef
MatSortModule
MatTooltip
MatProgressBar
|
||
| styleUrls | ./bulk-link-external-profiles.component.scss |
||
| templateVariables |
|
||
| templateUrl | ./bulk-link-external-profiles.component.html |
||
| styleUrl | ./bulk-link-external-profiles.component.scss |
No results matching.
ViewTitleComponent
ViewActionsComponent
MatButton
MatDialogClose
MatTable
MatColumnDef
MatHeaderCell
MatCell
EntityBlockComponent
MatCellDef
MatHeaderCellDef
MatHeaderRow
MatHeaderRowDef
MatRow
MatRowDef
MatSortModule
FaIconComponent
MatTooltip
PercentPipe
MatProgressBar
Properties |
Methods |
|
Inputs |
Accessors |
constructor()
|
| config | |
Type : FormFieldConfig
|
|
| entities | |
Type : Entity[]
|
|
|
The bulk-selected entities for which to search external profiles. |
|
| editMatch | ||||||
editMatch(record: RecordMatching)
|
||||||
|
Parameters :
Returns :
void
|
| Async save |
save()
|
|
Returns :
any
|
| unlinkMatch | ||||||
unlinkMatch(element: RecordMatching)
|
||||||
|
Parameters :
Returns :
void
|
| matchedRecordsCount |
Type : number
|
Default value : 0
|
| records |
Type : unknown
|
Default value : new MatTableDataSource<RecordMatching>([])
|
| sort | ||||||
setsort(sort: MatSort)
|
||||||
|
Parameters :
Returns :
void
|
import {
Component,
effect,
inject,
input,
ViewChild,
ChangeDetectionStrategy,
} from "@angular/core";
import { Entity } from "app/core/entity/model/entity";
import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component";
import { ViewActionsComponent } from "../../../core/common-components/view-actions/view-actions.component";
import { MatButton } from "@angular/material/button";
import { MatDialog, MatDialogClose } from "@angular/material/dialog";
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable,
MatTableDataSource,
} from "@angular/material/table";
import { EntityBlockComponent } from "../../../core/basic-datatypes/entity/entity-block/entity-block.component";
import { ExternalProfile } from "../skill-api/external-profile";
import { SkillApiService } from "../skill-api/skill-api.service";
import { MatSort, MatSortModule } from "@angular/material/sort";
import {
LinkExternalProfileDialogComponent,
LinkExternalProfileDialogData,
} from "../link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component";
import { EntitySchema } from "../../../core/entity/schema/entity-schema";
import { FormFieldConfig } from "../../../core/common-components/entity-form/FormConfig";
import { Logging } from "../../../core/logging/logging.service";
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
import { finalize, firstValueFrom, from, mergeMap } from "rxjs";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { MatTooltip } from "@angular/material/tooltip";
import { PercentPipe } from "@angular/common";
import { MatProgressBar } from "@angular/material/progress-bar";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-bulk-link-external-profiles",
imports: [
ViewTitleComponent,
ViewActionsComponent,
MatButton,
MatDialogClose,
MatTable,
MatColumnDef,
MatHeaderCell,
MatCell,
EntityBlockComponent,
MatCellDef,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatSortModule,
FaIconComponent,
MatTooltip,
PercentPipe,
MatProgressBar,
],
templateUrl: "./bulk-link-external-profiles.component.html",
styleUrl: "./bulk-link-external-profiles.component.scss",
})
export class BulkLinkExternalProfilesComponent {
private readonly skillApi = inject(SkillApiService);
private readonly dialog = inject(MatDialog);
private readonly entityMapper = inject(EntityMapperService);
/**
* The bulk-selected entities for which to search external profiles.
*/
entities = input<Entity[]>();
config = input<FormFieldConfig>();
private resolvedConfig: FormFieldConfig | undefined;
private MAX_CONCURRENT_REQUESTS = 20;
records = new MatTableDataSource<RecordMatching>([]);
matchedRecordsCount = 0;
@ViewChild(MatSort, { static: false }) set sort(sort: MatSort) {
if (this.records) {
this.records.sort = sort;
}
}
constructor() {
effect(() => {
this.entities();
this.config();
this.init();
});
}
private init() {
const entities = this.entities();
const config = this.config();
this.records = new MatTableDataSource<RecordMatching>([]);
this.matchedRecordsCount = 0;
this.resolvedConfig = config;
if (!this.resolvedConfig && entities?.[0]) {
// TODO: handle cases with multiple "external profile" fields (add dropdown to UI here?)
this.resolvedConfig = this.inferExternalProfileFieldFromEntitySchema(
entities[0].getSchema(),
);
}
if (!entities || !this.resolvedConfig) {
// abort
Logging.debug(
"BulkLinkExternalProfilesComponent: aborting init due to missing entities or config",
entities,
this.resolvedConfig,
);
return;
}
this.records = new MatTableDataSource(
entities.map((entity) => ({ entity }) as RecordMatching),
);
from(this.records.data)
.pipe(
mergeMap(
(r) => this.updateRecordWithMatches(r),
this.MAX_CONCURRENT_REQUESTS,
),
finalize(() => this.recalculatedMatchedRecordsCount()),
)
.subscribe();
}
/**
* fetch possible matches for each entity asynchronously and update the given record object
* @param record
* @private
*/
private async updateRecordWithMatches(
record: RecordMatching,
): Promise<RecordMatching> {
if (!this.resolvedConfig) {
return record;
}
await this.updateRecordWithCurrentlySelected(record);
try {
const possibleMatches = await firstValueFrom(
this.skillApi.getExternalProfiles(
this.skillApi.generateDefaultSearchParams(
record.entity,
this.resolvedConfig.additional,
),
),
);
record.possibleMatches = possibleMatches.results;
record.possibleMatchesCount = possibleMatches.pagination.totalElements;
// auto-select if only one possible match
if (possibleMatches?.results.length === 1 && !record.selected) {
record.selected = possibleMatches.results[0];
}
// warn if selected is not part of current search results
if (
record.selected &&
!record.possibleMatches.find((match) => match.id === record.selected.id)
) {
record.warning = {
...(record.warning ?? {}),
possibleMatches: $localize`:bulk-link external profile warning:The currently linked external profile was not part of the latest default search results.`,
};
}
} catch (err) {
Logging.warn("Could not load external profiles (bulk action)", err);
record.warning = {
...(record.warning ?? {}),
possibleMatches: $localize`:bulk-link external profile error:Error while fetching possible matches. Please try to manually search and select a profile to see details.`,
};
record.possibleMatchesCount = 0;
record.possibleMatches = [];
}
return record;
}
/**
* Check if external profile that is currently linked in the entity still exists
* and add it as pre-selected match if so.
* @param record
* @private
*/
private async updateRecordWithCurrentlySelected(record: RecordMatching) {
if (!this.resolvedConfig) {
return;
}
const existingExtProfileId = record.entity[this.resolvedConfig.id];
if (!existingExtProfileId) {
return;
}
const currentMatch = await firstValueFrom(
this.skillApi.getExternalProfileById(existingExtProfileId),
).catch((err) => {
Logging.debug(
"BulkLinkExternalProfilesComponent: error fetching previously selected external profile",
err,
);
record.warning = {
...(record.warning ?? {}),
selected: $localize`:bulk-link external profile error:Error while fetching currently linked external profile.`,
};
});
if (currentMatch) {
record.selected = currentMatch;
}
}
private recalculatedMatchedRecordsCount() {
this.matchedRecordsCount = this.records.data.filter(
(record) => record.selected,
).length;
}
editMatch(record: RecordMatching) {
this.dialog
.open(LinkExternalProfileDialogComponent, {
data: {
entity: record.entity,
config: this.resolvedConfig?.additional,
possibleMatches: record.possibleMatches,
} as LinkExternalProfileDialogData,
})
.afterClosed()
.subscribe((result: ExternalProfile | undefined) => {
if (result) {
record.selected = result;
}
this.recalculatedMatchedRecordsCount();
});
}
async save() {
if (!this.records || !this.resolvedConfig) {
return;
}
const newlyLinkedRecords = this.records.data.filter(
(r) => r.entity[this.resolvedConfig.id] !== r.selected?.id,
);
for (const record of newlyLinkedRecords) {
const updatedEntity: Entity = record.entity;
updatedEntity[this.resolvedConfig.id] = record.selected?.id;
try {
if (record.selected) {
await this.skillApi.applyDataFromExternalProfile(
record.selected, // if passed in as undefined, will reset the target values
this.resolvedConfig.additional,
updatedEntity,
);
}
} catch (e) {
Logging.error("Could not load data for external profile", e);
}
}
await this.entityMapper.saveAll(newlyLinkedRecords.map((r) => r.entity));
}
private inferExternalProfileFieldFromEntitySchema(
schema: EntitySchema,
): FormFieldConfig | undefined {
for (const [key, field] of schema.entries()) {
if (field.editComponent === "EditExternalProfileLink") {
return { id: key, ...field };
}
}
// TODO: throw or log warning?
return undefined;
}
unlinkMatch(element: RecordMatching) {
element.selected = undefined;
this.recalculatedMatchedRecordsCount();
}
}
interface RecordMatching {
entity: Entity;
possibleMatches?: ExternalProfile[];
possibleMatchesCount?: number;
selected?: ExternalProfile;
warning?: { selected?: string; possibleMatches?: string };
}
<app-view-title i18n>Bulk Link External Profiles</app-view-title>
<app-view-actions>
<div class="flex-row align-center gap-regular">
<div>
<button
mat-raised-button
color="accent"
(click)="save()"
mat-dialog-close
i18n
>
Link Profiles
</button>
<button mat-stroked-button mat-dialog-close i18n>Cancel</button>
</div>
<div>
@if (records?.data?.length > 0) {
<span i18n>Matched records:</span> {{ matchedRecordsCount }} /
{{ records.data.length }} ({{
matchedRecordsCount / records.data.length | percent: "1.0-0"
}})
}
</div>
</div>
</app-view-actions>
<table mat-table matSort [dataSource]="records">
<ng-container matColumnDef="entity">
<th mat-header-cell *matHeaderCellDef i18n>Record</th>
<td mat-cell *matCellDef="let element">
<app-entity-block [entity]="element.entity"></app-entity-block>
</td>
</ng-container>
<ng-container matColumnDef="selected">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>
Linked External Profile
</th>
<td mat-cell *matCellDef="let element">
@if (element.selected) {
{{ element.selected.fullName }} ({{ element.selected.email }})
<fa-icon
icon="times"
class="unlink-icon"
matTooltip="Unlink external profile"
i18n-matTooltip
(click)="unlinkMatch(element)"
></fa-icon>
}
@if (element.warning?.selected) {
<fa-icon
icon="exclamation-triangle"
class="error-icon"
[matTooltip]="element.warning.selected"
></fa-icon>
}
</td>
</ng-container>
<ng-container matColumnDef="possibleMatchesCount">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>
Possible Matches
</th>
<td mat-cell *matCellDef="let element">
{{ element.possibleMatchesCount }}
@if (element.warning?.possibleMatches) {
<fa-icon
icon="exclamation-triangle"
class="error-icon"
[matTooltip]="element.warning.possibleMatches"
></fa-icon>
}
</td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef i18n>Action</th>
<td mat-cell *matCellDef="let element">
<button mat-stroked-button (click)="editMatch(element)">
@if (!element.possibleMatches) {
<mat-progress-bar
style="min-width: 40px"
mode="indeterminate"
></mat-progress-bar>
} @else if (!element.selected) {
<span i18n>Select external profile</span>
} @else {
<span i18n>Change linked profile</span>
}
</button>
</td>
</ng-container>
@let columnsToDisplay =
["entity", "selected", "possibleMatchesCount", "action"];
<tr mat-header-row *matHeaderRowDef="columnsToDisplay"></tr>
<tr mat-row *matRowDef="let row; columns: columnsToDisplay"></tr>
</table>
./bulk-link-external-profiles.component.scss
.unlink-icon {
cursor: pointer;
vertical-align: middle;
}