Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SkillLab integration #2687

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from
Draft
12 changes: 12 additions & 0 deletions .github/workflows/pull-request-update-or-push-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,7 +24,7 @@ const preview: Preview = {
},
},

tags: ["autodocs"]
tags: ["autodocs"],
};

export default preview;
6 changes: 6 additions & 0 deletions proxy.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"/db": ""
}
},
"/api": {
"target": "http://localhost:9000",
"secure": true,
"logLevel": "debug",
"changeOrigin": true
},
"/query": {
"target": "http://localhost:9000",
"secure": true,
Expand Down
2 changes: 2 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -132,6 +133,7 @@ import { TemplateExportModule } from "./features/template-export/template-export
TodosModule,
AdminModule,
TemplateExportModule,
SkillModule,
// top level component
UiComponent,
// Global Angular Material modules
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<T extends Entity = Entity>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -34,7 +34,6 @@ import { EntityRegistry } from "../../entity/database-entity.decorator";
ReactiveFormsModule,
MatAutocompleteModule,
MatChipsModule,
NgForOf,
EntityBlockComponent,
FontAwesomeModule,
MatTooltipModule,
Expand All @@ -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<T>;
Expand Down Expand Up @@ -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);

Expand Down
57 changes: 57 additions & 0 deletions src/app/core/config/config-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/app/features/skill/esco-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
57 changes: 57 additions & 0 deletions src/app/features/skill/esco-api.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, EscoSkillDto>;
}

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<EscoSkillDto> {
return this.http
.get<EscoSkillResponseDto>(
"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;
}),
);
}
}
30 changes: 30 additions & 0 deletions src/app/features/skill/external-profile.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<div class="margin-bottom-regular flex-column gap-small">
@if (!formControl?.value) {
<button
mat-stroked-button
matTooltip="Search and link a profile from an external system with this record to load additional data automatically."
i18n-matTooltip
i18n
(click)="searchMatchingProfiles()"
>
Link external profile
</button>
} @else {
<div>
<fa-icon
icon="person-walking-arrow-right"
class="standard-icon-with-text"
></fa-icon>
<span
i18n
matTooltip="external ID: {{ formControl?.value }}"
i18n-matTooltip
>Linked to external profile</span
>
</div>

<div class="flex-row gap-small">
<button mat-stroked-button i18n (click)="unlinkExternalProfile()">
Unlink
</button>

<button
mat-stroked-button
class="flex-grow"
i18n
matTooltip="Load the latest linked external profile and update the related fields of this record with the external data."
i18n-matTooltip
(click)="updateExternalData()"
[disabled]="isLoading()"
>
@if (isLoading()) {
<div style="display: flex">
<label>loading data...</label>
<mat-spinner diameter="18" style="margin-left: 12px"></mat-spinner>
</div>
} @else {
Update data
}
</button>
</div>
}
</div>
Loading
Loading