Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ui/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
},
Expand Down
146 changes: 146 additions & 0 deletions ui/src/app/form/abstract-form-field.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ITestValueType|null> {
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<TestHostComponent>
let component: TestHostComponent
let testResult1: ITestValueType

function createComponent(T: Type<TestHostComponent>): 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)
})
})
67 changes: 67 additions & 0 deletions ui/src/app/form/abstract-form-field.component.ts
Original file line number Diff line number Diff line change
@@ -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<TValue> 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<div class="m-field" id="formfield-container-{{name}}">
<label *ngIf="required" for="formfield-{{name}}">{{label}} (Required)</label>
<label *ngIf="!required" for="formfield-{{name}}">{{label}}</label>
<p *ngIf="helpMessage" class="m-field-x-note">{{helpMessage}}</p>
<div class="m-field-x-input">
<div class="l-searchSelect">
<div class="l-searchSelect-x-search">
<div class="m-search" [class.m-is-error]="isError">
<svg class="m-search-x-icon t-icon" aria-hidden="true">
<use [attr.xlink:href]="iconSearch"></use>
</svg>
<input
[formControl]="searchControl"
class="m-search-x-input"
type="text"
placeholder="Search"
[attr.aria-label]="name"
role="search"
[attr.aria-describedby]="name + '-results'"
[attr.aria-controls]="name + '-results'"
(keydown.enter)="onEnterKeyDown($event)"
>
<div class="m-search-x-controls">
<div class="m-search-x-clear">
<button class="m-iconInteractive" type="button" (click)="onClearSearchClicked()">
<span class="t-visuallyHidden">Clear Search</span>
<div class="m-iconInteractive-x-icon">
<svg class="t-icon" aria-hidden="true">
<use [attr.xlink:href]="iconDismiss"></use>
</svg>
</div>
</button>
</div>
</div>
</div>
</div>
<!-- Loading results state -->
<div class="l-searchSelect-x-menu" *ngIf="currentlyLoading; else loaded">
<div class="m-selectMenu m-selectMenu-fixed m-selectMenu-is-active">
<div class="m-selectMenu-x-content">
<p class="m-selectMenu-x-message">
<span class="m-selectMenu-x-messageText">Loading&hellip;</span>
<span class="m-loaderGraphic m-loaderGraphic-medium"></span>
</p>
</div>
</div>
</div>
<p *ngIf="errorMessage && isError" class="m-field-x-error">{{errorMessage}}</p>
<div class="l-searchSelect-x-items">
<!-- Currently selected results -->
<button type="button" class="m-inputItem" *ngFor="let r of selectedResults" (click)="onSelectedResultClicked(r)">
<span class="m-inputItem-x-text"><span class="t-visuallyHidden">Remove </span>{{labelFor(r)}}</span>
<span class="m-inputItem-x-icon">
<svg class="t-icon" aria-hidden="true">
<use [attr.xlink:href]="iconDismiss"></use>
</svg>
</span>
</button>
</div>

<!-- Loaded results state -->
<ng-template #loaded>
<div id="{{name}}-results" class="l-searchSelect-x-menu" *ngIf="showResults" aria-live="assertive">
<div class="m-selectMenu m-selectMenu-fixed m-selectMenu-is-active">
<!-- Results -->
<div class="m-selectMenu-x-content" *ngIf="results && results.length > 0; else emptyResults">
<div class="t-visuallyHidden">Showing search results: </div>
<button class="m-buttonText" *ngFor="let r of results" type="button" (click)="onSearchResultClicked(r)" [disabled]="isResultSelected(r)">
<span class="m-buttonText-x-icon" *ngIf="isResultSelected(r)">
<svg class="t-icon" aria-hidden="true">
<use [attr.xlink:href]="iconCheck"></use>
</svg>
</span>
<span class="m-buttonText-x-text">{{labelFor(r)}}<span class="t-visuallyHidden">, </span></span>
</button>
</div>

<!-- Empty results -->
<ng-template #emptyResults>
<div class="m-selectMenu-x-content">
<p class="m-selectMenu-x-message">
<span class="m-selectMenu-x-messageText">No Results</span>
</p>
</div>
</ng-template>
</div>
</div>
</ng-template>

</div>
</div>
</div>
Loading