diff --git a/.github/workflows/pull-request-update-or-push-tag.yml b/.github/workflows/pull-request-update-or-push-tag.yml index 84cd06177b..0b6ecda195 100644 --- a/.github/workflows/pull-request-update-or-push-tag.yml +++ b/.github/workflows/pull-request-update-or-push-tag.yml @@ -210,6 +210,10 @@ jobs: env: SOURCE_DATE_EPOCH: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install Sentry CLI run: | npm install -g @sentry/cli @@ -276,6 +280,10 @@ jobs: if: ${{ github.event.ref == '' }} run: echo "TAG=pr-${{ github.event.number }}" >> $GITHUB_ENV + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install Sentry CLI run: | npm install -g @sentry/cli @@ -343,6 +351,10 @@ jobs: if: ${{ github.event.ref == '' }} run: echo "TAG=pr-${{ github.event.number }}" >> $GITHUB_ENV + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install Sentry CLI run: | npm install -g @sentry/cli diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 8f8149f097..937c51c57a 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -8,7 +8,7 @@ import { SessionType } from "../src/app/core/session/session-type"; // fixing a mocked "TODAY" to have persistent stories for visual regression testing MockDate.set(new Date("2023-06-09")); // polyfill buffer here as well -window.Buffer = buffer.Buffer; +window["Buffer"] = buffer.Buffer; environment.production = false; environment.session_type = SessionType.mock; environment.demo_mode = false; @@ -24,7 +24,7 @@ const preview: Preview = { }, }, - tags: ["autodocs"] + tags: ["autodocs"], }; export default preview; diff --git a/proxy.conf.json b/proxy.conf.json index b53b95efd7..82c159d2ae 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -8,6 +8,12 @@ "/db": "" } }, + "/api": { + "target": "http://localhost:9000", + "secure": true, + "logLevel": "debug", + "changeOrigin": true + }, "/query": { "target": "http://localhost:9000", "secure": true, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b544dec3b2..8e100ca455 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -88,6 +88,7 @@ import { AdminModule } from "./core/admin/admin.module"; import { Logging } from "./core/logging/logging.service"; import { APP_INITIALIZER_DEMO_DATA } from "./core/demo-data/demo-data.app-initializer"; import { TemplateExportModule } from "./features/template-export/template-export.module"; +import { SkillModule } from "./features/skill/skill.module"; /** * Main entry point of the application. @@ -132,6 +133,7 @@ import { TemplateExportModule } from "./features/template-export/template-export TodosModule, AdminModule, TemplateExportModule, + SkillModule, // top level component UiComponent, // Global Angular Material modules diff --git a/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts b/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts index 78cb272229..ef30fbec41 100644 --- a/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts +++ b/src/app/core/common-components/entity-form/entity-form/entity-form.component.ts @@ -17,6 +17,7 @@ import moment from "moment"; import { EntityFieldEditComponent } from "../../entity-field-edit/entity-field-edit.component"; import { FieldGroup } from "../../../entity-details/form/field-group"; import { EntityAbility } from "../../../permissions/ability/entity-ability"; +import { FormsModule } from "@angular/forms"; /** * A general purpose form component for displaying and editing entities. @@ -35,7 +36,13 @@ import { EntityAbility } from "../../../permissions/ability/entity-ability"; // Use no encapsulation because we want to change the value of children (the mat-form-fields that are // dynamically created) encapsulation: ViewEncapsulation.None, - imports: [NgForOf, NgIf, NgClass, EntityFieldEditComponent], + imports: [ + FormsModule, // importing FormsModule ensures that buttons anywhere inside do not trigger form submission / page reload + NgForOf, + NgIf, + NgClass, + EntityFieldEditComponent, + ], standalone: true, }) export class EntityFormComponent diff --git a/src/app/core/common-components/entity-select/entity-select.component.ts b/src/app/core/common-components/entity-select/entity-select.component.ts index 0f80f39d77..a96d5cd045 100644 --- a/src/app/core/common-components/entity-select/entity-select.component.ts +++ b/src/app/core/common-components/entity-select/entity-select.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; import { Entity } from "../../entity/model/entity"; import { BehaviorSubject, lastValueFrom } from "rxjs"; import { FormControl, FormsModule, ReactiveFormsModule } from "@angular/forms"; @@ -7,7 +7,7 @@ import { MatAutocompleteModule } from "@angular/material/autocomplete"; import { UntilDestroy } from "@ngneat/until-destroy"; import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; import { MatFormFieldModule } from "@angular/material/form-field"; -import { AsyncPipe, NgForOf, NgIf } from "@angular/common"; +import { AsyncPipe, NgIf } from "@angular/common"; import { EntityBlockComponent } from "../../basic-datatypes/entity/entity-block/entity-block.component"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { MatTooltipModule } from "@angular/material/tooltip"; @@ -34,7 +34,6 @@ import { EntityRegistry } from "../../entity/database-entity.decorator"; ReactiveFormsModule, MatAutocompleteModule, MatChipsModule, - NgForOf, EntityBlockComponent, FontAwesomeModule, MatTooltipModule, @@ -52,7 +51,8 @@ import { EntityRegistry } from "../../entity/database-entity.decorator"; export class EntitySelectComponent< E extends Entity, T extends string[] | string = string[], -> { +> implements OnChanges +{ readonly loadingPlaceholder = $localize`:A placeholder for the input element when select options are not loaded yet:loading...`; @Input() form: FormControl; @@ -135,6 +135,14 @@ export class EntitySelectComponent< @Input() additionalFilter: (e: E) => boolean = (_) => true; + ngOnChanges(changes: SimpleChanges): void { + if (changes["form"]) { + this.form.valueChanges.subscribe((value) => { + this.updateAvailableOptions().then((_) => {}); + }); + } + } + private async loadAvailableEntities() { this.loading.next(true); diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index 3c7dab26d8..9f10f6d389 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -8,6 +8,7 @@ import { EventAttendanceMap } from "../../child-dev-project/attendance/model/eve import { LongTextDatatype } from "../basic-datatypes/string/long-text.datatype"; import { RecurringActivity } from "../../child-dev-project/attendance/model/recurring-activity"; import { EntityConfig } from "../entity/entity-config"; +import { ExternalProfileLinkConfig } from "../../features/skill/link-external-profile/external-profile-link-config"; // prettier-ignore export const defaultJsonConfig = { @@ -586,6 +587,14 @@ export const defaultJsonConfig = { fields: ["name", "projectNumber", "admissionDate"], header: $localize`:Header for form section:Personal Information` }, + { + fields: [ + "externalProfileMockResults", + "externalProfile", + "skills" + ], + header: "Skill API Integration (under development)" + }, { fields: [ "dateOfBirth", @@ -1061,6 +1070,54 @@ export const defaultJsonConfig = { additional: { acceptedFileTypes: ".pdf" } + }, + + externalProfile: { + label: "External SkillLab Profile", + dataType: "string", + editComponent: "EditExternalProfileLink", + additional: { + searchFields: { + fullName: [ + "name", + "fullName", + "firstName", + "lastName" + ], + email: ["email"], + phone: ["phone"] + } + } as ExternalProfileLinkConfig + }, + skills: { + dataType: "entity", + isArray: true, + additional: "Skill", + label: "Skills" + }, + externalProfileMockResults: { + dataType: "number", + label: "Mock Profiles", + description: "Select the number of mocked results for the external profile search in order to test different UX.", + defaultValue: { mode: "static", value: 2 } + } + } + } as EntityConfig, + "entity:Skill": { + toStringAttributes: ["name"], + toBlockDetailsAttributes: { title: "name", fields: ["description", "escoUri"] }, + attributes: { + escoUri: { + dataType: "string", + label: "ESCO URI" + }, + name: { + dataType: "string", + label: "Skill Name" + }, + description: { + dataType: "long-text", + label: "Description" } } } as EntityConfig, diff --git a/src/app/features/skill/esco-api.service.spec.ts b/src/app/features/skill/esco-api.service.spec.ts new file mode 100644 index 0000000000..b6c731382d --- /dev/null +++ b/src/app/features/skill/esco-api.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EscoApiService } from './esco-api.service'; + +describe('EscoApiService', () => { + let service: EscoApiService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EscoApiService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/skill/esco-api.service.ts b/src/app/features/skill/esco-api.service.ts new file mode 100644 index 0000000000..2f5964502b --- /dev/null +++ b/src/app/features/skill/esco-api.service.ts @@ -0,0 +1,57 @@ +import { inject, Injectable } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; + +interface EscoSkillResponseDto { + count: number; + language: string; + _embedded: Map; +} + +export interface EscoSkillDto { + className: string; + classId: string; + uri: string; + title: string; + referenceLanguage: string[]; + preferredLabel: { [key: string]: string[] }; + alternativeLabel: { [key: string]: string[] }; + description: { + [key: string]: { + literal: string; + mimetype: string; + }; + }; + status: string; +} + +@Injectable({ + providedIn: "root", +}) +export class EscoApiService { + private http: HttpClient = inject(HttpClient); + + getEscoSkill(uri: string): Observable { + return this.http + .get( + "https://ec.europa.eu/esco/api/resource/skill", + { + params: { + uris: uri, + }, + }, + ) + .pipe( + map((value) => { + let dto: EscoSkillDto | undefined = value._embedded[uri]; + + if (dto == undefined) { + throw new Error("Skill not found"); + } + + return dto; + }), + ); + } +} diff --git a/src/app/features/skill/external-profile.ts b/src/app/features/skill/external-profile.ts new file mode 100644 index 0000000000..90cee2c405 --- /dev/null +++ b/src/app/features/skill/external-profile.ts @@ -0,0 +1,30 @@ +/** + * Basic data structure representing a profile from an external system + * that can be linked and used to import additional information. + */ +export interface ExternalProfile { + id: string; + fullName: string; + phone: string; + email: string; + skills: ExternalSkill[]; + updatedAtExternalSystem: string; + importedAt: string; + latestSyncAt: string; +} + +/** + * Skill data in a profile from an external system. + */ +export interface ExternalSkill { + /** + * The URI representing a specific skill in the ESCO classification. + * see https://esco.ec.europa.eu/en/classification/skill_main + */ + escoUri: string; + + /** + * the frequency of using this skill + */ + usage: "ALMOST_NEVER" | "SOMETIMES" | "OFTEN" | "ALMOST_ALWAYS" | "ALWAYS"; +} diff --git a/src/app/features/skill/link-external-profile/edit-external-profile-link.component.html b/src/app/features/skill/link-external-profile/edit-external-profile-link.component.html new file mode 100644 index 0000000000..eee4888985 --- /dev/null +++ b/src/app/features/skill/link-external-profile/edit-external-profile-link.component.html @@ -0,0 +1,51 @@ +
+ @if (!formControl?.value) { + + } @else { +
+ + Linked to external profile +
+ +
+ + + +
+ } +
diff --git a/src/app/features/skill/link-external-profile/edit-external-profile-link.component.scss b/src/app/features/skill/link-external-profile/edit-external-profile-link.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/features/skill/link-external-profile/edit-external-profile-link.component.spec.ts b/src/app/features/skill/link-external-profile/edit-external-profile-link.component.spec.ts new file mode 100644 index 0000000000..8be1638d85 --- /dev/null +++ b/src/app/features/skill/link-external-profile/edit-external-profile-link.component.spec.ts @@ -0,0 +1,120 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { EditExternalProfileLinkComponent } from "./edit-external-profile-link.component"; +import { SkillApiService } from "../skill-api.service"; +import { FormControl, FormGroup } from "@angular/forms"; +import { Entity } from "../../../core/entity/model/entity"; +import { TestEntity } from "../../../utils/test-utils/TestEntity"; +import { MatDialog, MatDialogRef } from "@angular/material/dialog"; +import { Subject } from "rxjs"; +import { ExternalProfile } from "../external-profile"; + +describe("LinkExternalProfileComponent", () => { + let component: EditExternalProfileLinkComponent; + let fixture: ComponentFixture; + + let mockSkillApi: jasmine.SpyObj; + let mockDialog: jasmine.SpyObj; + let mockDialogResult: Subject; + let formControl: FormControl; + let entity: Entity; + + beforeEach(async () => { + formControl = new FormControl(); + entity = new TestEntity(); + + mockSkillApi = jasmine.createSpyObj("SkillApiService", [ + "getExternalProfiles", + "getExternalProfileById", + "getSkillsFromExternalProfile", + ]); + + mockDialog = jasmine.createSpyObj("MatDialog", ["open"]); + mockDialogResult = new Subject(); + mockDialog.open.and.returnValue({ + afterClosed: () => mockDialogResult.asObservable(), + } as MatDialogRef); + + await TestBed.configureTestingModule({ + imports: [EditExternalProfileLinkComponent], + providers: [ + { provide: SkillApiService, useValue: mockSkillApi }, + { provide: MatDialog, useValue: mockDialog }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditExternalProfileLinkComponent); + component = fixture.componentInstance; + + component.formControl = formControl; + component.parent = new FormGroup({ + testField: formControl, + skills: new FormControl(), + }); + component.entity = entity; + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should search external profiles and store externalId in form field", fakeAsync(() => { + const mockMatch: ExternalProfile = { id: "123" } as any; + + component.searchMatchingProfiles(); + mockDialogResult.next(mockMatch); + tick(); + + expect(mockDialog.open).toHaveBeenCalled(); + expect(component.externalProfile).toEqual(mockMatch); + expect(component.formControl.value).toEqual(mockMatch.id); + expect(component.formControl.dirty).toBeTrue(); + })); + + it("should not change value if dialog is aborted", fakeAsync(() => { + component.formControl.setValue("original-id"); + component.formControl.markAsPristine(); + + component.searchMatchingProfiles(); + mockDialogResult.next(undefined); + tick(); + + expect(mockDialog.open).toHaveBeenCalled(); + expect(component.formControl.value).toEqual("original-id"); + expect(component.formControl.dirty).toBeFalse(); + })); + + it("should empty value if user clicks 'unlink'", fakeAsync(() => { + component.formControl.setValue("original-id"); + component.formControl.markAsPristine(); + + component.unlinkExternalProfile(); + + expect(component.formControl.value).toEqual(null); + expect(component.formControl.dirty).toBeTrue(); + })); + + it("should update related form field from latest external entity if user clicks 'update data'", fakeAsync(() => { + const mockSkills = [new TestEntity(), new TestEntity()] as any; + mockSkillApi.getSkillsFromExternalProfile.and.resolveTo(mockSkills); + component.formControl.setValue("external-id"); + + component.updateExternalData(); + tick(); + + expect(mockSkillApi.getSkillsFromExternalProfile).toHaveBeenCalledWith( + "external-id", + ); + // TODO: implement actual logic and configurable target field + const targetFormControl = component.parent.get("skills"); + expect(targetFormControl.value).toEqual(mockSkills); + expect(targetFormControl.dirty).toBeTrue(); + })); +}); diff --git a/src/app/features/skill/link-external-profile/edit-external-profile-link.component.ts b/src/app/features/skill/link-external-profile/edit-external-profile-link.component.ts new file mode 100644 index 0000000000..32c502cd2a --- /dev/null +++ b/src/app/features/skill/link-external-profile/edit-external-profile-link.component.ts @@ -0,0 +1,91 @@ +import { Component, inject, signal, WritableSignal } from "@angular/core"; +import { MatButton } from "@angular/material/button"; +import { MatDialog } from "@angular/material/dialog"; +import { + LinkExternalProfileDialogComponent, + LinkExternalProfileDialogData, +} from "./link-external-profile-dialog/link-external-profile-dialog.component"; +import { ExternalProfile } from "../external-profile"; +import { EditComponent } from "../../../core/entity/default-datatype/edit-component"; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; +import { MatTooltip } from "@angular/material/tooltip"; +import { SkillApiService } from "../skill-api.service"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { ExternalProfileLinkConfig } from "./external-profile-link-config"; +import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; + +@Component({ + selector: "app-edit-external-profile-link", + standalone: true, + imports: [ + MatButton, + FaIconComponent, + MatTooltip, + FormsModule, + ReactiveFormsModule, + MatProgressSpinnerModule, + ], + templateUrl: "./edit-external-profile-link.component.html", + styleUrl: "./edit-external-profile-link.component.scss", +}) +export class EditExternalProfileLinkComponent extends EditComponent { + /** + * The configuration details for this external profile link, + * defined in the config field's `additional` property. + */ + declare additional: ExternalProfileLinkConfig; + + isLoading: WritableSignal = signal(false); + externalProfile: ExternalProfile | undefined; + + private dialog: MatDialog = inject(MatDialog); + private skillApi: SkillApiService = inject(SkillApiService); + + async searchMatchingProfiles() { + // TODO: should this only be enabled in "Edit" mode of form? + + this.dialog + .open(LinkExternalProfileDialogComponent, { + data: { + entity: this.entity, + config: this.additional, + } as LinkExternalProfileDialogData, + }) + .afterClosed() + .subscribe((result: ExternalProfile | undefined) => { + if (result) { + this.linkProfile(result); + } + }); + } + + unlinkExternalProfile() { + this.externalProfile = undefined; + this.formControl.setValue(null); + this.formControl.markAsDirty(); + } + + async updateExternalData() { + this.isLoading.set(true); + + if (!this.formControl.value) { + return; + } + + const skills = await this.skillApi.getSkillsFromExternalProfile( + this.formControl.value, + ); + + // TODO: run import / update + const targetFormControl = this.parent.get("skills"); + targetFormControl?.setValue(skills); + + this.isLoading.set(false); + } + + private linkProfile(externalProfile: ExternalProfile) { + this.externalProfile = externalProfile; + this.formControl.setValue(externalProfile.id); + this.formControl.markAsDirty(); + } +} diff --git a/src/app/features/skill/link-external-profile/edit-external-profile-link.stories.ts b/src/app/features/skill/link-external-profile/edit-external-profile-link.stories.ts new file mode 100644 index 0000000000..bc2ab7498d --- /dev/null +++ b/src/app/features/skill/link-external-profile/edit-external-profile-link.stories.ts @@ -0,0 +1,84 @@ +import { applicationConfig, Meta, StoryObj } from "@storybook/angular"; +import { EditExternalProfileLinkComponent } from "./edit-external-profile-link.component"; +import { importProvidersFrom } from "@angular/core"; +import { StorybookBaseModule } from "../../../utils/storybook-base.module"; +import { FormControl } from "@angular/forms"; +import { TestEntity } from "../../../utils/test-utils/TestEntity"; +import { ExternalProfileLinkConfig } from "./external-profile-link-config"; +import { SkillApiService } from "../skill-api.service"; +import { delay, of } from "rxjs"; +import { ExternalProfile } from "../external-profile"; + +const mockSkillApi = { + getExternalProfiles: () => + of([createDummyData("1"), createDummyData("2")]).pipe(delay(1000)), +}; + +function createDummyData(externalId: string): ExternalProfile { + return { + id: externalId, + fullName: "John Doe " + externalId, + phone: "+1234567890", + email: "john@example.com", + skills: [ + { + escoUri: + "http://data.europa.eu/esco/skill/0ac31705-79ff-4409-a818-c9d0a6388e84", + usage: "ALWAYS", + }, + { + escoUri: + "http://data.europa.eu/esco/skill/2e040fb0-66b9-4529-bec6-466472b60773", + usage: "OFTEN", + }, + ], + importedAt: "2021-01-01T00:00:00Z", + latestSyncAt: "2021-01-01T00:00:00Z", + updatedAtExternalSystem: "2021-01-01T00:00:00Z", + }; +} + +const meta: Meta = { + title: "Features/Skill Integration/EditExternalProfileLink", + component: EditExternalProfileLinkComponent, + decorators: [ + applicationConfig({ + providers: [ + importProvidersFrom(StorybookBaseModule), + { provide: SkillApiService, useValue: mockSkillApi }, + ], + }), + ], +}; + +export default meta; +type Story = StoryObj; + +export const FindProfile: Story = { + args: { + formControl: new FormControl(), + entity: TestEntity.create({ + name: "John Doe", + other: "john@example.com", + }), + additional: { + searchFields: { + fullName: ["name"], + email: ["other"], + }, + } as ExternalProfileLinkConfig, + }, +}; + +export const LinkedProfile: Story = { + args: { + formControl: new FormControl("123"), + entity: new TestEntity(), + additional: { + searchFields: { + name: ["name"], + email: ["other"], + }, + } as ExternalProfileLinkConfig, + }, +}; diff --git a/src/app/features/skill/link-external-profile/external-profile-link-config.ts b/src/app/features/skill/link-external-profile/external-profile-link-config.ts new file mode 100644 index 0000000000..0ec8a03012 --- /dev/null +++ b/src/app/features/skill/link-external-profile/external-profile-link-config.ts @@ -0,0 +1,13 @@ +/** + * Config object for the EditExternalProfileLink fields. + */ +export interface ExternalProfileLinkConfig { + /** + * The entity fields to use as default search values for the external profile lookup. + */ + searchFields: { + fullName?: string[]; + email?: string[]; + phone?: string[]; + }; +} diff --git a/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.html b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.html new file mode 100644 index 0000000000..21ca8de343 --- /dev/null +++ b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.html @@ -0,0 +1,116 @@ +

Match External Profile

+ + +
+

+ You can adjust the search parameters below manually to find the correct + profile on the external platform. +

+ +
+ + + + + + + + + + + + + + + + + + + +
+
+ + @if (loading) { +

Searching possibly matching profiles ...

+ + } @else if (error) { + @if (error.message) { +

+ {{ error.message }} +

+ } @else { +

+ There was an error loading external profiles. Please try again later. +

+ } + } @else { +

Possible Matching Profiles:

+

+ Please select one of the results below and confirm the linking or adjust + the search parameters above and search again. +

+ + + @for (profile of possibleMatches; track profile) { + + {{ profile.fullName }} ({{ profile.email }}) + + } + + } +
+ + + + + diff --git a/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.scss b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.scss new file mode 100644 index 0000000000..dfaf146e55 --- /dev/null +++ b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.scss @@ -0,0 +1,4 @@ + +.display-none { + display: none; +} diff --git a/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.spec.ts b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.spec.ts new file mode 100644 index 0000000000..2ee87b91cd --- /dev/null +++ b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.spec.ts @@ -0,0 +1,111 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { LinkExternalProfileDialogComponent } from "./link-external-profile-dialog.component"; +import { MAT_DIALOG_DATA } from "@angular/material/dialog"; +import { SkillApiService } from "../../skill-api.service"; +import { of } from "rxjs"; +import { ExternalProfile } from "../../external-profile"; + +describe("LinkExternalProfileDialogComponent", () => { + let component: LinkExternalProfileDialogComponent; + let fixture: ComponentFixture; + let mockSkillApi: jasmine.SpyObj; + + beforeEach(async () => { + mockSkillApi = jasmine.createSpyObj("SkillApiService", [ + "getExternalProfiles", + "getExternalProfileById", + ]); + mockSkillApi.getExternalProfiles.and.returnValue(of([])); + + await TestBed.configureTestingModule({ + imports: [LinkExternalProfileDialogComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + entity: {}, + }, + }, + { provide: SkillApiService, useValue: mockSkillApi }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(LinkExternalProfileDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should automatically search upon init, update loading status and show results", fakeAsync(() => { + const mockApiResults: ExternalProfile[] = [ + { id: "1", fullName: "match 1" } as Partial as any, + { id: "2", fullName: "match 2" } as Partial as any, + ]; + mockSkillApi.getExternalProfiles.and.returnValue(of(mockApiResults)); + + component.error = { message: "previous error" }; + component.selected = { id: "previous selected" } as any; + component.possibleMatches = [{ id: "previous result" } as any]; + + component.ngOnInit(); + expect(component.loading).toBeTrue(); + tick(); + + expect(component.loading).toBeFalse(); + expect(component.possibleMatches).toEqual( + mockApiResults.map((r) => jasmine.objectContaining(r)), + ); + expect(component.selected).toBeUndefined(); + expect(component.error).toBeUndefined(); + })); + + it("should show error but not throw if API request fails", fakeAsync(() => { + const mockError = new Error("API error"); + mockSkillApi.getExternalProfiles.and.throwError(mockError); + component.possibleMatches = [ + { id: "1", fullName: "previous result" } as any, + ]; + + component.searchMatches(); + tick(); + + expect(component.error).toEqual(mockError); + expect(component.loading).toBeFalse(); + expect(component.possibleMatches).toEqual([]); + })); + + it("should show 'no results' error if API returns empty", fakeAsync(() => { + mockSkillApi.getExternalProfiles.and.returnValue(of([])); + + component.searchMatches(); + tick(); + + expect(component.error).toEqual({ + message: "No matching external profiles found", + }); + expect(component.loading).toBeFalse(); + expect(component.possibleMatches).toEqual([]); + })); + + it("should automatically select if API returns only a single result", fakeAsync(() => { + const mockResult: ExternalProfile = { id: "1", fullName: "match 1" } as any; + mockSkillApi.getExternalProfiles.and.returnValue(of([mockResult])); + + component.searchMatches(); + tick(); + + expect(component.possibleMatches).toEqual([ + jasmine.objectContaining(mockResult), + ]); + expect(component.selected).toEqual(jasmine.objectContaining(mockResult)); + })); +}); diff --git a/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.ts b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.ts new file mode 100644 index 0000000000..d73bab78f8 --- /dev/null +++ b/src/app/features/skill/link-external-profile/link-external-profile-dialog/link-external-profile-dialog.component.ts @@ -0,0 +1,152 @@ +import { Component, inject, Inject, OnInit } from "@angular/core"; +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogTitle, +} from "@angular/material/dialog"; +import { MatButton, MatIconButton } from "@angular/material/button"; +import { ExternalProfile } from "../../external-profile"; +import { MatProgressBar } from "@angular/material/progress-bar"; +import { SkillApiService } from "../../skill-api.service"; +import { firstValueFrom } from "rxjs"; +import { Logging } from "../../../../core/logging/logging.service"; +import { MatRadioButton, MatRadioGroup } from "@angular/material/radio"; +import { FormsModule } from "@angular/forms"; +import { MatTooltip } from "@angular/material/tooltip"; +import { Entity } from "../../../../core/entity/model/entity"; +import { ExternalProfileLinkConfig } from "../external-profile-link-config"; +import { MatFormField, MatSuffix } from "@angular/material/form-field"; +import { MatInput } from "@angular/material/input"; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; + +/** + * The data passed to the MatDialog opening LinkExternalProfileDialogComponent. + */ +export interface LinkExternalProfileDialogData { + /** + * The entity object for which to search and select matching external profiles. + */ + entity: Entity; + + /** + * The configuration including details like search field mappings. + */ + config: ExternalProfileLinkConfig; +} + +@Component({ + selector: "app-link-external-profile-dialog", + standalone: true, + imports: [ + MatDialogContent, + MatDialogActions, + MatButton, + MatDialogClose, + MatDialogTitle, + MatProgressBar, + MatRadioGroup, + FormsModule, + MatRadioButton, + MatTooltip, + MatFormField, + MatInput, + MatIconButton, + FaIconComponent, + MatSuffix, + ], + templateUrl: "./link-external-profile-dialog.component.html", + styleUrl: "./link-external-profile-dialog.component.scss", +}) +export class LinkExternalProfileDialogComponent implements OnInit { + private skillApiService: SkillApiService = inject(SkillApiService); + config: ExternalProfileLinkConfig; + + entity: Entity; + searchParams: SearchParams; + + loading: boolean = true; + + possibleMatches: (ExternalProfile & { _tooltip?: string })[]; + selected: ExternalProfile; + error: { message?: string } & any; + + constructor(@Inject(MAT_DIALOG_DATA) data: LinkExternalProfileDialogData) { + this.entity = data.entity; + this.config = data.config; + } + + async ngOnInit() { + this.searchParams = this.getDefaultSearchParams(); + + await this.searchMatches(); + } + + async searchMatches() { + this.loading = true; + this.error = undefined; + this.selected = undefined; + this.possibleMatches = []; + + try { + this.possibleMatches = ( + await firstValueFrom( + this.skillApiService.getExternalProfiles( + this.searchParams.fullName, + this.searchParams.email, + this.searchParams.phone, + ), + ) + ).map((profile) => ({ + ...profile, + _tooltip: this.generateTooltip(profile), + })); + } catch (e) { + Logging.warn("SkillModule: Failed to load external profiles", e); + this.error = e; + return; + } finally { + this.loading = false; + } + + if (this.possibleMatches.length === 1) { + this.selected = this.possibleMatches[0]; + // TODO: automatically return if exactly one result? + } + if (this.possibleMatches.length === 0) { + this.error = { + message: $localize`:external profile matching dialog:No matching external profiles found`, + }; + } + } + + private getDefaultSearchParams(): SearchParams { + // TODO: + + return { + fullName: (this.config?.searchFields.fullName ?? []) + .map((field) => this.entity[field]) + .filter((value) => !!value) + .join(" "), + email: (this.config?.searchFields.email ?? []) + .map((field) => this.entity[field]) + .filter((value) => !!value) + .join(" "), + phone: (this.config?.searchFields.phone ?? []) + .map((field) => this.entity[field]) + .filter((value) => !!value) + .join(" "), + }; + } + + private generateTooltip(profile: ExternalProfile) { + return JSON.stringify(profile, null, 2); + } +} + +interface SearchParams { + fullName: string | undefined; + email: string | undefined; + phone: string | undefined; +} diff --git a/src/app/features/skill/skill-api.service.spec.ts b/src/app/features/skill/skill-api.service.spec.ts new file mode 100644 index 0000000000..4950f87751 --- /dev/null +++ b/src/app/features/skill/skill-api.service.spec.ts @@ -0,0 +1,24 @@ +import { TestBed } from "@angular/core/testing"; + +import { SkillApiService } from "./skill-api.service"; +import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service"; +import { mockEntityMapper } from "../../core/entity/entity-mapper/mock-entity-mapper-service"; +import { EntityRegistry } from "../../core/entity/database-entity.decorator"; + +describe("SkillApiService", () => { + let service: SkillApiService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: EntityMapperService, useValue: mockEntityMapper() }, + { provide: EntityRegistry, useValue: new EntityRegistry() }, + ], + }); + service = TestBed.inject(SkillApiService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/skill/skill-api.service.ts b/src/app/features/skill/skill-api.service.ts new file mode 100644 index 0000000000..6c21d4332d --- /dev/null +++ b/src/app/features/skill/skill-api.service.ts @@ -0,0 +1,97 @@ +import { inject, Injectable } from "@angular/core"; +import { firstValueFrom, Observable } from "rxjs"; +import { ExternalProfile } from "./external-profile"; +import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service"; +import { Entity } from "app/core/entity/model/entity"; +import { EntityRegistry } from "../../core/entity/database-entity.decorator"; +import { HttpClient } from "@angular/common/http"; +import { catchError, map } from "rxjs/operators"; +import { Logging } from "../../core/logging/logging.service"; +import { EscoApiService, EscoSkillDto } from "./esco-api.service"; + +interface UserProfileResponseDto { + result: ExternalProfile[]; +} + +/** + * Interaction with Aam Digital backend providing skills integration functionality. + */ +@Injectable({ + providedIn: "root", +}) +export class SkillApiService { + private entityMapper: EntityMapperService = inject(EntityMapperService); + private entityRegistry: EntityRegistry = inject(EntityRegistry); + private escoApi: EscoApiService = inject(EscoApiService); + private http: HttpClient = inject(HttpClient); + + getExternalProfiles( + searchName?: string, + searchEmail?: string, + searchPhone?: string, + ): Observable { + const requestParams = {}; + if (searchName) requestParams["fullName"] = searchName; + if (searchEmail) requestParams["email"] = searchEmail; + if (searchPhone) requestParams["phone"] = searchPhone; + + return this.http + .get("/api/v1/skill/user-profile", { + params: requestParams, + }) + .pipe(map((value) => value.result)); + } + + getExternalProfileById(externalId: string): Observable { + return this.http.get( + "/api/v1/skill/user-profile/" + externalId, + ); + } + + async getSkillsFromExternalProfile(externalId: string): Promise { + const profile = await firstValueFrom( + this.getExternalProfileById(externalId), + ); + + const skills: Entity[] = []; + for (const extSkill of profile.skills) { + const skill = await this.loadOrCreateSkill(extSkill.escoUri); + skills.push(skill); + } + + return skills.map((s) => s.getId()); + } + + private async loadOrCreateSkill(escoUri: string): Promise { + let entity = await this.entityMapper.load("Skill", escoUri).catch((e) => { + if (e.status === 404) { + return undefined; + } else { + throw e; + } + }); + + if (!entity) { + let escoDto: EscoSkillDto = await firstValueFrom( + this.escoApi.getEscoSkill(escoUri).pipe( + catchError((err, caught) => { + Logging.error(err); + // todo error handling? + return caught; + }), + ), + ); + + const ctor = this.entityRegistry.get("Skill"); + // TODO: load actual esco skill details from API + entity = Object.assign(new ctor(escoUri), { + escoUri: escoUri, + name: escoDto.title, + description: escoDto.description["en"].literal, // todo use current language and fallback to en + }); + await this.entityMapper.save(entity); + } + + return entity; + } +} diff --git a/src/app/features/skill/skill.module.ts b/src/app/features/skill/skill.module.ts new file mode 100644 index 0000000000..5a133b2190 --- /dev/null +++ b/src/app/features/skill/skill.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { AsyncComponent, ComponentRegistry } from "../../dynamic-components"; + +/** + * Integration with external Skill Tagging services via API. + */ +@NgModule({ + declarations: [], + imports: [CommonModule], +}) +export class SkillModule { + constructor(components: ComponentRegistry) { + components.addAll(dynamicComponents); + } +} + +const dynamicComponents: [string, AsyncComponent][] = [ + [ + "EditExternalProfileLink", + () => + import( + "./link-external-profile/edit-external-profile-link.component" + ).then((c) => c.EditExternalProfileLinkComponent), + ], +];