From 85a7c0614b7b6eaddc89bfec3fe8b2d6618f7a07 Mon Sep 17 00:00:00 2001 From: Aaron James Arterburn Date: Thu, 16 Mar 2023 08:46:17 -0700 Subject: [PATCH] Refactored Search Select Form Fields to fix bugs caused by FieldControl being the control attached to search text input not selected values. --- ui/angular.json | 4 +- .../abstract-form-field.component.spec.ts | 146 ++++++++++ .../app/form/abstract-form-field.component.ts | 67 +++++ ...m-field-search-multi-select.component.html | 92 +++++++ ...ield-search-multi-select.component.spec.ts | 146 ++++++++++ ...orm-field-search-multi-select.component.ts | 61 +++++ ...form-field-search-select.component.spec.ts | 186 +++++++++++++ ...ract-form-field-search-select.component.ts | 142 +++++++--- ...-field-search-single-select.component.html | 75 +++++ ...eld-search-single-select.component.spec.ts | 132 +++++++++ ...rm-field-search-single-select.component.ts | 55 ++++ .../form/form-field-search-select/index.ts | 8 +- ...field-search-select-jobcode.component.html | 104 ------- ...ld-search-select-jobcode.component.spec.ts | 255 ----------------- ...m-field-search-select-jobcode.component.ts | 101 ------- .../jobcode-single-select.component.html | 1 - .../jobcode-single-select.component.spec.ts | 62 ----- .../jobcode-single-select.component.ts | 40 --- ...code-search-multi-select.component.spec.ts | 106 ++++++++ ...d-jobcode-search-multi-select.component.ts | 39 +++ ...ld-jobcode-search-select.component.spec.ts | 107 ++++++++ ...m-field-jobcode-search-select.component.ts | 40 +++ ...ld-jobcode-search-select.utilities.spec.ts | 41 +++ ...m-field-jobcode-search-select.utilities.ts | 21 ++ ...word-search-multi-select.component.spec.ts | 121 +++++++++ ...d-keyword-search-multi-select.component.ts | 79 ++++++ ...ld-keyword-search-select.component.spec.ts | 121 +++++++++ ...m-field-keyword-search-select.component.ts | 79 ++++++ ...ld-keyword-search-select.utilities.spec.ts | 163 +++++++++++ ...m-field-keyword-search-select.utilities.ts | 167 ++++++++++++ ...m-field-search-multi-select.component.html | 106 -------- ...orm-field-search-multi-select.component.ts | 67 ----- .../form-field-search-select.component.html | 87 ------ .../form-field-search-select.component.ts | 23 -- ui/src/app/form/form.module.ts | 26 +- ui/src/app/richskill/ApiSkill.spec.ts | 12 +- ui/src/app/richskill/ApiSkill.ts | 16 +- .../form/rich-skill-form.component.html | 42 +-- .../form/rich-skill-form.component.spec.ts | 256 +++++++----------- .../form/rich-skill-form.component.ts | 223 +++++++-------- .../advanced-search.component.html | 33 ++- .../advanced-search.component.spec.ts | 51 ++-- .../advanced-search.component.ts | 75 +++-- ui/src/styles.css | 35 +++ ui/test/resource/mock-data.ts | 2 +- 45 files changed, 2509 insertions(+), 1306 deletions(-) create mode 100644 ui/src/app/form/abstract-form-field.component.spec.ts create mode 100644 ui/src/app/form/abstract-form-field.component.ts create mode 100644 ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.html create mode 100644 ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.ts create mode 100644 ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.html create mode 100644 ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.ts delete mode 100644 ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.html delete mode 100644 ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.spec.ts delete mode 100644 ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.ts delete mode 100644 ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.html delete mode 100644 ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.spec.ts delete mode 100644 ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.ts create mode 100644 ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.ts create mode 100644 ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.ts create mode 100644 ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.ts create mode 100644 ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.ts create mode 100644 ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.ts create mode 100644 ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.spec.ts create mode 100644 ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.ts delete mode 100644 ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.html delete mode 100644 ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts delete mode 100644 ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.html delete mode 100644 ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.ts diff --git a/ui/angular.json b/ui/angular.json index daad105a5..b4849911e 100644 --- a/ui/angular.json +++ b/ui/angular.json @@ -54,8 +54,8 @@ } ], "styles": [ - "src/styles.css", - "node_modules/@concentricsky/wgu-design-system-patternlibrary/dist/css/screen.css" + "node_modules/@concentricsky/wgu-design-system-patternlibrary/dist/css/screen.css", + "src/styles.css" ], "scripts": [] }, diff --git a/ui/src/app/form/abstract-form-field.component.spec.ts b/ui/src/app/form/abstract-form-field.component.spec.ts new file mode 100644 index 000000000..a30d66237 --- /dev/null +++ b/ui/src/app/form/abstract-form-field.component.spec.ts @@ -0,0 +1,146 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {FormControl, Validators} from "@angular/forms" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../test/resource/mock-stubs" +import {AbstractFormField} from "./abstract-form-field.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends AbstractFormField { + get emptyValue() { return null } + + execProtected = { + setControlValue: (value: ITestValueType|null, emitEvent: boolean = true) => this.setControlValue(value, emitEvent), + handleValueChange: (newValue: string) => this.handleValueChange(newValue), + onValueChange: (newValue: string) => this.onValueChange(newValue) + } +} + +interface ITestValueType { + value : string +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: ITestValueType + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("AbstractFormFieldComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + AbstractFormField, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = { value: "abc-123" } as ITestValueType + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("get isError should return correct value", () => { + component.control = new FormControl(testResult1, Validators.required) + component.controlValue = component.emptyValue + expect(component.isError).toEqual(true) + component.controlValue = testResult1 + expect(component.isError).toEqual(false) + }) + + it("get isRequired should return correct value", () => { + expect(component.isRequired).toEqual(false) + component.required = true + expect(component.isRequired).toEqual(true) + }) + + it("get controlValue should return correct value", () => { + expect(component.controlValue).toEqual(component.emptyValue) + component.controlValue = testResult1 + expect(component.controlValue).toEqual(testResult1) + }) + + it("set controlValue should succeed", () => { + expect(component.controlValue === component.emptyValue).toEqual(true) + component.controlValue = testResult1 + expect(component.controlValue).toEqual(testResult1) + }) + + it("clearValue should succeed", () => { + component.controlValue = testResult1 + expect(component.controlValue).toEqual(testResult1) + component.clearValue() + expect(component.controlValue === component.emptyValue).toEqual(true) + }) + + it("clearField should succeed", () => { + component.controlValue = testResult1 + expect(component.controlValue).toEqual(testResult1) + component.clearField() + expect(component.controlValue === component.emptyValue).toEqual(true) + }) + + it("setControlValue should succeed", () => { + component.execProtected.setControlValue(testResult1, true) + expect(component.controlValue).toEqual(testResult1) + }) + + it("handleControlValue should succeed", () => { + expect((() => { + component.execProtected.handleValueChange(testResult1.value) + return true + })()).toEqual(true) + }) + + it("onValueChange should succeed", () => { + expect((() => { + component.execProtected.onValueChange(testResult1.value) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/abstract-form-field.component.ts b/ui/src/app/form/abstract-form-field.component.ts new file mode 100644 index 000000000..e63eb0e80 --- /dev/null +++ b/ui/src/app/form/abstract-form-field.component.ts @@ -0,0 +1,67 @@ +import {Component, Input, OnInit} from "@angular/core" +import {FormControl} from "@angular/forms" + +@Component({ + selector: "app-abstract-form-field", + template: "" +}) +export abstract class AbstractFormField implements OnInit { + + @Input() control: FormControl = new FormControl(null) + @Input() label: string = "" + @Input() placeholder: string = "" + @Input() includePlaceholder: boolean = true + @Input() errorMessage: string = "" + @Input() helpMessage: string = "" + @Input() required: boolean = false + @Input() name: string = "" + + abstract get emptyValue(): TValue + + constructor() { + this.clearField() + } + + ngOnInit(): void { + this.control.valueChanges.subscribe((v: string) => this.onValueChange(v) ) + } + + get isError(): boolean { + return this.control && (this.control.dirty || this.control.touched) && this.control.invalid + } + + get isRequired(): boolean { + return this.required + } + + get controlValue(): TValue { + return this.control.value as TValue ?? this.emptyValue + } + + set controlValue(value: TValue|null) { + this.setControlValue(value, true) + this.control.markAsDirty() + this.control.markAsTouched() + } + + clearValue(): void { + this.controlValue = this.emptyValue + } + + clearField(): void { + this.clearValue() + this.control.reset() + } + + protected setControlValue(value: TValue|null, emitEvent: boolean = true) { + this.control.setValue(value ?? null, { emitEvent: emitEvent }) + } + + protected handleValueChange(newValue: string): void { + return + } + + protected onValueChange(newValue: string): void { + this.handleValueChange(newValue) + } +} diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.html b/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.html new file mode 100644 index 000000000..683123559 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.html @@ -0,0 +1,92 @@ +
+ + +

{{helpMessage}}

+
+
+ + +
+
+
+

+ Loading… + +

+
+
+
+

{{errorMessage}}

+
+ + +
+ + + +
+
+ +
+
Showing search results:
+ +
+ + + +
+

+ No Results +

+
+
+
+
+
+ +
+
+
diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.spec.ts b/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.spec.ts new file mode 100644 index 000000000..ae5a83a40 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.spec.ts @@ -0,0 +1,146 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {Observable, of} from "rxjs" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../../test/resource/mock-stubs" +import {AbstractFormFieldSearchMultiSelect} from "./abstract-form-field-search-multi-select.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends AbstractFormFieldSearchMultiSelect { + constructor(searchService: KeywordSearchService) { super(searchService) } + areSearchResultsEqual(result1: ITestValueType, result2: ITestValueType): boolean { return true } + isSearchResultType(result: ITestValueType): result is ITestValueType { return true } + labelFor(result: ITestValueType): string|null { return result.value } + protected callSearchService(text: string): Observable { return of([]) } + protected searchResultFromString(value: string): ITestValueType|undefined { return { value: value } as ITestValueType } + + execProtected = { + handleClearSearchClicked: () => this.handleClearSearchClicked(), + handleSearchResultClicked: (result: ITestValueType) => this.handleSearchResultClicked(result), + handleSelectedResultClicked: (result: ITestValueType) => this.handleSelectedResultClicked(result) + } +} + +interface ITestValueType { + value : string +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: ITestValueType + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("AbstractFormFieldSearchMultiSelectComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + AbstractFormFieldSearchMultiSelect, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = { value: "abc-123" } as ITestValueType + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("get selectedResults should return correct result", () => { + expect(component.selectedResults).toEqual([]) + component.selectResult(testResult1) + expect(component.selectedResults).toEqual([testResult1]) + }) + + it("isResultSelected should return correct result", () => { + expect(component.isResultSelected(testResult1)).toEqual(false) + component.selectResult(testResult1) + expect(component.isResultSelected(testResult1)).toEqual(true) + }) + + it("selectResult should succeed", () => { + expect(component.isResultSelected(testResult1)).toEqual(false) + component.selectResult(testResult1) + expect(component.isResultSelected(testResult1)).toEqual(true) + }) + + it("unselectResult should succeed", () => { + expect(component.isResultSelected(testResult1)).toEqual(false) + component.selectResult(testResult1) + expect(component.isResultSelected(testResult1)).toEqual(true) + component.unselectResult(testResult1) + expect(component.isResultSelected(testResult1)).toEqual(false) + }) + + it("onSelectedResultClicked should succeed", () => { + expect((() => { + component.onSelectedResultClicked(testResult1) + return true + })()).toEqual(true) + }) + + it("handleClearSearchClicked should succeed", () => { + expect((() => { + component.execProtected.handleClearSearchClicked() + return true + })()).toEqual(true) + }) + + it("handleSearchResultClicked should succeed", () => { + expect((() => { + component.execProtected.handleSearchResultClicked(testResult1) + return true + })()).toEqual(true) + }) + + it("handleSelectedResultClicked should succeed", () => { + expect((() => { + component.execProtected.handleSelectedResultClicked(testResult1) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.ts b/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.ts new file mode 100644 index 000000000..64ec29040 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-multi-select.component.ts @@ -0,0 +1,61 @@ +import {Component} from "@angular/core" +import {Observable} from "rxjs" +import {AbstractFormFieldSearchSelect} from "./abstract-form-field-search-select.component" + +@Component({ + selector: "app-abstract-form-field-search-multi-select", + template: "./abstract-form-field-search-multi-select.component.html" +}) +export abstract class AbstractFormFieldSearchMultiSelect + extends AbstractFormFieldSearchSelect { + + abstract areSearchResultsEqual(result1: TValue, result2: TValue): boolean + abstract isSearchResultType(result: any): result is TValue + abstract labelFor(result: TValue): string|null + protected abstract callSearchService(text: string): Observable + protected abstract searchResultFromString(value: string): TValue|undefined + + get selectedResults(): TValue[] { + return this.controlValue?.map(r => r) ?? [] + } + + isResultSelected(result: TValue): boolean { + if (this.isSearchResultType(result)) { + return !!(this.controlValue?.find((r: TValue) => this.areSearchResultsEqual(r, result))) + } + + return false + } + + selectResult(result: TValue): void { + if (this.isSearchResultType(result) && !this.isResultSelected(result)){ + let selected: TValue[] = this.controlValue ?? [] + selected.push(result) + this.controlValue = selected + this.clearSearch() + } + } + + unselectResult(result: TValue): void { + if (this.isSearchResultType(result)){ + let selected = this.controlValue?.filter((r: TValue) => !this.areSearchResultsEqual(r, result)) ?? [] + this.controlValue = (selected.length > 0) ? selected : null + } + } + + onSelectedResultClicked(result: TValue): void { + this.handleSelectedResultClicked(result) + } + + protected handleClearSearchClicked(): void { + this.clearSearch() + } + + protected handleSearchResultClicked(result: TValue): void { + this.selectResult(result) + } + + protected handleSelectedResultClicked(result: TValue): void { + this.unselectResult(result) + } +} diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.spec.ts b/ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.spec.ts new file mode 100644 index 000000000..a03a48bba --- /dev/null +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.spec.ts @@ -0,0 +1,186 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {Observable, of} from "rxjs" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../../test/resource/mock-stubs" +import {AbstractFormFieldSearchSelect} from "./abstract-form-field-search-select.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends AbstractFormFieldSearchSelect { + constructor(searchService: KeywordSearchService) { super(searchService) } + areSearchResultsEqual(result1: ITestValueType, result2: ITestValueType): boolean { return result1?.value === result2?.value } + isSearchResultType(result: ITestValueType): result is ITestValueType { return true } + labelFor(result: ITestValueType): string|null { return result?.value ?? null } + protected callSearchService(text: string): Observable { return of([{ value: "abc-123" } as ITestValueType]) } + protected handleClearSearchClicked(): void { return } + protected handleSearchValueChange(newValue: string): void { return } + protected searchResultFromString(value: string): ITestValueType|undefined { return { value: value } as ITestValueType } + + execProtected = { + setSearchControlValue: (value: string|null, emitEvent: boolean = true) => this.setSearchControlValue(value, emitEvent), + performSearch: (text: string) => this.performSearch(text), + selectSearchValue: () => this.selectSearchValue(), + onSearchValueChange: (newValue: string) => this.onSearchValueChange(newValue), + handleSearchValueChange: (newValue: string) => this.handleSearchValueChange(newValue) + } +} + +interface ITestValueType { + value : string +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: ITestValueType + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("AbstractFormFieldSearchSelectComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + AbstractFormFieldSearchSelect, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = { value: "abc-123" } as ITestValueType + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("get isSearchDirty should return correct result", () => { + expect(component.isSearchDirty).toEqual(false) + component.searchControlValue =testResult1.value + expect(component.isSearchDirty).toEqual(true) + }) + + it("get isSearchEmpty should return correct result", () => { + expect(component.isSearchEmpty).toBeTruthy() + component.searchControlValue = testResult1.value + expect(component.isSearchEmpty).toBeFalsy() + }) + + it("get searchControlValue should return corrrect result", () => { + expect(component.searchControlValue).toEqual("") + component.searchControlValue = testResult1.value + expect(component.searchControlValue).toEqual(testResult1.value) + }) + + it("get searchControlResultValue should return correct result", () => { + expect(component.searchControlResultValue).toEqual(undefined) + component.searchControlValue = testResult1.value + component.results = [testResult1] + expect(component.searchControlResultValue).toEqual(testResult1) + }) + + it("get showResults should return correct result", () => { + expect(component.showResults).toEqual(false) + component.searchControlValue = testResult1.value + component.results = [testResult1] + expect(component.showResults).toEqual(true) + }) + + it("clearSearch should succeed", () => { + component.searchControlValue = testResult1.value + component.results = [testResult1] + component.clearSearch() + expect(component.searchControlValue).toEqual("") + expect(component.results === undefined).toEqual(true) + }) + + it("clearSearchResults should succeed", () => { + component.results = [testResult1] + component.clearSearchResults() + expect(component.results === undefined).toEqual(true) + }) + + it("makeHtmlId should return correct result", () => { + expect(component.makeHtmlId(testResult1)).toEqual("abc123") + }) + + it("performSearch not return", () => { + component.execProtected.performSearch(testResult1.value) + expect(component.results?.length).toEqual(1) + }) + + it("selectSearchValue should succeed", () => { + expect((() => { + component.execProtected.selectSearchValue() + return true + })()).toEqual(true) + }) + + it("onClearSearchClicked should succeed", () => { + expect((() => { + component.onClearSearchClicked() + return true + })()).toEqual(true) + }) + + it("onEnterKeyDown should succeed", () => { + expect((() => { + component.onEnterKeyDown(null) + return true + })()).toEqual(true) + }) + + it("onEnterKeyDown should succeed", () => { + expect((() => { + component.onEnterKeyDown(null) + return true + })()).toEqual(true) + }) + + it("handleSearchValueChange should succeed", () => { + expect((() => { + component.execProtected.handleSearchValueChange(testResult1.value) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.ts b/ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.ts index dd0c0eb8f..478c46df6 100644 --- a/ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.ts +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-select.component.ts @@ -1,17 +1,20 @@ import {Component, Input, OnDestroy, OnInit} from "@angular/core" -import {KeywordType} from "../../richskill/ApiSkill" +import {FormControl} from "@angular/forms" import {SvgHelper, SvgIcon} from "../../core/SvgHelper" -import {Subscription} from "rxjs" +import {Observable, Subscription} from "rxjs" +import {AbstractFormField} from "../abstract-form-field.component" import {KeywordSearchService} from "../../richskill/service/keyword-search.service" -import {FormField} from "../form-field.component" @Component({ selector: "app-abstract-form-field-search-select", - template: `` + template: "" }) -export abstract class AbstractFormFieldSearchSelectComponent extends FormField implements OnInit, OnDestroy { +export abstract class AbstractFormFieldSearchSelect + extends AbstractFormField implements OnInit, OnDestroy { - @Input() keywordType!: KeywordType + @Input() createNonExisting = false + + searchControl: FormControl = new FormControl("") iconSearch = SvgHelper.path(SvgIcon.SEARCH) iconDismiss = SvgHelper.path(SvgIcon.DISMISS) @@ -19,35 +22,84 @@ export abstract class AbstractFormFieldSearchSelectComponent extends FormField i queryInProgress!: Subscription currentlyLoading = false - results!: string[] | undefined + results!: TSearch[] | undefined + + protected searchService: KeywordSearchService - protected constructor( - protected searchService: KeywordSearchService - ) { + constructor(searchService: KeywordSearchService) { super() + this.searchService = searchService } - ngOnInit(): void { - this.control.valueChanges.subscribe(next => { this.performSearch(next) }) + ngOnInit() { + this.searchControl.valueChanges.subscribe((v: string) => this.onSearchValueChange(v) ) } - ngOnDestroy(): void { + ngOnDestroy() { this.queryInProgress?.unsubscribe() } - abstract selectResult(result: string): void - abstract isResultSelected(result: string): boolean + abstract areSearchResultsEqual(result1: TSearch, result2: TSearch): boolean + abstract isResultSelected(result: TSearch): void + abstract isSearchResultType(result: any): result is TSearch + abstract labelFor(result: TSearch): string|null + abstract selectResult(result: TSearch): void + protected abstract callSearchService(text: string): Observable + protected abstract handleClearSearchClicked(): void + protected abstract handleSearchResultClicked(result: TSearch): void + protected abstract searchResultFromString(value: string): TSearch|undefined + + get emptyValue(): TValue|null { return null } + + get isSearchDirty(): boolean { + return this.searchControl.dirty + } + + get isSearchEmpty(): boolean { + return this.searchControlValue.length <= 0 + } + + get searchControlValue(): string { + return this.searchControl.value?.trim() ?? "" + } + + set searchControlValue(value: string) { + this.setSearchControlValue(value, true) + this.searchControl.markAsDirty() + this.searchControl.markAsTouched() + } + + get searchControlResultValue(): TSearch | undefined { + const searchResult = this.searchResultFromString(this.searchControlValue) + + if (searchResult) { + const existingResult =this.results?.find((r: TSearch) => this.areSearchResultsEqual(r, searchResult)) + return existingResult ?? (this.createNonExisting) ? searchResult : undefined + } - get currentCategory(): string { - return this.control.value + return undefined } - makeHtmlId(r: string): string { - return r.replace(new RegExp("\\W"), "") + get showResults(): boolean { + return this.isSearchDirty && !this.isSearchEmpty && this.results !== undefined + } + + clearSearch(): void { + this.clearSearchResults() + this.setSearchControlValue("", false) + this.searchControl.markAsPristine() } - performSearch(text: string): void { - if (!text || !this.keywordType) { + clearSearchResults(): void { + this.results = undefined + } + + makeHtmlId(result: TSearch): string { + return this.labelFor(result)?.replace(new RegExp("\\W"), "") ?? "" + } + + protected performSearch(text: string): void { + if (!text) { return // no search to perform } this.currentlyLoading = true @@ -56,22 +108,48 @@ export abstract class AbstractFormFieldSearchSelectComponent extends FormField i this.queryInProgress.unsubscribe() // unsub to existing query first } - this.queryInProgress = this.searchService.searchKeywords(this.keywordType, text) - .subscribe(searchResults => { - this.results = searchResults.filter(r => !!r && !!r.name).map(r => r.name as string) - this.currentlyLoading = false - }) + this.queryInProgress = this.callSearchService(text).subscribe(searchResults => { + this.results = searchResults + this.currentlyLoading = false + }) } - onEnterKeyDown(event: Event): boolean { + protected selectSearchValue() { + let searchResult = this.searchControlResultValue + searchResult = searchResult ?? (this.createNonExisting) + ? this.searchResultFromString(this.searchControlValue) : undefined + + if (searchResult) { + this.selectResult(searchResult) + this.clearSearchResults() + } else if (this.createNonExisting) { + this.clearValue() + this.clearSearchResults() + } + } + + protected setSearchControlValue(value: string|null, emitEvent: boolean = true) { + this.searchControl.setValue(value ?? "", { emitEvent: emitEvent }) + } + + onClearSearchClicked(): void { + this.handleClearSearchClicked() + } + + onEnterKeyDown(event: any): boolean { + this.selectSearchValue() return false } - onEnterKeyup(event: Event): void { - this.results = undefined + + onSearchResultClicked(result: TSearch): void { + this.handleSearchResultClicked(result) } - get showResults(): boolean { - const isEmpty = this.valueFromControl?.trim()?.length <= 0 - return !isEmpty && this.results !== undefined + protected onSearchValueChange(newValue: string): void { + this.handleSearchValueChange(newValue) + } + + protected handleSearchValueChange(newValue: string): void { + this.performSearch(newValue) } } diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.html b/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.html new file mode 100644 index 000000000..d83d680f5 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.html @@ -0,0 +1,75 @@ +
+ + +

{{helpMessage}}

+
+
+ + +
+
+
+

+ Loading… + +

+
+
+
+

{{errorMessage}}

+ + + +
+
+ + +
+
Showing search results:
+ +
+ + + +
+

+ No Results +

+
+
+
+
+
+
+
+
diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.spec.ts b/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.spec.ts new file mode 100644 index 000000000..9ffc5d976 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.spec.ts @@ -0,0 +1,132 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {Observable, of} from "rxjs" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../../test/resource/mock-stubs" +import {AbstractFormFieldSearchSingleSelect} from "./abstract-form-field-search-single-select.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends AbstractFormFieldSearchSingleSelect { + constructor(searchService: KeywordSearchService) { super(searchService) } + areSearchResultsEqual(result1: ITestValueType, result2: ITestValueType): boolean { return result1?.value === result2?.value } + isSearchResultType(result: ITestValueType): result is ITestValueType { return true } + labelFor(result: ITestValueType): string|null { return result?.value ?? null } + protected callSearchService(text: string): Observable { return of([]) } + protected searchResultFromString(value: string): ITestValueType|undefined { return { value: value } as ITestValueType } + + execProtected = { + setSearchToValue: () => this.setSearchToValue(), + handleClearSearchClicked: () => this.handleClearSearchClicked(), + handleSearchResultClicked: (result: ITestValueType) => this.handleSearchResultClicked(result), + handleSearchValueChange: (newValue: string) => this.handleSearchValueChange(newValue) + } +} + +interface ITestValueType { + value : string +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: ITestValueType + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("AbstractFormFieldSearchSingleSelectComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + AbstractFormFieldSearchSingleSelect, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = { value: "abc-123" } as ITestValueType + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("isResultSelected should return correct result", () => { + expect(component.isResultSelected(testResult1)).toEqual(false) + component.selectResult(testResult1) + expect(component.isResultSelected(testResult1)).toEqual(true) + }) + + it("selectResult should succeed", () => { + expect(component.isResultSelected(testResult1)).toEqual(false) + component.selectResult(testResult1) + expect(component.isResultSelected(testResult1)).toEqual(true) + }) + + it("setSearchToValue should succeed", () => { + component.searchControlValue = "" + component.selectResult(testResult1) + component.execProtected.setSearchToValue() + expect(component.searchControlValue).toEqual(testResult1.value) + }) + + it("handleClearSearchClicked should succeed", () => { + component.selectResult(testResult1) + component.execProtected.handleClearSearchClicked() + expect(component.controlValue === null).toEqual(true) + }) + + it("handleSearchResultClicked should succeed", () => { + expect((() => { + component.execProtected.handleSearchResultClicked(testResult1) + return true + })()).toEqual(true) + }) + + it("handleSearchValueChange should succeed", () => { + expect((() => { + component.execProtected.handleSearchValueChange(testResult1.value) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.ts b/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.ts new file mode 100644 index 000000000..94a07a2c9 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/abstract-form-field-search-single-select.component.ts @@ -0,0 +1,55 @@ +import {Component, OnInit} from "@angular/core" +import {Observable} from "rxjs" +import {AbstractFormFieldSearchSelect} from "./abstract-form-field-search-select.component" + +@Component({ + selector: "app-abstract-form-field-search-single-select", + template: "./abstract-form-field-search-single-select.component.html" +}) +export abstract class AbstractFormFieldSearchSingleSelect + extends AbstractFormFieldSearchSelect implements OnInit { + + ngOnInit() { + super.ngOnInit(); + this.setSearchToValue() + } + + abstract areSearchResultsEqual(result1: TValue, result2: TValue): boolean + abstract isSearchResultType(result: any): result is TValue + abstract labelFor(result: TValue): string|null + protected abstract callSearchService(text: string): Observable + protected abstract searchResultFromString(value: string): TValue|undefined + + isResultSelected(result: TValue): boolean { + if (this.isSearchResultType(result) && this.isSearchResultType(this.controlValue)) { + return this.areSearchResultsEqual(result, this.controlValue) + } + + return false + } + + selectResult(result: TValue): void { + this.controlValue = result + this.setSearchToValue() + } + + protected setSearchToValue() { + const search = (this.controlValue) ? this.labelFor(this.controlValue) : "" + this.setSearchControlValue(search, false) + } + + protected handleClearSearchClicked(): void { + this.clearValue() + this.clearSearch() + } + + protected handleSearchResultClicked(result: TValue) { + this.selectResult(result) + this.clearSearchResults() + } + + protected handleSearchValueChange(newValue: string): void { + this.selectSearchValue() + super.handleSearchValueChange(newValue) + } +} diff --git a/ui/src/app/form/form-field-search-select/index.ts b/ui/src/app/form/form-field-search-select/index.ts index de5babb99..cfa19c083 100644 --- a/ui/src/app/form/form-field-search-select/index.ts +++ b/ui/src/app/form/form-field-search-select/index.ts @@ -1,4 +1,4 @@ -export * from "./jobcode-select/form-field-search-select-jobcode.component" -export * from "./jobcode-single-select/jobcode-single-select.component" -export * from "./mulit-select/form-field-search-multi-select.component" -export * from "./single-select/form-field-search-select.component" +export {FormFieldJobCodeSearchSelect} from "./jobcode/form-field-jobcode-search-select.component"; +export {FormFieldJobCodeSearchMultiSelect} from "./jobcode/form-field-jobcode-search-multi-select.component" +export {FormFieldKeywordSearchSelect} from "./keyword/form-field-keyword-search-select.component" +export {FormFieldKeywordSearchMultiSelect} from "./keyword/form-field-keyword-search-multi-select.component" diff --git a/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.html b/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.html deleted file mode 100644 index acf832f5e..000000000 --- a/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.html +++ /dev/null @@ -1,104 +0,0 @@ - -
- - -
-
-
-

- Loading… - -

-
-
-
- - - -
-
- - -
-
Showing search results:
- -
- - - -
-

- No Results -

-
-
-
-
-
- - - - -
-
-
-
diff --git a/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.spec.ts b/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.spec.ts deleted file mode 100644 index ce80b8114..000000000 --- a/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.spec.ts +++ /dev/null @@ -1,255 +0,0 @@ -// noinspection LocalVariableNamingConventionJS - -import { HttpClientTestingModule } from "@angular/common/http/testing" -import { Component, Type } from "@angular/core" -import { async, ComponentFixture, TestBed } from "@angular/core/testing" -import { FormsModule, ReactiveFormsModule } from "@angular/forms" -import { By } from "@angular/platform-browser" -import { ActivatedRoute, Router } from "@angular/router" -import { RouterTestingModule } from "@angular/router/testing" -import { first } from "rxjs/operators" -import { AppConfig } from "src/app/app.config" -import { EnvironmentService } from "src/app/core/environment.service" -import { ActivatedRouteStubSpec } from "test/util/activated-route-stub.spec" -import { createMockJobcode } from "../../../../../test/resource/mock-data" -import { KeywordSearchServiceStub } from "../../../../../test/resource/mock-stubs" -import { ApiJobCode } from "../../../job-codes/Jobcode" -import { KeywordSearchService } from "../../../richskill/service/keyword-search.service" -import { FormFieldSearchSelectJobcodeComponent } from "./form-field-search-select-jobcode.component" - - -let hostSelectedCodes: string[] | undefined = [] - -@Component({ - template: ` - ` -}) -class TestHostComponent { - myExisting = [] - - handleOutput(codes: string[]): void { - hostSelectedCodes = codes - } -} - - -export function createComponent(T: Type): Promise { - hostFixture = TestBed.createComponent(T) - hostComponent = hostFixture.componentInstance - - const debugEl = hostFixture.debugElement.query(By.directive(FormFieldSearchSelectJobcodeComponent)) - childComponent = debugEl.componentInstance - - // 1st change detection triggers ngOnInit which gets a hero - hostFixture.detectChanges() - - return hostFixture.whenStable().then(() => { - // 2nd change detection displays the async-fetched hero - hostFixture.detectChanges() - }) -} - - -let activatedRoute: ActivatedRouteStubSpec -let hostFixture: ComponentFixture -let hostComponent: TestHostComponent -let childComponent: FormFieldSearchSelectJobcodeComponent - - -describe("FormFieldSearchSelectJobcodeComponent", () => { - beforeEach(() => { - activatedRoute = new ActivatedRouteStubSpec() - }) - - beforeEach(async(() => { - const routerSpy = ActivatedRouteStubSpec.createRouterSpy() - - TestBed.configureTestingModule({ - declarations: [ - FormFieldSearchSelectJobcodeComponent, - TestHostComponent - ], - imports: [ - FormsModule, // Required for ([ngModel]) - ReactiveFormsModule, - RouterTestingModule, // Required for routerLink - HttpClientTestingModule, // Needed to avoid the toolName race condition below - ], - providers: [ - EnvironmentService, // Needed to avoid the toolName race condition below - AppConfig, // Needed to avoid the toolName race condition below - { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, - { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: Router, useValue: routerSpy }, - ] - }) - .compileComponents() - - const appConfig = TestBed.inject(AppConfig) - AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName - - activatedRoute.setParams({ userId: 126 }) - createComponent(TestHostComponent) - })) - - it("should be created", () => { - expect(hostComponent).toBeTruthy() - }) - - it("isResultSelected should return", () => { - // Arrange - const apiJobCode = new ApiJobCode(createMockJobcode()) - childComponent.internalSelectedResults = [ apiJobCode ] - - // Act - const result = childComponent.isResultSelected(apiJobCode) - - // Assert - expect(result).toBeTruthy() - }) - it("isResultSelected should not return", () => { - // Arrange - const apiJobCode = new ApiJobCode(createMockJobcode()) - childComponent.internalSelectedResults = [] - - // Act - const result = childComponent.isResultSelected(apiJobCode) - - // Assert - expect(result).toBeFalse() - }) - - it("performInitialSearchAndPopulation should return", () => { - // Arrange - childComponent.internalSelectedResults = [] - const jobCode1 = createMockJobcode(1) - const jobCode2 = createMockJobcode(2) - childComponent.existing = [ jobCode1, jobCode2 ] - - // Act - childComponent.performInitialSearchAndPopulation() - - // Assert - const apiJobCode1 = new ApiJobCode(jobCode1) - const apiJobCode2 = new ApiJobCode(jobCode2) - expect(childComponent.internalSelectedResults.length).toEqual(2) - expect(childComponent.internalSelectedResults).toEqual([ apiJobCode1, apiJobCode2 ]) - }) - - it("performSearch should return", () => { - // Arrange - childComponent.results = undefined - - // Act - childComponent.performSearch("one") - - // Assert - expect(childComponent.results).toBeTruthy() - if (childComponent.results) { - const results: ApiJobCode[] = childComponent.results - expect(results.length).toEqual(1) - expect(results[0].targetNodeName).toEqual("one") - } - }) - it("performSearch should work even with a prior search", () => { - // Arrange - childComponent.results = undefined - - // Act - childComponent.performSearch("one") - childComponent.performSearch("two") - - // Assert - expect(childComponent.results).toBeTruthy() - if (childComponent.results) { - const results: ApiJobCode[] = childComponent.results - expect(results.length).toEqual(0) - } - }) - - it("selectResult should return", () => { - // Arrange - const apiJobCode = new ApiJobCode(createMockJobcode()) - childComponent.internalSelectedResults = [ ] - - // Act - childComponent.selectResult(apiJobCode) - - // Assert - expect(childComponent.internalSelectedResults).toEqual([ apiJobCode ]) - }) - it("selectResult should return (no change)", () => { - // Arrange - const apiJobCode = new ApiJobCode(createMockJobcode()) - childComponent.internalSelectedResults = [ apiJobCode ] - - // Act - childComponent.selectResult(apiJobCode) - - // Assert - expect(childComponent.internalSelectedResults).toEqual([ apiJobCode ]) - }) - - it("unselectResult should return", () => { - // Arrange - const apiJobCode = new ApiJobCode(createMockJobcode()) - childComponent.internalSelectedResults = [ apiJobCode ] - - // Act - childComponent.unselectResult(apiJobCode) - - // Assert - expect(childComponent.internalSelectedResults).toEqual([ ]) - }) - it("unselectResult should return (no change)", () => { - // Arrange - const apiJobCode1 = new ApiJobCode(createMockJobcode()) - const apiJobCode2 = new ApiJobCode(createMockJobcode()) - childComponent.internalSelectedResults = [ apiJobCode1 ] - - // Act - childComponent.unselectResult(apiJobCode2) - - // Assert - expect(childComponent.internalSelectedResults).toEqual([ apiJobCode1 ]) - }) - - it("joinNameAndCode should return", () => { - // Arrange - // noinspection MagicNumberJS - const apiJobCode = new ApiJobCode(createMockJobcode(42, "foo", "bar")) - - // Act - const result = childComponent.joinNameAndCode(apiJobCode) - - // Assert - expect(result).toEqual("foo bar") - }) - - it("currentSelection should be emitted", () => { - // Arrange - hostSelectedCodes = undefined - const apiJobCode1 = new ApiJobCode(createMockJobcode(1, "job 1", "job1")) - const apiJobCode2 = new ApiJobCode(createMockJobcode(2, "job 2", "job2")) - const expected = [ apiJobCode1.code, apiJobCode2.code ] - childComponent.internalSelectedResults = [ apiJobCode1 ] - let selectedCodes: string[] = [] - childComponent.currentSelection.pipe(first()).subscribe( - // tslint:disable-next-line:variable-name - (codes) => { selectedCodes = codes; return } - ) - - // Act - console.log("FormFieldSearchSelectJobCodeComponent.spec: 1", childComponent.internalSelectedResults.map(j => j.code)) - childComponent.selectResult(apiJobCode2) - console.log("FormFieldSearchSelectJobCodeComponent.spec: 2", childComponent.internalSelectedResults.map(j => j.code)) - - // Assert - while (!hostSelectedCodes) {} // wait for response - expect(selectedCodes).toEqual(expected) - expect(hostSelectedCodes).toEqual(expected) - }) -}) diff --git a/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.ts b/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.ts deleted file mode 100644 index 372dce585..000000000 --- a/ui/src/app/form/form-field-search-select/jobcode-select/form-field-search-select-jobcode.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -import {Component, Output, EventEmitter, OnInit, OnDestroy, Input} from "@angular/core" -import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" -import {SvgHelper, SvgIcon} from "../../../core/SvgHelper" -import {FormField} from "../../form-field.component" -import {Subscription} from "rxjs" -import {ApiJobCode, IJobCode} from "../../../job-codes/Jobcode" - - -@Component({ - selector: "app-form-field-search-select-jobcode", - templateUrl: "./form-field-search-select-jobcode.component.html" -}) -export class FormFieldSearchSelectJobcodeComponent extends FormField implements OnInit, OnDestroy { - - @Input() existing?: IJobCode[] - - @Output() currentSelection = new EventEmitter() - - iconSearch = SvgHelper.path(SvgIcon.SEARCH) - iconDismiss = SvgHelper.path(SvgIcon.DISMISS) - iconCheck = SvgHelper.path(SvgIcon.CHECK) - - queryInProgress!: Subscription - currentlyLoading = false - - results!: ApiJobCode[] | undefined - - internalSelectedResults: ApiJobCode[] = [] - - constructor(protected searchService: KeywordSearchService) { - super() - } - - ngOnInit(): void { - this.control.valueChanges.subscribe(next => { this.performSearch(next) }) - this.performInitialSearchAndPopulation() - } - - ngOnDestroy(): void { - this.queryInProgress?.unsubscribe() - } - - get showResults(): boolean { - const isEmpty = this.valueFromControl?.trim()?.length <= 0 - const isDirty = this.control.dirty - return isDirty && !isEmpty && this.results !== undefined - } - - isResultSelected(result: ApiJobCode): boolean { - return !!this.internalSelectedResults.find(value => value.code === result.code) - } - - performInitialSearchAndPopulation(): void { - if (this.existing) { - this.internalSelectedResults = this.existing.map(it => new ApiJobCode(it)) - this.control.setValue("") - this.emitCurrentSelection() - } - } - - // This component calls a different api than the others, otherwise is just another search field with multi-select - performSearch(text: string): void { - if (!text) { - return // no search to perform - } - this.currentlyLoading = true - - if (this.queryInProgress) { - this.queryInProgress.unsubscribe() // unsub to existing query first - } - - this.queryInProgress = this.searchService.searchJobcodes(text) - .subscribe(searchResults => { - this.results = searchResults.filter(r => !!r && !!r.code && !!r.targetNodeName) - this.currentlyLoading = false - }) - } - - selectResult(result: ApiJobCode): void { - if (!this.isResultSelected(result)) { - this.internalSelectedResults.push(result) - } - this.emitCurrentSelection() - } - - unselectResult(result: ApiJobCode): void { - this.internalSelectedResults = this.internalSelectedResults.filter(r => r !== result) - this.control.markAsDirty() - this.emitCurrentSelection() - } - - joinNameAndCode(apiCode: ApiJobCode): string { - return `${apiCode.targetNodeName} ${apiCode.code}` - } - - private emitCurrentSelection(): void { - this.currentSelection.emit(this.internalSelectedResults.map(selectedItem => selectedItem.code)) - } -} - - diff --git a/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.html b/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.html deleted file mode 100644 index 6710abbc9..000000000 --- a/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.html +++ /dev/null @@ -1 +0,0 @@ -

jobcode-single-select works!

diff --git a/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.spec.ts b/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.spec.ts deleted file mode 100644 index 30e778538..000000000 --- a/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing" - -import { JobcodeSingleSelectComponent } from "./jobcode-single-select.component" -import {HttpClientTestingModule} from "@angular/common/http/testing" -import {AuthService} from "../../../auth/auth-service" -import {AuthServiceStub, KeywordSearchServiceStub} from "../../../../../test/resource/mock-stubs" -import {AppConfig} from "../../../app.config" -import {FormModule} from "../../form.module" -import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" - -describe("JobcodeSingleSelectComponent", () => { - let component: JobcodeSingleSelectComponent - let fixture: ComponentFixture - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ JobcodeSingleSelectComponent ], - imports: [ - HttpClientTestingModule, - FormModule - ], - providers: [ - AppConfig, - { provide: AuthService, useClass: AuthServiceStub }, - { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, - ] - }) - .compileComponents() - - const appConfig = TestBed.inject(AppConfig) - AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName - }) - - beforeEach(() => { - fixture = TestBed.createComponent(JobcodeSingleSelectComponent) - component = fixture.componentInstance - fixture.detectChanges() - }) - - it("should create", () => { - expect(component).toBeTruthy() - }) - - it("performSearch should work", () => { - const service = TestBed.inject(KeywordSearchService) - const spy = spyOn(service, "searchJobcodes").and.callThrough() - component.performSearch("jobcode 1") - expect(spy).toHaveBeenCalled() - }) - - it("performSearch should not be called", () => { - const service = TestBed.inject(KeywordSearchService) - const spy = spyOn(service, "searchJobcodes").and.callThrough() - component.performSearch("") - expect(spy).not.toHaveBeenCalled() - }) - - it("control value is ok", () => { - component.selectResult("12-23|Jobcode name") - expect(component.control.value).toBe("Jobcode name") - }) -}) diff --git a/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.ts b/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.ts deleted file mode 100644 index efaaaeb16..000000000 --- a/ui/src/app/form/form-field-search-select/jobcode-single-select/jobcode-single-select.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Component, OnInit } from "@angular/core" -import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" -import {FormFieldSearchSelectComponent} from "../.." -import {ApiJobCode} from "../../../job-codes/Jobcode" - -@Component({ - selector: "app-jobcode-single-select", - templateUrl: "../../form-field-search-select/single-select/form-field-search-select.component.html", -}) -export class JobcodeSingleSelectComponent extends FormFieldSearchSelectComponent { - - constructor( - searchService: KeywordSearchService - ) { - super(searchService) - } - - performSearch(text: string): void { - if (!text) { - return // no search to perform - } - this.currentlyLoading = true - - if (this.queryInProgress) { - this.queryInProgress.unsubscribe() // unsub to existing query first - } - - this.queryInProgress = this.searchService.searchJobcodes(text) - .subscribe(searchResults => { - this.results = searchResults.filter(r => !!r && !!r.targetNodeName).map(r => r.code + "|" + r.targetNodeName) - this.currentlyLoading = false - }) - } - - selectResult(result: string): void { - this.control.setValue(result.split("|")[1], {emitEvent: false}) - this.results = undefined - } - -} diff --git a/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.spec.ts b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.spec.ts new file mode 100644 index 000000000..e01ea87ce --- /dev/null +++ b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.spec.ts @@ -0,0 +1,106 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../../../test/resource/mock-stubs" +import {ApiJobCode, IJobCode} from "../../../job-codes/Jobcode" +import {FormFieldJobCodeSearchMultiSelect} from "./form-field-jobcode-search-multi-select.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends FormFieldJobCodeSearchMultiSelect { + execProtected = { + callSearchService: (text: string) => this.callSearchService(text) + } +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: IJobCode +let testResult2: IJobCode + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("FormFieldJobCodeSearchMultiSelectComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + FormFieldJobCodeSearchMultiSelect, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = new ApiJobCode({ targetNodeName: "Job Code 1", code: "1.101" }) + testResult2 = new ApiJobCode({ targetNodeName: "Job Code 2", code: "1.102" }) + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("areSearchResultsEqual should return correct result", () => { + expect(component.areSearchResultsEqual(testResult1, testResult1)).toEqual(true) + expect(component.areSearchResultsEqual(testResult1, testResult2)).toEqual(false) + }) + + it("isSearchResultType should return correct result", () => { + expect(component.isSearchResultType(testResult1)).toEqual(true) + expect(component.isSearchResultType({})).toEqual(false) + }) + + it("labelFor should return correct result", () => { + expect(component.labelFor(testResult1)).toEqual(`${testResult1.code}|${testResult1.targetNodeName}`) + }) + + it("searchResultFromString should return correct result", () => { + expect(component.searchResultFromString(testResult1.targetNodeName!!)).toEqual(undefined) + }) + + it("callSearchService should succeed", () => { + expect((() => { + component.execProtected.callSearchService(testResult1.targetNodeName!!) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.ts b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.ts new file mode 100644 index 000000000..318b6d117 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-multi-select.component.ts @@ -0,0 +1,39 @@ +import {Component} from "@angular/core" +import {Observable} from "rxjs" +import {map} from "rxjs/operators" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {IJobCode} from "../../../job-codes/Jobcode" +import {isJobCode} from "./form-field-jobcode-search-select.utilities" +import {areSearchResultsEqual, labelFor, searchResultFromString} from "./form-field-jobcode-search-select.utilities" +import {AbstractFormFieldSearchMultiSelect} from "../abstract-form-field-search-multi-select.component" + +@Component({ + selector: "app-form-field-jobcode-search-multi-select", + templateUrl: "../abstract-form-field-search-multi-select.component.html" +}) +export class FormFieldJobCodeSearchMultiSelect extends AbstractFormFieldSearchMultiSelect { + + constructor(searchService: KeywordSearchService) { + super(searchService) + } + + areSearchResultsEqual(result1: IJobCode, result2: IJobCode): boolean { + return areSearchResultsEqual(result1, result2) + } + + isSearchResultType(result: any): result is IJobCode { + return isJobCode(result) + } + + labelFor(result: IJobCode): string|null { + return labelFor(result) + } + + searchResultFromString(value: string): IJobCode|undefined { + return searchResultFromString(value) + } + + protected callSearchService(text: string): Observable { + return this.searchService.searchJobcodes(text).pipe(map(results => results.map(r => r))) + } +} diff --git a/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.spec.ts b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.spec.ts new file mode 100644 index 000000000..f5faad8a7 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.spec.ts @@ -0,0 +1,107 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../../../test/resource/mock-stubs" +import {ApiJobCode, IJobCode} from "../../../job-codes/Jobcode" +import {FormFieldJobCodeSearchSelect} from "./form-field-jobcode-search-select.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends FormFieldJobCodeSearchSelect { + execProtected = { + callSearchService: (text: string) => this.callSearchService(text) + } +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: IJobCode +let testResult2: IJobCode + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("FormFieldJobCodeSearchSelectComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + FormFieldJobCodeSearchSelect, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = new ApiJobCode({ targetNodeName: "Job Code 1", code: "1.101" }) + testResult2 = new ApiJobCode({ targetNodeName: "Job Code 2", code: "1.102" }) + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("areSearchResultsEqual should return correct result", () => { + const copy = new ApiJobCode() + expect(component.areSearchResultsEqual(testResult1, testResult1)).toEqual(true) + expect(component.areSearchResultsEqual(testResult1, testResult2)).toEqual(false) + }) + + it("isSearchResultType should return correct result", () => { + expect(component.isSearchResultType(testResult1)).toEqual(true) + expect(component.isSearchResultType({})).toEqual(false) + }) + + it("labelFor should return correct result", () => { + expect(component.labelFor(testResult1)).toEqual(`${testResult1.code}|${testResult1.targetNodeName}`) + }) + + it("searchResultFromString should return correct result", () => { + expect(component.searchResultFromString(testResult1.targetNodeName!!)).toEqual(undefined) + }) + + it("callSearchService should succeed", () => { + expect((() => { + component.execProtected.callSearchService(testResult1.targetNodeName!!) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.ts b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.ts new file mode 100644 index 000000000..feedab67b --- /dev/null +++ b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.component.ts @@ -0,0 +1,40 @@ +import {Component} from "@angular/core" +import {Observable} from "rxjs" +import {map} from "rxjs/operators" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {IJobCode} from "../../../job-codes/Jobcode" +import {isJobCode} from "./form-field-jobcode-search-select.utilities" +import {areSearchResultsEqual, labelFor, searchResultFromString} from "./form-field-jobcode-search-select.utilities" +import {AbstractFormFieldSearchSingleSelect} from "../abstract-form-field-search-single-select.component" + + +@Component({ + selector: "app-form-field-jobcode-search-select", + templateUrl: "../abstract-form-field-search-single-select.component.html" +}) +export class FormFieldJobCodeSearchSelect extends AbstractFormFieldSearchSingleSelect{ + + constructor(searchService: KeywordSearchService) { + super(searchService) + } + + areSearchResultsEqual(result1: IJobCode, result2: IJobCode): boolean { + return areSearchResultsEqual(result1, result2) + } + + isSearchResultType(result: any): result is IJobCode { + return isJobCode(result) + } + + labelFor(result: IJobCode): string|null { + return labelFor(result) + } + + searchResultFromString(value: string): IJobCode|undefined { + return searchResultFromString(value) + } + + protected callSearchService(text: string): Observable { + return this.searchService.searchJobcodes(text).pipe(map(results => results.map(r => r))) + } +} diff --git a/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.spec.ts b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.spec.ts new file mode 100644 index 000000000..4b5fd4e9c --- /dev/null +++ b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.spec.ts @@ -0,0 +1,41 @@ +// noinspection LocalVariableNamingConventionJS +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {IJobCode} from "../../../job-codes/Jobcode" +import {createMockJobcode} from "../../../../../test/resource/mock-data" +import { + areSearchResultsEqual, + isJobCode, + labelFor, + searchResultFromString +} from "./form-field-jobcode-search-select.utilities" + +describe("form-field-jobcode-search-select.utilities", () => { + + let testJobCode1: IJobCode + let testJobCode2: IJobCode + + beforeEach(() => { + testJobCode1 = createMockJobcode(101, "Job Code 1", "1.101") + testJobCode2 = createMockJobcode(102, "Job Code 2", "1.102") + }) + + it("areSearchResultsEqual should return correct result", () => { + const copy = createMockJobcode(testJobCode1.targetNode, testJobCode1.targetNodeName, testJobCode1.code) + expect(areSearchResultsEqual(testJobCode1, copy)).toEqual(true) + expect(areSearchResultsEqual(testJobCode1, testJobCode2)).toEqual(false) + }) + + it("isJobCode should return correct result", () => { + expect(isJobCode(testJobCode1)).toEqual(true) + expect(isJobCode({})).toEqual(false) + expect(isJobCode(testJobCode1.targetNodeName!!)).toEqual(false) + }) + + it("labelFor should return correct result", () => { + expect(labelFor(testJobCode1)).toEqual(`${testJobCode1.code}|${testJobCode1.targetNodeName}`) + }) + + it("searchResultFromString should return correct result", () => { + expect(searchResultFromString(testJobCode1.targetNodeName!!)).toEqual(undefined) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.ts b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.ts new file mode 100644 index 000000000..97cb1e69b --- /dev/null +++ b/ui/src/app/form/form-field-search-select/jobcode/form-field-jobcode-search-select.utilities.ts @@ -0,0 +1,21 @@ +import {IJobCode} from "../../../job-codes/Jobcode" + +export const areSearchResultsEqual = (result1: IJobCode, result2: IJobCode): boolean => { + if (result1 && result2) { + return result1.targetNodeName === result2.targetNodeName && result1.code === result1.code + } + + return false +} + +export const isJobCode = (value: any): value is IJobCode => { + return value instanceof Object && "targetNodeName" in value && "code" in value +} + +export const labelFor = (value: IJobCode): string|null => { + return (value) ? `${value.code}|${value.targetNodeName}` : null +} + +export const searchResultFromString = (value: string): IJobCode|undefined => { + return undefined +} diff --git a/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.spec.ts b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.spec.ts new file mode 100644 index 000000000..a92a906ba --- /dev/null +++ b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.spec.ts @@ -0,0 +1,121 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../../../test/resource/mock-stubs" +import {ApiNamedReference, INamedReference, KeywordType} from "../../../richskill/ApiSkill" +import {FormFieldKeywordSearchMultiSelect} from "./form-field-keyword-search-multi-select.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends FormFieldKeywordSearchMultiSelect { + keywordType = KeywordType.Certification + createNonExisting = true + + execProtected = { + callSearchService: (text: string) => this.callSearchService(text) + } +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: INamedReference +let testResult2: INamedReference + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("FormFieldKeywordSearchMultiSelectComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + FormFieldKeywordSearchMultiSelect, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = new ApiNamedReference({ name: "abc-123" }) + testResult2 = new ApiNamedReference({ name: "321-cba" }) + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("get isAlignmentKeywordType should return correct result", () => { + expect(component.isAlignmentKeywordType).toEqual(false) + }) + + it("get isNamedReferenceKeywordType should return correct result", () => { + expect(component.isNamedReferenceKeywordType).toEqual(true) + }) + + it("get isStringKeywordType should return correct result", () => { + expect(component.isStringKeywordType).toEqual(false) + }) + + it("areSearchResultsEqual should return correct result", () => { + expect(component.areSearchResultsEqual(testResult1, testResult1)).toEqual(true) + expect(component.areSearchResultsEqual(testResult1, testResult2)).toEqual(false) + }) + + it("isSearchResultType should return correct result", () => { + expect(component.isSearchResultType(testResult1)).toEqual(true) + expect(component.isSearchResultType({})).toEqual(false) + }) + + it("labelFor should return correct result", () => { + expect(component.labelFor(testResult1)).toEqual(testResult1.name!!) + }) + + it("searchResultFromString should return correct result", () => { + expect(component.searchResultFromString(testResult1.name!!)).toEqual(testResult1) + }) + + it("callSearchService should succeed", () => { + expect((() => { + component.execProtected.callSearchService(testResult1.name!!) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.ts b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.ts new file mode 100644 index 000000000..c52919e54 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-multi-select.component.ts @@ -0,0 +1,79 @@ +import {Component, Input} from "@angular/core" +import {Observable} from "rxjs" +import {map} from "rxjs/operators" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {IAlignment, INamedReference, KeywordType} from "../../../richskill/ApiSkill" +import {isAlignment, isAlignmentKeywordType, searchServiceResultToAlignment} from "./form-field-keyword-search-select.utilities" +import {isNamedReference, isNamedReferenceKeywordType, searchServiceResultToNamedReference} from "./form-field-keyword-search-select.utilities" +import {isStringKeyword, isStringKeywordKeywordType, searchServiceResultToStringKeyword} from "./form-field-keyword-search-select.utilities" +import {areSearchResultsEqual, labelFor, searchResultFromString} from "./form-field-keyword-search-select.utilities"; +import {AbstractFormFieldSearchMultiSelect} from "../abstract-form-field-search-multi-select.component" + +@Component({ + selector: "app-form-field-keyword-search-multi-select", + templateUrl: "../abstract-form-field-search-multi-select.component.html" +}) +export class FormFieldKeywordSearchMultiSelect + extends AbstractFormFieldSearchMultiSelect { + + @Input() keywordType: KeywordType | null = null + + constructor(searchService: KeywordSearchService) { + super(searchService) + } + + get isAlignmentKeywordType(): boolean { + return (this.keywordType) ? isAlignmentKeywordType(this.keywordType) : false + } + + get isNamedReferenceKeywordType(): boolean { + return (this.keywordType) ? isNamedReferenceKeywordType(this.keywordType) : false + } + + get isStringKeywordType(): boolean { + return (this.keywordType) ? isStringKeywordKeywordType(this.keywordType) : false + } + + areSearchResultsEqual( + result1: IAlignment|INamedReference|string, + result2: IAlignment|INamedReference|string + ): boolean { + return areSearchResultsEqual(result1, result2) + } + + isSearchResultType(result: any): result is IAlignment|INamedReference|string { + return isAlignment(result) || isNamedReference(result) || isStringKeyword(result) + } + + labelFor(value: IAlignment|INamedReference|string): string|null { + return (value) ? labelFor(value) : null + } + + searchResultFromString(value: string): IAlignment|INamedReference|string|undefined { + if (!this.keywordType) { + return undefined + } + + return searchResultFromString(this.keywordType, value) + } + + protected callSearchService(text: string): Observable<(IAlignment|INamedReference|string)[]> { + if (!this.keywordType) { + throw new Error("KeywordType is not set") + } + + let transformFn = (r: INamedReference): IAlignment|INamedReference|string|undefined => r + + if (this.isAlignmentKeywordType) { + transformFn = searchServiceResultToAlignment + } else if (this.isNamedReferenceKeywordType) { + transformFn = searchServiceResultToNamedReference + } else if (this.isStringKeywordType) { + transformFn = searchServiceResultToStringKeyword + } + + return this.searchService.searchKeywords(this.keywordType, text).pipe( + map((results): (IAlignment|INamedReference|string)[] => results.flatMap(r => transformFn(r) ?? [])) + ) + } +} diff --git a/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.spec.ts b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.spec.ts new file mode 100644 index 000000000..89cbec8e8 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.spec.ts @@ -0,0 +1,121 @@ +// noinspection LocalVariableNamingConventionJS +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {Component, Type} from "@angular/core" +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {FormsModule, ReactiveFormsModule} from "@angular/forms" +import {ActivatedRoute, Router} from "@angular/router" +import {RouterTestingModule} from "@angular/router/testing" +import {AppConfig} from "src/app/app.config" +import {EnvironmentService} from "src/app/core/environment.service" +import {ActivatedRouteStubSpec} from "test/util/activated-route-stub.spec" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {KeywordSearchServiceStub} from "../../../../../test/resource/mock-stubs" +import {ApiNamedReference, INamedReference, KeywordType} from "../../../richskill/ApiSkill" +import {FormFieldKeywordSearchSelect} from "./form-field-keyword-search-select.component" + +@Component({ + template: "" +}) +export abstract class TestHostComponent extends FormFieldKeywordSearchSelect { + keywordType = KeywordType.Certification + createNonExisting = true + + execProtected = { + callSearchService: (text: string) => this.callSearchService(text) + } +} + +let activatedRoute: ActivatedRouteStubSpec +let fixture: ComponentFixture +let component: TestHostComponent +let testResult1: INamedReference +let testResult2: INamedReference + +function createComponent(T: Type): void { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + fixture.detectChanges() + fixture.whenStable().then(() => fixture.detectChanges()) +} + +describe("FormFieldKeywordSearchSelectComponent", () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStubSpec() + }) + + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + + TestBed.configureTestingModule({ + declarations: [ + FormFieldKeywordSearchSelect, + TestHostComponent + ], + imports: [ + FormsModule, // Required for ([ngModel]) + ReactiveFormsModule, + RouterTestingModule, // Required for routerLink + HttpClientTestingModule, // Needed to avoid the toolName race condition below + ], + providers: [ + AppConfig, // Needed to avoid the toolName race condition below + EnvironmentService, // Needed to avoid the toolName race condition below + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: KeywordSearchService, useClass: KeywordSearchServiceStub }, + { provide: Router, useValue: routerSpy }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() // This avoids the race condition on reading the config's whitelabel.toolName + + activatedRoute.setParams({ userId: 126 }) + + testResult1 = new ApiNamedReference({ name: "abc-123" }) + testResult2 = new ApiNamedReference({ name: "321-cba" }) + + // @ts-ignore + createComponent(TestHostComponent) + })) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("get isAlignmentKeywordType should return correct result", () => { + expect(component.isAlignmentKeywordType).toEqual(false) + }) + + it("get isNamedReferenceKeywordType should return correct result", () => { + expect(component.isNamedReferenceKeywordType).toEqual(true) + }) + + it("get isStringKeywordType should return correct result", () => { + expect(component.isStringKeywordType).toEqual(false) + }) + + it("areSearchResultsEqual should return correct result", () => { + expect(component.areSearchResultsEqual(testResult1, testResult1)).toEqual(true) + expect(component.areSearchResultsEqual(testResult1, testResult2)).toEqual(false) + }) + + it("isSearchResultType should return correct result", () => { + expect(component.isSearchResultType(testResult1)).toEqual(true) + expect(component.isSearchResultType({})).toEqual(false) + }) + + it("labelFor should return correct result", () => { + expect(component.labelFor(testResult1)).toEqual(testResult1.name!!) + }) + + it("searchResultFromString should return correct result", () => { + expect(component.searchResultFromString(testResult1.name!!)).toEqual(testResult1) + }) + + it("callSearchService should succeed", () => { + expect((() => { + component.execProtected.callSearchService(testResult1.name!!) + return true + })()).toEqual(true) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.ts b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.ts new file mode 100644 index 000000000..59c2bc1cd --- /dev/null +++ b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.component.ts @@ -0,0 +1,79 @@ +import {Component, Input} from "@angular/core" +import {Observable} from "rxjs" +import {map} from "rxjs/operators" +import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" +import {IAlignment, INamedReference, KeywordType} from "../../../richskill/ApiSkill" +import {isAlignment, isAlignmentKeywordType, searchServiceResultToAlignment} from "./form-field-keyword-search-select.utilities" +import {isNamedReference, isNamedReferenceKeywordType, searchServiceResultToNamedReference} from "./form-field-keyword-search-select.utilities" +import {isStringKeyword, isStringKeywordKeywordType, searchServiceResultToStringKeyword} from "./form-field-keyword-search-select.utilities" +import {areSearchResultsEqual, labelFor, searchResultFromString} from "./form-field-keyword-search-select.utilities" +import {AbstractFormFieldSearchSingleSelect} from "../abstract-form-field-search-single-select.component" + +@Component({ + selector: "app-form-field-keyword-search-select", + templateUrl: "../abstract-form-field-search-single-select.component.html" +}) +export class FormFieldKeywordSearchSelect + extends AbstractFormFieldSearchSingleSelect { + + @Input() keywordType: KeywordType | null = null + + constructor(searchService: KeywordSearchService) { + super(searchService) + } + + get isAlignmentKeywordType(): boolean { + return (this.keywordType) ? isAlignmentKeywordType(this.keywordType) : false + } + + get isNamedReferenceKeywordType(): boolean { + return (this.keywordType) ? isNamedReferenceKeywordType(this.keywordType) : false + } + + get isStringKeywordType(): boolean { + return (this.keywordType) ? isStringKeywordKeywordType(this.keywordType) : false + } + + areSearchResultsEqual( + result1: IAlignment|INamedReference|string, + result2: IAlignment|INamedReference|string + ): boolean { + return areSearchResultsEqual(result1, result2) + } + + isSearchResultType(result: any): result is IAlignment|INamedReference|string { + return isAlignment(result) || isNamedReference(result) || isStringKeyword(result) + } + + labelFor(value: IAlignment|INamedReference|string): string|null { + return (value) ? labelFor(value) : null + } + + searchResultFromString(value: string): IAlignment|INamedReference|string|undefined { + if (!this.keywordType) { + return undefined + } + + return searchResultFromString(this.keywordType, value) + } + + protected callSearchService(text: string): Observable<(IAlignment|INamedReference|string)[]> { + if (!this.keywordType) { + throw new Error("KeywordType is not set") + } + + let transformFn = (r: INamedReference|string|undefined): IAlignment|INamedReference|string|undefined => r + + if (this.isAlignmentKeywordType) { + transformFn = searchServiceResultToAlignment + } else if (this.isNamedReferenceKeywordType) { + transformFn = searchServiceResultToNamedReference + } else if (this.isStringKeywordType) { + transformFn = searchServiceResultToStringKeyword + } + + return this.searchService.searchKeywords(this.keywordType, text).pipe( + map((results): (IAlignment|INamedReference|string)[] => results.flatMap(r => transformFn(r) ?? [])) + ) + } +} diff --git a/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.spec.ts b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.spec.ts new file mode 100644 index 000000000..c788c09f0 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.spec.ts @@ -0,0 +1,163 @@ +// noinspection LocalVariableNamingConventionJS +import {async, ComponentFixture, TestBed} from "@angular/core/testing" +import {ApiAlignment, ApiNamedReference, IAlignment, INamedReference, KeywordType} from "../../../richskill/ApiSkill" +import { + areAlignmentsEqual, + areNamedReferencesEqual, + areSearchResultsEqual, + areStringKeywordsEqual, + isAlignment, + isAlignmentKeywordType, + isNamedReference, + isNamedReferenceKeywordType, + isStringKeyword, + isStringKeywordKeywordType, + labelFor, + labelForAlignment, + labelForNamedReference, + labelForStringKeyword, + searchResultFromString, + searchServiceResultToAlignment, + searchServiceResultToNamedReference, + searchServiceResultToStringKeyword +} from "./form-field-keyword-search-select.utilities"; + +describe("form-field-keyword-search-select.utilities", () => { + + let testAlignment1: IAlignment + let testAlignment2: IAlignment + let testNamedReference1: INamedReference + let testNamedReference2: INamedReference + let testStringKeyword1: string + let testStringKeyword2: string + + beforeEach(() => { + testAlignment1 = new ApiAlignment({ skillName: "Alignment 1" }) + testAlignment2 = new ApiAlignment({ skillName: "Alignment 2" }) + testNamedReference1 = new ApiNamedReference({ name: "abc-123" }) + testNamedReference2 = new ApiNamedReference({ name: "321-cba" }) + testStringKeyword1 = "abc-123" + testStringKeyword2 = "321-cba" + }) + + it("areAlignmentsEqual should return correct result", () => { + const copy = new ApiAlignment({ id: testAlignment1.id, skillName: testAlignment1.skillName }) + expect(areAlignmentsEqual(testAlignment1, copy)).toEqual(true) + expect(areAlignmentsEqual(testAlignment1, testAlignment2)).toEqual(false) + }) + + it("isAlignment should return correct result", () => { + expect(isAlignment(testAlignment1)).toEqual(true) + expect(isAlignment(testNamedReference1)).toEqual(false) + expect(isAlignment(testStringKeyword1)).toEqual(false) + }) + + it("isAlignmentKeywordType should return correct result", () => { + expect(isAlignmentKeywordType(KeywordType.Alignment)).toEqual(true) + expect(isAlignmentKeywordType(KeywordType.Author)).toEqual(false) + expect(isAlignmentKeywordType(KeywordType.Category)).toEqual(false) + expect(isAlignmentKeywordType(KeywordType.Certification)).toEqual(false) + expect(isAlignmentKeywordType(KeywordType.Employer)).toEqual(false) + expect(isAlignmentKeywordType(KeywordType.Keyword)).toEqual(false) + expect(isAlignmentKeywordType(KeywordType.Standard)).toEqual(true) + }) + + it("labelForAlignment should return correct result", () => { + expect(labelForAlignment(testAlignment1)).toEqual(testAlignment1.skillName!!) + }) + + it("searchServiceResultToAlignment should return correct result", () => { + expect(searchServiceResultToAlignment({ id: "", name: testAlignment1.skillName })).toEqual(testAlignment1) + expect(searchServiceResultToAlignment(testAlignment1.skillName)).toEqual(testAlignment1) + }) + + it("areNamedReferencesEqual should return correct result", () => { + const copy = new ApiNamedReference({ id: testNamedReference1.id, name: testNamedReference1.name }) + expect(areNamedReferencesEqual(testNamedReference1, copy)).toEqual(true) + expect(areNamedReferencesEqual(testNamedReference1, testNamedReference2)).toEqual(false) + }) + + it("isNamedReference should return correct result", () => { + expect(isNamedReference(testAlignment1)).toEqual(false) + expect(isNamedReference(testNamedReference1)).toEqual(true) + expect(isNamedReference(testStringKeyword1)).toEqual(false) + }) + + it("isNamedReferenceKeywordType should return correct result", () => { + expect(isNamedReferenceKeywordType(KeywordType.Alignment)).toEqual(false) + expect(isNamedReferenceKeywordType(KeywordType.Author)).toEqual(false) + expect(isNamedReferenceKeywordType(KeywordType.Category)).toEqual(false) + expect(isNamedReferenceKeywordType(KeywordType.Certification)).toEqual(true) + expect(isNamedReferenceKeywordType(KeywordType.Employer)).toEqual(true) + expect(isNamedReferenceKeywordType(KeywordType.Keyword)).toEqual(false) + expect(isNamedReferenceKeywordType(KeywordType.Standard)).toEqual(false) + }) + + it("labelForNamedReference should return correct result", () => { + expect(labelForNamedReference(testNamedReference1)).toEqual(testNamedReference1.name!!) + }) + + it("searchServiceResultToNamedReference should return correct result", () => { + expect(searchServiceResultToNamedReference(testNamedReference1)).toEqual(testNamedReference1) + expect(searchServiceResultToNamedReference(testNamedReference1.name)).toEqual(testNamedReference1) + }) + + it("areStringKeywordsEqual should return correct result", () => { + const copy = testStringKeyword1 + expect(areStringKeywordsEqual(testStringKeyword1, copy)).toEqual(true) + expect(areStringKeywordsEqual(testStringKeyword1, testStringKeyword2)).toEqual(false) + }) + + it("isStringKeyword should return correct result", () => { + expect(isStringKeyword(testAlignment1)).toEqual(false) + expect(isStringKeyword(testNamedReference1)).toEqual(false) + expect(isStringKeyword(testStringKeyword1)).toEqual(true) + }) + + it("isStringKeywordType should return correct result", () => { + expect(isStringKeywordKeywordType(KeywordType.Alignment)).toEqual(false) + expect(isStringKeywordKeywordType(KeywordType.Author)).toEqual(true) + expect(isStringKeywordKeywordType(KeywordType.Category)).toEqual(true) + expect(isStringKeywordKeywordType(KeywordType.Certification)).toEqual(false) + expect(isStringKeywordKeywordType(KeywordType.Employer)).toEqual(false) + expect(isStringKeywordKeywordType(KeywordType.Keyword)).toEqual(true) + expect(isStringKeywordKeywordType(KeywordType.Standard)).toEqual(false) + }) + + it("labelForStringKeyword should return correct result", () => { + expect(labelForStringKeyword(testStringKeyword1)).toEqual(testStringKeyword1) + }) + + it("searchServiceResultToStringKeyword should return correct result", () => { + expect(searchServiceResultToStringKeyword({ id: "", name: testStringKeyword1 })).toEqual(testStringKeyword1) + expect(searchServiceResultToStringKeyword(testStringKeyword1)).toEqual(testStringKeyword1) + }) + + it("areSearchResultsEqual should return correct result", () => { + expect(areSearchResultsEqual(testAlignment1, testAlignment1)).toEqual(true) + expect(areSearchResultsEqual(testAlignment1, testNamedReference1)).toEqual(false) + expect(areSearchResultsEqual(testAlignment1, testStringKeyword1)).toEqual(false) + expect(areSearchResultsEqual(testNamedReference1, testAlignment1)).toEqual(false) + expect(areSearchResultsEqual(testNamedReference1, testNamedReference1)).toEqual(true) + expect(areSearchResultsEqual(testNamedReference1, testStringKeyword1)).toEqual(false) + expect(areSearchResultsEqual(testStringKeyword1, testAlignment1)).toEqual(false) + expect(areSearchResultsEqual(testStringKeyword1, testNamedReference1)).toEqual(false) + expect(areSearchResultsEqual(testStringKeyword1, testStringKeyword1)).toEqual(true) + }) + + it("labelFor should return correct result", () => { + expect(labelFor(testAlignment1)).toEqual(testAlignment1.skillName!!) + expect(labelFor(testNamedReference1)).toEqual(testNamedReference1.name!!) + expect(labelFor(testStringKeyword1)).toEqual(testStringKeyword1) + }) + + it("searchResultFromString should return correct result", () => { + expect(searchResultFromString(KeywordType.Alignment, testAlignment1.skillName!!)).toEqual(testAlignment1) + expect(searchResultFromString(KeywordType.Author, testStringKeyword1)).toEqual(testStringKeyword1) + expect(searchResultFromString(KeywordType.Category, testNamedReference1.name!!)).toEqual(testStringKeyword1) + expect(searchResultFromString(KeywordType.Certification, testNamedReference1.name!!)).toEqual(testNamedReference1) + expect(searchResultFromString(KeywordType.Employer, testNamedReference1.name!!)).toEqual(testNamedReference1) + expect(searchResultFromString(KeywordType.Keyword, testStringKeyword1)).toEqual(testStringKeyword1) + expect(searchResultFromString(KeywordType.Standard, testAlignment1.skillName!!)).toEqual(testAlignment1) + }) +}) diff --git a/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.ts b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.ts new file mode 100644 index 000000000..a1b16e306 --- /dev/null +++ b/ui/src/app/form/form-field-search-select/keyword/form-field-keyword-search-select.utilities.ts @@ -0,0 +1,167 @@ +import {ApiAlignment, ApiNamedReference, IAlignment, INamedReference, KeywordType} from "../../../richskill/ApiSkill" + +export const ALIGNMENT_KEYWORD_TYPES: KeywordType[] = [ + KeywordType.Alignment, + KeywordType.Standard +] + +export const NAMED_REFERENCE_KEYWORD_TYPES: KeywordType[] = [ + KeywordType.Certification, + KeywordType.Employer +] + +export const STRING_KEYWORD_TYPES: KeywordType[] = [ + KeywordType.Author, + KeywordType.Category, + KeywordType.Keyword +] + +export const areAlignmentsEqual = (value1: IAlignment, value2: IAlignment): boolean => { + const v1 = new ApiAlignment(value1) + const v2 = new ApiAlignment(value2) + return v1?.equals(v2) ?? false +} + +export const isAlignment = (value: any): value is IAlignment => { + return value instanceof Object && "id" in value && "skillName" in value +} + +export const isAlignmentKeywordType = (type: KeywordType): boolean => { + return type && !!ALIGNMENT_KEYWORD_TYPES.find(t => t === type) +} + +export const labelForAlignment = (value: IAlignment): string|null => { + return value?.skillName ?? value?.id ?? null +} + +export const searchServiceResultToAlignment = ( + result: INamedReference|string|undefined +): IAlignment|undefined => { + if (!!result) { + if (isNamedReference(result)) { + if (result.name) { + return ApiAlignment.fromString(result.name) + } + } else { + return ApiAlignment.fromString(result) + } + } + + return undefined +} + +export const areNamedReferencesEqual = (value1: INamedReference, value2: INamedReference): boolean => { + const v1 = new ApiNamedReference(value1) + const v2 = new ApiNamedReference(value2) + return v1?.equals(v2) ?? false +} + +export const isNamedReference = (value: any): value is INamedReference => { + return value instanceof Object && "id" in value && "name" in value +} + +export const isNamedReferenceKeywordType = (type: KeywordType): boolean => { + return type && !!NAMED_REFERENCE_KEYWORD_TYPES.find(t => t === type) +} + +export const labelForNamedReference = (value: INamedReference): string|null => { + return value?.name ?? value?.id ?? null +} + +export const searchServiceResultToNamedReference = ( + result: INamedReference|string|undefined +): INamedReference|undefined => { + if (!!result) { + return (isNamedReference(result)) ? result : ApiNamedReference.fromString(result) + } + + return undefined +} + +export const areStringKeywordsEqual = (value1: string, value2: string): boolean => { + return value1 === value2 +} + +export const isStringKeyword = (value: any): value is string => { + return typeof value === "string" +} + +export const isStringKeywordKeywordType = (type: KeywordType): boolean => { + return type && !!STRING_KEYWORD_TYPES.find(t => t === type) +} + +export const labelForStringKeyword = (value: string): string|null => { + return value ?? null +} + +export const searchServiceResultToStringKeyword = ( + result: INamedReference|string|undefined +): string|undefined => { + if (!!result) { + return (isNamedReference(result)) ? result.name : result + } + + return undefined +} + +export const areSearchResultsEqual = ( + result1: IAlignment|INamedReference|string, + result2: IAlignment|INamedReference|string +): boolean => { + if (!result1 || !result2) { + return result1 === result2 + } + + if (isAlignment(result1) && isAlignment(result2)) { + return areAlignmentsEqual(result1, result2) + } + + if (isNamedReference(result1) && isNamedReference(result2)) { + return areNamedReferencesEqual(result1, result2) + } + + if (isStringKeyword(result1) && isStringKeyword(result2)) { + return areStringKeywordsEqual(result1, result2) + } + + return false +} + +export const labelFor = (value: IAlignment|INamedReference|string): string|null => { + if (isAlignment(value)) { + return labelForAlignment(value) + } + + if (isNamedReference(value)) { + return labelForNamedReference(value) + } + + if (isStringKeyword(value)) { + return labelForStringKeyword(value) + } + + return null +} + +export const searchResultFromString = ( + keywordType: KeywordType, + value: string +): IAlignment|INamedReference|string|undefined => { + if (!keywordType || !value) { + return undefined + } + + if (isAlignmentKeywordType(keywordType)) { + return ApiAlignment.fromString(value) ?? undefined + } + + if (isNamedReferenceKeywordType(keywordType)) { + return ApiNamedReference.fromString(value) ?? undefined + } + + if (isStringKeywordKeywordType(keywordType)) { + return (value.length > 0) ? value : undefined + } + + return undefined +} diff --git a/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.html b/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.html deleted file mode 100644 index 255555bf0..000000000 --- a/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.html +++ /dev/null @@ -1,106 +0,0 @@ - -
- - -
-
-
-

- Loading… - -

-
-
-
- - - -
-
- - -
-
Showing search results:
- -
- - - -
-

- No Results -

-
-
-
-
-
- - -
- - {{r}} - -
- -
-
-
-
diff --git a/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts b/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts deleted file mode 100644 index 8b5868b3e..000000000 --- a/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {Component, EventEmitter, OnDestroy, OnInit, Output} from "@angular/core" -import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" -import {AbstractFormFieldSearchSelectComponent} from "../abstract-form-field-search-select.component" - -@Component({ - selector: "app-form-field-search-multi-select", - templateUrl: "./form-field-search-multi-select.component.html" -}) -export class FormFieldSearchMultiSelectComponent extends AbstractFormFieldSearchSelectComponent implements OnInit, OnDestroy { - - @Output() currentSelection = new EventEmitter() - - internalSelectedResults: string[] = [] - - constructor(searchService: KeywordSearchService) { - super(searchService) - } - - ngOnInit(): void { - super.ngOnInit() - this.performInitialSearchAndPopulation() - } - - performInitialSearchAndPopulation(): void { - const value = this.control.value as string - this.control.setValue("") - this.internalSelectedResults = value.split(";").map(s => s.trim()).filter(s => s.length > 0) - this.emitCurrentSelection() - } - - get showResults(): boolean { - const isEmpty = this.valueFromControl?.trim()?.length <= 0 - const isDirty = this.control.dirty - const show = isDirty && !isEmpty && this.results !== undefined - return show - } - - isResultSelected(result: string): boolean { - return !!this.internalSelectedResults.find(value => value === result) - } - - selectResult(result: string): void { - if (!this.isResultSelected(result)) { - this.internalSelectedResults.push(result) - } - this.emitCurrentSelection() - } - - unselectResult(result: string): void { - this.internalSelectedResults = this.internalSelectedResults.filter(r => r !== result) - this.control.markAsDirty() - this.emitCurrentSelection() - } - - protected emitCurrentSelection(): void { - this.currentSelection.emit(this.internalSelectedResults) - } - - handleKeyDownEnter($event: any): boolean { - const value = this.valueFromControl.trim() - if (value.length > 0) { - this.selectResult(this.valueFromControl) - this.clearField() - } - return false - } -} diff --git a/ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.html b/ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.html deleted file mode 100644 index 50530c153..000000000 --- a/ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.html +++ /dev/null @@ -1,87 +0,0 @@ - -
- - -
-
-
-

- Loading… - -

-
-
-
- - - -
-
- - -
-
Showing search results:
- -
- - - -
-

- No Results -

-
-
-
-
-
-
-
diff --git a/ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.ts b/ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.ts deleted file mode 100644 index 4ef795b95..000000000 --- a/ui/src/app/form/form-field-search-select/single-select/form-field-search-select.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {Component} from "@angular/core" -import {KeywordSearchService} from "../../../richskill/service/keyword-search.service" -import {AbstractFormFieldSearchSelectComponent} from "../abstract-form-field-search-select.component" - -@Component({ - selector: "app-form-field-search-select", - templateUrl: "./form-field-search-select.component.html" -}) -export class FormFieldSearchSelectComponent extends AbstractFormFieldSearchSelectComponent { - - constructor(searchService: KeywordSearchService) { - super(searchService) - } - - selectResult(result: string): void { - this.control.setValue(result, {emitEvent: false}) - this.results = undefined - } - - isResultSelected(result: string): boolean { - return this.valueFromControl === result - } -} diff --git a/ui/src/app/form/form.module.ts b/ui/src/app/form/form.module.ts index 501b12290..bac33dcbf 100644 --- a/ui/src/app/form/form.module.ts +++ b/ui/src/app/form/form.module.ts @@ -1,11 +1,11 @@ import {NgModule} from "@angular/core" -import {JobcodeSingleSelectComponent} from "./form-field-search-select" -import {FormFieldSearchSelectJobcodeComponent} from "../form" import {FormsModule, ReactiveFormsModule} from "@angular/forms" -import {FormField} from "./form-field.component" import {CommonModule} from "@angular/common" -import {FormFieldSearchMultiSelectComponent} from "./form-field-search-select" -import {FormFieldSearchSelectComponent} from "./form-field-search-select" +import {FormField} from "./form-field.component" +import {FormFieldJobCodeSearchSelect} from "./form-field-search-select" +import {FormFieldJobCodeSearchMultiSelect} from "./form-field-search-select" +import {FormFieldKeywordSearchSelect} from "./form-field-search-select" +import {FormFieldKeywordSearchMultiSelect} from "./form-field-search-select" import {FormFieldSubmit} from "./form-field-submit.component" import {FormFieldText} from "./form-field-text.component" import {FormFieldTextArea} from "./form-field-textarea.component" @@ -16,10 +16,10 @@ import {FormFieldTextArea} from "./form-field-textarea.component" FormFieldSubmit, FormFieldText, FormFieldTextArea, - FormFieldSearchSelectJobcodeComponent, - FormFieldSearchMultiSelectComponent, - FormFieldSearchSelectComponent, - JobcodeSingleSelectComponent + FormFieldJobCodeSearchSelect, + FormFieldJobCodeSearchMultiSelect, + FormFieldKeywordSearchSelect, + FormFieldKeywordSearchMultiSelect ], imports: [ CommonModule, @@ -31,10 +31,10 @@ import {FormFieldTextArea} from "./form-field-textarea.component" FormFieldSubmit, FormFieldText, FormFieldTextArea, - FormFieldSearchSelectJobcodeComponent, - FormFieldSearchMultiSelectComponent, - FormFieldSearchSelectComponent, - JobcodeSingleSelectComponent + FormFieldJobCodeSearchSelect, + FormFieldJobCodeSearchMultiSelect, + FormFieldKeywordSearchSelect, + FormFieldKeywordSearchMultiSelect ] }) export class FormModule { diff --git a/ui/src/app/richskill/ApiSkill.spec.ts b/ui/src/app/richskill/ApiSkill.spec.ts index 101a6edd2..28aa96eef 100644 --- a/ui/src/app/richskill/ApiSkill.spec.ts +++ b/ui/src/app/richskill/ApiSkill.spec.ts @@ -9,6 +9,7 @@ import { import { deepEqualSkipOuterType, mismatched } from "../../../test/util/deep-equals" import { PublishStatus } from "../PublishStatus" import { + ApiAlignment, ApiAuditLog, ApiNamedReference, ApiSkill, @@ -19,6 +20,7 @@ import { ISkill, IUuidReference } from "./ApiSkill" +import { ApiJobCode } from "../job-codes/Jobcode" // An example of a class-level test @@ -180,11 +182,11 @@ describe("ApiSkill", () => { expect(apiSkill.category).toEqual(iSkill.category) expect(apiSkill.collections).toEqual(iSkill.collections) expect(apiSkill.keywords).toEqual(iSkill.keywords) - expect(apiSkill.alignments).toEqual(iSkill.alignments) - expect(apiSkill.standards).toEqual(iSkill.standards) - expect(apiSkill.certifications).toEqual(iSkill.certifications) - expect(apiSkill.occupations).toEqual(iSkill.occupations) - expect(apiSkill.employers).toEqual(iSkill.employers) + expect(apiSkill.alignments).toEqual(iSkill.alignments.map(v => new ApiAlignment(v))) + expect(apiSkill.standards).toEqual(iSkill.standards.map(v => new ApiAlignment(v))) + expect(apiSkill.certifications).toEqual(iSkill.certifications.map(v => new ApiNamedReference(v))) + expect(apiSkill.occupations).toEqual(iSkill.occupations.map(v => new ApiJobCode(v))) + expect(apiSkill.employers).toEqual(iSkill.employers.map(v => new ApiNamedReference(v))) expect(apiSkill.author).toEqual(iSkill.author) }) }) diff --git a/ui/src/app/richskill/ApiSkill.ts b/ui/src/app/richskill/ApiSkill.ts index e24753538..13f68389d 100644 --- a/ui/src/app/richskill/ApiSkill.ts +++ b/ui/src/app/richskill/ApiSkill.ts @@ -1,4 +1,4 @@ -import {IJobCode} from "../job-codes/Jobcode" +import {ApiJobCode, IJobCode} from "../job-codes/Jobcode" import {PublishStatus} from "../PublishStatus" @@ -163,16 +163,16 @@ export class ApiSkill { this.skillName = iRichSkill.skillName this.skillStatement = iRichSkill.skillStatement this.author = iRichSkill.author - this.keywords = iRichSkill.keywords - this.collections = iRichSkill.collections + this.keywords = iRichSkill.keywords?.map(it => it) ?? null + this.collections = iRichSkill.collections?.map(it => it) ?? null this.status = iRichSkill.status this.category = iRichSkill.category - this.certifications = iRichSkill.certifications - this.alignments = iRichSkill.alignments - this.standards = iRichSkill.standards + this.certifications = iRichSkill.certifications?.map(it => new ApiNamedReference(it)) ?? null + this.alignments = iRichSkill.alignments?.map(it => new ApiAlignment(it)) ?? null + this.standards = iRichSkill.standards?.map(it => new ApiAlignment(it)) ?? null this.type = iRichSkill.type - this.employers = iRichSkill.employers - this.occupations = iRichSkill.occupations + this.employers = iRichSkill.employers?.map(it => new ApiNamedReference(it)) ?? null + this.occupations = iRichSkill.occupations?.map(it => new ApiJobCode(it)) ?? null } get sortedAlignments(): IAlignment[] { diff --git a/ui/src/app/richskill/form/rich-skill-form.component.html b/ui/src/app/richskill/form/rich-skill-form.component.html index ac3a211b8..e30c37260 100644 --- a/ui/src/app/richskill/form/rich-skill-form.component.html +++ b/ui/src/app/richskill/form/rich-skill-form.component.html @@ -28,7 +28,7 @@

placeholder="Disseminate Intelligence" [includePlaceholder]="!this.skillUuid" required="true" - [errorMessage]="nameErrorMessage()" + [errorMessage]="nameErrorMessage" > @@ -40,7 +40,7 @@

name="author" label="Author" required="true" - errorMessage="Author required" + [errorMessage]="authorErrorMessage" > @@ -53,7 +53,7 @@

placeholder="Disseminate items of highest intelligence value in a timely manner." [includePlaceholder]="!this.skillUuid" helpMessage='A description of the applied capabilities and/or behaviors of an individual for a given task, occupation, or need. The recommended format, to support interoperability and the common use of RSDs across organizations, is a predicate and context. Optionally, a skill statement may include a subject, such as "The employee ..." ' - errorMessage="Skill statement required" + [errorMessage]="skillStatementErrorMessage" (blur)="handleStatementBlur($event)" [isWarning]="hasStatementWarning" > @@ -65,74 +65,74 @@

- + [createNonExisting]="true" + >
- + [createNonExisting]="true" + >
- + [createNonExisting]="true" + >
- + [createNonExisting]="true" + >
- + >
- + [createNonExisting]="true" + >
diff --git a/ui/src/app/richskill/form/rich-skill-form.component.spec.ts b/ui/src/app/richskill/form/rich-skill-form.component.spec.ts index a9af3c1ff..f56841465 100644 --- a/ui/src/app/richskill/form/rich-skill-form.component.spec.ts +++ b/ui/src/app/richskill/form/rich-skill-form.component.spec.ts @@ -18,9 +18,7 @@ import { import { EnvironmentServiceStub, RichSkillServiceStub } from "../../../../test/resource/mock-stubs" import { ActivatedRouteStubSpec } from "../../../../test/util/activated-route-stub.spec" import { AppConfig } from "../../app.config" -import { initializeApp } from "../../app.module" import { EnvironmentService } from "../../core/environment.service" -import { IJobCode } from "../../job-codes/Jobcode" import { PublishStatus } from "../../PublishStatus" import { ToastService } from "../../toast/toast.service" import {ApiAlignment, ApiNamedReference, ApiSkill, INamedReference} from "../ApiSkill" @@ -34,6 +32,7 @@ import { } from "../ApiSkillUpdate" import { RichSkillService } from "../service/rich-skill.service" import { RichSkillFormComponent } from "./rich-skill-form.component" +import {ApiJobCode} from "../../job-codes/Jobcode"; export function createComponent(T: Type): Promise { @@ -118,7 +117,7 @@ describe("RichSkillFormComponent", () => { // Arrange component.skillForm.get("skillName")?.setValue("Copy of unique name") // Act - const result1 = component.nameErrorMessage() + const result1 = component.nameErrorMessage // Assert expect(result1).toEqual("Name is still a copy") @@ -126,9 +125,19 @@ describe("RichSkillFormComponent", () => { // Arrange component.skillForm.get("skillName")?.setValue("") // Act - const result2 = component.nameErrorMessage() + const result2 = component.nameErrorMessage // Assert - expect(result2).toEqual("Name is required") + expect(result2).toEqual("Name required") + }) + + it("authorErrorMessage should return error ", () => { + // Assert + expect(component.authorErrorMessage).toEqual("Author required") + }) + + it("skillStatementErrorMessage should return error ", () => { + // Assert + expect(component.skillStatementErrorMessage).toEqual("Skill Statement required") }) it("diffUuidList should be correct", () => { @@ -210,53 +219,65 @@ describe("RichSkillFormComponent", () => { }) it("diffReferenceList should be correct", () => { + // Arrange const references = [ createMockNamedReference("id1", "zebra"), createMockNamedReference("id2", "hippo"), createMockNamedReference("id3", "fox"), - createMockNamedReference("id4", "aardvark") + createMockNamedReference("id4", "aardvark"), + createMockNamedReference("id5", "giraffe") + ] + + const existing = [ + references[0], + references[1], + references[2], + references[3] ] // Act - adding 1, removing 2 - const updates1 = component.diffReferenceList([ "aardvark", "giraffe", "zebra" ], references) + const updates1 = component.diffNamedReferenceList( + [references[3], references[4], references[0]], + existing + ) // Assert expect(updates1).toEqual(new ApiReferenceListUpdate( - [ new ApiNamedReference({ name: "giraffe" }) ], - [ new ApiNamedReference({ name: "hippo" }), new ApiNamedReference({ name: "fox" }) ] + [ new ApiNamedReference(references[4]) ], + [ new ApiNamedReference(references[1]), new ApiNamedReference(references[2]) ] )) // Act - adding 0, removing 2 - const updates2 = component.diffReferenceList([ "aardvark", "zebra" ], references) + const updates2 = component.diffNamedReferenceList( + [ references[3], references[0] ], + existing + ) // Assert expect(updates2).toEqual(new ApiReferenceListUpdate( [ ], - [ new ApiNamedReference({ name: "hippo" }), new ApiNamedReference({ name: "fox" }) ] + [ new ApiNamedReference(references[1]), new ApiNamedReference(references[2]) ] )) // Act - adding 1, removing 0 - const updates3 = component.diffReferenceList([ "aardvark", "giraffe", "hippo", "zebra", "fox" ], references) + const updates3 = component.diffNamedReferenceList( + [references[3], references[1], references[4], references[0], references[2]], + existing + ) // Assert expect(updates3).toEqual(new ApiReferenceListUpdate( - [ new ApiNamedReference({ name: "giraffe" }) ], - [ ] + [ new ApiNamedReference(references[4]) ], + [] )) // Act - const updates4 = component.diffReferenceList([ "fox", "aardvark", "hippo", "zebra" ], references) + const updates4 = component.diffNamedReferenceList( + [ references[2], references[3], references[1], references[0] ], + existing + ) // Assert expect(updates4).toEqual(undefined) }) - it("splitTextArea should split into words", () => { - // Arrange - const words = "apple; banana;chocolate" // 'banana' has a space that will be trimmed out - // Act - const result = component.splitTextarea(words) - // Assert - expect(result).toEqual([ "apple", "banana", "chocolate" ]) - }) - it("nonEmptyOrNull should trim and return null for empty strings", () => { expect(component.nonEmptyOrNull(undefined)).toEqual(undefined) expect(component.nonEmptyOrNull("")).toEqual(undefined) @@ -265,14 +286,19 @@ describe("RichSkillFormComponent", () => { }) it("updateObject should return updates", () => { + // Arrange const { // These should not be modified + author, category, collections, skillName, skillStatement, // tslint:disable-next-line:no-any } = setupForm(false) as any + component.isDuplicating = false + component.existingSkill = null // For this test, assume the best + const { // These will be overwritten by the component's selectedXYZ fields certifications, employers, @@ -280,9 +306,26 @@ describe("RichSkillFormComponent", () => { occupations, standards // tslint:disable-next-line:no-any - } = setupSelectedFields(false) as any - component.isDuplicating = false - component.existingSkill = null // For this test, assume the best + } = { + certifications: [ApiNamedReference.fromString("cert1"), ApiNamedReference.fromString("cert2")].filter(v => !!v), + employers: [ApiNamedReference.fromString("empl1"), ApiNamedReference.fromString("empl2")].filter(v => !!v), + keywords: ["kywd1", "kywd2"].filter(v => !!v), + occupations: [new ApiJobCode({ code: "occp1" }), new ApiJobCode({ code: "occp2" })], + standards: [ApiAlignment.fromString("stnd1"), ApiAlignment.fromString("stnd2")].filter(v => !!v) + } + + component.skillForm.setValue({ + skillName: skillName, + skillStatement: skillStatement, + author: author, + category: category, + collections: collections, + certifications: certifications, + employers: employers, + keywords: keywords, + occupations: occupations, + standards: standards + }) // Act const update = component.updateObject() @@ -291,12 +334,12 @@ describe("RichSkillFormComponent", () => { expect(update.skillName).toEqual(skillName) expect(update.skillStatement).toEqual(skillStatement) expect(update.category).toEqual(category) - expect((update.keywords as IStringListUpdate).add).toEqual(keywords) - expect((update.standards as IReferenceListUpdate).add).toEqual(standards) + expect((update.keywords as IStringListUpdate).add).toEqual(keywords as string[]) + expect((update.standards as IReferenceListUpdate).add).toEqual(standards as INamedReference[]) expect((update.collections as IStringListUpdate).add).toEqual(collections) - expect((update.certifications as IReferenceListUpdate).add).toEqual(certifications) - expect((update.occupations as IStringListUpdate).add).toEqual(occupations) - expect((update.employers as IReferenceListUpdate).add).toEqual(employers) + expect((update.certifications as IReferenceListUpdate).add).toEqual(certifications as INamedReference[]) + expect((update.occupations as IStringListUpdate).add).toEqual(occupations.map(o => component.stringFromJobCode(o))) + expect((update.employers as IReferenceListUpdate).add).toEqual(employers as INamedReference[]) }) @@ -311,7 +354,6 @@ describe("RichSkillFormComponent", () => { component.existingSkill = skill component.skillUuid = iSkill.uuid setupForm(false) - setupSelectedFields(false) const richSkillService = TestBed.inject(RichSkillService) spyOn(richSkillService, "createSkill").and.callThrough() spyOn(richSkillService, "updateSkill").and.callFake( @@ -329,18 +371,6 @@ describe("RichSkillFormComponent", () => { expect(router.navigate).toHaveBeenCalledWith(["/skills/my skill uuid/manage"]) }) - it("namedReferenceString should return NamedReference or undefined", () => { - expect(component.namedReferenceForString("")).toEqual(undefined) - expect(component.namedReferenceForString("a://b")).toEqual(new ApiNamedReference({ id: "a://b" })) - expect(component.namedReferenceForString("abc")).toEqual(new ApiNamedReference({ name: "abc" })) - }) - - it("stringFromJobCode should return string", () => { - expect(component.stringFromJobCode(undefined)).toEqual("") - expect(component.stringFromJobCode({ code: "abcd" })).toEqual("abcd") - expect(component.stringFromJobCode({ frameworkName: "id1" } as IJobCode)).toEqual("") - }) - it("setSkill should be correct", () => { // Arrange component.isDuplicating = false @@ -378,82 +408,6 @@ describe("RichSkillFormComponent", () => { expect(component.showAuthor()).toBeTruthy() }) - it("populateTypeAheadFieldsWithResults should fill form with defaults", () => { - // Arrange - const form = component.skillForm.value - setupSelectedFields(false) - - // Act - component.populateTypeAheadFieldsWithResults() - - // Assert - expect(form.standards).toEqual("standard1; standard2") - expect(form.occupations).toEqual("occupation1; occupation2") - expect(form.keywords).toEqual("keyword1; keyword2") - expect(form.certifications).toEqual("certification1; certification2") - expect(form.employers).toEqual("employer1; employer2") - }) - - it("handleStandardsTypeAheadResults should be correct", () => { - // Arrange - component.selectedStandards = [] - const strings = [ "string1", "string2" ] - - // Act - component.handleStandardsTypeAheadResults(strings) - - // Assert - expect(component.selectedStandards).toEqual(strings) - }) - - it("handleJobCodesTypeAheadResults should be correct", () => { - // Arrange - component.selectedJobCodes = [] - const strings = [ "string1", "string2" ] - - // Act - component.handleJobCodesTypeAheadResults(strings) - - // Assert - expect(component.selectedJobCodes).toEqual(strings) - }) - - it("handleKeywordTypeAheadResults should be correct", () => { - // Arrange - component.selectedKeywords = [] - const strings = [ "string1", "string2" ] - - // Act - component.handleKeywordTypeAheadResults(strings) - - // Assert - expect(component.selectedKeywords).toEqual(strings) - }) - - it("handleCertificationTypeAheadResults should be correct", () => { - // Arrange - component.selectedCertifications = [] - const strings = [ "string1", "string2" ] - - // Act - component.handleCertificationTypeAheadResults(strings) - - // Assert - expect(component.selectedCertifications).toEqual(strings) - }) - - it("handleEmployersTypeAheadResults should be correct", () => { - // Arrange - component.selectedEmployers = [] - const strings = [ "string1", "string2" ] - - // Act - component.handleEmployersTypeAheadResults(strings) - - // Assert - expect(component.selectedEmployers).toEqual(strings) - }) - it("handleStatementBlur should be correct", () => { // Arrange const value = "test" @@ -525,9 +479,29 @@ describe("RichSkillFormComponent (with parameter)", () => { }) }) - function setupForm(isBlank: boolean): object { const form = component.skillForm + + const occupations = [ "occp1", "occp2", "occp3" ] + + const standards = [ + ApiNamedReference.fromString("stnd1"), + ApiNamedReference.fromString("stnd2"), + ApiNamedReference.fromString("stnd3") + ] + + const certifications = [ + ApiNamedReference.fromString("cert1"), + ApiNamedReference.fromString("cert2"), + ApiNamedReference.fromString("cert3") + ] + + const employers = [ + ApiNamedReference.fromString("empl1"), + ApiNamedReference.fromString("empl2"), + ApiNamedReference.fromString("empl3") + ] + const fields = isBlank ? { skillName: "", @@ -545,40 +519,14 @@ function setupForm(isBlank: boolean): object { skillName: "my skill", skillStatement: "my statement", author: "my author", - category: "my category", - keywords: ["keyword1", "keyword2", "keyword3"], + category: ApiNamedReference.fromString("my category"), + keywords: ["kywd1", "kywd2", "kywd3"], collections: ["collection1", "collection2"], - occupations: ["occupation1", "occupation2", "occupation3"], - standards: ["standard1", "standard2", "standard3"], - certifications: ["certification1", "certification2", "certification3"], - employers: ["employer1", "employer2", "employer3"] + occupations: occupations, + standards: standards, + certifications: certifications, + employers: employers } form.setValue(fields) return fields } - -function setupSelectedFields(isBlank: boolean): object { - if (isBlank) { - component.selectedKeywords = [""] - component.selectedJobCodes = [] - component.selectedStandards = [] - component.selectedCertifications = [] - component.selectedEmployers = [] - } - else { - component.selectedKeywords = ["keyword1", "keyword2"] - component.selectedJobCodes = ["occupation1", "occupation2"] - component.selectedStandards = ["standard1", "standard2"] - component.selectedCertifications = ["certification1", "certification2"] - component.selectedEmployers = ["employer1", "employer2"] - } - - // Return the new values for easy deconstruction - return { - keywords: component.selectedKeywords, - occupations: component.selectedJobCodes, - standards: component.selectedStandards.map(x => new ApiAlignment({ id: undefined, skillName: x }) as INamedReference), - certifications: component.selectedCertifications.map(x => new ApiNamedReference({ id: undefined, name: x })), - employers: component.selectedEmployers.map(x => new ApiNamedReference({ id: undefined, name: x })) - } -} diff --git a/ui/src/app/richskill/form/rich-skill-form.component.ts b/ui/src/app/richskill/form/rich-skill-form.component.ts index cf7033b13..cb525db74 100644 --- a/ui/src/app/richskill/form/rich-skill-form.component.ts +++ b/ui/src/app/richskill/form/rich-skill-form.component.ts @@ -1,7 +1,7 @@ -import {Component, Injectable, OnInit} from "@angular/core" -import {AbstractControl, FormBuilder, FormControl, FormGroup, ValidationErrors, Validators} from "@angular/forms" +import {Component,OnInit} from "@angular/core" +import {AbstractControl, FormBuilder, FormControl, FormGroup, Validators} from "@angular/forms" import {Location} from "@angular/common" -import {ActivatedRoute, ActivatedRouteSnapshot, CanDeactivate, Router, RouterStateSnapshot} from "@angular/router" +import {ActivatedRoute, Router} from "@angular/router" import {RichSkillService} from "../service/rich-skill.service" import {Observable} from "rxjs" import { @@ -11,18 +11,17 @@ import { KeywordType, IUuidReference, ApiAlignment, - IRef, IAlignment + IAlignment } from "../ApiSkill" import { ApiStringListUpdate, - IStringListUpdate, ApiSkillUpdate, ApiReferenceListUpdate, ApiAlignmentListUpdate } from "../ApiSkillUpdate" import {AppConfig} from "../../app.config" import {urlValidator} from "../../validators/url.validator" -import { IJobCode } from "src/app/job-codes/Jobcode" +import {IJobCode} from "src/app/job-codes/Jobcode" import {ToastService} from "../../toast/toast.service" import {Title} from "@angular/platform-browser" import {HasFormGroup} from "../../core/abstract-form.component" @@ -45,13 +44,6 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has skillSaved: Observable | null = null isDuplicating = false - // Type ahead storage to append to the field on submit - selectedStandards: string[] = [] - selectedJobCodes: string[] = [] - selectedKeywords: string[] = [] - selectedCertifications: string[] = [] - selectedEmployers: string[] = [] - // This allows this enum's constants to be used in the template keywordType = KeywordType @@ -71,6 +63,32 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has super() } + get formValid(): boolean { + const alignments_valid = !this.alignmentForms.map(group => group.valid).some(x => !x) + return alignments_valid && this.skillForm.valid + } + + get formDirty(): boolean { + const alignments_dirty = this.alignmentForms.map(x => x.dirty).some(x => x) + return alignments_dirty || this.skillForm.dirty + } + + get hasStatementWarning(): boolean { + return (this.similarSkills?.length ?? -1) > 0 + } + + get nameErrorMessage(): string { + return this.skillForm.get("skillName")?.hasError("notACopy") ? "Name is still a copy" : "Name required" + } + + get authorErrorMessage(): string { + return "Author required" + } + + get skillStatementErrorMessage(): string { + return "Skill Statement required" + } + formGroup(): FormGroup { return this.skillForm } ngOnInit(): void { @@ -101,28 +119,24 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has return `${this.existingSkill != null ? "Edit" : "Create"} Rich Skill Descriptor` } - nameErrorMessage(): string { - return this.skillForm.get("skillName")?.hasError("notACopy") ? "Name is still a copy" : "Name is required" - } - getFormDefinitions(): {[key: string]: AbstractControl} { const fields = { skillName: new FormControl("", Validators.compose([Validators.required, notACopyValidator])), skillStatement: new FormControl("", Validators.required), - category: new FormControl(""), - keywords: new FormControl(""), - standards: new FormControl(""), - collections: new FormControl(""), - certifications: new FormControl(""), - occupations: new FormControl(""), - employers: new FormControl(""), + category: new FormControl(null), + keywords: new FormControl(null), + standards: new FormControl(null), + collections: new FormControl(null), + certifications: new FormControl(null), + occupations: new FormControl(null), + employers: new FormControl(null), author: new FormControl(AppConfig.settings.defaultAuthorValue, Validators.required) } return fields } diffUuidList(words: string[], collections?: IUuidReference[]): ApiStringListUpdate | undefined { - const existing: Set = new Set(collections?.map(it => it.name)) + const existing: Set = new Set(collections?.map(it => it.name) ?? []) const provided = new Set(words) const removing = [...existing].filter(x => !provided.has(x)) const adding = [...provided].filter(x => !existing.has(x)) @@ -130,36 +144,29 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has } diffStringList(words: string[], keywords?: string[]): ApiStringListUpdate | undefined { - const existing = new Set(keywords) + const existing = new Set(keywords ?? []) const provided = new Set(words) const removing: string[] = [...existing].filter(x => !provided.has(x)) const adding: string[] = [...provided].filter(x => !existing.has(x)) return (removing.length > 0 || adding.length > 0) ? new ApiStringListUpdate(adding, removing) : undefined } - diffReferenceList(words: string[], refs?: INamedReference[]): ApiReferenceListUpdate | undefined { - const existing: Set = new Set(refs?.map(it => ApiNamedReference.formatRef(it)).filter(it => it) ?? []) - const provided: Set = new Set(words.filter(it => it)) - const removing = [...existing].filter(x => !provided.has(x)).map(it => ApiNamedReference.fromString(it)) - .filter(it => it).map(it => it as ApiNamedReference) // filterNotNull - const adding = [...provided].filter(x => !existing.has(x)).map(it => ApiNamedReference.fromString(it)) - .filter(it => it).map(it => it as ApiNamedReference) // filterNotNull + diffNamedReferenceList(provided: INamedReference[], existing?: INamedReference[]): ApiReferenceListUpdate | undefined { + const newValues = provided?.map(v => new ApiNamedReference(v)) ?? [] + const prevValues = existing?.map(v => new ApiNamedReference(v)) ?? [] + const removing: ApiNamedReference[] = [...prevValues].filter(x => newValues.findIndex(y => x.equals(y)) === -1) + const adding: ApiNamedReference[] = [...newValues].filter(x => prevValues.findIndex(y => x.equals(y)) === -1) return (removing.length > 0 || adding.length > 0) ? new ApiReferenceListUpdate(adding, removing) : undefined } - diffAlignmentList(provided: ApiAlignment[], refs?: IAlignment[]): ApiAlignmentListUpdate | undefined { - const existing: ApiAlignment[] = refs?.map(it => new ApiAlignment(it)) ?? [] - const removing: ApiAlignment[] = [...existing].filter(x => provided.findIndex(y => x.equals(y)) === -1) - const adding: ApiAlignment[] = [...provided].filter(x => existing.findIndex(y => x.equals(y)) === -1) + diffAlignmentList(provided: IAlignment[], existing?: IAlignment[]): ApiAlignmentListUpdate | undefined { + const newValues = provided?.map(v => new ApiAlignment(v)) ?? [] + const prevValues = existing?.map(v => new ApiAlignment(v)) ?? [] + const removing: ApiAlignment[] = [...prevValues].filter(x => newValues.findIndex(y => x.equals(y)) === -1) + const adding: ApiAlignment[] = [...newValues].filter(x => prevValues.findIndex(y => x.equals(y)) === -1) return (removing.length > 0 || adding.length > 0) ? new ApiAlignmentListUpdate(adding, removing) : undefined } - - - splitTextarea(textValue: string): Array { - return textValue.split(";").map(it => it.trim()) - } - nonEmptyOrNull(s?: string): string | undefined { const val: string | undefined = s?.trim() if (val === undefined) { return undefined } @@ -170,9 +177,6 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has const update = new ApiSkillUpdate({}) const formValue = this.skillForm.value - // pre-populate type-ahead values into field - this.populateTypeAheadFieldsWithResults() - const inputName = this.nonEmptyOrNull(formValue.skillName) if (inputName && (this.isDuplicating || this.existingSkill?.skillName !== inputName)) { update.skillName = inputName @@ -188,36 +192,55 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has update.author = author } - const inputCategory = this.nonEmptyOrNull(formValue.category) + const inputCategory = formValue.category if (this.isDuplicating || this.existingSkill?.category !== inputCategory) { update.category = inputCategory ?? "" } - const collectionsDiff = this.diffUuidList(formValue.collections, this.existingSkill?.collections) - if (this.isDuplicating || collectionsDiff) { update.collections = collectionsDiff } + const collectionsDiff = this.diffUuidList( + formValue.collections ?? [], + this.existingSkill?.collections + ) + if (this.isDuplicating || collectionsDiff) { + update.collections = collectionsDiff + } - const keywordDiff = this.diffStringList(this.splitTextarea(formValue.keywords), this.existingSkill?.keywords) - if (this.isDuplicating || keywordDiff) { update.keywords = keywordDiff } + const keywordDiff = this.diffStringList( + formValue.keywords ?? [], + this.existingSkill?.keywords + ) + if (this.isDuplicating || keywordDiff) { + update.keywords = keywordDiff + } const occupationsDiff = this.diffStringList( - this.splitTextarea(formValue.occupations), - this.existingSkill?.occupations?.map(it => this.stringFromJobCode(it)) + formValue.occupations?.map((it: IJobCode) => this.stringFromJobCode(it)), + this.existingSkill?.occupations?.map((it: IJobCode) => this.stringFromJobCode(it)) ) - if (this.isDuplicating || occupationsDiff) { update.occupations = occupationsDiff } - - const standards = this.splitTextarea(formValue.standards).map(s => new ApiAlignment({skillName: s})) + if (this.isDuplicating || occupationsDiff) { + update.occupations = occupationsDiff + } - const standardsDiff = this.diffAlignmentList(standards, this.existingSkill?.standards) + const standardsDiff = this.diffAlignmentList( + formValue.standards ?? [], + this.existingSkill?.standards + ) if (this.isDuplicating || standardsDiff) { update.standards = standardsDiff } - const certificationsDiff = this.diffReferenceList(this.splitTextarea(formValue.certifications), this.existingSkill?.certifications) + const certificationsDiff = this.diffNamedReferenceList( + formValue.certifications ?? [], + this.existingSkill?.certifications + ) if (this.isDuplicating || certificationsDiff) { update.certifications = certificationsDiff } - const employersDiff = this.diffReferenceList(this.splitTextarea(formValue.employers), this.existingSkill?.employers) + const employersDiff = this.diffNamedReferenceList( + formValue.employers ?? [], + this.existingSkill?.employers + ) if (this.isDuplicating || employersDiff) { update.employers = employersDiff } @@ -231,7 +254,11 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has isPartOf: frameworkName ? new ApiNamedReference({name: frameworkName}) : undefined }) }).filter(a => a.id || a.skillName) - const alignmentDiff = this.diffAlignmentList(alignments, this.existingSkill?.alignments) + + const alignmentDiff = this.diffAlignmentList( + alignments ?? [], + this.existingSkill?.alignments + ) if (this.isDuplicating || alignmentDiff) { update.alignments = alignmentDiff } @@ -240,16 +267,6 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has return update } - get formValid(): boolean { - const alignments_valid = !this.alignmentForms.map(group => group.valid).some(x => !x) - return alignments_valid && this.skillForm.valid - } - - get formDirty(): boolean { - const alignments_dirty = this.alignmentForms.map(x => x.dirty).some(x => x) - return alignments_dirty || this.skillForm.dirty - } - onSubmit(): void { const updateObject = this.updateObject() @@ -281,25 +298,6 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has } } - stringFromAlignment(ref?: IAlignment): string { - return ref?.skillName ?? ref?.id ?? "" - } - - namedReferenceForString(value: string): ApiNamedReference | undefined { - const str = value.trim() - if (str.length < 1) { - return undefined - } else if (str.indexOf("://") !== -1) { - return new ApiNamedReference({id: str}) - } else { - return new ApiNamedReference({name: str}) - } - } - - stringFromNamedReference(ref?: INamedReference): string { - return ref?.name ?? ref?.id ?? "" - } - stringFromJobCode(jobcode?: IJobCode): string { return jobcode?.code ?? "" } @@ -316,15 +314,16 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has const fields = { skillName: (this.isDuplicating ? "Copy of " : "") + skill.skillName, skillStatement: skill.skillStatement, - category: skill.category ?? "", - keywords: skill.keywords?.join("; ") ?? "", - standards: skill.standards?.map(it => this.stringFromAlignment(it)).join("; ") ?? "", + category: (skill.category && skill.category.length > 0) ? skill.category : null, + keywords: skill.keywords?.map(it => it) ?? null, + standards: skill.standards?.map(it => it) ?? null, collections: skill.collections?.map(it => it.name) ?? [], - certifications: skill.certifications?.map(it => this.stringFromNamedReference(it)).join("; ") ?? "", - occupations: skill.occupations?.map(it => this.stringFromJobCode(it)).join("; ") ?? "", - employers: skill.employers?.map(it => this.stringFromNamedReference(it)).join("; ") ?? "", + certifications: skill.certifications?.map(it => it) ?? null, + occupations: skill.occupations?.map(it => it) ?? null, + employers: skill.employers?.map(it => it) ?? null, author: skill.author } + this.skillForm.setValue(fields) skill.alignments.forEach(a => this.addAlignment(a)) @@ -391,35 +390,6 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has return false } - populateTypeAheadFieldsWithResults(): void { - const formValue = this.skillForm.value - formValue.standards = this.selectedStandards.join("; ") - formValue.occupations = this.selectedJobCodes.join("; ") - formValue.keywords = this.selectedKeywords.join("; ") - formValue.certifications = this.selectedCertifications.join("; ") - formValue.employers = this.selectedEmployers.join("; ") - } - - handleStandardsTypeAheadResults(standards: string[]): void { - this.selectedStandards = standards - } - - handleJobCodesTypeAheadResults(jobCodes: string[]): void { - this.selectedJobCodes = jobCodes - } - - handleKeywordTypeAheadResults(keywords: string[]): void { - this.selectedKeywords = keywords - } - - handleCertificationTypeAheadResults(certifications: string[]): void { - this.selectedCertifications = certifications - } - - handleEmployersTypeAheadResults(employers: string[]): void { - this.selectedEmployers = employers - } - handleStatementBlur($event: FocusEvent): void { const statement = this.skillForm.controls.skillStatement.value @@ -434,10 +404,6 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has }) } - get hasStatementWarning(): boolean { - return (this.similarSkills?.length ?? -1) > 0 - } - addAlignment(existing?: IAlignment): boolean { const fields = { @@ -458,6 +424,7 @@ export class RichSkillFormComponent extends Whitelabelled implements OnInit, Has return false } + removeAlignment(alignmentIndex: number): boolean { this.alignmentForms.splice(alignmentIndex, 1) diff --git a/ui/src/app/search/advanced-search/advanced-search.component.html b/ui/src/app/search/advanced-search/advanced-search.component.html index 9d91805e7..acbc08bc0 100644 --- a/ui/src/app/search/advanced-search/advanced-search.component.html +++ b/ui/src/app/search/advanced-search/advanced-search.component.html @@ -14,7 +14,6 @@

Advanced Search

- {{getSemicolonHelpMessage()}} @@ -29,13 +28,13 @@

- - +
@@ -49,71 +48,71 @@

- - +
- - +
- - +
- - +
- + >
- - +
- - +
diff --git a/ui/src/app/search/advanced-search/advanced-search.component.spec.ts b/ui/src/app/search/advanced-search/advanced-search.component.spec.ts index d143a4488..1421dd32f 100644 --- a/ui/src/app/search/advanced-search/advanced-search.component.spec.ts +++ b/ui/src/app/search/advanced-search/advanced-search.component.spec.ts @@ -9,7 +9,8 @@ import { AppConfig } from "../../app.config" import { EnvironmentService } from "../../core/environment.service" import { FormFieldText } from "../../form/form-field-text.component" import { FormField } from "../../form/form-field.component" -import { INamedReference } from "../../richskill/ApiSkill" +import { ApiNamedReference, IAlignment } from "../../richskill/ApiSkill" +import { ApiJobCode, IJobCode } from "../../job-codes/Jobcode" import { ApiSearch } from "../../richskill/service/rich-skill-search.service" import { SearchService } from "../search.service" import { AdvancedSearchHorizontalActionBarComponent } from "./action-bar/advanced-search-horizontal-action-bar.component" @@ -103,12 +104,12 @@ describe("AdvancedSearchComponent", () => { author, skillStatement, category, - keywords: tokenizeString(keywords), - standards: prepareNamedReferences(standards), - certifications: prepareNamedReferences(certifications), - occupations: tokenizeString(occupations), - employers: prepareNamedReferences(employers), - alignments: prepareNamedReferences(alignments), + keywords: keywords, + standards: standards.map((v: IAlignment) => new ApiNamedReference({ name: v.skillName })), + certifications: certifications, + occupations: occupations.map((v: IJobCode) => v.code), + employers: employers, + alignments: alignments.map((v: IAlignment) => new ApiNamedReference({ name: v.skillName })), collectionName } const expected = new ApiSearch({ advanced, filtered: {} }) @@ -130,12 +131,12 @@ function setupForm(isBlank: boolean): object { skillStatement: "", author: "", category: "", - keywords: [].join(";"), - standards: [].join(";"), - certifications: [].join(";"), - occupations: [].join(";"), - employers: [].join(";"), - alignments: [].join(";"), + keywords: [], + standards: [], + certifications: [], + occupations: [], + employers: [], + alignments: [], collectionName: "" } : { @@ -143,26 +144,14 @@ function setupForm(isBlank: boolean): object { skillStatement: "my statement", author: "my author", category: "my category", - keywords: ["keyword1", "keyword2", "keyword3"].join(";"), - standards: ["standard1", "standard2", "standard3"].join(";"), - certifications: ["certification1", "certification2", "certification3"].join(";"), - occupations: ["occupation1", "occupation2", "occupation3"].join(";"), - employers: ["employer1", "employer2", "employer3"].join(";"), - alignments: ["alignment1", "alignment2"].join(";"), + keywords: ["keyword1", "keyword2", "keyword3"], + standards: ["standard1", "standard2", "standard3"].map(v => new ApiNamedReference({ name: v})), + certifications: ["certification1", "certification2", "certification3"].map(v => new ApiNamedReference({ name: v})), + occupations: ["occupation1", "occupation2", "occupation3"].map(v => new ApiJobCode({ code: v })), + employers: ["employer1", "employer2", "employer3"].map(v => new ApiNamedReference({ name: v })), + alignments: ["alignment1", "alignment2"].map(v => new ApiNamedReference({ name: v })), collectionName: "collection 1" } form.setValue(fields) return fields } - -function tokenizeString(s: string, token: string = ";"): string[] | undefined { - return s - .split(token) - .map(v => v.trim()) - .filter(v => v.length > 0) - || undefined -} - -function prepareNamedReferences(value: string, token: string = ";"): INamedReference[] | undefined { - return tokenizeString(value, token)?.map(v => ({name: v})) || undefined -} diff --git a/ui/src/app/search/advanced-search/advanced-search.component.ts b/ui/src/app/search/advanced-search/advanced-search.component.ts index 2cbebcdb3..82e72d1a0 100644 --- a/ui/src/app/search/advanced-search/advanced-search.component.ts +++ b/ui/src/app/search/advanced-search/advanced-search.component.ts @@ -5,9 +5,10 @@ import {AppConfig} from "../../app.config" import {urlValidator} from "../../validators/url.validator" import {SearchService} from "../search.service" import {ApiAdvancedSearch} from "../../richskill/service/rich-skill-search.service" -import {ApiNamedReference, INamedReference, KeywordType} from "../../richskill/ApiSkill" +import {ApiNamedReference, IAlignment, INamedReference, KeywordType} from "../../richskill/ApiSkill" import {Title} from "@angular/platform-browser"; import {Whitelabelled} from "../../../whitelabel"; +import {IJobCode} from "../../job-codes/Jobcode"; @Component({ selector: "app-advanced-search", @@ -33,15 +34,15 @@ export class AdvancedSearchComponent extends Whitelabelled implements OnInit { getFormDefinitions(): {[key: string]: AbstractControl} { const fields = { name: new FormControl(""), - author: new FormControl(""), + author: new FormControl(null), skillStatement: new FormControl(""), - category: new FormControl(""), - keywords: new FormControl(""), - standards: new FormControl(""), - certifications: new FormControl(""), - occupations: new FormControl(""), - employers: new FormControl(""), - alignments: new FormControl("", urlValidator), + category: new FormControl(null), + keywords: new FormControl(null), + standards: new FormControl(null), + certifications: new FormControl(null), + occupations: new FormControl(null), + employers: new FormControl(null), + alignments: new FormControl(null, urlValidator), collectionName: new FormControl(""), } return fields @@ -62,12 +63,20 @@ export class AdvancedSearchComponent extends Whitelabelled implements OnInit { const author: string = form.author const skillStatement: string = form.skillStatement const category: string = form.category - const keywords = this.tokenizeString(form.keywords) - const standards = this.prepareNamedReferences(form.standards) - const certifications = this.prepareNamedReferences(form.certifications) - const occupations = this.tokenizeString(form.occupations) - const employers = this.prepareNamedReferences(form.employers) - const alignments = this.prepareNamedReferences(form.alignments) + const keywords = (form.keywords && form.keywords.length > 0) + ? form.keywords.map((v: INamedReference) => v) : undefined + // Necessary because of type mismatch between ApiSkill(IAlignment[]) & ApiAdvancedSearch(INamedReference[]) + const standards = (form.standards && form.standards.length > 0) + ? form.standards.map((v: IAlignment) => new ApiNamedReference({ name: v.skillName })) : undefined + const certifications = (form.certifications && form.certifications.length > 0) + ? form.certifications.map((v: INamedReference) => v) : undefined + const occupations = (form.occupations && form.occupations.length > 0) + ? form.occupations.map((v: IJobCode) => v.code) : undefined + const employers = (form.employers && form.employers.length > 0) + ? form.employers.map((v: INamedReference) => v) : undefined + // Necessary because of type mismatch between ApiSkill(IAlignment[]) & ApiAdvancedSearch(INamedReference[]) + const alignments = (form.alignments && form.alignments.length > 0) + ? form.alignments.map((v: IAlignment) => new ApiNamedReference({ name: v.skillName})) : undefined const collectionName = form.collectionName // if a property would be falsey, then completely omit it @@ -76,43 +85,21 @@ export class AdvancedSearchComponent extends Whitelabelled implements OnInit { ...!isSearchingCollections && { skillName: name }, ...author && { author }, ...skillStatement && { skillStatement }, - ...category && { category}, - ...form.keywords && { keywords }, - ...form.standards && { standards }, - ...form.certifications && { certifications }, - ...form.occupations && { occupations }, - ...form.employers && { employers }, - ...form.alignments && { alignments }, + ...category && { category }, + ...keywords && { keywords }, + ...standards && { standards }, + ...certifications && { certifications }, + ...occupations && { occupations }, + ...employers && { employers }, + ...alignments && { alignments }, ...collectionName && { collectionName } } } - scrubReference(value: string): INamedReference | undefined { - value = value.trim() - return (value.length > 0) ? {name: value} : undefined - } - - prepareNamedReferences(value: string, token: string = ";"): INamedReference[] | undefined { - return this.tokenizeString(value, token)?.map(v => ({name: v})) || undefined - } - - // used for advance search to tokenize string fields - tokenizeString(value: string, token: string = ";"): string[] | undefined { - return value - .split(token) - .map(v => v.trim()) - .filter(v => v.length > 0) - || undefined - } - showAuthor(): boolean { return AppConfig.settings.editableAuthor } - getSemicolonHelpMessage(): string { - return "Use a semicolon to separate multiple entries in a field." - } - getOccupationHelpMessage(): string { return "BLS or O*NET job names or codes" } diff --git a/ui/src/styles.css b/ui/src/styles.css index e763da4df..bd3ce5f8e 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -3,3 +3,38 @@ pointer-events: none; opacity: 0.4; } + +/* [START] Needed for search control refactor until design system is updated */ +.m-search .m-search-x-controls:before, +.m-search .m-search-x-input:valid+.m-search-x-controls:before { + background-color: var(--color-background300); + border: 1px solid var(--color-background500); + border-style: solid; + border-color: var(--color-background500); + border-width: 1px; + border-radius: 8px; + outline: none; + outline-offset: 2px; + box-shadow: none; + transition: outline var(--t-transition), outline-offset var(--t-transition), border-radius var(--t-transition), border-color var(--t-transition), background-color var(--t-transition), box-shadow var(--t-transition); +} + +.m-search .m-search-x-input:focus+.m-search-x-controls:before { + border-radius: 0 !important; + border-width: 2px; + border-color: var(--color-interactive2); + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +.m-search .m-search-x-input:disabled+.m-search-x-controls:before { + background-color: var(--color-background200); + border-color: var(--color-background300); + cursor: not-allowed; +} + +.m-search.m-is-error .m-search-x-input+.m-search-x-controls:before { + border-color: var(--color-attention); + border-width: 2px; +} +/* [END] Needed for search control refactor until design system is updated */ diff --git a/ui/test/resource/mock-data.ts b/ui/test/resource/mock-data.ts index 4307ae3ac..ac394ef62 100644 --- a/ui/test/resource/mock-data.ts +++ b/ui/test/resource/mock-data.ts @@ -237,7 +237,7 @@ export function createMockSkill(creationDate: Date, updateDate: Date, status: Pu collections: [createMockUuidReference("1", "coll")], keywords: ["keyword 1", "keyword 2"], alignments: [createMockAlignment("2", "alignment", { id: "22", name: "myFramework" })], - standards: [createMockNamedReference("3", "standard")], + standards: [createMockAlignment("3", "standard")], certifications: [createMockNamedReference("4", "cert")], occupations: [createMockJobcode(5, "jobcode", "my jobcode")], employers: [createMockNamedReference("6", "employer")],