diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d6e7cdc6e4..1fedda7f89 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,6 +26,8 @@ jobs: run: ./find-ignored-tests.sh - name: Run unit tests run: npm run test-ci + env: + TZ: Europe/Zurich - name: Run e2e tests run: | npm run webdriver-update diff --git a/package-lock.json b/package-lock.json index 3ffd3e27e0..4606115a0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "dsp-app", "version": "5.3.0", "dependencies": { "@angular/animations": "^11.2.9", @@ -3643,7 +3642,8 @@ }, "node_modules/angular-split": { "version": "4.0.0", - "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/angular-split/-/angular-split-4.0.0.tgz", + "integrity": "sha512-HiTEazVlnpovjeIL0gEgOWdfjTcm8/hdhtOx8rkLJTN//uc9ImExVXnVRBGZwAPeAHMJ8O+8IJWizpzIhRwk/g==", "dependencies": { "tslib": "^1.9.0" }, @@ -4917,7 +4917,6 @@ "version": "9.0.0", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "tslib": "^1.10.0" } @@ -4926,7 +4925,6 @@ "version": "9.0.0", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "rxjs": "^6.5.3", "tslib": "^1.10.0", @@ -12314,7 +12312,6 @@ "node_modules/pdfjs-dist": { "version": "2.7.570", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "worker-loader": "^3.0.7" } @@ -21038,6 +21035,8 @@ }, "angular-split": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/angular-split/-/angular-split-4.0.0.tgz", + "integrity": "sha512-HiTEazVlnpovjeIL0gEgOWdfjTcm8/hdhtOx8rkLJTN//uc9ImExVXnVRBGZwAPeAHMJ8O+8IJWizpzIhRwk/g==", "requires": { "tslib": "^1.9.0" }, @@ -21892,6 +21891,8 @@ "version": "6.0.2", "dev": true, "requires": { + "@angular/compiler": "9.0.0", + "@angular/core": "9.0.0", "app-root-path": "^3.0.0", "aria-query": "^3.0.0", "axobject-query": "2.0.2", @@ -21909,13 +21910,11 @@ "@angular/compiler": { "version": "9.0.0", "dev": true, - "peer": true, "requires": {} }, "@angular/core": { "version": "9.0.0", "dev": true, - "peer": true, "requires": {} }, "source-map": { @@ -26184,6 +26183,7 @@ "ng2-pdf-viewer": { "version": "7.0.1", "requires": { + "pdfjs-dist": "~2.7.570", "tslib": "^2.0.0" } }, @@ -26988,7 +26988,6 @@ }, "pdfjs-dist": { "version": "2.7.570", - "peer": true, "requires": {} }, "performance-now": { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ef3c6ba794..990fff494d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -114,6 +114,39 @@ import { KnoraDatePipe } from './main/pipes/formatting/knoradate.pipe'; import { LinkifyPipe } from './main/pipes/string-transformation/linkify.pipe'; import { StringifyStringLiteralPipe } from './main/pipes/string-transformation/stringify-string-literal.pipe'; import { TruncatePipe } from './main/pipes/string-transformation/truncate.pipe'; +import { DragDropDirective } from './workspace/resource/directives/drag-drop.directive'; +import { TextValueHtmlLinkDirective } from './workspace/resource/directives/text-value-html-link.directive'; +import { BooleanValueComponent } from './workspace/resource/values/boolean-value/boolean-value.component'; +import { ColorValueComponent } from './workspace/resource/values/color-value/color-value.component'; +import { ColorPickerComponent } from './workspace/resource/values/color-value/color-picker/color-picker.component'; +import { JDNDatepickerDirective } from './workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive'; +import { DateValueComponent } from './workspace/resource/values/date-value/date-value.component'; +import { CalendarHeaderComponent } from './workspace/resource/values/date-value/calendar-header/calendar-header.component'; +import { DateInputComponent } from './workspace/resource/values/date-value/date-input/date-input.component'; +import { DateInputTextComponent } from './workspace/resource/values/date-value/date-input-text/date-input-text.component'; +import { DateEditComponent } from './workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component'; +import { ColorPickerModule } from 'ngx-color-picker'; +import { DecimalValueComponent } from './workspace/resource/values/decimal-value/decimal-value.component'; +import { GeonameValueComponent } from './workspace/resource/values/geoname-value/geoname-value.component'; +import { IntValueComponent } from './workspace/resource/values/int-value/int-value.component'; +import { IntervalValueComponent } from './workspace/resource/values/interval-value/interval-value.component'; +import { IntervalInputComponent } from './workspace/resource/values/interval-value/interval-input/interval-input.component'; +import { LinkValueComponent } from './workspace/resource/values/link-value/link-value.component'; +import { ListValueComponent } from './workspace/resource/values/list-value/list-value.component'; +import { SublistValueComponent } from './workspace/resource/values/list-value/subList-value/sublist-value.component'; +import { TextValueAsHtmlComponent } from './workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component'; +import { TextValueAsStringComponent } from './workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component'; +import { TextValueAsXMLComponent } from './workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component'; +import { CKEditorModule } from '@ckeditor/ckeditor5-angular'; +import { TimeValueComponent } from './workspace/resource/values/time-value/time-value.component'; +import { TimeInputComponent } from './workspace/resource/values/time-value/time-input/time-input.component'; +import { UriValueComponent } from './workspace/resource/values/uri-value/uri-value.component'; +import { AddValueComponent } from './workspace/resource/operations/add-value/add-value.component'; +import { DisplayEditComponent } from './workspace/resource/operations/display-edit/display-edit.component'; +import { ListViewComponent } from './workspace/results/list-view/list-view.component'; +import { ResourceGridComponent } from './workspace/results/list-view/resource-grid/resource-grid.component'; +import { ResourceListComponent } from './workspace/results/list-view/resource-list/resource-list.component'; +import { ComparisonComponent } from './workspace/comparison/comparison.component'; // translate: AoT requires an exported function for factories export function httpLoaderFactory(httpClient: HttpClient) { @@ -214,13 +247,46 @@ export function httpLoaderFactory(httpClient: HttpClient) { LinkifyPipe, StringifyStringLiteralPipe, TruncatePipe, + DragDropDirective, + TextValueHtmlLinkDirective, + BooleanValueComponent, + ColorValueComponent, + ColorPickerComponent, + JDNDatepickerDirective, + DateValueComponent, + CalendarHeaderComponent, + DateInputComponent, + DateInputTextComponent, + DateEditComponent, + DecimalValueComponent, + GeonameValueComponent, + IntValueComponent, + IntervalValueComponent, + IntervalInputComponent, + LinkValueComponent, + ListValueComponent, + SublistValueComponent, + TextValueAsHtmlComponent, + TextValueAsStringComponent, + TextValueAsXMLComponent, + TimeValueComponent, + TimeInputComponent, + UriValueComponent, + AddValueComponent, + DisplayEditComponent, + ListViewComponent, + ResourceGridComponent, + ResourceListComponent, + ComparisonComponent, ], imports: [ AppRoutingModule, AngularSplitModule.forRoot(), BrowserAnimationsModule, BrowserModule, + CKEditorModule, ClipboardModule, + ColorPickerModule, CommonModule, DspActionModule, DspCoreModule, diff --git a/src/app/main/directive/base-value.directive.ts b/src/app/main/directive/base-value.directive.ts index b251a8caaf..aa895ed362 100644 --- a/src/app/main/directive/base-value.directive.ts +++ b/src/app/main/directive/base-value.directive.ts @@ -6,7 +6,6 @@ import { Subscription } from 'rxjs'; @Directive() export abstract class BaseValueDirective { - /** * value to be displayed, if any. */ @@ -17,6 +16,21 @@ export abstract class BaseValueDirective { */ @Input() mode: 'read' | 'update' | 'create' | 'search'; + /** + * parent FormGroup that contains all child FormGroups + */ + @Input() parentForm?: FormGroup; + + /** + * name of the FormGroup, used to add to the parentForm because the name needs to be unique + */ + @Input() formName = 'Untitled FormGroup'; + + /** + * controls if the value should be required. + */ + @Input() valueRequiredValidator = true; + shouldShowComment = false; /** @@ -67,7 +81,7 @@ export abstract class BaseValueDirective { => ValidatorFn = (initValue: any, initComment: string, commentFormControl: FormControl): ValidatorFn => (control: AbstractControl): { [key: string]: any } | null => { const invalid = this.standardValueComparisonFunc(initValue, control.value) - && (initComment === commentFormControl.value || (initComment === null && commentFormControl.value === '')); + && (initComment === commentFormControl.value || (initComment === null && commentFormControl.value === '')); return invalid ? { valueNotChanged: { value: control.value } } : null; }; @@ -107,8 +121,11 @@ export abstract class BaseValueDirective { this.valueFormControl.setValidators([Validators.required, this.standardValidatorFunc(initialValue, initialComment, this.commentFormControl)].concat(this.customValidators)); } else { // console.log('reset read/create validators'); - this.valueFormControl.setValidators([Validators.required].concat(this.customValidators)); - + if (this.valueRequiredValidator) { + this.valueFormControl.setValidators([Validators.required].concat(this.customValidators)); + } else { + this.valueFormControl.setValidators(this.customValidators); + } } this.valueFormControl.updateValueAndValidity(); @@ -138,6 +155,31 @@ export abstract class BaseValueDirective { this.shouldShowComment = !this.shouldShowComment; } + /** + * add the value components FormGroup to a parent FormGroup if one is defined + */ + addToParentFormGroup(name: string, form: FormGroup) { + if (this.parentForm) { + this.parentForm.addControl(name, form); + } + } + + /** + * remove the value components FormGroup from a parent FormGroup if one is defined + */ + removeFromParentFormGroup(name: string) { + if (this.parentForm) { + this.parentForm.removeControl(name); + } + } + + /** + * checks if the value is empty. + */ + isEmptyVal(): boolean { + return this.valueFormControl.value === null || this.valueFormControl.value === ''; + } + /** * returns the initially given value set via displayValue. * Returns null if no value was given. @@ -151,10 +193,9 @@ export abstract class BaseValueDirective { abstract getNewValue(): CreateValue | false; /** - * returns a value that is to be updated. - * Returns false if invalid. - */ + * returns a value that is to be updated. + * Returns false if invalid. + */ abstract getUpdatedValue(): UpdateValue | false; - } diff --git a/src/app/search/services/advanced-search-params.service.spec.ts b/src/app/search/services/advanced-search-params.service.spec.ts new file mode 100644 index 0000000000..cba4fed669 --- /dev/null +++ b/src/app/search/services/advanced-search-params.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; + +import { AdvancedSearchParams, AdvancedSearchParamsService } from './advanced-search-params.service'; + +describe('SearchParamsService', () => { + let service: AdvancedSearchParamsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AdvancedSearchParamsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return false when initialized', () => { + const searchParams: AdvancedSearchParams = service.getSearchParams(); + + expect(searchParams.generateGravsearch(0)).toBeFalsy(); + }); + + it('should set the parameters of an advanced search', () => { + const testMethod1 = (offset: number) => 'test1'; + + service.changeSearchParamsMsg(new AdvancedSearchParams(testMethod1)); + + const searchParams: AdvancedSearchParams = service.getSearchParams(); + + expect(searchParams.generateGravsearch(0)).toEqual('test1'); + + // check if value is still present + expect(searchParams.generateGravsearch(0)).toEqual('test1'); + + const testMethod2 = (offset: number) => 'test2'; + + service.changeSearchParamsMsg(new AdvancedSearchParams(testMethod2)); + + const searchParams2: AdvancedSearchParams = service.getSearchParams(); + + expect(searchParams2.generateGravsearch(0)).toEqual('test2'); + + // check if value is still present + expect(searchParams2.generateGravsearch(0)).toEqual('test2'); + + }); + +}); diff --git a/src/app/search/services/advanced-search-params.service.ts b/src/app/search/services/advanced-search-params.service.ts new file mode 100644 index 0000000000..f54228a3b1 --- /dev/null +++ b/src/app/search/services/advanced-search-params.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +/* + * represents the parameters of an advanced search. + */ +export class AdvancedSearchParams { + + /** + * + * @param generateGravsearch a function that generates a Gravsearch query. + * + * The function takes the offset + * as a parameter and returns a Gravsearch query string. + * Returns false if not set correctly (init state). + */ + constructor(public generateGravsearch: (offset: number) => string | boolean) { + + } + +} + +@Injectable({ + providedIn: 'root' +}) +export class AdvancedSearchParamsService { + + private _currentSearchParams; + + constructor() { + // init with a dummy function that returns false + // if the application is reloaded, this will be returned + this._currentSearchParams = new BehaviorSubject(new AdvancedSearchParams((offset: number) => false)); + } + + /** + * updates the parameters of an advanced search. + * + * @param searchParams new advanced search params. + */ + changeSearchParamsMsg(searchParams: AdvancedSearchParams): void { + this._currentSearchParams.next(searchParams); + } + + /** + * gets the search params of an advanced search. + * + */ + getSearchParams(): AdvancedSearchParams { + return this._currentSearchParams.getValue(); + } + +} diff --git a/src/app/workspace/comparison/comparison.component.html b/src/app/workspace/comparison/comparison.component.html new file mode 100644 index 0000000000..3084fcbd9d --- /dev/null +++ b/src/app/workspace/comparison/comparison.component.html @@ -0,0 +1,20 @@ +
+ + + + + + + + + + + + + + + + + +
diff --git a/src/app/workspace/comparison/comparison.component.scss b/src/app/workspace/comparison/comparison.component.scss new file mode 100644 index 0000000000..394a9eae7f --- /dev/null +++ b/src/app/workspace/comparison/comparison.component.scss @@ -0,0 +1,4 @@ +.content { + width: 100%; + height: 1400px; +} diff --git a/src/app/workspace/comparison/comparison.component.spec.ts b/src/app/workspace/comparison/comparison.component.spec.ts new file mode 100644 index 0000000000..347baf7c28 --- /dev/null +++ b/src/app/workspace/comparison/comparison.component.spec.ts @@ -0,0 +1,74 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { AngularSplitModule } from 'angular-split'; +import { ComparisonComponent } from './comparison.component'; + +/** + * test host component to simulate child component, here resource-view. + */ +@Component({ + selector: 'app-resource', + template: '' +}) +class TestResourceComponent { + @Input() resourceIri: string; +} + +/** + * test host component to simulate parent component. + */ +@Component({ + selector: 'app-comparison-host-component', + template: '' +}) +class TestHostComparisonComponent { + + @ViewChild('comparison') comparison: ComparisonComponent; + + resourceIds = [ + 'http://rdfh.ch/0803/18a671b8a601', + 'http://rdfh.ch/0803/7e4cfc5417', + 'http://rdfh.ch/0803/6ad3e2c47501', + 'http://rdfh.ch/0803/009e225a5f01', + 'http://rdfh.ch/0803/00ed33070f02' + ]; + noOfResources = this.resourceIds.length; +} + +describe('ComparisonComponent', () => { + + let testHostComponent: TestHostComparisonComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + declarations: [ + ComparisonComponent, + TestHostComparisonComponent, + TestResourceComponent + ], + imports: [AngularSplitModule] + }) + .compileComponents(); + + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComparisonComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent.comparison).toBeTruthy(); + }); + + it('expect top row with 2 resources', () => { + expect(testHostComponent.comparison.topRow.length).toEqual(2); + }); + + it('expect bottom row with 3 resources', () => { + expect(testHostComponent.comparison.bottomRow.length).toEqual(3); + }); +}); diff --git a/src/app/workspace/comparison/comparison.component.ts b/src/app/workspace/comparison/comparison.component.ts new file mode 100644 index 0000000000..c2dea94c1e --- /dev/null +++ b/src/app/workspace/comparison/comparison.component.ts @@ -0,0 +1,55 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { ShortResInfo } from '@dasch-swiss/dsp-ui'; + +@Component({ + selector: 'app-comparison', + templateUrl: './comparison.component.html', + styleUrls: ['./comparison.component.scss'] +}) +export class ComparisonComponent implements OnChanges { + + /** + * number of resources + */ + @Input() noOfResources?: number; + + /** + * resource ids + */ + @Input() resourceIds?: string[]; + + /** + * list of resources with id and label + */ + @Input() resources?: ShortResInfo[]; + + // if number of selected resources > 3, divide them into 2 rows + topRow: string[] = []; + bottomRow: string[] = []; + + constructor() { } + + ngOnChanges(): void { + + if (this.resources && this.resources.length) { + this.resourceIds = []; + this.resources.forEach(res => { + this.resourceIds.push(res.id); + }); + } + + if (!this.noOfResources) { + this.noOfResources = ((this.resourceIds && this.resourceIds.length) ? this.resourceIds.length : this.resources.length); + } + + // if number of resources are more than 3, divide it into 2 rows + // otherwise display then in 1 row only + if (this.noOfResources < 4) { + this.topRow = this.resourceIds; + } else { + this.topRow = this.resourceIds.slice(0, this.noOfResources / 2); + this.bottomRow = this.resourceIds.slice(this.noOfResources / 2); + } + } + +} diff --git a/src/app/workspace/resource/directives/drag-drop.directive.spec.ts b/src/app/workspace/resource/directives/drag-drop.directive.spec.ts new file mode 100644 index 0000000000..6fd3ddebd2 --- /dev/null +++ b/src/app/workspace/resource/directives/drag-drop.directive.spec.ts @@ -0,0 +1,124 @@ +import { Component, DebugElement } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { DragDropDirective } from './drag-drop.directive'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
` +}) +class TestHostComponent { + + files: FileList; + + filesDropped(files: FileList) { + this.files = files; + } + +} + +describe('DragDropDirective', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + let dragDropInput: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + DragDropDirective, + TestHostComponent + ], + imports: [ + BrowserAnimationsModule, + MatIconModule, + MatInputModule, + MatSnackBarModule, + ReactiveFormsModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + dragDropInput = testHostFixture.debugElement.query(By.css('.dd-container')); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create an instance', () => { + const directive = new DragDropDirective(); + expect(directive).toBeTruthy(); + }); + + it('should change background-color of input on dragover event', () => { + const dragOver = new DragEvent('dragover'); + const color = 'rgb(221, 221, 221)'; // = #ddd + + spyOn(dragOver, 'preventDefault'); + spyOn(dragOver, 'stopPropagation'); + + dragDropInput.triggerEventHandler('dragover', dragOver); + testHostFixture.detectChanges(); + + expect(dragDropInput.nativeElement.style.backgroundColor).toBe(color); + + expect(dragOver.stopPropagation).toHaveBeenCalled(); + expect(dragOver.preventDefault).toHaveBeenCalled(); + }); + + it('should change background-color of input on dragleave event', () => { + const dragLeave = new DragEvent('dragleave'); + const color = 'rgb(242, 242, 242)'; // = #f2f2f2 + + spyOn(dragLeave, 'preventDefault'); + spyOn(dragLeave, 'stopPropagation'); + + dragDropInput.triggerEventHandler('dragleave', dragLeave); + testHostFixture.detectChanges(); + + expect(dragDropInput.nativeElement.style.backgroundColor).toBe(color); + + expect(dragLeave.stopPropagation).toHaveBeenCalled(); + expect(dragLeave.preventDefault).toHaveBeenCalled(); + }); + + it('should change background-color of input on drop event', () => { + const mockFile = new File(['1'], 'testfile'); + + // https://stackoverflow.com/questions/57080760/fake-file-drop-event-for-unit-testing + const drop = { + preventDefault: () => {}, + stopPropagation: () => {}, + dataTransfer: { files: [mockFile] } + }; + + const color = 'rgb(242, 242, 242)'; // = #f2f2f2 + + spyOn(drop, 'preventDefault'); + spyOn(drop, 'stopPropagation'); + + expect(testHostComponent.files).toBeUndefined(); + + dragDropInput.triggerEventHandler('drop', drop); + testHostFixture.detectChanges(); + + expect(dragDropInput.nativeElement.style.backgroundColor).toBe(color); + expect(testHostComponent.files.length).toEqual(1); + expect(testHostComponent.files[0].name).toEqual('testfile'); + + expect(drop.stopPropagation).toHaveBeenCalled(); + expect(drop.preventDefault).toHaveBeenCalled(); + }); +}); diff --git a/src/app/workspace/resource/directives/drag-drop.directive.ts b/src/app/workspace/resource/directives/drag-drop.directive.ts new file mode 100644 index 0000000000..537e524208 --- /dev/null +++ b/src/app/workspace/resource/directives/drag-drop.directive.ts @@ -0,0 +1,34 @@ +import { Directive, EventEmitter, HostBinding, HostListener, Output } from '@angular/core'; + +@Directive({ + selector: '[appDragDrop]' +}) +export class DragDropDirective { + + @HostBinding('style.background-color') background = '#f2f2f2'; + + @Output() fileDropped = new EventEmitter(); + + @HostListener('dragover', ['$event']) onDragOver(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.background = '#ddd'; + } + + @HostListener('dragleave', ['$event']) onDragLeave(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.background = '#f2f2f2'; + } + + @HostListener('drop', ['$event']) onDrop(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.background = '#f2f2f2'; + const files = event.dataTransfer.files; + if (files.length > 0) { + this.fileDropped.emit(files); + } + } + +} diff --git a/src/app/workspace/resource/directives/text-value-html-link.directive.spec.ts b/src/app/workspace/resource/directives/text-value-html-link.directive.spec.ts new file mode 100644 index 0000000000..9d20510fe3 --- /dev/null +++ b/src/app/workspace/resource/directives/text-value-html-link.directive.spec.ts @@ -0,0 +1,134 @@ +import { Component } from '@angular/core'; +import { TextValueHtmlLinkDirective } from './text-value-html-link.directive'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
` +}) +class TestHostComponent { + + // the href attribute of the external link is empty + // because otherwise the test browser would attempt to access it + html = 'This is a test external link and a test internal link'; + + internalLinkClickedIri: string; + + internalLinkHoveredIri: string; + + clicked(iri: string) { + this.internalLinkClickedIri = iri; + } + + hovered(iri: string) { + this.internalLinkHoveredIri = iri; + } +} + +describe('TextValueHtmlLinkDirective', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule + ], + declarations: [ + TextValueHtmlLinkDirective, + TestHostComponent + ] + }).compileComponents(); + + })); + + beforeEach(() => { + + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create an instance', () => { + expect(testHostComponent).toBeTruthy(); + }); + + it('should react to clicking on an internal link', () => { + expect(testHostComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective)); + + const internalLinkDe = directiveDe.query(By.css('a.salsah-link')); + + internalLinkDe.nativeElement.click(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.internalLinkClickedIri).toEqual('http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'); + + }); + + it('should not react to clicking on an external link', () => { + expect(testHostComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective)); + + const externalLinkDe = directiveDe.query(By.css('a:not(.salsah-link)')); + + externalLinkDe.nativeElement.click(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.internalLinkClickedIri).toBeUndefined(); + + }); + + it('should react to hovering over an internal link', () => { + expect(testHostComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective)); + + const internalLinkDe = directiveDe.query(By.css('a.salsah-link')); + + internalLinkDe.nativeElement.dispatchEvent(new MouseEvent('mouseover', { + view: window, + bubbles: true, + cancelable: true + })); + + testHostFixture.detectChanges(); + + expect(testHostComponent.internalLinkHoveredIri).toEqual('http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'); + + }); + + it('should not react to hovering over an external link', () => { + expect(testHostComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective)); + + const externalLinkDe = directiveDe.query(By.css('a:not(.salsah-link)')); + + externalLinkDe.nativeElement.dispatchEvent(new MouseEvent('mouseover', { + view: window, + bubbles: true, + cancelable: true + })); + + testHostFixture.detectChanges(); + + expect(testHostComponent.internalLinkHoveredIri).toBeUndefined(); + + }); +}); diff --git a/src/app/workspace/resource/directives/text-value-html-link.directive.ts b/src/app/workspace/resource/directives/text-value-html-link.directive.ts new file mode 100644 index 0000000000..72c9549cd0 --- /dev/null +++ b/src/app/workspace/resource/directives/text-value-html-link.directive.ts @@ -0,0 +1,44 @@ +import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; +import { Constants } from '@dasch-swiss/dsp-js'; + +@Directive({ + selector: '[appHtmlLink]' +}) +export class TextValueHtmlLinkDirective { + + @Output() internalLinkClicked = new EventEmitter(); + @Output() internalLinkHovered = new EventEmitter(); + + /** + * react to a click event for an internal link. + * + * @param targetElement the element that was clicked. + */ + @HostListener('click', ['$event.target']) + onClick(targetElement) { + if (targetElement.nodeName.toLowerCase() === 'a' + && targetElement.className.toLowerCase().indexOf(Constants.SalsahLink) !== -1) { + this.internalLinkClicked.emit(targetElement.href); + + // preventDefault (propagation) + return false; + } + } + + /** + * react to a mouseover event for an internal link. + * + * @param targetElement the element that was hovered. + */ + @HostListener('mouseover', ['$event.target']) + onMouseOver(targetElement) { + if (targetElement.nodeName.toLowerCase() === 'a' + && targetElement.className.toLowerCase().indexOf(Constants.SalsahLink) !== -1) { + this.internalLinkHovered.emit(targetElement.href); + + // preventDefault (propagation) + return false; + } + } + +} diff --git a/src/app/workspace/resource/operations/add-value/add-value.component.html b/src/app/workspace/resource/operations/add-value/add-value.component.html new file mode 100644 index 0000000000..2df68ed401 --- /dev/null +++ b/src/app/workspace/resource/operations/add-value/add-value.component.html @@ -0,0 +1,38 @@ +
+
+ + + + + + + + + + + + + + + + +
+
+ + + +
+
diff --git a/src/app/workspace/resource/operations/add-value/add-value.component.scss b/src/app/workspace/resource/operations/add-value/add-value.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/workspace/resource/operations/add-value/add-value.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/operations/add-value/add-value.component.spec.ts b/src/app/workspace/resource/operations/add-value/add-value.component.spec.ts new file mode 100644 index 0000000000..a75b269be7 --- /dev/null +++ b/src/app/workspace/resource/operations/add-value/add-value.component.spec.ts @@ -0,0 +1,322 @@ +import { Component, Inject, Input, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + ApiResponseError, + CreateIntValue, + CreateValue, + MockResource, + ReadIntValue, + ReadResource, + ResourcePropertyDefinition, + UpdateResource, + ValuesEndpointV2, + WriteValueResponse +} from '@dasch-swiss/dsp-js'; +import { of, throwError } from 'rxjs'; +import { AjaxError } from 'rxjs/ajax'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { AddedEventValue, EmitEvent, Events, ValueOperationEventService } from '../../services/value-operation-event.service'; +import { AddValueComponent } from './add-value.component'; + +@Component({ + selector: 'app-int-value', + template: '' +}) +class TestIntValueComponent implements OnInit { + + @Input() mode; + + @Input() displayValue; + + form: FormGroup; + + valueFormControl: FormControl; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { } + + ngOnInit(): void { + this.valueFormControl = new FormControl(null, [Validators.required]); + + this.form = this._fb.group({ + test: this.valueFormControl + }); + } + + getNewValue(): CreateValue { + const createIntVal = new CreateIntValue(); + + createIntVal.int = 123; + + return createIntVal; + } + + updateCommentVisibility(): void { } +} + +@Component({ + selector: 'app-time-value', + template: '' +}) +class TestTimeValueComponent { + @Input() mode; + + @Input() displayValue; +} + +/** + * test host component to simulate parent component. + */ +@Component({ + selector: 'app-add-value-host-component', + template: ` + ` +}) +class DspAddValueTestComponent implements OnInit { + + @ViewChild('testAddVal') testAddValueComponent: AddValueComponent; + + readResource: ReadResource; + resourcePropertyDefinition: ResourcePropertyDefinition; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + this.readResource = res; + }); + + } + + assignResourcePropDef(propIri: string) { + const definitionForProp = this.readResource.entityInfo.getAllPropertyDefinitions() + .filter( (resourcePropDef: ResourcePropertyDefinition) => resourcePropDef.id === propIri) as ResourcePropertyDefinition[]; + + if (definitionForProp.length !== 1) { + throw console.error('Property definition not found'); + } + + this.resourcePropertyDefinition = definitionForProp[0]; + } + +} + +describe('AddValueComponent', () => { + let testHostComponent: DspAddValueTestComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + + const valuesSpyObj = { + v2: { + values: jasmine.createSpyObj('values', ['createValue', 'getValue']) + } + }; + + const eventSpy = jasmine.createSpyObj('ValueOperationEventService', ['emit']); + + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + MatIconModule, + ], + declarations: [ + AddValueComponent, + DspAddValueTestComponent, + TestIntValueComponent, + TestTimeValueComponent + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: valuesSpyObj + }, + { + provide: ValueOperationEventService, + useValue: eventSpy + }, + FormBuilder + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + + const valueEventSpy = TestBed.inject(ValueOperationEventService); + + (valueEventSpy as jasmine.SpyObj).emit.and.stub(); + + testHostFixture = TestBed.createComponent(DspAddValueTestComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should choose the apt component for an integer value', () => { + + testHostComponent.assignResourcePropDef('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'); + + testHostFixture.detectChanges(); + + expect(testHostComponent.testAddValueComponent).toBeTruthy(); + + expect(testHostComponent.resourcePropertyDefinition.objectType).toEqual('http://api.knora.org/ontology/knora-api/v2#IntValue'); + + expect(testHostComponent.testAddValueComponent.createValueComponent instanceof TestIntValueComponent).toBeTruthy(); + }); + + it('should choose the apt component for a time value', () => { + + testHostComponent.assignResourcePropDef('http://0.0.0.0:3333/ontology/0001/anything/v2#hasTimeStamp'); + + testHostFixture.detectChanges(); + + expect(testHostComponent.testAddValueComponent).toBeTruthy(); + + expect(testHostComponent.resourcePropertyDefinition.objectType).toEqual('http://api.knora.org/ontology/knora-api/v2#TimeValue'); + + expect(testHostComponent.testAddValueComponent.createValueComponent instanceof TestTimeValueComponent).toBeTruthy(); + }); + + describe('add new value', () => { + + let hostCompDe; + let addValueComponentDe; + + beforeEach(() => { + testHostComponent.assignResourcePropDef('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'); + + testHostFixture.detectChanges(); + + expect(testHostComponent.testAddValueComponent).toBeTruthy(); + + hostCompDe = testHostFixture.debugElement; + + addValueComponentDe = hostCompDe.query(By.directive(AddValueComponent)); + + expect(testHostComponent).toBeTruthy(); + + testHostComponent.testAddValueComponent.createModeActive = true; + + testHostFixture.detectChanges(); + }); + + it('should add a new value to a property', () => { + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + const valueEventSpy = TestBed.inject(ValueOperationEventService); + + (valuesSpy.v2.values as jasmine.SpyObj).createValue.and.callFake( + () => { + + const response = new WriteValueResponse(); + + response.id = 'newID'; + response.type = 'type'; + response.uuid = 'uuid'; + + return of(response); + } + ); + + (valuesSpy.v2.values as jasmine.SpyObj).getValue.and.callFake( + () => { + + const createdVal = new ReadIntValue(); + + createdVal.id = 'newID'; + createdVal.int = 1; + + const resource = new ReadResource(); + + resource.properties = { + 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger': [createdVal] + }; + + return of(resource); + } + ); + + expect(testHostComponent.testAddValueComponent.createModeActive).toBeTruthy(); + + testHostComponent.testAddValueComponent.createValueComponent.form.setValue({ test: 123 }); + + testHostFixture.detectChanges(); + + const saveButtonDebugElement = addValueComponentDe.query(By.css('button.save')); + const saveButtonNativeElement = saveButtonDebugElement.nativeElement; + + expect(saveButtonNativeElement).toBeDefined(); + + saveButtonNativeElement.click(); + + testHostFixture.detectChanges(); + + const expectedUpdateResource = new UpdateResource(); + + expectedUpdateResource.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'; + expectedUpdateResource.type = 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'; + expectedUpdateResource.property = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'; + + const expectedCreateVal = new CreateIntValue(); + expectedCreateVal.int = testHostComponent.testAddValueComponent.createValueComponent.form.value.test; + + expectedUpdateResource.value = expectedCreateVal; + + const newReadValue = new ReadIntValue(); + newReadValue.id = 'newID'; + newReadValue.int = 1; + + expect(valuesSpy.v2.values.createValue).toHaveBeenCalledWith(expectedUpdateResource); + expect(valuesSpy.v2.values.createValue).toHaveBeenCalledTimes(1); + + expect(valuesSpy.v2.values.getValue).toHaveBeenCalledTimes(1); + expect(valuesSpy.v2.values.getValue).toHaveBeenCalledWith(testHostComponent.readResource.id, 'uuid'); + + expect(valueEventSpy.emit).toHaveBeenCalledTimes(1); + expect(valueEventSpy.emit).toHaveBeenCalledWith(new EmitEvent(Events.ValueAdded, new AddedEventValue(newReadValue))); + + }); + + it('should handle an ApiResponseError with status of 400 correctly', () => { + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + const error = ApiResponseError.fromAjaxError({} as AjaxError); + + error.status = 400; + + (valuesSpy.v2.values as jasmine.SpyObj).createValue.and.returnValue(throwError(error)); + + expect(testHostComponent.testAddValueComponent.createModeActive).toBeTruthy(); + + testHostComponent.testAddValueComponent.createValueComponent.form.controls.test.clearValidators(); + testHostComponent.testAddValueComponent.createValueComponent.form.controls.test.updateValueAndValidity(); + testHostFixture.detectChanges(); + + const saveButtonDebugElement = addValueComponentDe.query(By.css('button.save')); + const saveButtonNativeElement = saveButtonDebugElement.nativeElement; + + expect(saveButtonNativeElement).toBeDefined(); + + saveButtonNativeElement.click(); + + testHostFixture.detectChanges(); + + const formErrors = testHostComponent.testAddValueComponent.createValueComponent.valueFormControl.errors; + + const expectedErrors = { + duplicateValue: true + }; + + expect(formErrors).toEqual(expectedErrors); + }); + }); + +}); diff --git a/src/app/workspace/resource/operations/add-value/add-value.component.ts b/src/app/workspace/resource/operations/add-value/add-value.component.ts new file mode 100644 index 0000000000..5b73cce12a --- /dev/null +++ b/src/app/workspace/resource/operations/add-value/add-value.component.ts @@ -0,0 +1,159 @@ +import { + AfterViewInit, Component, + EventEmitter, + Inject, + Input, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { + ApiResponseError, Constants, + CreateValue, + KnoraApiConnection, + ReadResource, + ResourcePropertyDefinition, + UpdateResource, + WriteValueResponse +} from '@dasch-swiss/dsp-js'; +import { mergeMap } from 'rxjs/operators'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; +import { AddedEventValue, EmitEvent, Events, ValueOperationEventService } from '../../services/value-operation-event.service'; +import { ValueService } from '../../services/value.service'; + +@Component({ + selector: 'app-add-value', + templateUrl: './add-value.component.html', + styleUrls: ['./add-value.component.scss'] +}) +export class AddValueComponent implements OnInit, AfterViewInit { + + @ViewChild('createVal') createValueComponent: BaseValueDirective; + + @Input() resourcePropertyDefinition: ResourcePropertyDefinition; + + @Input() parentResource: ReadResource; + + @Input() configuration?: object; + + @Output() operationCancelled = new EventEmitter(); + + constants = Constants; + + mode: 'read' | 'update' | 'create' | 'search'; + + createModeActive = false; + + submittingValue = false; + + // 0 will display a loading animation + progressIndicatorStatus = 0; + + progressIndicatorColor = 'blue'; + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _valueOperationEventService: ValueOperationEventService, + private _valueService: ValueService) { } + + ngOnInit() { + + this.mode = 'create'; + + // since simple text values and rich text values share the same object type 'TextValue', + // we need to use the ValueTypeService in order to assign it the correct object type for the ngSwitch in the template + if (this.resourcePropertyDefinition.objectType === 'http://api.knora.org/ontology/knora-api/v2#TextValue') { + this.resourcePropertyDefinition.objectType = this._valueService.getTextValueClass(this.resourcePropertyDefinition); + } + + } + + // wait to show the save/cancel buttons until the form is initialized so that the template checks using the form's validity work + ngAfterViewInit() { + setTimeout(() => { + this.createModeActive = true; + }, 0); + } + + /** + * add a new value to an existing property of a resource. + */ + saveAddValue() { + if (this.parentResource) { + // hide the CRUD buttons + this.createModeActive = false; + + // show the progress indicator + this.submittingValue = true; + + // get a new CreateValue from the base class and grab the values from the form + const createVal = this.createValueComponent.getNewValue(); + + if (createVal instanceof CreateValue) { + + // create a new UpdateResource with the same properties as the parent resource + const updateRes = new UpdateResource(); + updateRes.id = this.parentResource.id; + updateRes.type = this.parentResource.type; + updateRes.property = this.resourcePropertyDefinition.id; + + // assign the new value to the UpdateResource value + updateRes.value = createVal; + + this._dspApiConnection.v2.values.createValue(updateRes as UpdateResource).pipe( + mergeMap((res: WriteValueResponse) => + // if successful, get the newly created value + this._dspApiConnection.v2.values.getValue(this.parentResource.id, res.uuid) + ) + ).subscribe( + (res2: ReadResource) => { + // emit a ValueAdded event to the listeners in: + // property-view component to hide the add value form + // resource-view component to trigger a refresh of the resource + this._valueOperationEventService.emit( + new EmitEvent(Events.ValueAdded, new AddedEventValue(res2.getValues(updateRes.property)[0]))); + + // hide the progress indicator + this.submittingValue = false; + }, + (error: ApiResponseError) => { + // hide the progress indicator + this.submittingValue = false; + + // show the CRUD buttons + this.createModeActive = true; + + switch (error.status) { + case 400: + this.createValueComponent.valueFormControl.setErrors({ duplicateValue: true }); + break; + default: + console.log('There was an error processing your request. Details: ', error); + break; + } + } + ); + } else { + console.error('Expected instance of CreateVal, received: ', createVal); + + // hide the progress indicator + this.submittingValue = false; + } + } else { + console.error('A ReadResource is required to save a new value.'); + } + } + + /** + * cancel the add value operation and hide the add value form. + */ + cancelAddValue() { + // show the CRUD buttons + this.createModeActive = false; + + // emit an event to trigger hideAddValueForm() in property-view component to hide the create value form + this.operationCancelled.emit(); + } + +} diff --git a/src/app/workspace/resource/operations/display-edit/display-edit.component.html b/src/app/workspace/resource/operations/display-edit/display-edit.component.html new file mode 100644 index 0000000000..a50aa647ec --- /dev/null +++ b/src/app/workspace/resource/operations/display-edit/display-edit.component.html @@ -0,0 +1,76 @@ +
+
+ + + + + + + + + + + + + + + + + {{displayValue.strval}} + +
+
+ + + + + + +
+
+
+
+ + +
+
diff --git a/src/app/workspace/resource/operations/display-edit/display-edit.component.scss b/src/app/workspace/resource/operations/display-edit/display-edit.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/workspace/resource/operations/display-edit/display-edit.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/operations/display-edit/display-edit.component.spec.ts b/src/app/workspace/resource/operations/display-edit/display-edit.component.spec.ts new file mode 100644 index 0000000000..220307bec4 --- /dev/null +++ b/src/app/workspace/resource/operations/display-edit/display-edit.component.spec.ts @@ -0,0 +1,1199 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, EventEmitter, Inject, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatDialogHarness } from '@angular/material/dialog/testing'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + ApiResponseError, + Constants, + DeleteValue, + DeleteValueResponse, + MockResource, + MockUsers, + ReadBooleanValue, + ReadColorValue, + ReadDecimalValue, + ReadGeonameValue, + ReadIntervalValue, + ReadIntValue, + ReadLinkValue, + ReadListValue, + ReadResource, + ReadTextValueAsHtml, + ReadTextValueAsString, ReadTextValueAsXml, + ReadTimeValue, + ReadUriValue, + ReadValue, + ResourcePropertyDefinition, + UpdateIntValue, + UpdateResource, + UpdateValue, + ValuesEndpointV2, + WriteValueResponse +} from '@dasch-swiss/dsp-js'; +import { of, throwError } from 'rxjs'; +import { AjaxError } from 'rxjs/ajax'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { PropertyInfoValues } from '../../properties/properties.component'; +import { UserService } from '../../services/user.service'; +import { + DeletedEventValue, + EmitEvent, + Events, + UpdatedEventValues, + ValueOperationEventService +} from '../../services/value-operation-event.service'; +import { ValueService } from '../../services/value.service'; +import { DisplayEditComponent } from './display-edit.component'; + +@Component({ + selector: 'app-text-value-as-string', + template: '' +}) +class TestTextValueAsStringComponent { + + @Input() mode; + + @Input() displayValue; +} + +@Component({ + selector: 'app-list-value', + template: '' +}) +class TestListValueComponent { + @Input() mode; + + @Input() displayValue; + + @Input() propertyDef; +} + +@Component({ + selector: 'app-link-value', + template: '' +}) +class TestLinkValueComponent { + + @Input() mode; + + @Input() displayValue; + + @Input() parentResource; + + @Input() propIri; + + @Output() referredResourceClicked: EventEmitter = new EventEmitter(); + + @Output() referredResourceHovered: EventEmitter = new EventEmitter(); +} + +@Component({ + selector: 'app-text-value-as-html', + template: '' +}) +class TestTextValueAsHtmlComponent { + + @Input() mode; + + @Input() displayValue; +} + +@Component({ + selector: 'app-text-value-as-xml', + template: '' +}) +class TestTextValueAsXmlComponent { + + @Input() mode; + + @Input() displayValue; + + @Output() internalLinkClicked: EventEmitter = new EventEmitter(); + + @Output() internalLinkHovered: EventEmitter = new EventEmitter(); +} + +@Component({ + selector: 'app-uri-value', + template: '' +}) +class TestUriValueComponent { + + @Input() mode; + + @Input() displayValue; +} + +@Component({ + selector: 'app-int-value', + template: '' +}) +class TestIntValueComponent implements OnInit { + + @Input() mode; + + @Input() displayValue; + + form: FormGroup; + + valueFormControl: FormControl; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { } + + ngOnInit(): void { + this.valueFormControl = new FormControl(null, [Validators.required]); + + this.form = this._fb.group({ + test: this.valueFormControl + }); + } + + getUpdatedValue(): UpdateValue { + const updateIntVal = new UpdateIntValue(); + + updateIntVal.id = this.displayValue.id; + updateIntVal.int = 1; + + return updateIntVal; + } + + updateCommentVisibility(): void { } +} + +@Component({ + selector: 'app-boolean-value', + template: '' +}) +class TestBooleanValueComponent { + + @Input() mode; + + @Input() displayValue; + +} + +@Component({ + selector: 'app-interval-value', + template: '' +}) +class TestIntervalValueComponent { + + @Input() mode; + + @Input() displayValue; + +} + +@Component({ + selector: 'app-decimal-value', + template: '' +}) +class TestDecimalValueComponent { + + @Input() mode; + + @Input() displayValue; + +} + +@Component({ + selector: 'app-time-value', + template: '' +}) +class TestTimeValueComponent { + @Input() mode; + + @Input() displayValue; +} + +@Component({ + selector: 'app-color-value', + template: '' +}) +class TestColorValueComponent { + @Input() mode; + + @Input() displayValue; +} + +@Component({ + selector: 'app-geoname-value', + template: '' +}) +class TestGeonameValueComponent { + + @Input() mode; + + @Input() displayValue; + +} + +@Component({ + selector: 'app-date-value', + template: '' +}) +class TestDateValueComponent { + @Input() mode; + + @Input() displayValue; +} + +/** + * test host component to simulate parent component. + */ +@Component({ + selector: 'lib-host-component', + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('displayEditVal') displayEditValueComponent: DisplayEditComponent; + + readResource: ReadResource; + readValue: ReadValue; + propArray: PropertyInfoValues[] = []; + + mode: 'read' | 'update' | 'create' | 'search'; + + deleteIsAllowed: boolean; + + linkValClicked: ReadLinkValue | string = 'init'; // "init" is set because there is a test that checks that this does not emit for standoff links + // (and if it emits undefined because of a bug, we cannot check) + linkValHovered: ReadLinkValue | string = 'init'; // see comment above + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + this.readResource = res; + + this.mode = 'read'; + + this.deleteIsAllowed = true; + }); + } + + // assigns a value when called -> app-display-edit will be instantiated + assignValue(prop: string, comment?: string) { + const readVal = + this.readResource.getValues(prop)[0]; + + readVal.userHasPermission = 'M'; + + readVal.valueHasComment = comment; + + // standoff link value handling + // a text value linking to another resource has a corresponding standoff link value + if (prop === 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext') { + + // adapt ReadLinkValue so it looks like a standoff link value + const standoffLinkVal: ReadLinkValue + = this.readResource.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue', ReadLinkValue)[0]; + + standoffLinkVal.linkedResourceIri = 'testIri'; + + const propDefinition = this.readResource.entityInfo.properties['http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue']; + propDefinition.id = Constants.HasStandoffLinkToValue; + + const guiDefinition = this.readResource.entityInfo.classes['http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'].propertiesList.filter( + propDefForGui => propDefForGui.propertyIndex === 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue' + ); + + guiDefinition[0].propertyIndex = Constants.HasStandoffLinkToValue; + + const propInfo: PropertyInfoValues = { + values: [standoffLinkVal], + propDef: propDefinition, + guiDef: guiDefinition[0] + }; + + // add standoff link value to property array + this.propArray.push(propInfo); + } + + this.readValue = readVal; + } + + internalLinkClicked(linkVal: ReadLinkValue) { + this.linkValClicked = linkVal; + } + + internalLinkHovered(linkVal: ReadLinkValue) { + this.linkValHovered = linkVal; + } +} + +describe('DisplayEditComponent', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + + const valuesSpyObj = { + v2: { + values: jasmine.createSpyObj('values', ['updateValue', 'getValue', 'deleteValue']) + } + }; + + const eventSpy = jasmine.createSpyObj('ValueOperationEventService', ['emit']); + + const userServiceSpy = jasmine.createSpyObj('UserService', ['getUser']); + + const valueServiceSpy = jasmine.createSpyObj('ValueService', ['getValueTypeOrClass', 'isReadOnly']); + + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + MatIconModule, + MatDialogModule, + MatTooltipModule, + ReactiveFormsModule + ], + declarations: [ + DisplayEditComponent, + TestHostDisplayValueComponent, + TestTextValueAsStringComponent, + TestTextValueAsHtmlComponent, + TestTextValueAsXmlComponent, + TestIntValueComponent, + TestLinkValueComponent, + TestIntervalValueComponent, + TestListValueComponent, + TestBooleanValueComponent, + TestUriValueComponent, + TestDecimalValueComponent, + TestGeonameValueComponent, + TestTimeValueComponent, + TestColorValueComponent, + TestDateValueComponent + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: valuesSpyObj + }, + { + provide: ValueOperationEventService, + useValue: eventSpy + }, + { + provide: UserService, + useValue: userServiceSpy + }, + { + provide: MAT_DIALOG_DATA, + useValue: {} + }, + { + provide: MatDialogRef, + useValue: {} + }, + { + provide: ValueService, + useValue: valueServiceSpy + } + ] + }) + .compileComponents(); + + })); + + beforeEach(() => { + + const userSpy = TestBed.inject(UserService); + + // mock getUserByIri response + (userSpy as jasmine.SpyObj).getUser.and.callFake( + () => { + const user = MockUsers.mockUser(); + + return of(user.body); + } + ); + + const valueServiceSpy = TestBed.inject(ValueService); + + // actual ValueService + // mocking the service's behaviour would duplicate the actual implementation + const valueService = new ValueService(); + + // spy for getValueTypeOrClass + (valueServiceSpy as jasmine.SpyObj).getValueTypeOrClass.and.callFake( + (value: ReadValue) => valueService.getValueTypeOrClass(value) + ); + + // spy for isReadOnly + (valueServiceSpy as jasmine.SpyObj).isReadOnly.and.callFake( + (typeOrClass: string, value: ReadValue, propDef: ResourcePropertyDefinition) => valueService.isReadOnly(typeOrClass, value, propDef) + ); + + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + describe('display a value with the appropriate component', () => { + + it('should choose the apt component for a plain text value in the template', () => { + + const valueServiceSpy = TestBed.inject(ValueService); + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasText'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestTextValueAsStringComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadTextValueAsString).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + + // make sure the value service has been called as expected on initialization + + expect(valueServiceSpy.getValueTypeOrClass).toHaveBeenCalledTimes(1); + expect(valueServiceSpy.getValueTypeOrClass).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'http://api.knora.org/ontology/knora-api/v2#TextValue' + })); + + expect(valueServiceSpy.isReadOnly).toHaveBeenCalledTimes(1); + expect(valueServiceSpy.isReadOnly).toHaveBeenCalledWith( + 'ReadTextValueAsString', + jasmine.objectContaining({ + type: 'http://api.knora.org/ontology/knora-api/v2#TextValue' + }), + jasmine.objectContaining({ + id: 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasText' + }) + ); + + }); + + it('should choose the apt component for an XML value in the template', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestTextValueAsXmlComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadTextValueAsXml).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + + }); + + it('should react to clicking on a standoff link', () => { + + // assign value also updates the standoff link in propArray + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext'); + testHostFixture.detectChanges(); + + expect(testHostComponent.linkValClicked).toEqual('init'); + + (testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestTextValueAsXmlComponent).internalLinkClicked.emit('testIri'); + + expect((testHostComponent.linkValClicked as ReadLinkValue).linkedResourceIri).toEqual('testIri'); + + }); + + it('should not react to clicking on a standoff link when there is no corresponding standoff link value', () => { + + // assign value also updates the standoff link in propArray + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext'); + + // simulate situation + // where the standoff link was not updated + testHostComponent.propArray[0].values = []; + + testHostFixture.detectChanges(); + + expect(testHostComponent.linkValClicked).toEqual('init'); + + (testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestTextValueAsXmlComponent).internalLinkClicked.emit('testIri'); + + expect(testHostComponent.linkValClicked).toEqual('init'); + }); + + it('should react to hovering on a standoff link', () => { + + // assign value also updates the standoff link in propArray + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext'); + testHostFixture.detectChanges(); + + expect(testHostComponent.linkValHovered).toEqual('init'); + + (testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestTextValueAsXmlComponent).internalLinkHovered.emit('testIri'); + + expect((testHostComponent.linkValHovered as ReadLinkValue).linkedResourceIri).toEqual('testIri'); + + }); + + it('should not react to hovering on a standoff link when there is no corresponding standoff link value', () => { + + // assign value also updates the standoff link in propArray + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext'); + + // simulate situation + // where the standoff link was not updated + testHostComponent.propArray[0].values = []; + + testHostFixture.detectChanges(); + + expect(testHostComponent.linkValHovered).toEqual('init'); + + (testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestTextValueAsXmlComponent).internalLinkHovered.emit('testIri'); + + expect(testHostComponent.linkValHovered).toEqual('init'); + + }); + + it('should choose the apt component for an HTML text value in the template', () => { + + const inputVal: ReadTextValueAsHtml = new ReadTextValueAsHtml(); + + inputVal.property = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext'; + inputVal.hasPermissions = 'CR knora-admin:Creator|M knora-admin:ProjectMember|V knora-admin:KnownUser|RV knora-admin:UnknownUser'; + inputVal.userHasPermission = 'CR'; + inputVal.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + inputVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/TEST_ID'; + inputVal.html = + '

This is a very simple HTML document with a link

'; + + testHostComponent.readValue = inputVal; + + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestTextValueAsHtmlComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadTextValueAsHtml).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for an integer value in the template', () => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestIntValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadIntValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for a boolean value in the template', () => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasBoolean'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestBooleanValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadBooleanValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for a URI value in the template', () => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasUri'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestUriValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadUriValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for a decimal value in the template', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestDecimalValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadDecimalValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for a color value in the template', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasColor'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestColorValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadColorValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for an interval value in the template', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInterval'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestIntervalValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadIntervalValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for a time value in the template', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasTimeStamp'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestTimeValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadTimeValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for a link value in the template', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestLinkValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadLinkValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + expect((testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent).parentResource instanceof ReadResource).toBe(true); + expect((testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent).propIri). + toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue'); + + const userServiceSpy = TestBed.inject(UserService); + + expect(userServiceSpy.getUser).toHaveBeenCalledTimes(1); + expect(userServiceSpy.getUser).toHaveBeenCalledWith('http://rdfh.ch/users/BhkfBc3hTeS_IDo-JgXRbQ'); + + }); + + it('should choose the apt component for a link value in the template and react to a click event', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue'); + testHostFixture.detectChanges(); + + expect(testHostComponent.linkValClicked).toEqual('init'); + + (testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent) + .referredResourceClicked + .emit((testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent).displayValue); + + expect((testHostComponent.linkValClicked as ReadLinkValue).linkedResourceIri).toEqual('http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ'); + + }); + + it('should choose the apt component for a link value in the template and react to a hover event', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue'); + testHostFixture.detectChanges(); + + expect(testHostComponent.linkValHovered).toEqual('init'); + + (testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent) + .referredResourceHovered + .emit((testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent).displayValue); + + expect((testHostComponent.linkValHovered as ReadLinkValue).linkedResourceIri).toEqual('http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ'); + + }); + + it('should choose the apt component for a link value (standoff link) in the template', () => { + + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue'); + testHostComponent.readValue.property = Constants.HasStandoffLinkToValue; + testHostComponent.readValue.attachedToUser = 'http://www.knora.org/ontology/knora-admin#SystemUser'; // sstandoff links are managed by the system + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestLinkValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadLinkValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + expect((testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent).parentResource instanceof ReadResource).toBe(true); + expect((testHostComponent.displayEditValueComponent.displayValueComponent as unknown as TestLinkValueComponent).propIri).toEqual(Constants.HasStandoffLinkToValue); + + const userServiceSpy = TestBed.inject(UserService); + + // user info should not be retrieved for system user + expect(userServiceSpy.getUser).toHaveBeenCalledTimes(0); + + }); + + it('should choose the apt component for a list value in the template', () => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasListItem'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestListValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadListValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + it('should choose the apt component for a geoname value in the template', () => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasGeoname'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + expect(testHostComponent.displayEditValueComponent.displayValueComponent instanceof TestGeonameValueComponent).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue instanceof ReadGeonameValue).toBe(true); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.mode).toEqual('read'); + }); + + }); + + describe('change from display to edit mode', () => { + let hostCompDe; + let displayEditComponentDe; + + beforeEach(() => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + hostCompDe = testHostFixture.debugElement; + displayEditComponentDe = hostCompDe.query(By.directive(DisplayEditComponent)); + + testHostComponent.displayEditValueComponent.showActionBubble = true; + testHostFixture.detectChanges(); + + + }); + + it('should display an edit button if the user has the necessary permissions', () => { + expect(testHostComponent.displayEditValueComponent.canModify).toBeTruthy(); + expect(testHostComponent.displayEditValueComponent.editModeActive).toBeFalsy(); + + const editButtonDebugElement = displayEditComponentDe.query(By.css('button.edit')); + + expect(editButtonDebugElement).toBeTruthy(); + expect(editButtonDebugElement.nativeElement).toBeTruthy(); + + }); + + it('should switch to edit mode when the edit button is clicked', () => { + + const editButtonDebugElement = displayEditComponentDe.query(By.css('button.edit')); + const editButtonNativeElement = editButtonDebugElement.nativeElement; + + editButtonNativeElement.click(); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.editModeActive).toBeTruthy(); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.form.valid).toBeFalsy(); + + const saveButtonDebugElement = displayEditComponentDe.query(By.css('button.save')); + const saveButtonNativeElement = saveButtonDebugElement.nativeElement; + + expect(saveButtonNativeElement.disabled).toBeTruthy(); + + }); + + it('should save a new version of a value', () => { + + const valueEventSpy = TestBed.inject(ValueOperationEventService); + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valueEventSpy as jasmine.SpyObj).emit.and.stub(); + + (valuesSpy.v2.values as jasmine.SpyObj).updateValue.and.callFake( + () => { + + const response = new WriteValueResponse(); + + response.id = 'newID'; + response.type = 'type'; + response.uuid = 'uuid'; + + return of(response); + } + ); + + (valuesSpy.v2.values as jasmine.SpyObj).getValue.and.callFake( + () => { + + const updatedVal = new ReadIntValue(); + + updatedVal.id = 'newID'; + updatedVal.int = 1; + + const resource = new ReadResource(); + + resource.properties = { + 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger': [updatedVal] + }; + + return of(resource); + } + ); + + testHostComponent.displayEditValueComponent.canModify = true; + testHostComponent.displayEditValueComponent.editModeActive = true; + testHostComponent.displayEditValueComponent.mode = 'update'; + + testHostComponent.displayEditValueComponent.displayValueComponent.form.controls.test.clearValidators(); + testHostComponent.displayEditValueComponent.displayValueComponent.form.controls.test.updateValueAndValidity(); + + testHostFixture.detectChanges(); + + const saveButtonDebugElement = displayEditComponentDe.query(By.css('button.save')); + const saveButtonNativeElement = saveButtonDebugElement.nativeElement; + + expect(saveButtonNativeElement.disabled).toBeFalsy(); + + saveButtonNativeElement.click(); + + testHostFixture.detectChanges(); + + const expectedUpdateResource = new UpdateResource(); + + expectedUpdateResource.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'; + expectedUpdateResource.type = 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'; + expectedUpdateResource.property = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'; + + const expectedUpdateVal = new UpdateIntValue(); + expectedUpdateVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/dJ1ES8QTQNepFKF5-EAqdg'; + expectedUpdateVal.int = 1; + + expectedUpdateResource.value = expectedUpdateVal; + + expect(valuesSpy.v2.values.updateValue).toHaveBeenCalledWith(expectedUpdateResource); + expect(valuesSpy.v2.values.updateValue).toHaveBeenCalledTimes(1); + + expect(valueEventSpy.emit).toHaveBeenCalledTimes(1); + expect(valueEventSpy.emit).toHaveBeenCalledWith(new EmitEvent(Events.ValueUpdated, new UpdatedEventValues( + testHostComponent.readValue, testHostComponent.displayEditValueComponent.displayValue))); + + expect(valuesSpy.v2.values.getValue).toHaveBeenCalledTimes(1); + expect(valuesSpy.v2.values.getValue).toHaveBeenCalledWith(testHostComponent.readResource.id, 'uuid'); + + expect(testHostComponent.displayEditValueComponent.displayValue.id).toEqual('newID'); + expect(testHostComponent.displayEditValueComponent.displayValueComponent.displayValue.id).toEqual('newID'); + expect(testHostComponent.displayEditValueComponent.mode).toEqual('read'); + + + + }); + + it('should handle an ApiResponseError with status of 400 correctly', () => { + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + const error = ApiResponseError.fromAjaxError({} as AjaxError); + + error.status = 400; + + (valuesSpy.v2.values as jasmine.SpyObj).updateValue.and.returnValue(throwError(error)); + + testHostComponent.displayEditValueComponent.canModify = true; + testHostComponent.displayEditValueComponent.editModeActive = true; + testHostComponent.displayEditValueComponent.mode = 'update'; + + testHostComponent.displayEditValueComponent.displayValueComponent.form.controls.test.clearValidators(); + testHostComponent.displayEditValueComponent.displayValueComponent.form.controls.test.updateValueAndValidity(); + + testHostFixture.detectChanges(); + + const saveButtonDebugElement = displayEditComponentDe.query(By.css('button.save')); + const saveButtonNativeElement = saveButtonDebugElement.nativeElement; + + expect(saveButtonNativeElement.disabled).toBeFalsy(); + + saveButtonNativeElement.click(); + + testHostFixture.detectChanges(); + + const formErrors = testHostComponent.displayEditValueComponent.displayValueComponent.valueFormControl.errors; + + const expectedErrors = { + duplicateValue: true + }; + + expect(formErrors).toEqual(expectedErrors); + + }); + + }); + + describe('do not change from display to edit mode for an html text value', () => { + let hostCompDe; + let displayEditComponentDe; + + it('should not display the edit button', () => { + const inputVal: ReadTextValueAsHtml = new ReadTextValueAsHtml(); + + inputVal.property = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext'; + inputVal.hasPermissions = 'CR knora-admin:Creator|M knora-admin:ProjectMember|V knora-admin:KnownUser|RV knora-admin:UnknownUser'; + inputVal.userHasPermission = 'CR'; + inputVal.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + inputVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/TEST_ID'; + inputVal.html = + '

This is a very simple HTML document with a link

'; + + testHostComponent.readValue = inputVal; + + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + hostCompDe = testHostFixture.debugElement; + displayEditComponentDe = hostCompDe.query(By.directive(DisplayEditComponent)); + + const editButtonDebugElement = displayEditComponentDe.query(By.css('button.edit')); + expect(editButtonDebugElement).toBe(null); + + + }); + + }); + + describe('comment toggle button', () => { + let hostCompDe; + let displayEditComponentDe; + + beforeEach(() => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger', 'comment'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + hostCompDe = testHostFixture.debugElement; + displayEditComponentDe = hostCompDe.query(By.directive(DisplayEditComponent)); + + testHostComponent.displayEditValueComponent.showActionBubble = true; + testHostFixture.detectChanges(); + }); + + it('should display a comment button if the value has a comment', () => { + expect(testHostComponent.displayEditValueComponent.editModeActive).toBeFalsy(); + expect(testHostComponent.displayEditValueComponent.shouldShowCommentToggle).toBeTruthy(); + + const commentButtonDebugElement = displayEditComponentDe.query(By.css('button.comment-toggle')); + + expect(commentButtonDebugElement).toBeTruthy(); + expect(commentButtonDebugElement.nativeElement).toBeTruthy(); + + }); + + it('should not display a comment button if the comment is deleted', () => { + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valuesSpy.v2.values as jasmine.SpyObj).updateValue.and.callFake( + () => { + + const response = new WriteValueResponse(); + + response.id = 'newID'; + response.type = 'type'; + response.uuid = 'uuid'; + + return of(response); + } + ); + + (valuesSpy.v2.values as jasmine.SpyObj).getValue.and.callFake( + () => { + + const updatedVal = new ReadIntValue(); + + updatedVal.id = 'newID'; + updatedVal.int = 1; + updatedVal.valueHasComment = ''; + + const resource = new ReadResource(); + + resource.properties = { + 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger': [updatedVal] + }; + + return of(resource); + } + ); + + testHostComponent.displayEditValueComponent.canModify = true; + testHostComponent.displayEditValueComponent.editModeActive = true; + testHostComponent.displayEditValueComponent.mode = 'update'; + + testHostComponent.displayEditValueComponent.displayValueComponent.form.controls.test.clearValidators(); + testHostComponent.displayEditValueComponent.displayValueComponent.form.controls.test.updateValueAndValidity(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent.shouldShowCommentToggle).toBeTruthy(); + + const saveButtonDebugElement = displayEditComponentDe.query(By.css('button.save')); + const saveButtonNativeElement = saveButtonDebugElement.nativeElement; + + expect(saveButtonNativeElement.disabled).toBeFalsy(); + + saveButtonNativeElement.click(); + + testHostFixture.detectChanges(); + + const expectedUpdateResource = new UpdateResource(); + + expectedUpdateResource.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'; + expectedUpdateResource.type = 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'; + expectedUpdateResource.property = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'; + + const expectedUpdateVal = new UpdateIntValue(); + expectedUpdateVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/dJ1ES8QTQNepFKF5-EAqdg'; + expectedUpdateVal.int = 1; + + expectedUpdateResource.value = expectedUpdateVal; + + expect(valuesSpy.v2.values.updateValue).toHaveBeenCalledWith(expectedUpdateResource); + expect(valuesSpy.v2.values.updateValue).toHaveBeenCalledTimes(1); + + expect(valuesSpy.v2.values.getValue).toHaveBeenCalledTimes(1); + expect(valuesSpy.v2.values.getValue).toHaveBeenCalledWith(testHostComponent.readResource.id, + 'uuid'); + + expect(testHostComponent.displayEditValueComponent.displayValue.id).toEqual('newID'); + expect(testHostComponent.displayEditValueComponent.displayValue.valueHasComment).toEqual(''); + + expect(testHostComponent.displayEditValueComponent.shouldShowCommentToggle).toBeFalsy(); + expect(testHostComponent.displayEditValueComponent.mode).toEqual('read'); + + }); + + }); + + describe('deleteValue method', () => { + let hostCompDe; + let displayEditComponentDe; + let rootLoader: HarnessLoader; + let overlayContainer: OverlayContainer; + + beforeEach(() => { + testHostComponent.assignValue('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'); + testHostFixture.detectChanges(); + + expect(testHostComponent.displayEditValueComponent).toBeTruthy(); + + hostCompDe = testHostFixture.debugElement; + displayEditComponentDe = hostCompDe.query(By.directive(DisplayEditComponent)); + + testHostComponent.displayEditValueComponent.showActionBubble = true; + testHostFixture.detectChanges(); + + overlayContainer = TestBed.inject(OverlayContainer); + rootLoader = TestbedHarnessEnvironment.documentRootLoader(testHostFixture); + }); + + afterEach(async () => { + const dialogs = await rootLoader.getAllHarnesses(MatDialogHarness); + await Promise.all(dialogs.map(async d => await d.close())); + + // angular won't call this for us so we need to do it ourselves to avoid leaks. + overlayContainer.ngOnDestroy(); + }); + + it('should delete a value from a property', async () => { + const valueEventSpy = TestBed.inject(ValueOperationEventService); + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valueEventSpy as jasmine.SpyObj).emit.and.stub(); + + (valuesSpy.v2.values as jasmine.SpyObj).deleteValue.and.callFake( + () => { + + const response = new DeleteValueResponse(); + + response.result = 'success'; + + return of(response); + } + ); + + const deleteButton = await rootLoader.getHarness(MatButtonHarness.with({ selector: '.delete' })); + await deleteButton.click(); + + const dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness); + + expect(dialogHarnesses.length).toEqual(1); + + const okButton = await rootLoader.getHarness(MatButtonHarness.with({ selector: '.ok' })); + + await okButton.click(); + + const expectedUpdateResource = new UpdateResource(); + + expectedUpdateResource.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'; + expectedUpdateResource.type = 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'; + expectedUpdateResource.property = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'; + + const deleteVal = new DeleteValue(); + deleteVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/dJ1ES8QTQNepFKF5-EAqdg'; + deleteVal.type = 'http://api.knora.org/ontology/knora-api/v2#IntValue'; + deleteVal.deleteComment = undefined; + + expectedUpdateResource.value = deleteVal; + + testHostFixture.whenStable().then(() => { + expect(valuesSpy.v2.values.deleteValue).toHaveBeenCalledWith(expectedUpdateResource); + expect(valuesSpy.v2.values.deleteValue).toHaveBeenCalledTimes(1); + + expect(valueEventSpy.emit).toHaveBeenCalledTimes(1); + expect(valueEventSpy.emit).toHaveBeenCalledWith(new EmitEvent(Events.ValueDeleted, new DeletedEventValue(deleteVal))); + }); + + }); + + it('should send a deletion comment to Knora if one is provided', async () => { + const valueEventSpy = TestBed.inject(ValueOperationEventService); + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valueEventSpy as jasmine.SpyObj).emit.and.stub(); + + (valuesSpy.v2.values as jasmine.SpyObj).deleteValue.and.callFake( + () => { + + const response = new DeleteValueResponse(); + + response.result = 'success'; + + return of(response); + } + ); + + testHostComponent.displayEditValueComponent.deleteValue('my deletion comment'); + + const expectedUpdateResource = new UpdateResource(); + + expectedUpdateResource.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'; + expectedUpdateResource.type = 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'; + expectedUpdateResource.property = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger'; + + const deleteVal = new DeleteValue(); + deleteVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/dJ1ES8QTQNepFKF5-EAqdg'; + deleteVal.type = 'http://api.knora.org/ontology/knora-api/v2#IntValue'; + deleteVal.deleteComment = 'my deletion comment'; + + expectedUpdateResource.value = deleteVal; + + testHostFixture.whenStable().then(() => { + expect(valuesSpy.v2.values.deleteValue).toHaveBeenCalledWith(expectedUpdateResource); + expect(valuesSpy.v2.values.deleteValue).toHaveBeenCalledTimes(1); + }); + }); + + it('should disable the delete button', async () => { + testHostComponent.displayEditValueComponent.canDelete = false; + + testHostFixture.detectChanges(); + + const deleteButton = await rootLoader.getHarness(MatButtonHarness.with({ selector: '.delete' })); + expect(deleteButton.isDisabled).toBeTruthy; + }); + }); +}); diff --git a/src/app/workspace/resource/operations/display-edit/display-edit.component.ts b/src/app/workspace/resource/operations/display-edit/display-edit.component.ts new file mode 100644 index 0000000000..ac1b13a15c --- /dev/null +++ b/src/app/workspace/resource/operations/display-edit/display-edit.component.ts @@ -0,0 +1,396 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { Component, EventEmitter, Inject, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { + ApiResponseError, + Constants, + DeleteValue, + DeleteValueResponse, + KnoraApiConnection, + PermissionUtil, + ReadLinkValue, + ReadResource, + ReadUser, + ReadValue, + ResourcePropertyDefinition, + UpdateResource, + UpdateValue, + WriteValueResponse +} from '@dasch-swiss/dsp-js'; +import { mergeMap } from 'rxjs/operators'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; +import { + ConfirmationDialogComponent, + ConfirmationDialogData, + ConfirmationDialogValueDeletionPayload +} from '../../../../main/action/confirmation-dialog/confirmation-dialog.component'; +import { PropertyInfoValues } from '../../properties/properties.component'; +import { UserService } from '../../services/user.service'; +import { + DeletedEventValue, + EmitEvent, + Events, + UpdatedEventValues, + ValueOperationEventService +} from '../../services/value-operation-event.service'; +import { ValueService } from '../../services/value.service'; + +@Component({ + selector: 'app-display-edit', + templateUrl: './display-edit.component.html', + styleUrls: ['./display-edit.component.scss'], + animations: [ + // the fade-in/fade-out animation. + // https://www.kdechant.com/blog/angular-animations-fade-in-and-fade-out + trigger('simpleFadeAnimation', [ + + // the "in" style determines the "resting" state of the element when it is visible. + state('in', style({ opacity: 1 })), + + // fade in when created. + transition(':enter', [ + // the styles start from this point when the element appears + style({ opacity: 0 }), + // and animate toward the "in" state above + animate(150) + ]), + + // fade out when destroyed. + transition(':leave', + // fading out uses a different syntax, with the "style" being passed into animate() + animate(150, style({ opacity: 0 }))) + ]) + ] +}) +export class DisplayEditComponent implements OnInit { + + @ViewChild('displayVal') displayValueComponent: BaseValueDirective; + + @Input() displayValue: ReadValue; + + @Input() propArray: PropertyInfoValues[]; + + @Input() parentResource: ReadResource; + + @Input() configuration?: object; + + @Input() canDelete: boolean; + + @Output() referredResourceClicked: EventEmitter = new EventEmitter(); + + @Output() referredResourceHovered: EventEmitter = new EventEmitter(); + + constants = Constants; + + mode: 'read' | 'update' | 'create' | 'search'; + + canModify: boolean; + + editModeActive = false; + + shouldShowCommentToggle: boolean; + + // type of given displayValue + // or knora-api-js-lib class representing the value + valueTypeOrClass: string; + + // indicates if value can be edited + readOnlyValue: boolean; + + // indicates if the action bubble with the CRUD buttons should be shown + showActionBubble = false; + + // string used as class name to add add to value-component element on hover + backgroundColor = ''; + + dateDisplayOptions: 'era' | 'calendar' | 'all'; + + showDateLabels = false; + + dateFormat: string; + + user: ReadUser; + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _valueOperationEventService: ValueOperationEventService, + private _dialog: MatDialog, + private _userService: UserService, + private _valueService: ValueService) { + } + + ngOnInit() { + + this.mode = 'read'; + this.dateDisplayOptions = 'all'; + this.showDateLabels = true; + this.dateFormat = 'dd.MM.YYYY'; + + // determine if user has modify permissions + const allPermissions = PermissionUtil.allUserPermissions(this.displayValue.userHasPermission as 'RV' | 'V' | 'M' | 'D' | 'CR'); + + this.canModify = allPermissions.indexOf(PermissionUtil.Permissions.M) !== -1; + + // check if comment toggle button should be shown + this.checkCommentToggleVisibility(); + + this.valueTypeOrClass = this._valueService.getValueTypeOrClass(this.displayValue); + + // get the resource property definition + const resPropDef = this.parentResource.entityInfo.getPropertyDefinitionsByType(ResourcePropertyDefinition).filter( + (propDef: ResourcePropertyDefinition) => propDef.id === this.displayValue.property + ); + + if (resPropDef.length !== 1) { + // this should never happen because we always have the property info for the given value + throw new Error('Resource Property Definition could not be found: ' + this.displayValue.property); + } + + this.readOnlyValue = this._valueService.isReadOnly(this.valueTypeOrClass, this.displayValue, resPropDef[0]); + + // prevent getting info about system user (standoff link values are managed by the system) + if (this.displayValue.attachedToUser !== 'http://www.knora.org/ontology/knora-admin#SystemUser') { + this._userService.getUser(this.displayValue.attachedToUser).subscribe( + user => { + this.user = user.user; + } + ); + } + } + + getTooltipText(): string { + const creationDate = 'Creation date: ' + this.displayValue.valueCreationDate; + + const creatorInfo = this.user ? '\n Value creator: ' + this.user?.givenName + ' ' + this.user?.familyName : ''; + + return creationDate + creatorInfo; + } + + /** + * react when a standoff link in a text has received a click event. + * + * @param resIri the Iri of the resource the standoff link refers to. + */ + standoffLinkClicked(resIri: string): void { + + // find the corresponding standoff link value + const referredResStandoffLinkVal: ReadLinkValue[] = this._getStandoffLinkValueForResource(resIri); + + // only emit an event if the corresponding standoff link value could be found + if (referredResStandoffLinkVal.length === 1) { + this.referredResourceClicked.emit(referredResStandoffLinkVal[0]); + } + } + + /** + * react when a standoff link in a text has received a hover event. + * + * @param resIri the Iri of the resource the standoff link refers to. + */ + standoffLinkHovered(resIri: string): void { + + // find the corresponding standoff link value + const referredResStandoffLinkVal: ReadLinkValue[] = this._getStandoffLinkValueForResource(resIri); + + // only emit an event if the corresponding standoff link value could be found + if (referredResStandoffLinkVal.length === 1) { + this.referredResourceHovered.emit(referredResStandoffLinkVal[0]); + } + + } + + /** + * show the form components and CRUD buttons to update an existing value or add a new value. + */ + activateEditMode() { + this.editModeActive = true; + this.backgroundColor = ''; + this.mode = 'update'; + + // hide comment toggle button while in edit mode + this.checkCommentToggleVisibility(); + + // hide read mode comment when switching to edit mode + this.displayValueComponent.shouldShowComment = false; + } + + /** + * save a new version of an existing property value. + */ + saveEditValue() { + this.editModeActive = false; + this.showActionBubble = false; + const updatedVal = this.displayValueComponent.getUpdatedValue(); + + if (updatedVal instanceof UpdateValue) { + const updateRes = new UpdateResource(); + updateRes.id = this.parentResource.id; + updateRes.type = this.parentResource.type; + updateRes.property = this.displayValue.property; + updateRes.value = updatedVal; + this._dspApiConnection.v2.values.updateValue(updateRes as UpdateResource).pipe( + mergeMap((res: WriteValueResponse) => this._dspApiConnection.v2.values.getValue(this.parentResource.id, res.uuid)) + ).subscribe( + (res2: ReadResource) => { + this._valueOperationEventService.emit( + new EmitEvent(Events.ValueUpdated, new UpdatedEventValues( + this.displayValue, res2.getValues(this.displayValue.property)[0]))); + + this.displayValue = res2.getValues(this.displayValue.property)[0]; + this.mode = 'read'; + + // hide comment once back in read mode + this.displayValueComponent.updateCommentVisibility(); + + // check if comment toggle button should be shown + this.checkCommentToggleVisibility(); + }, + (error: ApiResponseError) => { + // error handling + this.editModeActive = true; + switch (error.status) { + case 400: + this.displayValueComponent.valueFormControl.setErrors({ duplicateValue: true }); + break; + default: + console.log('There was an error processing your request. Details: ', error); + break; + + } + } + ); + + } else { + console.error('invalid value'); + } + } + + /** + * open a confirmation dialog box to ensure the user would like to complete the action. + */ + openDialog() { + const dialogData = new ConfirmationDialogData(); + dialogData.value = this.displayValue; + dialogData.buttonTextOk = 'Yes, delete the value'; + dialogData.buttonTextCancel = 'No, keep the value'; + + const dialogRef = + this._dialog.open(ConfirmationDialogComponent, { data: dialogData }); + + dialogRef.afterClosed().subscribe((payload: ConfirmationDialogValueDeletionPayload) => { + if (payload && payload.confirmed) { + this.deleteValue(payload.deletionComment); + } + }); + } + + /** + * delete a value from a property. + * Emits an event that can be listened to. + */ + deleteValue(comment?: string) { + const deleteVal = new DeleteValue(); + deleteVal.id = this.displayValue.id; + deleteVal.type = this.displayValue.type; + deleteVal.deleteComment = comment; + + const updateRes = new UpdateResource(); + updateRes.type = this.parentResource.type; + updateRes.id = this.parentResource.id; + updateRes.property = this.displayValue.property; + updateRes.value = deleteVal; + + this._dspApiConnection.v2.values.deleteValue(updateRes as UpdateResource).pipe( + mergeMap((res: DeleteValueResponse) => { + // emit a ValueDeleted event to the listeners in resource-view component to trigger an update of the UI + this._valueOperationEventService.emit(new EmitEvent(Events.ValueDeleted, new DeletedEventValue(deleteVal))); + return res.result; + })).subscribe(); + } + + /** + * hide the form components and CRUD buttons and show the value in read mode. + */ + cancelEditValue() { + this.editModeActive = false; + this.showActionBubble = false; + this.mode = 'read'; + + // hide comment once back in read mode + this.displayValueComponent.updateCommentVisibility(); + + // check if comment toggle button should be shown + this.checkCommentToggleVisibility(); + } + + /** + * show or hide the comment. + */ + toggleComment() { + this.displayValueComponent.toggleCommentVisibility(); + } + + /** + * check if the comment toggle button should be shown. + * Only show the comment toggle button if user is in READ mode and a comment exists for the value. + */ + checkCommentToggleVisibility() { + this.shouldShowCommentToggle = ( + this.mode === 'read' && + this.displayValue.valueHasComment !== '' && + this.displayValue.valueHasComment !== undefined + ); + } + + /** + * show CRUD buttons and add 'highlighted' class to the element only if editModeActive is false + */ + mouseEnter() { + this.showActionBubble = true; + if (!this.editModeActive) { + this.backgroundColor = 'highlighted'; + } + } + + /** + * hide CRUD buttons and remove the 'hightlighted' class from the element + */ + mouseLeave() { + this.showActionBubble = false; + this.backgroundColor = ''; + } + + /** + * given a resource Iri, finds the corresponding standoff link value. + * Returns an empty array if the standoff link cannot be found. + * + * @param resIri the Iri of the resource. + */ + private _getStandoffLinkValueForResource(resIri: string): ReadLinkValue[] { + + // find the PropertyInfoValues for the standoff link value + const standoffLinkPropInfoVals: PropertyInfoValues[] = this.propArray.filter( + resPropInfoVal => resPropInfoVal.propDef.id === Constants.HasStandoffLinkToValue + ); + + if (standoffLinkPropInfoVals.length === 1) { + + // find the corresponding standoff link value + const referredResStandoffLinkVal: ReadValue[] = standoffLinkPropInfoVals[0].values.filter( + (standoffLinkVal: ReadValue) => standoffLinkVal instanceof ReadLinkValue + && (standoffLinkVal as ReadLinkValue).linkedResourceIri === resIri + ); + + // if no corresponding standoff link value was found, + // this array is empty + return referredResStandoffLinkVal as ReadLinkValue[]; + + } else { + // this should actually never happen + // because all resource types have a cardinality for a standoff link value + return []; + } + } + +} diff --git a/src/app/workspace/resource/properties/properties.component.ts b/src/app/workspace/resource/properties/properties.component.ts index 837359c6f4..9e57a90ddf 100644 --- a/src/app/workspace/resource/properties/properties.component.ts +++ b/src/app/workspace/resource/properties/properties.component.ts @@ -8,9 +8,11 @@ import { DeleteResource, DeleteResourceResponse, DeleteValue, + IHasPropertyWithPropertyDefinition, KnoraApiConnection, PermissionUtil, ProjectResponse, + PropertyDefinition, ReadLinkValue, ReadProject, ReadResource, @@ -29,7 +31,6 @@ import { DspApiConnectionToken, Events, NotificationService, - PropertyInfoValues, UpdatedEventValues, UserService, ValueOperationEventService, @@ -43,6 +44,13 @@ import { RepresentationConstants } from '../representation/file-representation'; import { IncomingService } from '../incoming.service'; import { PageEvent } from '@angular/material/paginator'; +// object of property information from ontology class, properties and property values +export interface PropertyInfoValues { + guiDef: IHasPropertyWithPropertyDefinition; + propDef: PropertyDefinition; + values: ReadValue[]; +} + @Component({ selector: 'app-properties', templateUrl: './properties.component.html', diff --git a/src/app/workspace/resource/representation/upload/upload.component.html b/src/app/workspace/resource/representation/upload/upload.component.html index 3611bdf72b..a61d2c605f 100644 --- a/src/app/workspace/resource/representation/upload/upload.component.html +++ b/src/app/workspace/resource/representation/upload/upload.component.html @@ -1,6 +1,6 @@
-
diff --git a/src/app/workspace/resource/services/geoname.service.spec.ts b/src/app/workspace/resource/services/geoname.service.spec.ts new file mode 100644 index 0000000000..9bdc18cb6e --- /dev/null +++ b/src/app/workspace/resource/services/geoname.service.spec.ts @@ -0,0 +1,370 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AppInitService } from 'src/app/main/services/app-init.service'; +import { DisplayPlace, GeonameService } from './geoname.service'; + +const geonamesGetResponse = { + 'timezone': { 'gmtOffset': 1, 'timeZoneId': 'Europe/Zurich', 'dstOffset': 2 }, + 'asciiName': 'Zuerich Enge', + 'astergdem': 421, + 'countryId': '2658434', + 'fcl': 'S', + 'srtm3': 412, + 'adminId2': '6458798', + 'adminId3': '7287650', + 'countryCode': 'CH', + 'adminCodes1': { 'ISO3166_2': 'ZH' }, + 'adminId1': '2657895', + 'lat': '47.3641', + 'fcode': 'RSTN', + 'continentCode': 'EU', + 'adminCode2': '112', + 'adminCode3': '261', + 'adminCode1': 'ZH', + 'lng': '8.53081', + 'geonameId': 11963110, + 'toponymName': 'Zürich Enge', + 'population': 0, + 'wikipediaURL': 'en.wikipedia.org/wiki/Z%C3%BCrich_Enge_railway_station', + 'adminName5': '', + 'adminName4': '', + 'adminName3': 'Zurich', + 'alternateNames': [{ + 'name': '8503010', + 'lang': 'uicn' + }, { 'name': 'https://en.wikipedia.org/wiki/Z%C3%BCrich_Enge_railway_station', 'lang': 'link' }, { + 'name': 'ZEN', + 'lang': 'abbr' + }, { 'isShortName': true, 'isPreferredName': true, 'name': 'Zürich Enge' }], + 'adminName2': 'Zürich District', + 'name': 'Zürich Enge', + 'fclName': 'spot, building, farm', + 'countryName': 'Switzerland', + 'fcodeName': 'railroad station', + 'adminName1': 'Zurich' +}; + +const geonamesSearchResponse = { + 'totalResultsCount': 203, 'geonames': [{ + 'timezone': { 'gmtOffset': 1, 'timeZoneId': 'Europe/Zurich', 'dstOffset': 2 }, + 'bbox': { + 'east': 7.634148441523814, + 'south': 47.523628289543254, + 'north': 47.58955415634046, + 'west': 7.554659665553558, + 'accuracyLevel': 10 + }, + 'asciiName': 'Basel', + 'astergdem': 287, + 'countryId': '2658434', + 'fcl': 'P', + 'srtm3': 279, + 'score': 23.979536056518555, + 'adminId2': '6458763', + 'adminId3': '7285161', + 'countryCode': 'CH', + 'adminCodes1': { 'ISO3166_2': 'BS' }, + 'adminId1': '2661602', + 'lat': '47.55839', + 'fcode': 'PPLA', + 'continentCode': 'EU', + 'adminCode2': '1200', + 'adminCode3': '2701', + 'adminCode1': 'BS', + 'lng': '7.57327', + 'geonameId': 2661604, + 'toponymName': 'Basel', + 'population': 164488, + 'adminName5': '', + 'adminName4': '', + 'adminName3': 'Basel', + 'alternateNames': [{ 'name': 'Basel', 'lang': 'als' }, { 'name': 'ባዝል', 'lang': 'am' }, { + 'name': 'بازل', + 'lang': 'ar' + }, { 'name': 'بازل', 'lang': 'arz' }, { 'name': 'بازل', 'lang': 'azb' }, { + 'name': 'Базель', + 'lang': 'be' + }, { 'name': 'Базел', 'lang': 'bg' }, { 'name': 'বাজেল', 'lang': 'bn' }, { + 'name': 'པ་སེལ།', + 'lang': 'bo' + }, { 'name': 'Basel', 'lang': 'bs' }, { 'name': 'Basilea', 'lang': 'ca' }, { + 'name': 'Базель', + 'lang': 'ce' + }, { 'name': 'بازل', 'lang': 'ckb' }, { 'name': 'Basilej', 'lang': 'cs' }, { + 'name': 'Базель', + 'lang': 'cv' + }, { 'name': 'Basel', 'lang': 'da' }, { 'name': 'Basel', 'lang': 'de' }, { + 'name': 'Βασιλεία', + 'lang': 'el' + }, { 'name': 'Basel', 'lang': 'en' }, { 'name': 'Bazelo', 'lang': 'eo' }, { + 'name': 'Basilea', + 'lang': 'es' + }, { 'name': 'بازل', 'lang': 'fa' }, { 'name': 'Basel', 'lang': 'fi' }, { + 'name': 'Bâle', + 'lang': 'fr' + }, { 'name': 'Bâla', 'lang': 'frp' }, { 'name': 'Bāsel', 'lang': 'frr' }, { + 'name': 'בזל', + 'lang': 'he' + }, { 'name': 'Bázel', 'lang': 'hu' }, { 'name': 'Բազել', 'lang': 'hy' }, { + 'isPreferredName': true, + 'name': 'BSL', + 'lang': 'iata' + }, { 'name': 'Basel', 'lang': 'id' }, { 'name': 'Basilea', 'lang': 'it' }, { + 'name': 'バーゼル', + 'lang': 'ja' + }, { 'name': 'ბაზელი', 'lang': 'ka' }, { 'name': 'Базель', 'lang': 'kk' }, { + 'name': '바젤', + 'lang': 'ko' + }, { 'name': 'Robur', 'lang': 'la' }, { + 'name': 'https://en.wikipedia.org/wiki/Basel', + 'lang': 'link' + }, { 'name': 'Bazelis', 'lang': 'lt' }, { 'name': 'Bāzele', 'lang': 'lv' }, { + 'name': 'Базел', + 'lang': 'mk' + }, { 'name': 'Базель хот', 'lang': 'mn' }, { 'name': 'बासल', 'lang': 'mr' }, { + 'name': 'Bazel', + 'lang': 'nl' + }, { 'name': 'Basel', 'lang': 'nn' }, { 'name': 'Basel', 'lang': 'no' }, { + 'name': 'Basilèa', + 'lang': 'oc' + }, { 'name': 'Базель', 'lang': 'os' }, { 'name': 'Bazylea', 'lang': 'pl' }, { + 'isPreferredName': true, + 'name': '4000', + 'lang': 'post' + }, { 'name': 'Basileia', 'lang': 'pt' }, { 'name': 'Basilea', 'lang': 'rm' }, { + 'name': 'Basel', + 'lang': 'ro' + }, { 'name': 'Базель', 'lang': 'ru' }, { 'name': 'Bazilej', 'lang': 'sk' }, { + 'name': 'Basel', + 'lang': 'sl' + }, { 'name': 'Bazeli', 'lang': 'sq' }, { 'name': 'Базел', 'lang': 'sr' }, { + 'name': 'Basel', + 'lang': 'sv' + }, { 'name': 'பேசெல்', 'lang': 'ta' }, { 'name': 'บาเซิล', 'lang': 'th' }, { + 'name': 'Basel', + 'lang': 'tr' + }, { 'name': 'Базель', 'lang': 'uk' }, { 'name': 'CHBSL', 'lang': 'unlc' }, { + 'name': 'بازل', + 'lang': 'ur' + }, { 'name': 'Baxiłea', 'lang': 'vec' }, { 'name': '白才尔', 'lang': 'wuu' }, { + 'name': 'באזעל', + 'lang': 'yi' + }, { 'name': '巴塞爾', 'lang': 'yue' }, { 'name': '巴塞尔', 'lang': 'zh' }, { 'name': '巴塞尔', 'lang': 'zh-CN' }], + 'adminName2': 'Basel-City', + 'name': 'Basel', + 'fclName': 'city, village,...', + 'countryName': 'Schweiz', + 'fcodeName': 'seat of a first-order administrative division', + 'adminName1': 'Basel-Stadt' + }, { + 'timezone': { 'gmtOffset': 1, 'timeZoneId': 'Europe/Brussels', 'dstOffset': 2 }, + 'bbox': { + 'east': 4.328900686384307, + 'south': 51.12543286042041, + 'north': 51.169661437124496, + 'west': 4.23076509693852, + 'accuracyLevel': 10 + }, + 'asciiName': 'Bazel', + 'astergdem': 10, + 'countryId': '2802361', + 'fcl': 'P', + 'srtm3': 14, + 'score': 21.20476722717285, + 'adminId2': '2789733', + 'adminId3': '2786577', + 'countryCode': 'BE', + 'adminId4': '2793941', + 'adminCodes2': { 'ISO3166_2': 'VOV' }, + 'adminCodes1': { 'ISO3166_2': 'VLG' }, + 'adminId1': '3337388', + 'lat': '51.14741', + 'fcode': 'PPL', + 'continentCode': 'EU', + 'adminCode2': 'VOV', + 'adminCode3': '46', + 'adminCode1': 'VLG', + 'lng': '4.30129', + 'geonameId': 2802529, + 'toponymName': 'Bazel', + 'adminCode4': '46013', + 'population': 5687, + 'adminName5': '', + 'adminName4': 'Kruibeke', + 'adminName3': 'Arrondissement Sint-Niklaas', + 'alternateNames': [{ 'name': 'https://en.wikipedia.org/wiki/Bazel', 'lang': 'link' }, { + 'name': '9150', + 'lang': 'post' + }, { 'name': 'BEBAZ', 'lang': 'unlc' }], + 'adminName2': 'Provinz Ostflandern', + 'name': 'Basel', + 'fclName': 'city, village,...', + 'countryName': 'Belgien', + 'fcodeName': 'populated place', + 'adminName1': 'Flandern' + }] +}; + +describe('GeonameService', () => { + let service: GeonameService; + let httpTestingController: HttpTestingController; + + const appInitSpy = { + config: { + geonameToken: 'token' + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + { + provide: AppInitService, + useValue: appInitSpy + } + ] + }); + + service = TestBed.inject(GeonameService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Method resolveGeonameID', () => { + + it('should resolve a given geoname id', done => { + + service.resolveGeonameID('2661604').subscribe( + (displayPlace: DisplayPlace) => { + expect(displayPlace.displayName).toEqual('Zürich Enge, Zurich, Switzerland'); + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); + + expect(httpRequest.request.method).toEqual('GET'); + + const expectedResponse = geonamesGetResponse; + + httpRequest.flush(expectedResponse); + + }); + + it('should return an error if the requests fails', done => { + + service.resolveGeonameID('2661604').subscribe( + name => { + }, + err => { + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); + + expect(httpRequest.request.method).toEqual('GET'); + + const mockErrorResponse = { status: 400, statusText: 'Bad Request' }; + + httpRequest.flush(mockErrorResponse); + + }); + + it('should return an error if the requests response does not contain the expected information', done => { + + service.resolveGeonameID('2661604').subscribe( + name => { + }, + err => { + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); + + expect(httpRequest.request.method).toEqual('GET'); + + const expectedResponse = { place: 'Basel' }; + + httpRequest.flush(expectedResponse); + + }); + + describe('Method searchPlace', () => { + + it('should search for a place', done => { + + service.searchPlace('Basel').subscribe( + places => { + expect(places.length).toEqual(2); + + const placeBasel = places[0]; + expect(placeBasel.displayName).toEqual('Basel, Basel-Stadt, Schweiz'); + expect(placeBasel.id).toEqual('2661604'); + + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/searchJSON?userName=token&lang=en&style=full&maxRows=12&name_startsWith=Basel'); + + expect(httpRequest.request.method).toEqual('GET'); + + const expectedResponse = geonamesSearchResponse; + + httpRequest.flush(expectedResponse); + + }); + + it('should return an error if the requests fails', done => { + + service.searchPlace('Basel').subscribe( + name => { + }, + err => { + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/searchJSON?userName=token&lang=en&style=full&maxRows=12&name_startsWith=Basel'); + + expect(httpRequest.request.method).toEqual('GET'); + + const mockErrorResponse = { status: 400, statusText: 'Bad Request' }; + + httpRequest.flush(mockErrorResponse); + + }); + + it('should return an error if the requests response does not contain the expected information', done => { + + service.searchPlace('Basel').subscribe( + name => { + }, + err => { + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/searchJSON?userName=token&lang=en&style=full&maxRows=12&name_startsWith=Basel'); + + expect(httpRequest.request.method).toEqual('GET'); + + const expectedResponse = { place: 'Basel' }; + + httpRequest.flush(expectedResponse); + + }); + + }); + + }); + +}); diff --git a/src/app/workspace/resource/services/geoname.service.ts b/src/app/workspace/resource/services/geoname.service.ts new file mode 100644 index 0000000000..7f63c97d46 --- /dev/null +++ b/src/app/workspace/resource/services/geoname.service.ts @@ -0,0 +1,126 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map, shareReplay } from 'rxjs/operators'; +import { AppInitService } from 'src/app/main/services/app-init.service'; + +export interface GIS { + longitude: number; + latitude: number; +} + +export interface DisplayPlace { + displayName: string; + name: string; + country: string; + administrativeName?: string; + wikipediaUrl?: string; + location: GIS; +} + +export interface SearchPlace { + id: string; + displayName: string; + name: string; + administrativeName?: string; + country: string; + locationType: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class GeonameService { + + constructor( + private readonly _http: HttpClient, + private _appInitService: AppInitService + ) { + } + + /** + * given a geoname id, resolves the identifier. + * + * @param id the geiname id to resolve. + */ + resolveGeonameID(id: string): Observable { + + return this._http.get('https://ws.geonames.net/getJSON?geonameId=' + id + '&username=' + this._appInitService.config['geonameToken'] + '&style=short').pipe( + map( + (geo: { name: string; countryName: string; adminName1?: string; wikipediaURL?: string; lat: number; lng: number }) => { // assertions for TS compiler + + if (!(('name' in geo) && ('countryName' in geo) && ('lat' in geo) && ('lng' in geo))) { + // at least one of the expected properties is not present + throw new Error('required property missing in geonames response'); + } + + return { + displayName: geo.name + (geo.adminName1 !== undefined ? ', ' + geo.adminName1 : '') + ', ' + geo.countryName, + name: geo.name, + administrativeName: geo.adminName1, + country: geo.countryName, + wikipediaUrl: geo.wikipediaURL, + location: { + longitude: geo.lng, + latitude: geo.lat + } + }; + } + ), + shareReplay({ refCount: false, bufferSize: 1 }), // several subscribers may use the same source Observable (one HTTP request to geonames) + catchError(error => + // an error occurred + throwError(error) + ) + ); + } + + /** + * given a search string, searches for places matching the string. + * + * @param searchString place to search for. + */ + searchPlace(searchString: string): Observable { + + return this._http.get('https://ws.geonames.net/searchJSON?userName=' + + this._appInitService.config['geonameToken'] + + '&lang=en&style=full&maxRows=12&name_startsWith=' + encodeURIComponent(searchString)).pipe( + map( + (places: { + geonames: { geonameId: string; name: string; countryName: string; adminName1?: string; fclName: string }[]; // assertions for TS compiler + }) => { + + if (!Array.isArray(places.geonames)) { + // there is no top level array + throw new Error('search did not return an array of results'); + } + + return places.geonames.map( + geo => { + + if (!(('geonameId' in geo) && ('name' in geo) && ('countryName' in geo) && ('fclName' in geo))) { + // at least one of the expected properties is not present + throw new Error('required property missing in geonames response'); + } + + return { + id: geo.geonameId.toString(), + displayName: geo.name + (geo.adminName1 !== undefined ? ', ' + geo.adminName1 : '') + ', ' + geo.countryName, + name: geo.name, + administrativeName: geo.adminName1, + country: geo.countryName, + locationType: geo.fclName + }; + } + ); + + } + ), + catchError(error => + // an error occurred + throwError(error) + ) + ); + + } +} diff --git a/src/app/workspace/resource/services/upload-file.service.spec.ts b/src/app/workspace/resource/services/upload-file.service.spec.ts new file mode 100644 index 0000000000..1e09789a5a --- /dev/null +++ b/src/app/workspace/resource/services/upload-file.service.spec.ts @@ -0,0 +1,100 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AppInitService } from 'src/app/main/services/app-init.service'; +import { Session, SessionService } from 'src/app/main/services/session.service'; +import { UploadedFileResponse, UploadFileService } from './upload-file.service'; + +describe('UploadFileService', () => { + let service: UploadFileService; + let httpTestingController: HttpTestingController; + + const file = new File(['1'], 'testfile'); + const mockUploadData = new FormData(); + mockUploadData.append('test', file); + + beforeEach(() => { + + const appInitSpy = { + config: { + sipiUrl: 'https://sipi.dasch.swiss/' + } + }; + + const sessionSpy = jasmine.createSpyObj('SessionService', ['getSession']); + + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + { provide: AppInitService, useValue: appInitSpy }, + { provide: SessionService, useValue: sessionSpy }, + ] + }); + + service = TestBed.inject(UploadFileService); + httpTestingController = TestBed.inject(HttpTestingController); + + const sessionServiceSpy = TestBed.inject(SessionService) as jasmine.SpyObj; + + sessionServiceSpy.getSession.and.callFake( + () => { + const session: Session = { + id: 12345, + user: { + name: 'username', + jwt: 'myToken', + lang: 'en', + sysAdmin: false, + projectAdmin: [] + } + }; + + return session; + } + ); + }); + + afterEach(() => { + // after every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return expected file response on upload', done => { + + const expectedResponse: UploadedFileResponse = { + uploadedFiles: [{ + fileType: 'image', + internalFilename: '8R0cJE3TSgB-BssuQyeW1rE.jp2', + originalFilename: 'Screenshot 2020-10-28 at 14.16.34.png', + temporaryUrl: 'http://sipi:1024/tmp/8R0cJE3TSgB-BssuQyeW1rE.jp2' + }] + }; + + service.upload(mockUploadData).subscribe( + res => { + expect(res.uploadedFiles.length).toEqual(1); + expect(res.uploadedFiles[0].internalFilename).toEqual('8R0cJE3TSgB-BssuQyeW1rE.jp2'); + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://sipi.dasch.swiss/upload?token=myToken'); + + expect(httpRequest.request.method).toEqual('POST'); + + const expectedFormData = new FormData(); + const mockFile = new File(['1'], 'testfile', { type: 'image/jpeg' }); + + expectedFormData.append(mockFile.name, mockFile); + expect(httpRequest.request.body).toEqual(expectedFormData); + + httpRequest.flush(expectedResponse); + + }); + +}); diff --git a/src/app/workspace/resource/services/upload-file.service.ts b/src/app/workspace/resource/services/upload-file.service.ts new file mode 100644 index 0000000000..f80d983f5d --- /dev/null +++ b/src/app/workspace/resource/services/upload-file.service.ts @@ -0,0 +1,46 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AppInitService } from 'src/app/main/services/app-init.service'; +import { SessionService } from 'src/app/main/services/session.service'; + +interface UploadedFile { + fileType: string; + internalFilename: string; + originalFilename: string; + temporaryUrl: string; +} + +export interface UploadedFileResponse { + uploadedFiles: UploadedFile[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class UploadFileService { + + sipiHost: string = this._is.config['sipiUrl']; + + constructor( + private readonly _is: AppInitService, + private readonly _http: HttpClient, + private readonly _ss: SessionService + ) { } + + /** + * uploads files to SIPI + * @param (file) + */ + upload(file: FormData): Observable { + const baseUrl = `${this.sipiHost}upload`; + + // checks if user is logged in + const jwt = this._ss.getSession()?.user.jwt; + const params = new HttpParams().set('token', jwt); + + // tODO in order to track the progress change below to true and 'events' + const options = { params, reportProgress: false, observe: 'body' as 'body' }; + return this._http.post(baseUrl, file, options); + } +} diff --git a/src/app/workspace/resource/services/user.service.spec.ts b/src/app/workspace/resource/services/user.service.spec.ts new file mode 100644 index 0000000000..391f58e3fc --- /dev/null +++ b/src/app/workspace/resource/services/user.service.spec.ts @@ -0,0 +1,49 @@ +import { waitForAsync, TestBed } from '@angular/core/testing'; +import { MockUsers } from '@dasch-swiss/dsp-js'; +import { UserService } from './user.service'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; + +describe('UserService', () => { + let service: UserService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: DspApiConnectionToken, + useValue: {} + } + ] + }); + + service = TestBed.inject(UserService); + })); + + it('should be created', () => { + expect(service).toBeTruthy(); + expect(service['_userCache']).toBeDefined(); + }); + + it('should get a user', done => { + + const userCacheSpy = spyOn(service['_userCache'], 'getUser').and.callFake( + () => { + const user = MockUsers.mockUser(); + + return of(user.body); + } + ); + + service.getUser('http://rdfh.ch/users/root').subscribe( + user => { + expect(user.user.id).toEqual('http://rdfh.ch/users/root'); + expect(userCacheSpy).toHaveBeenCalledTimes(1); + expect(userCacheSpy).toHaveBeenCalledWith('http://rdfh.ch/users/root'); + done(); + } + ); + + }); + +}); diff --git a/src/app/workspace/resource/services/user.service.ts b/src/app/workspace/resource/services/user.service.ts new file mode 100644 index 0000000000..022cb99451 --- /dev/null +++ b/src/app/workspace/resource/services/user.service.ts @@ -0,0 +1,28 @@ +import { Inject, Injectable } from '@angular/core'; +import { KnoraApiConnection, UserCache, UserResponse } from '@dasch-swiss/dsp-js'; +import { Observable } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + + // instance of user cache + private _userCache: UserCache; + + constructor(@Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection) { + // instantiate user cache + this._userCache = new UserCache(this._dspApiConnection); + } + + /** + * retrieves information about the specified user. + * + * @param userIri the Iri identifying the user. + */ + getUser(userIri: string): Observable { + return this._userCache.getUser(userIri); + } + +} diff --git a/src/app/workspace/resource/services/value-operation-event.service.spec.ts b/src/app/workspace/resource/services/value-operation-event.service.spec.ts new file mode 100644 index 0000000000..5ac3dfe5a2 --- /dev/null +++ b/src/app/workspace/resource/services/value-operation-event.service.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { EmitEvent, Events, ValueOperationEventService } from './value-operation-event.service'; + +describe('ValueOperationEventService', () => { + let service: ValueOperationEventService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ValueOperationEventService] + }); + service = TestBed.inject(ValueOperationEventService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should listen for events and execute code in the callback when the event is emitted', () => { + let valuesCount = 2; + + // listen for ValueAdded event + service.on(Events.ValueAdded, () => valuesCount += 1); + + // listen for ValueDeleted event + service.on(Events.ValueDeleted, () => valuesCount -= 1); + + // emit ValueAdded event + service.emit(new EmitEvent(Events.ValueAdded)); + + expect(valuesCount).toEqual(3); + + // emit ValueDeleted event + service.emit(new EmitEvent(Events.ValueDeleted)); + + expect(valuesCount).toEqual(2); + }); + + it('should no longer execute the callback code when an event is emitted after unsubscribing', () => { + let valuesCount = 2; + + // listen for ValueAdded event + const valueOperationEventSubscription = service.on(Events.ValueAdded, () => valuesCount += 1); + + // emit ValueAdded event + service.emit(new EmitEvent(Events.ValueAdded)); + + expect(valuesCount).toEqual(3); + + valueOperationEventSubscription.unsubscribe(); + + // emit ValueAdded event again, this time it should not trigger the callback + service.emit(new EmitEvent(Events.ValueAdded)); + + expect(valuesCount).toEqual(3); + + }); +}); diff --git a/src/app/workspace/resource/services/value-operation-event.service.ts b/src/app/workspace/resource/services/value-operation-event.service.ts new file mode 100644 index 0000000000..ba1252653e --- /dev/null +++ b/src/app/workspace/resource/services/value-operation-event.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { DeleteValue, ReadValue } from '@dasch-swiss/dsp-js'; +import { Subject, Subscription } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +/** + * https://stackoverflow.com/questions/56290722/how-pass-a-event-from-deep-nested-child-to-parent-in-angular-2 + * This service is used as a way to enable components to communicate with each other no matter how nested they are. + * This is intended to provide a cleaner way to emit events from nested components than chaining '@Outputs'. + * The ValueOperationEventService essentially creates a direct communication channel between + * the emitting component and the listening component. + */ +@Injectable() // must be provided on component level, i.e. resource view component. +export class ValueOperationEventService { + + // create a subject to hold data which can be subscribed to. + // you only get the data after you subscribe. + private _subject$ = new Subject(); + + // used in the listening component. + // i.e. this.valueOperationEventSubscription = this._valueOperationEventService.on(Events.ValueAdded, () => doSomething()); + on(event: Events, action: (value: EventValue) => void): Subscription { + return this._subject$ + .pipe( + // filter down based on event name to any events that are emitted out of the subject from the emit method below. + filter((e: EmitEvent) => e.name === event), + map((e: EmitEvent) => e.value) + ) + .subscribe(action); // subscribe to the subject to get the data. + } + + // used in the emitting component. + // i.e. this.valueOperationEventService.emit(new EmitEvent(Events.ValueAdded, new EventValues(new ReadValue())); + emit(event: EmitEvent) { + this._subject$.next(event); + } +} + +export class EmitEvent { + constructor(public name: Events, public value?: EventValue) { } +} + +// possible events that can be emitted. +export enum Events { + ValueAdded, + ValueDeleted, + ValueUpdated +} + +export abstract class EventValue { } + +export class AddedEventValue extends EventValue { + constructor(public addedValue: ReadValue) { + super(); + } +} + +export class UpdatedEventValues extends EventValue { + constructor(public currentValue: ReadValue, public updatedValue: ReadValue) { + super(); + } +} + +export class DeletedEventValue extends EventValue { + constructor(public deletedValue: DeleteValue) { + super(); + } +} diff --git a/src/app/workspace/resource/services/value.service.spec.ts b/src/app/workspace/resource/services/value.service.spec.ts new file mode 100644 index 0000000000..0fc72f4876 --- /dev/null +++ b/src/app/workspace/resource/services/value.service.spec.ts @@ -0,0 +1,334 @@ +import { TestBed } from '@angular/core/testing'; + +import { + Constants, + KnoraDate, + MockResource, + ReadDateValue, + ReadIntValue, + ReadLinkValue, + ReadTextValueAsHtml, + ReadTextValueAsString, + ReadTextValueAsXml, + ResourcePropertyDefinition +} from '@dasch-swiss/dsp-js'; +import { ValueService } from './value.service'; + +describe('ValueService', () => { + let service: ValueService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ValueService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getValueTypeOrClass', () => { + + it('should return type of int', () => { + const readIntValue = new ReadIntValue(); + readIntValue.type = 'http://api.knora.org/ontology/knora-api/v2#IntValue'; + expect(service.getValueTypeOrClass(readIntValue)).toEqual('http://api.knora.org/ontology/knora-api/v2#IntValue'); + }); + + it('should return class of ReadTextValueAsString', () => { + const readTextValueAsString = new ReadTextValueAsString(); + readTextValueAsString.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + expect(service.getValueTypeOrClass(readTextValueAsString)).toEqual('ReadTextValueAsString'); + }); + }); + + describe('isTextEditable', () => { + + it('should determine if a given text with the standard mapping is editable', () => { + + const readTextValueAsXml = new ReadTextValueAsXml(); + readTextValueAsXml.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + readTextValueAsXml.mapping = Constants.StandardMapping; + expect(service.isTextEditable(readTextValueAsXml)).toBeTruthy(); + + }); + + it('should determine if a given text with a custom mapping is editable', () => { + + const readTextValueAsXml = new ReadTextValueAsXml(); + readTextValueAsXml.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + readTextValueAsXml.mapping = 'http://rdfh.ch/standoff/mappings/CustomMapping'; + expect(service.isTextEditable(readTextValueAsXml)).toBeFalsy(); + + }); + + }); + + describe('isReadOnly', () => { + + it('should not mark a ReadIntValue as ReadOnly', () => { + + const readIntValue = new ReadIntValue(); + readIntValue.type = 'http://api.knora.org/ontology/knora-api/v2#IntValue'; + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = true; + + const valueClass = service.getValueTypeOrClass(readIntValue); + expect(service.isReadOnly(valueClass, readIntValue, resPropDef)).toBeFalsy(); + + }); + + it('should not mark ReadTextValueAsString as ReadOnly', () => { + const readTextValueAsString = new ReadTextValueAsString(); + readTextValueAsString.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = true; + + const valueClass = service.getValueTypeOrClass(readTextValueAsString); + expect(service.isReadOnly(valueClass, readTextValueAsString, resPropDef)).toBeFalsy(); + }); + + it('should mark ReadTextValueAsHtml as ReadOnly', () => { + const readTextValueAsHtml = new ReadTextValueAsHtml(); + readTextValueAsHtml.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = true; + + const valueClass = service.getValueTypeOrClass(readTextValueAsHtml); + expect(service.isReadOnly(valueClass, readTextValueAsHtml, resPropDef)).toBeTruthy(); + }); + + it('should not mark ReadTextValueAsXml with standard mapping as ReadOnly', () => { + const readTextValueAsXml = new ReadTextValueAsXml(); + readTextValueAsXml.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + readTextValueAsXml.mapping = Constants.StandardMapping; + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = true; + + const valueClass = service.getValueTypeOrClass(readTextValueAsXml); + expect(service.isReadOnly(valueClass, readTextValueAsXml, resPropDef)).toBeFalsy(); + }); + + it('should mark ReadTextValueAsXml with custom mapping as ReadOnly', () => { + const readTextValueAsXml = new ReadTextValueAsXml(); + readTextValueAsXml.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + readTextValueAsXml.mapping = 'http://rdfh.ch/standoff/mappings/CustomMapping'; + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = true; + + const valueClass = service.getValueTypeOrClass(readTextValueAsXml); + expect(service.isReadOnly(valueClass, readTextValueAsXml, resPropDef)).toBeTruthy(); + }); + + it('should mark a standoff link value as ReadOnly', () => { + const readStandoffLinkValue = new ReadLinkValue(); + readStandoffLinkValue.type = 'http://api.knora.org/ontology/knora-api/v2#LinkValue'; + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = false; + + const valueClass = service.getValueTypeOrClass(readStandoffLinkValue); + expect(service.isReadOnly(valueClass, readStandoffLinkValue, resPropDef)).toBeTruthy(); + }); + + it('should not mark ReadDateValue with supported era as ReadOnly', done => { + + MockResource.getTestThing().subscribe(res => { + const date: ReadDateValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate', ReadDateValue)[0]; + + date.date = new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13); + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = true; + + const valueClass = service.getValueTypeOrClass(date); + expect(service.isReadOnly(valueClass, date, resPropDef)).toBeFalsy(); + + done(); + + }); + + }); + + it('should not mark ReadDateValue with supported precision as ReadOnly', done => { + + MockResource.getTestThing().subscribe(res => { + const date: ReadDateValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate', ReadDateValue)[0]; + + date.date = new KnoraDate('GREGORIAN', 'CE', 2019, 5, 1); + + const resPropDef = new ResourcePropertyDefinition(); + resPropDef.isEditable = true; + + const valueClass = service.getValueTypeOrClass(date); + expect(service.isReadOnly(valueClass, date, resPropDef)).toBeFalsy(); + + done(); + + }); + + }); + + }); + + describe('compareObjectTypeWithValueType', () => { + + it('should successfully compare "http://api.knora.org/ontology/knora-api/v2#TextValue" with "ReadTextValueAsString"', () => { + expect(service.compareObjectTypeWithValueType( + 'ReadTextValueAsString', + 'http://api.knora.org/ontology/knora-api/v2#TextValue')) + .toBeTruthy(); + }); + + it('should successfully compare "http://api.knora.org/ontology/knora-api/v2#TextValue" with "ReadTextValueAsHtml"', () => { + expect(service.compareObjectTypeWithValueType( + 'ReadTextValueAsHtml', + 'http://api.knora.org/ontology/knora-api/v2#TextValue')) + .toBeTruthy(); + }); + + it('should successfully compare "http://api.knora.org/ontology/knora-api/v2#TextValue" with "ReadTextValueAsXml"', () => { + expect(service.compareObjectTypeWithValueType( + 'ReadTextValueAsXml', + 'http://api.knora.org/ontology/knora-api/v2#TextValue')) + .toBeTruthy(); + }); + + it('should successfully compare "http://api.knora.org/ontology/knora-api/v2#IntValue" with "http://api.knora.org/ontology/knora-api/v2#IntValue"', () => { + expect(service.compareObjectTypeWithValueType( + 'http://api.knora.org/ontology/knora-api/v2#IntValue', + 'http://api.knora.org/ontology/knora-api/v2#IntValue')) + .toBeTruthy(); + }); + + it('should fail to compare an IntValue with a DecimalValue', () => { + expect(service.compareObjectTypeWithValueType( + 'http://api.knora.org/ontology/knora-api/v2#IntValue', + 'http://api.knora.org/ontology/knora-api/v2#DecimalValue')) + .toBeFalsy(); + }); + + it('should fail to compare "http://api.knora.org/ontology/knora-api/v2#IntValue" with "ReadTextValueAsString"', () => { + expect(service.compareObjectTypeWithValueType( + 'ReadTextValueAsString', + 'http://api.knora.org/ontology/knora-api/v2#IntValue')) + .toBeFalsy(); + }); + + }); + + describe('calculateDaysInMonth', () => { + + it('should calculate the number of days in February in a leap year', () => { + + expect(service.calculateDaysInMonth('GREGORIAN', 2020, 2)).toEqual(29); + + }); + + it('should calculate the number of days in February in a non leap year', () => { + + expect(service.calculateDaysInMonth('GREGORIAN', 2021, 2)).toEqual(28); + + }); + + it('should calculate the number of days in March', () => { + + expect(service.calculateDaysInMonth('GREGORIAN', 2021, 3)).toEqual(31); + + }); + + }); + + describe('createJDNCalendarDateFromKnoraDate', () => { + + it('should create a JDN calendar date from a Knora date with day precision in the Gregorian calendar', () => { + + const calDateJDN = service.createJDNCalendarDateFromKnoraDate(new KnoraDate('GREGORIAN', 'CE', 2021, 3, 15)); + + const period = calDateJDN.toJDNPeriod(); + + expect(period.periodStart).toEqual(2459289); + expect(period.periodEnd).toEqual(2459289); + + }); + + it('should create a JDN calendar date from a Knora date BCE with day precision in the Gregorian calendar', () => { + + const calDateJDN = service.createJDNCalendarDateFromKnoraDate(new KnoraDate('GREGORIAN', 'BCE', 1, 1, 1)); + + const period = calDateJDN.toJDNPeriod(); + + expect(period.periodStart).toEqual(1721058); + expect(period.periodEnd).toEqual(1721058); + + }); + + it('should create a JDN calendar date from a Knora date with day precision in the Julian calendar', () => { + + const calDateJDN = service.createJDNCalendarDateFromKnoraDate(new KnoraDate('JULIAN', 'CE', 2021, 3, 15)); + + const period = calDateJDN.toJDNPeriod(); + + expect(period.periodStart).toEqual(2459302); + expect(period.periodEnd).toEqual(2459302); + + }); + + it('should create a JDN calendar date from a Knora date with month precision in the Gregorian calendar', () => { + + const calDateJDN = service.createJDNCalendarDateFromKnoraDate(new KnoraDate('GREGORIAN', 'CE', 2021, 3)); + + const period = calDateJDN.toJDNPeriod(); + + expect(period.periodStart).toEqual(2459275); + expect(period.periodEnd).toEqual(2459305); + + }); + + it('should create a JDN calendar date from a Knora date with year precision in the Gregorian calendar', () => { + + const calDateJDN = service.createJDNCalendarDateFromKnoraDate(new KnoraDate('GREGORIAN', 'CE', 2021)); + + const period = calDateJDN.toJDNPeriod(); + + expect(period.periodStart).toEqual(2459216); + expect(period.periodEnd).toEqual(2459580); + + }); + + }); + + describe('convertHistoricalYearToAstronomicalYear', () => { + + it('should convert the year 1 BCE to its astronomical representation', () => { + + expect(service.convertHistoricalYearToAstronomicalYear(1, 'BCE', 'JULIAN')).toEqual(0); + + }); + + it('should convert the year 2 BCE to its astronomical representation', () => { + + expect(service.convertHistoricalYearToAstronomicalYear(2, 'BCE', 'JULIAN')).toEqual(-1); + + }); + + it('should convert the year 1 CE to its astronomical representation', () => { + + expect(service.convertHistoricalYearToAstronomicalYear(1, 'CE', 'JULIAN')).toEqual(1); + + }); + + it('should convert the year 1 in the Islamic to its astronomical representation', () => { + + expect(service.convertHistoricalYearToAstronomicalYear(1, 'noEra', 'ISLAMIC')).toEqual(1); + + }); + + }); +}); diff --git a/src/app/workspace/resource/services/value.service.ts b/src/app/workspace/resource/services/value.service.ts new file mode 100644 index 0000000000..b126fee2b6 --- /dev/null +++ b/src/app/workspace/resource/services/value.service.ts @@ -0,0 +1,215 @@ +import { Injectable } from '@angular/core'; +import { + Constants, + KnoraDate, + Precision, + ReadTextValueAsHtml, + ReadTextValueAsString, + ReadTextValueAsXml, + ReadValue, + ResourcePropertyDefinition +} from '@dasch-swiss/dsp-js'; +import { + CalendarDate, + CalendarPeriod, + GregorianCalendarDate, + IslamicCalendarDate, + JDNConvertibleCalendar, + JulianCalendarDate +} from 'jdnconvertiblecalendar'; + +@Injectable({ + providedIn: 'root' +}) +export class ValueService { + + constants = Constants; + + private readonly _readTextValueAsString = 'ReadTextValueAsString'; + + private readonly _readTextValueAsXml = 'ReadTextValueAsXml'; + + private readonly _readTextValueAsHtml = 'ReadTextValueAsHtml'; + + constructor() { + } + + /** + * given a value, determines the type or class representing it. + * + * For text values, this method determines the specific class in use. + * For all other types, the given type is returned. + * + * @param value the given value. + */ + getValueTypeOrClass(value: ReadValue): string { + if (value.type === this.constants.TextValue) { + if (value instanceof ReadTextValueAsString) { + return this._readTextValueAsString; + } else if (value instanceof ReadTextValueAsXml) { + return this._readTextValueAsXml; + } else if (value instanceof ReadTextValueAsHtml) { + return this._readTextValueAsHtml; + } else { + throw new Error(`unknown TextValue class ${value}`); + } + } else { + return value.type; + } + } + + /** + * given a ResourcePropertyDefinition of a #hasText property, determines the class representing it. + * + * @param resourcePropDef the given ResourcePropertyDefinition. + */ + getTextValueClass(resourcePropDef: ResourcePropertyDefinition): string { + switch (resourcePropDef.guiElement) { + case 'http://api.knora.org/ontology/salsah-gui/v2#SimpleText': + return this._readTextValueAsString; + case 'http://api.knora.org/ontology/salsah-gui/v2#Richtext': + return this._readTextValueAsXml; + default: + return this._readTextValueAsString; + } + + } + + /** + * given the ObjectType of a PropertyDefinition, compares it to the provided value type. + * Primarily used to check if a TextValue type is equal to one of the readonly strings in this class. + * + * @param objectType PropertyDefinition ObjectType + * @param valueType Value type (ReadValue, DeleteValue, BaseValue, etc.) + */ + compareObjectTypeWithValueType(objectType: string, valueType: string): boolean { + return (objectType === this._readTextValueAsString && valueType === this.constants.TextValue) || + (objectType === this._readTextValueAsHtml && valueType === this.constants.TextValue) || + (objectType === this._readTextValueAsXml && valueType === this.constants.TextValue) || + objectType === valueType; + } + + /** + * determines if a text can be edited using the text editor. + * + * @param textValue the text value to be checked. + */ + isTextEditable(textValue: ReadTextValueAsXml): boolean { + return textValue.mapping === Constants.StandardMapping; + } + + /** + * determines if the given value can be edited. + * + * @param valueTypeOrClass the type or class of the given value. + * @param value the given value. + * @param propertyDef the given values property definition. + */ + isReadOnly(valueTypeOrClass: string, value: ReadValue, propertyDef: ResourcePropertyDefinition): boolean { + + // if value is not editable in general from the ontology, + // flag it as read-only + if (!propertyDef.isEditable) { + return true; + } + + // only texts complying with the standard mapping can be edited using CKEditor. + const xmlValueNonStandardMapping + = valueTypeOrClass === this._readTextValueAsXml + && (value instanceof ReadTextValueAsXml && !this.isTextEditable(value)); + + return valueTypeOrClass === this._readTextValueAsHtml || + valueTypeOrClass === this.constants.GeomValue || + xmlValueNonStandardMapping; + } + + /** + * calculates the number of days in a month for a given year. + * + * @param calendar the date's calendar. + * @param year the date's year. + * @param month the date's month. + */ + calculateDaysInMonth(calendar: string, year: number, month: number): number { + const date = new CalendarDate(year, month, 1); + if (calendar === 'GREGORIAN') { + const calDate = new GregorianCalendarDate(new CalendarPeriod(date, date)); + return calDate.daysInMonth(date); + } else if (calendar === 'JULIAN') { + const calDate = new JulianCalendarDate(new CalendarPeriod(date, date)); + return calDate.daysInMonth(date); + } else if (calendar === 'ISLAMIC') { + const calDate = new IslamicCalendarDate(new CalendarPeriod(date, date)); + return calDate.daysInMonth(date); + } else { + throw Error('Unknown calendar ' + calendar); + } + + } + + /** + * given a historical date (year), returns the astronomical year. + * + * @param year year of the given date. + * @param era era of the given date. + * @param calendar calendar of the given date. + */ + convertHistoricalYearToAstronomicalYear(year: number, era: string, calendar: string) { + + let yearAstro = year; + if (era === 'BCE') { + // convert historical date to astronomical date + yearAstro = (yearAstro * -1) + 1; + } + return yearAstro; + } + + /** + * given a Knora calendar date, creates a JDN calendar date + * taking into account precision. + * + * @param date the Knora calendar date. + */ + createJDNCalendarDateFromKnoraDate(date: KnoraDate): JDNConvertibleCalendar { + + let calPeriod: CalendarPeriod; + + const yearAstro = this.convertHistoricalYearToAstronomicalYear(date.year, date.era, date.calendar); + + if (date.precision === Precision.dayPrecision) { + + calPeriod = new CalendarPeriod( + new CalendarDate(yearAstro, date.month, date.day), + new CalendarDate(yearAstro, date.month, date.day) + ); + + } else if (date.precision === Precision.monthPrecision) { + + calPeriod = new CalendarPeriod( + new CalendarDate(yearAstro, date.month, 1), + new CalendarDate(yearAstro, date.month, this.calculateDaysInMonth(date.calendar, date.year, date.month)) + ); + + } else if (date.precision === Precision.yearPrecision) { + + calPeriod = new CalendarPeriod( + new CalendarDate(yearAstro, 1, 1), + new CalendarDate(yearAstro, 12, this.calculateDaysInMonth(date.calendar, date.year, 12)) + ); + + } else { + throw Error('Invalid precision'); + } + + if (date.calendar === 'GREGORIAN') { + return new GregorianCalendarDate(calPeriod); + } else if (date.calendar === 'JULIAN') { + return new JulianCalendarDate(calPeriod); + } else if (date.calendar === 'ISLAMIC') { + return new IslamicCalendarDate(calPeriod); + } else { + throw Error('Invalid calendar'); + } + + } +} diff --git a/src/app/workspace/resource/values/boolean-value/boolean-value.component.html b/src/app/workspace/resource/values/boolean-value/boolean-value.component.html new file mode 100644 index 0000000000..a661132095 --- /dev/null +++ b/src/app/workspace/resource/values/boolean-value/boolean-value.component.html @@ -0,0 +1,27 @@ + + {{valueFormControl.value | formattedBoolean:preferredDisplayType}} + {{commentFormControl.value}} + + + + + {{valueFormControl.value | formattedBoolean:preferredDisplayType}} + + + New value must be different than the current value. + + + + + + diff --git a/src/app/workspace/resource/values/boolean-value/boolean-value.component.scss b/src/app/workspace/resource/values/boolean-value/boolean-value.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/workspace/resource/values/boolean-value/boolean-value.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/boolean-value/boolean-value.component.spec.ts b/src/app/workspace/resource/values/boolean-value/boolean-value.component.spec.ts new file mode 100644 index 0000000000..a2c8e02c85 --- /dev/null +++ b/src/app/workspace/resource/values/boolean-value/boolean-value.component.spec.ts @@ -0,0 +1,350 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatOptionModule } from '@angular/material/core'; +import { BooleanValueComponent } from './boolean-value.component'; +import { Component, OnInit, ViewChild, DebugElement } from '@angular/core'; +import { ReadBooleanValue, MockResource, UpdateBooleanValue, CreateBooleanValue } from '@dasch-swiss/dsp-js'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { FormattedBooleanPipe } from 'src/app/main/pipes/formatting/formatted-boolean.pipe'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('booleanVal') booleanValueComponent: BooleanValueComponent; + + displayBooleanVal: ReadBooleanValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + MockResource.getTestThing().subscribe(res => { + const booleanVal: ReadBooleanValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasBoolean', ReadBooleanValue)[0]; + + this.displayBooleanVal = booleanVal; + + this.mode = 'read'; + }); + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('booleanVal') booleanValueComponent: BooleanValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + this.mode = 'create'; + } +} + +describe('BooleanValueComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + BooleanValueComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent, + FormattedBooleanPipe + ], + imports: [ + ReactiveFormsModule, + MatCheckboxModule, + MatInputModule, + MatSelectModule, + MatOptionModule, + BrowserAnimationsModule + ] + }) + .compileComponents(); + })); + + describe('display and edit a boolean value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueBooleanDebugElement: DebugElement; + let valueBooleanNativeElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + let checkboxEl; + let checkboxLabel; + let commentBooleanDebugElement: DebugElement; + let commentBooleanNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.booleanValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + valueComponentDe = hostCompDe.query(By.directive(BooleanValueComponent)); + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + }); + + it('should display an existing value', () => { + + expect(testHostComponent.booleanValueComponent.displayValue.bool).toEqual(true); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.booleanValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('true'); + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueBooleanDebugElement = valueComponentDe.query(By.css('mat-checkbox')); + valueBooleanNativeElement = valueBooleanDebugElement.nativeElement; + + checkboxEl = valueBooleanDebugElement.query(By.css('input[type="checkbox"]')).nativeElement; + checkboxLabel = valueBooleanDebugElement.query(By.css('span[class="mat-checkbox-label"]')).nativeElement; + + expect(testHostComponent.booleanValueComponent.mode).toEqual('update'); + + expect(checkboxEl.disabled).toBe(false); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeFalsy(); + + expect(checkboxEl.checked).toBe(true); + + expect(checkboxLabel.innerText).toEqual('true'); + + checkboxEl.click(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.booleanValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateBooleanValue).toBeTruthy(); + + expect((updatedValue as UpdateBooleanValue).bool).toBe(false); + + expect(checkboxEl.checked).toBe(false); + + expect(checkboxEl.disabled).toBe(false); + + expect(checkboxLabel.innerText).toEqual('false'); + }); + + it('should validate an existing value with an added comment', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueBooleanDebugElement = valueComponentDe.query(By.css('mat-checkbox')); + valueBooleanNativeElement = valueBooleanDebugElement.nativeElement; + + checkboxEl = valueBooleanDebugElement.query(By.css('input[type="checkbox"]')).nativeElement; + checkboxLabel = valueBooleanDebugElement.query(By.css('span[class="mat-checkbox-label"]')).nativeElement; + + commentBooleanDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentBooleanNativeElement = commentBooleanDebugElement.nativeElement; + + expect(testHostComponent.booleanValueComponent.mode).toEqual('update'); + + expect(checkboxEl.disabled).toBe(false); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeFalsy(); + + expect(checkboxEl.checked).toBe(true); + + expect(checkboxLabel.innerText).toEqual('true'); + + commentBooleanNativeElement.value = 'this is a comment'; + + commentBooleanNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.booleanValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateBooleanValue).toBeTruthy(); + + expect((updatedValue as UpdateBooleanValue).valueHasComment).toEqual('this is a comment'); + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueBooleanDebugElement = valueComponentDe.query(By.css('mat-checkbox')); + valueBooleanNativeElement = valueBooleanDebugElement.nativeElement; + + checkboxEl = valueBooleanDebugElement.query(By.css('input[type="checkbox"]')).nativeElement; + checkboxLabel = valueBooleanDebugElement.query(By.css('span[class="mat-checkbox-label"]')).nativeElement; + + expect(testHostComponent.booleanValueComponent.mode).toEqual('update'); + + expect(checkboxEl.disabled).toBe(false); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeFalsy(); + + expect(checkboxEl.checked).toBe(true); + + expect(checkboxLabel.innerText).toEqual('true'); + + checkboxEl.click(); + + testHostFixture.detectChanges(); + + expect(checkboxEl.checked).toBe(false); + + expect(checkboxLabel.innerText).toEqual('false'); + + testHostComponent.booleanValueComponent.resetFormControl(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeFalsy(); + + expect(checkboxEl.checked).toBe(true); + + expect(checkboxLabel.innerText).toEqual('true'); + }); + + it('should set a new display value', () => { + + const newBool = new ReadBooleanValue(); + + newBool.bool = false; + newBool.id = 'updatedId'; + + testHostComponent.displayBooleanVal = newBool; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('false'); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.booleanValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.booleanValueComponent.ngOnDestroy(); + + expect(testHostComponent.booleanValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + + }); + + describe('create a boolean value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueBooleanDebugElement: DebugElement; + let valueBooleanNativeElement; + let checkboxEl; + let checkboxLabel; + let commentBooleanDebugElement: DebugElement; + let commentBooleanNativeElement; + + beforeEach(() => { + + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.booleanValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(BooleanValueComponent)); + valueBooleanDebugElement = valueComponentDe.query(By.css('mat-checkbox')); + valueBooleanNativeElement = valueBooleanDebugElement.nativeElement; + checkboxEl = valueBooleanDebugElement.query(By.css('input[type="checkbox"]')).nativeElement; + checkboxLabel = valueBooleanDebugElement.query(By.css('span[class="mat-checkbox-label"]')).nativeElement; + + commentBooleanDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentBooleanNativeElement = commentBooleanDebugElement.nativeElement; + + expect(testHostComponent.booleanValueComponent.displayValue).toEqual(undefined); + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + expect(checkboxEl.disabled).toBe(false); + expect(checkboxEl.checked).toBe(false); + expect(checkboxLabel.innerText).toEqual('false'); + expect(commentBooleanNativeElement.value).toEqual(''); + }); + + it('should create a value', () => { + + checkboxEl.click(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.booleanValueComponent.mode).toEqual('create'); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.booleanValueComponent.getNewValue(); + + expect(newValue instanceof CreateBooleanValue).toBeTruthy(); + + expect((newValue as CreateBooleanValue).bool).toEqual(true); + }); + + it('should reset form after cancellation', () => { + + commentBooleanNativeElement.value = 'created comment'; + + commentBooleanNativeElement.dispatchEvent(new Event('input')); + + checkboxEl.click(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.booleanValueComponent.mode).toEqual('create'); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + + testHostComponent.booleanValueComponent.resetFormControl(); + + expect(testHostComponent.booleanValueComponent.form.valid).toBeTruthy(); + + expect(checkboxEl.checked).toBe(true); + + expect(commentBooleanNativeElement.value).toEqual(''); + + }); + }); + +}); diff --git a/src/app/workspace/resource/values/boolean-value/boolean-value.component.ts b/src/app/workspace/resource/values/boolean-value/boolean-value.component.ts new file mode 100644 index 0000000000..4541aa7e93 --- /dev/null +++ b/src/app/workspace/resource/values/boolean-value/boolean-value.component.ts @@ -0,0 +1,123 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { CreateBooleanValue, ReadBooleanValue, UpdateBooleanValue } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-boolean-value', + templateUrl: './boolean-value.component.html', + styleUrls: ['./boolean-value.component.scss'] +}) +export class BooleanValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @Input() displayValue?: ReadBooleanValue; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + + customValidators = []; + + displayTypes = []; + + preferredDisplayType: string; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + getInitValue(): boolean | null { + if (this.displayValue !== undefined) { + return this.displayValue.bool; + } else { + return false; + } + } + + ngOnInit() { + // initialize form control elements + this.valueFormControl = new FormControl(null); + this.commentFormControl = new FormControl(null); + + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + onSubmit() { + this.valueFormControl.markAsDirty(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateBooleanValue | false { + if (this.mode !== 'create' || !this.form.valid) { + return false; + } + + const newBooleanValue = new CreateBooleanValue(); + + newBooleanValue.bool = this.valueFormControl.value; + + // add the submitted new comment to newBooleanValue only if the user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newBooleanValue.valueHasComment = this.commentFormControl.value; + } + + return newBooleanValue; + } + + getUpdatedValue(): UpdateBooleanValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedBooleanValue = new UpdateBooleanValue(); + + updatedBooleanValue.id = this.displayValue.id; + + updatedBooleanValue.bool = this.valueFormControl.value; + + // add the submitted comment to updatedBooleanValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedBooleanValue.valueHasComment = this.commentFormControl.value; + } + + return updatedBooleanValue; + } + +} diff --git a/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.html b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.html new file mode 100644 index 0000000000..d72bd7b232 --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.html @@ -0,0 +1,22 @@ +
+ + {{ value }} + + + + Color is required + + +
diff --git a/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.scss b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.scss new file mode 100644 index 0000000000..e64b7e98f8 --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.scss @@ -0,0 +1,3 @@ +.child-input-component { + height: 48px; +} diff --git a/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.spec.ts b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.spec.ts new file mode 100644 index 0000000000..2153b0f623 --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.spec.ts @@ -0,0 +1,98 @@ +import { Component, OnInit, ViewChild, DebugElement } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { ColorPickerComponent } from './color-picker.component'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ColorPickerModule } from 'ngx-color-picker'; +import { By } from '@angular/platform-browser'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class TestHostComponent implements OnInit { + + @ViewChild('colorInput') colorPickerComponent: ColorPickerComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + colorValue: '#901453' + }); + + } +} + +describe('ColorPickerComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let colorPickerComponentDe: DebugElement; + let colorInputDebugElement: DebugElement; + let colorInputNativeElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ColorPickerModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, BrowserAnimationsModule], + declarations: [ColorPickerComponent, TestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.colorPickerComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + colorPickerComponentDe = hostCompDe.query(By.directive(ColorPickerComponent)); + colorInputDebugElement = colorPickerComponentDe.query(By.css('input.color')); + colorInputNativeElement = colorInputDebugElement.nativeElement; + + expect(colorInputNativeElement.getAttribute('ng-reflect-cp-disabled')).toEqual('false'); + }); + + it('should initialize the color correctly', () => { + expect(colorInputNativeElement.value).toEqual('#901453'); + }); + + it('should propagate changes made by the user', () => { + + colorInputNativeElement.value = '#f1f1f1'; + colorInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.form.controls.colorValue).toBeTruthy(); + expect(testHostComponent.form.controls.colorValue.value).toEqual('#f1f1f1'); + + }); + + it('should return "null" for an empty (invalid) input', () => { + + colorInputNativeElement.value = ''; + colorInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.form.controls.colorValue.value).toEqual(''); + }); + +}); diff --git a/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.ts b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.ts new file mode 100644 index 0000000000..cc43f52704 --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-picker/color-picker.component.ts @@ -0,0 +1,187 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Component, DoCheck, ElementRef, HostBinding, Input, OnDestroy, Optional, Self } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgControl, NgForm, Validators } from '@angular/forms'; +import { CanUpdateErrorState, CanUpdateErrorStateCtor, ErrorStateMatcher, mixinErrorState } from '@angular/material/core'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { Subject } from 'rxjs'; + +/** error when invalid control is dirty, touched, or submitted. */ +export class ColorPickerErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = form && form.submitted; + return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)); + } +} + +class MatInputBase { + constructor( + public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl) { } +} +const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase = + mixinErrorState(MatInputBase); + + +@Component({ + selector: 'app-color-picker', + templateUrl: './color-picker.component.html', + styleUrls: ['./color-picker.component.scss'], + providers: [ + { provide: MatFormFieldControl, useExisting: ColorPickerComponent } + ] +}) +export class ColorPickerComponent extends _MatInputMixinBase implements ControlValueAccessor, MatFormFieldControl, DoCheck, CanUpdateErrorState, OnDestroy { + + static nextId = 0; + + @Input() errorStateMatcher: ErrorStateMatcher; + + @HostBinding() id = `app-color-picker-${ColorPickerComponent.nextId++}`; + + @HostBinding('attr.aria-describedby') describedBy = ''; + colorForm: FormGroup; + stateChanges = new Subject(); + focused = false; + errorState = false; + controlType = 'app-color-picker'; + matcher = new ColorPickerErrorStateMatcher(); + + private _required = false; + private _disabled = false; + private _placeholder: string; + + onChange = (_: any) => { }; + onTouched = () => { }; + + get empty() { + const colorInput = this.colorForm.value; + return !colorInput.color; + } + + @HostBinding('class.floating') + get shouldLabelFloat() { + return this.focused || !this.empty; + } + + @Input() + get required() { + return this._required; + } + + set required(req) { + this._required = coerceBooleanProperty(req); + this.stateChanges.next(); + } + + @Input() + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this._disabled ? this.colorForm.disable() : this.colorForm.enable(); + this.stateChanges.next(); + } + + @Input() + get placeholder() { + return this._placeholder; + } + + set placeholder(plh) { + this._placeholder = plh; + this.stateChanges.next(); + } + + setDescribedByIds(ids: string[]) { + this.describedBy = ids.join(' '); + } + + @Input() + get value(): string | null { + const colorValue = this.colorForm.value; + if (colorValue !== null) { + return colorValue.color; + } + return null; + } + + set value(colorValue: string | null) { + if (colorValue !== null) { + this.colorForm.setValue({ color: colorValue }); + } else { + this.colorForm.setValue({ color: null }); + } + this.stateChanges.next(); + } + + // eslint-disable-next-line @typescript-eslint/member-ordering + constructor( + fb: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + private _fm: FocusMonitor, + private _elRef: ElementRef, + @Optional() _parentForm: NgForm, + @Optional() _parentFormGroup: FormGroupDirective, + _defaultErrorStateMatcher: ErrorStateMatcher) { + + super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + + this.colorForm = fb.group({ + color: [null, Validators.required] + }); + + _fm.monitor(_elRef.nativeElement, true).subscribe(origin => { + this.focused = !!origin; + this.stateChanges.next(); + }); + + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + + this.placeholder = 'Click to select a color'; + } + + ngDoCheck() { + if (this.ngControl) { + this.updateErrorState(); + } + } + + ngOnDestroy() { + this.stateChanges.complete(); + } + + onContainerClick(event: MouseEvent) { + if ((event.target as Element).tagName.toLowerCase() !== 'input') { + this._elRef.nativeElement.querySelector('input').focus(); + } + } + + writeValue(colorValue: string | null): void { + this.value = colorValue; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + _handleInput() { + this.onChange(this.value); + } + +} diff --git a/src/app/workspace/resource/values/color-value/color-value.component.html b/src/app/workspace/resource/values/color-value/color-value.component.html new file mode 100644 index 0000000000..54edf8af38 --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-value.component.html @@ -0,0 +1,43 @@ + +
+ {{ valueFormControl.value }} +
+ + {{ commentFormControl.value }} + +
+ + + + + + + New value must be different than the current value. + + + Please enter a hex color value. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/color-value/color-value.component.scss b/src/app/workspace/resource/values/color-value/color-value.component.scss new file mode 100644 index 0000000000..89c102ce75 --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-value.component.scss @@ -0,0 +1,25 @@ +@import "../../../../../assets/style/viewer"; + +:host ::ng-deep .child-value-component { + width: fit-content; + + .mat-form-field-underline { + display: none; + } + + .mat-form-field-infix { + border-top: 0.2em solid transparent !important; + .mat-form-field-underline { + display: block; + } + } +} + +.rm-value { + border: 1px solid rgba(33,33,33,.5); + border-radius: 4px; + padding: 3px 12px; + margin: 6px 0 10px 0 !important; + width: 6em; + min-height: 8px; +} diff --git a/src/app/workspace/resource/values/color-value/color-value.component.spec.ts b/src/app/workspace/resource/values/color-value/color-value.component.spec.ts new file mode 100644 index 0000000000..3891a7a8aa --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-value.component.spec.ts @@ -0,0 +1,463 @@ +import { Component, DebugElement, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ControlValueAccessor, NgControl, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatFormFieldControl, MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MockResource, ReadColorValue, UpdateColorValue, CreateColorValue } from '@dasch-swiss/dsp-js'; +import { ColorPickerModule } from 'ngx-color-picker'; +import { Subject } from 'rxjs'; +import { ColorValueComponent } from './color-value.component'; + + +@Component({ + selector: 'app-color-picker', + template: '', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => TestColorPickerComponent), + }, + { provide: MatFormFieldControl, useExisting: TestColorPickerComponent } + ] +}) +class TestColorPickerComponent implements ControlValueAccessor, MatFormFieldControl { + + @Input() value; + @Input() disabled: boolean; + @Input() empty: boolean; + @Input() placeholder: string; + @Input() required: boolean; + @Input() shouldLabelFloat: boolean; + @Input() errorStateMatcher: ErrorStateMatcher; + + stateChanges = new Subject(); + errorState = false; + focused = false; + id = 'testid'; + ngControl: NgControl | null; + onChange = (_: any) => { + }; + + writeValue(colorValue: string | null): void { + this.value = colorValue; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + } + + onContainerClick(event: MouseEvent): void { + } + + setDescribedByIds(ids: string[]): void { + } + + _handleInput(): void { + this.onChange(this.value); + } + +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('colorVal') colorValueComponent: ColorValueComponent; + + displayColorVal: ReadColorValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + showColorHex = false; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const colorVal: ReadColorValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasColor', ReadColorValue)[0]; + + this.displayColorVal = colorVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('colorValue') colorValueComponent: ColorValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + this.mode = 'create'; + } +} + + +describe('ColorValueComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + ColorPickerModule, + BrowserAnimationsModule + ], + declarations: [ + ColorValueComponent, + TestColorPickerComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent + ] + }) + .compileComponents(); + })); + + describe('display and edit a color value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.colorValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(ColorValueComponent)); + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + }); + + it('should display an existing value without a hex color code', () => { + + expect(testHostComponent.colorValueComponent.displayValue.color).toEqual('#ff3333'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.style.backgroundColor).not.toBeUndefined(); + + expect(valueReadModeNativeElement.innerText).toEqual(''); + + }); + + it('should display an existing value with a hex color code', () => { + + testHostComponent.showColorHex = true; + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.displayValue.color).toEqual('#ff3333'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.style.backgroundColor).not.toBeUndefined(); + + expect(valueReadModeNativeElement.innerText).toEqual('#ff3333'); + + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('update'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.colorValueComponent.colorPickerComponent.value).toEqual('#ff3333'); + + // simulate user input + const newColor = '#b1b1b1'; + + testHostComponent.colorValueComponent.colorPickerComponent.value = newColor; + testHostComponent.colorValueComponent.colorPickerComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.valueFormControl.value).toBeTruthy(); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.colorValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateColorValue).toBeTruthy(); + + expect((updatedValue as UpdateColorValue).color).toEqual('#b1b1b1'); + + }); + + it('should validate an existing value with an added comment', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.colorValueComponent.mode).toEqual('update'); + + expect(testHostComponent.colorValueComponent.displayValue.color).toEqual('#ff3333'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeFalsy(); + + commentInputNativeElement.value = 'this is a comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.colorValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateColorValue).toBeTruthy(); + + expect((updatedValue as UpdateColorValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should not return an invalid update value', () => { + + // simulate user input + const newColor = '54iu45po'; + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('update'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeFalsy(); + + testHostComponent.colorValueComponent.colorPickerComponent.value = null; + testHostComponent.colorValueComponent.colorPickerComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('update'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.colorValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('update'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeFalsy(); + + // simulate user input + const newColor = '#g7g7g7'; + + testHostComponent.colorValueComponent.colorPickerComponent.value = newColor; + testHostComponent.colorValueComponent.colorPickerComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.valueFormControl.value).toEqual('#g7g7g7'); + + testHostComponent.colorValueComponent.resetFormControl(); + + expect(testHostComponent.colorValueComponent.colorPickerComponent.value).toEqual('#ff3333'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newColor = new ReadColorValue(); + + newColor.color = '#d8d8d8'; + newColor.id = 'updatedId'; + + testHostComponent.displayColorVal = newColor; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.style.backgroundColor).not.toBeUndefined(); + + expect(valueReadModeNativeElement.innerText).toEqual(''); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + }); + + it('should set a new display value not showing the hex color code', () => { + + const newColor = new ReadColorValue(); + + newColor.color = '#d8d8d8'; + newColor.id = 'updatedId'; + + testHostComponent.displayColorVal = newColor; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.style.backgroundColor).not.toBeUndefined(); + + expect(valueReadModeNativeElement.innerText).toEqual(''); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + }); + + it('should set a new display value showing the hex color code', () => { + + testHostComponent.showColorHex = true; + + const newColor = new ReadColorValue(); + + newColor.color = '#d8d8d8'; + newColor.id = 'updatedId'; + + testHostComponent.displayColorVal = newColor; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.style.backgroundColor).not.toBeUndefined(); + + expect(valueReadModeNativeElement.innerText).toEqual('#d8d8d8'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.colorValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.colorValueComponent.ngOnDestroy(); + + expect(testHostComponent.colorValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + + }); + + describe('create a color value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.colorValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(ColorValueComponent)); + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + }); + + it('should create a value', () => { + + expect(testHostComponent.colorValueComponent.colorPickerComponent.value).toEqual(null); + + // simulate user input + const newColor = '#f5f5f5'; + + testHostComponent.colorValueComponent.colorPickerComponent.value = newColor; + testHostComponent.colorValueComponent.colorPickerComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('create'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.colorValueComponent.getNewValue(); + + expect(newValue instanceof CreateColorValue).toBeTruthy(); + + expect((newValue as CreateColorValue).color).toEqual('#f5f5f5'); + + }); + + it('should reset form after cancellation', () => { + // simulate user input + const newColor = '#f8f8f8'; + + testHostComponent.colorValueComponent.colorPickerComponent.value = newColor; + testHostComponent.colorValueComponent.colorPickerComponent._handleInput(); + + testHostFixture.detectChanges(); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.colorValueComponent.mode).toEqual('create'); + + expect(testHostComponent.colorValueComponent.form.valid).toBeTruthy(); + + testHostComponent.colorValueComponent.resetFormControl(); + + expect(testHostComponent.colorValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.colorValueComponent.colorPickerComponent.value).toEqual(null); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + + }); + +}); diff --git a/src/app/workspace/resource/values/color-value/color-value.component.ts b/src/app/workspace/resource/values/color-value/color-value.component.ts new file mode 100644 index 0000000000..d1c7eb3009 --- /dev/null +++ b/src/app/workspace/resource/values/color-value/color-value.component.ts @@ -0,0 +1,159 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { CreateColorValue, ReadColorValue, UpdateColorValue } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { ColorPickerComponent } from './color-picker/color-picker.component'; +import { CustomRegex } from '../custom-regex'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-color-value', + templateUrl: './color-value.component.html', + styleUrls: ['./color-value.component.scss'] +}) +export class ColorValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @ViewChild('colorInput') colorPickerComponent: ColorPickerComponent; + + @Input() displayValue?: ReadColorValue; + + @Input() showHexCode = false; + + valueFormControl: FormControl; + commentFormControl: FormControl; + form: FormGroup; + valueChangesSubscription: Subscription; + customValidators = [Validators.pattern(CustomRegex.COLOR_REGEX)]; + matcher = new ValueErrorStateMatcher(); + textColor: string; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + getInitValue(): string | null { + if (this.displayValue !== undefined) { + return this.displayValue.color; + } else { + return null; + } + } + + ngOnInit() { + + // initialize form control elements + this.valueFormControl = new FormControl(null); + this.commentFormControl = new FormControl(null); + + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + this.textColor = this.getTextColor(this.valueFormControl.value); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + + if (this.showHexCode && this.valueFormControl !== undefined) { + this.textColor = this.getTextColor(this.valueFormControl.value); + } + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateColorValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newColorValue = new CreateColorValue(); + + newColorValue.color = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newColorValue.valueHasComment = this.commentFormControl.value; + } + + + return newColorValue; + } + + getUpdatedValue(): UpdateColorValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedColorValue = new UpdateColorValue(); + + updatedColorValue.id = this.displayValue.id; + + updatedColorValue.color = this.valueFormControl.value; + + // add the submitted comment to updatedIntValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedColorValue.valueHasComment = this.commentFormControl.value; + } + + return updatedColorValue; + } + + // calculate text color + getTextColor(hex: string): string { + if (!hex || hex === null) { + return; + } + + // convert hexadicemal color value into rgb color value + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function (m, r, g, b) { + return r + r + g + g + b + b; + }); + + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + const rgb: {r: number; g: number; b: number} = result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; + + // calculate luminance + const a = [rgb.r, rgb.g, rgb.b].map(function (v) { + v /= 255; + return v <= 0.03928 + ? v / 12.92 + : Math.pow( (v + 0.055) / 1.055, 2.4 ); + }); + const luminance = a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + + return ((luminance > 0.179) ? '#000000' : '#ffffff'); + } + +} diff --git a/src/app/workspace/resource/values/custom-regex.ts b/src/app/workspace/resource/values/custom-regex.ts new file mode 100644 index 0000000000..244f005b77 --- /dev/null +++ b/src/app/workspace/resource/values/custom-regex.ts @@ -0,0 +1,27 @@ +// @dynamic +// https://github.com/ng-packagr/ng-packagr/issues/641 + +export class CustomRegex { + + public static readonly INT_REGEX = /^-?\d+$/; + + public static readonly DECIMAL_REGEX = /^[-+]?[0-9]*\.?[0-9]*$/; + + public static readonly URI_REGEX = /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/; + + public static readonly COLOR_REGEX = /^#(?:[0-9a-fA-F]{6})$/; + + public static readonly TIME_REGEX = /^([0-1]{1}[0-9]{1}|[2]{1}[0-4]{1}):{1}[0-5]{1}[0-9]{1}$/; + + public static readonly GEONAME_REGEX = /^[0-9]{7}$/; + + public static readonly EMAIL_REGEX = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; + + public static readonly PASSWORD_REGEX = /^(?=.*\d)(?=.*[a-zA-Z]).{8,}$/i; + + public static readonly USERNAME_REGEX = /^[a-zA-Z0-9]+$/; + + public static readonly SHORTNAME_REGEX = /^[a-zA-Z]+\S*$/; + + public static readonly ONTOLOGYNAME_REGEX = /^(?![vV][0-9]|[0-9]|[\u00C0-\u017F]).[a-zA-Z0-9]+\S*$/; +} diff --git a/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.html b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.html new file mode 100644 index 0000000000..250e5154e5 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.html @@ -0,0 +1,4 @@ + + {{cal}} + + diff --git a/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.scss b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.scss new file mode 100644 index 0000000000..fc98f08199 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.scss @@ -0,0 +1,6 @@ +:host { + .mat-select.dsp-calendar-header { + margin: 16px 16px 0 16px !important; + width: calc(100% - 32px) !important; + } +} diff --git a/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.spec.ts b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.spec.ts new file mode 100644 index 0000000000..32bb925691 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.spec.ts @@ -0,0 +1,149 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CalendarHeaderComponent } from './calendar-header.component'; +import { ACTIVE_CALENDAR, JDNConvertibleCalendarDateAdapter } from 'jdnconvertiblecalendardateadapter'; +import { MatSelectModule } from '@angular/material/select'; +import { DateAdapter, MatOptionModule } from '@angular/material/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatCalendar, MatDatepickerContent } from '@angular/material/datepicker'; +import { BehaviorSubject } from 'rxjs'; +import { Component } from '@angular/core'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + CalendarDate, + CalendarPeriod, + GregorianCalendarDate, + JDNConvertibleCalendar, + JulianCalendarDate +} from 'jdnconvertiblecalendar'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatSelectHarness } from '@angular/material/select/testing'; + +@Component({ + selector: 'mat-calendar-header', + template: '' +}) +class TestMatCalendarHeaderComponent { + +} + +describe('CalendarHeaderComponent', () => { + let component: CalendarHeaderComponent; + let fixture: ComponentFixture>; + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatSelectModule, + MatOptionModule, + BrowserAnimationsModule + ], + declarations: [CalendarHeaderComponent, TestMatCalendarHeaderComponent], + providers: [ + { + provide: MatCalendar, useValue: { + activeDate: new GregorianCalendarDate(new CalendarPeriod(new CalendarDate(2020, 3, 17), new CalendarDate(2020, 3, 17))), + updateTodaysDate: () => { + } + } + }, + { provide: DateAdapter, useClass: JDNConvertibleCalendarDateAdapter }, + { provide: ACTIVE_CALENDAR, useValue: new BehaviorSubject('Gregorian') }, + { + provide: MatDatepickerContent, useValue: { + datepicker: { + select: () => { + } + } + } + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CalendarHeaderComponent); + component = fixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(fixture); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should init the selected value and options correctly', async () => { + + const select = await loader.getHarness(MatSelectHarness); + const initVal = await select.getValueText(); + + expect(initVal).toEqual('Gregorian'); + + await select.open(); + + const options = await select.getOptions(); + + expect(options.length).toEqual(2); + + const option1 = await options[0].getText(); + + expect(option1).toEqual('Gregorian'); + + const option2 = await options[1].getText(); + + expect(option2).toEqual('Julian'); + + }); + + it('should perform a calendar conversion when the selection is changed', async () => { + + const dateAdapter: DateAdapter = TestBed.inject(DateAdapter); + + const dateAdapterSpy = spyOn(dateAdapter as JDNConvertibleCalendarDateAdapter, 'convertCalendar').and.callFake( + (date, calendar) => new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2020, 3, 4), new CalendarDate(2020, 3, 4)))); + + const matCal = TestBed.inject(MatCalendar); + + const matCalendarSpy = spyOn(matCal, 'updateTodaysDate').and.stub(); + + const datepickerContent = TestBed.inject(MatDatepickerContent); + + const datepickerContentSpy = spyOn(datepickerContent.datepicker, 'select').and.stub(); + + const select = await loader.getHarness(MatSelectHarness); + + await select.open(); + + const options = await select.getOptions({ text: 'Julian' }); + + expect(options.length).toEqual(1); + + await options[0].click(); + + expect(dateAdapterSpy).toHaveBeenCalledTimes(1); + + expect(dateAdapterSpy).toHaveBeenCalledWith(new GregorianCalendarDate(new CalendarPeriod(new CalendarDate(2020, 3, 17), new CalendarDate(2020, 3, 17))), 'Julian'); + + expect(matCal.activeDate).toEqual(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2020, 3, 4), new CalendarDate(2020, 3, 4)))); + + expect(datepickerContentSpy).toHaveBeenCalledTimes(1); + + expect(matCalendarSpy).toHaveBeenCalledTimes(1); + + }); + + it('should unsubscribe from value changes subscription when the component is destroyed', () => { + + expect(component.valueChangesSubscription.closed).toEqual(false); + + component.ngOnDestroy(); + + expect(component.valueChangesSubscription.closed).toEqual(true); + + }); + +}); diff --git a/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.ts b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.ts new file mode 100644 index 0000000000..0da33b6de1 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/calendar-header/calendar-header.component.ts @@ -0,0 +1,85 @@ +/** custom header component containing a calendar format switcher */ +import { JDNConvertibleCalendarDateAdapter } from 'jdnconvertiblecalendardateadapter'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { JDNConvertibleCalendar } from 'jdnconvertiblecalendar'; +import { MatCalendar, MatDatepickerContent } from '@angular/material/datepicker'; +import { DateAdapter } from '@angular/material/core'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-calendar-header', + templateUrl: './calendar-header.component.html', + styleUrls: ['./calendar-header.component.scss'] +}) +export class CalendarHeaderComponent implements OnInit, OnDestroy { + form: FormGroup; + formControl: FormControl; + valueChangesSubscription: Subscription; + + // a list of supported calendars (Gregorian and Julian) + supportedCalendars = ['Gregorian', 'Julian']; + + constructor(private _calendar: MatCalendar, + private _dateAdapter: DateAdapter, + private _datepickerContent: MatDatepickerContent, + @Inject(FormBuilder) private _fb: FormBuilder) { + } + + ngOnInit() { + + let activeCal; + + // get the currently active calendar from the date adapter + if (this._dateAdapter instanceof JDNConvertibleCalendarDateAdapter) { + activeCal = this._dateAdapter.activeCalendar; + } else { + console.log('date adapter is expected to be an instance of JDNConvertibleCalendarDateAdapter'); + } + + this.formControl = new FormControl(activeCal, Validators.required); + + // build a form for the calendar format selection + this.form = this._fb.group({ + calendar: this.formControl + }); + + // do the conversion when the user selects another calendar format + this.valueChangesSubscription = this.form.valueChanges.subscribe((data) => { + // pass the target calendar format to the conversion method + this.convertDate(data.calendar); + }); + + } + + ngOnDestroy(): void { + if (this.valueChangesSubscription !== undefined) { + this.valueChangesSubscription.unsubscribe(); + } + } + + /** + * converts the date into the target format. + * + * @param calendar the target calendar format. + */ + convertDate(calendar: 'Gregorian' | 'Julian') { + + if (this._dateAdapter instanceof JDNConvertibleCalendarDateAdapter) { + + // convert the date into the target calendar format + const convertedDate = this._dateAdapter.convertCalendar(this._calendar.activeDate, calendar); + + // set the new date + this._calendar.activeDate = convertedDate; + + // select the new date in the datepicker UI + this._datepickerContent.datepicker.select(convertedDate); + + // update view after calendar format conversion + this._calendar.updateTodaysDate(); + } else { + console.log('date adapter is expected to be an instance of JDNConvertibleCalendarDateAdapter'); + } + } +} diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.html b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.html new file mode 100644 index 0000000000..6cc4040781 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.html @@ -0,0 +1,41 @@ +
+ +
+ + Before Common Era + Common Era + +
+ + + Year + +
+ + A year is required. + + + A valid year is greater than 0. + +
+
+ + Month + + no selection + + {{month}} + + + + + Day + + no selection + + {{day}} + + + +
diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.scss b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.scss new file mode 100644 index 0000000000..0e721a1585 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.scss @@ -0,0 +1,9 @@ +@import "../../../../../../../assets/style/viewer"; + +.era-radio { + margin-bottom: 15px; +} + +.date-form-error { + margin-top: -16px; +} diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.spec.ts b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.spec.ts new file mode 100644 index 0000000000..fc2e4f971a --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.spec.ts @@ -0,0 +1,498 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { DateEditComponent } from './date-edit.component'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { KnoraDate } from '@dasch-swiss/dsp-js'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatRadioModule } from '@angular/material/radio'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputHarness } from '@angular/material/input/testing'; +import { MatSelectHarness } from '@angular/material/select/testing'; +import { MatRadioGroupHarness } from '@angular/material/radio/testing'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class TestHostComponent implements OnInit { + + @ViewChild('dateEdit') dateEditComponent: DateEditComponent; + + form: FormGroup; + + calendar = 'JULIAN'; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + date: [new KnoraDate('JULIAN', 'CE', 2018, 5, 19)] + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class TestHostComponentNoValueRequiredComponent implements OnInit { + + @ViewChild('dateEdit') dateEditComponent: DateEditComponent; + + form: FormGroup; + + calendar = 'JULIAN'; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + date: [null] + }); + + } +} + +describe('DateEditComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + BrowserAnimationsModule, + MatSelectModule + ], + declarations: [DateEditComponent, TestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should initialize a date with day precision correctly', async () => { + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.calendar).toEqual('JULIAN'); + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.yearControl.value).toEqual(2018); + expect(testHostComponent.dateEditComponent.monthControl.value).toEqual(5); + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(19); + expect(testHostComponent.dateEditComponent.form.valid).toBeTrue(); + + const value = testHostComponent.dateEditComponent.value; + + expect(value.calendar).toEqual('JULIAN'); + expect(value.year).toEqual(2018); + expect(value.month).toEqual(5); + expect(value.day).toEqual(19); + + const eraRadioGroup = await loader.getHarness(MatRadioGroupHarness.with({ selector: '.era' })); + expect(await (await eraRadioGroup.getCheckedRadioButton()).getValue()).toEqual('CE'); + + const yearInput = await loader.getHarness(MatInputHarness.with({ selector: '.year' })); + expect(await yearInput.getValue()).toEqual('2018'); + expect(await yearInput.isDisabled()).toBeFalse(); + + const monthInput = await loader.getHarness(MatSelectHarness.with({ selector: '.month' })); + expect(await monthInput.getValueText()).toEqual('5'); + expect(await monthInput.isDisabled()).toBeFalse(); + + const dayInput = await loader.getHarness(MatSelectHarness.with({ selector: '.day' })); + expect(await dayInput.getValueText()).toEqual('19'); + expect(await dayInput.isDisabled()).toBeFalse(); + + }); + + it('should initialize a date with month precision correctly', async () => { + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + testHostComponent.form.controls.date.setValue(new KnoraDate('JULIAN', 'CE', 2018, 5)); + + expect(testHostComponent.dateEditComponent.calendar).toEqual('JULIAN'); + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.yearControl.value).toEqual(2018); + expect(testHostComponent.dateEditComponent.monthControl.value).toEqual(5); + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(null); + expect(testHostComponent.dateEditComponent.form.valid).toBeTrue(); + + const value = testHostComponent.dateEditComponent.value; + + expect(value.calendar).toEqual('JULIAN'); + expect(value.year).toEqual(2018); + expect(value.month).toEqual(5); + expect(value.day).toBeUndefined(); + + const eraRadioGroup = await loader.getHarness(MatRadioGroupHarness.with({ selector: '.era' })); + expect(await (await eraRadioGroup.getCheckedRadioButton()).getValue()).toEqual('CE'); + + const yearInput = await loader.getHarness(MatInputHarness.with({ selector: '.year' })); + expect(await yearInput.getValue()).toEqual('2018'); + expect(await yearInput.isDisabled()).toBeFalse(); + + const monthInput = await loader.getHarness(MatSelectHarness.with({ selector: '.month' })); + expect(await monthInput.getValueText()).toEqual('5'); + expect(await monthInput.isDisabled()).toBeFalse(); + + const dayInput = await loader.getHarness(MatSelectHarness.with({ selector: '.day' })); + expect(await dayInput.getValueText()).toEqual(''); + expect(await dayInput.isDisabled()).toBeFalse(); + + }); + + it('should initialize a date with year precision correctly', async () => { + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + testHostComponent.form.controls.date.setValue(new KnoraDate('JULIAN', 'CE', 2018)); + + expect(testHostComponent.dateEditComponent.calendar).toEqual('JULIAN'); + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.yearControl.value).toEqual(2018); + expect(testHostComponent.dateEditComponent.monthControl.value).toEqual(null); + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(null); + expect(testHostComponent.dateEditComponent.form.valid).toBeTrue(); + + const value = testHostComponent.dateEditComponent.value; + + expect(value.calendar).toEqual('JULIAN'); + expect(value.year).toEqual(2018); + expect(value.month).toBeUndefined(); + expect(value.day).toBeUndefined(); + + const eraRadioGroup = await loader.getHarness(MatRadioGroupHarness.with({ selector: '.era' })); + expect(await (await eraRadioGroup.getCheckedRadioButton()).getValue()).toEqual('CE'); + + const yearInput = await loader.getHarness(MatInputHarness.with({ selector: '.year' })); + expect(await yearInput.getValue()).toEqual('2018'); + expect(await yearInput.isDisabled()).toBeFalse(); + + const monthInput = await loader.getHarness(MatSelectHarness.with({ selector: '.month' })); + expect(await monthInput.getValueText()).toEqual(''); + expect(await monthInput.isDisabled()).toBeFalse(); + + const dayInput = await loader.getHarness(MatSelectHarness.with({ selector: '.day' })); + expect(await dayInput.getValueText()).toEqual(''); + expect(await dayInput.isDisabled()).toBeTrue(); + + }); + + it('should set CE era for an empty value in the Julian calendar', async () => { + + testHostComponent.form.controls.date.setValue(null); + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.eraControl.enabled).toBeTrue(); + + expect(testHostComponent.dateEditComponent.value).toEqual(null); + + }); + + it('should disable era for an empty value in the ISLAMIC calendar', async () => { + + testHostComponent.form.controls.date.setValue(null); + + testHostComponent.calendar = 'ISLAMIC'; + + testHostFixture.detectChanges(); + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.eraControl.value).toBeNull(); + expect(testHostComponent.dateEditComponent.eraControl.disabled).toBeTrue(); + + expect(testHostComponent.dateEditComponent.value).toEqual(null); + + }); + + it('should disable era for an ISLAMIC calendar date', async () => { + + testHostComponent.form.controls.date.setValue(new KnoraDate('ISLAMIC', 'noEra', 1441)); + + testHostComponent.calendar = 'ISLAMIC'; + + testHostFixture.detectChanges(); + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.eraControl.value).toBeNull(); + expect(testHostComponent.dateEditComponent.eraControl.disabled).toBeTrue(); + + expect(testHostComponent.dateEditComponent.value).toEqual(new KnoraDate('ISLAMIC', 'noEra', 1441)); + + }); + + it('should disable era when changing to the ISLAMIC calendar', async () => { + + testHostComponent.calendar = 'ISLAMIC'; + + testHostFixture.detectChanges(); + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.eraControl.value).toBeNull(); + expect(testHostComponent.dateEditComponent.eraControl.disabled).toBeTrue(); + + expect(testHostComponent.dateEditComponent.value).toEqual(new KnoraDate('ISLAMIC', 'noEra', 2018, 5, 19)); + + }); + + it('should enable era when changing from the Islamic calendar to the Gregorian calendar', async () => { + + testHostComponent.calendar = 'ISLAMIC'; + + testHostFixture.detectChanges(); + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.eraControl.value).toBeNull(); + expect(testHostComponent.dateEditComponent.eraControl.disabled).toBeTrue(); + + testHostComponent.calendar = 'GREGORIAN'; + + testHostFixture.detectChanges(); + + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.eraControl.enabled).toBeTrue(); + + }); + + it('should react to changing the calendar', async () => { + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.calendar).toEqual('JULIAN'); + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.yearControl.value).toEqual(2018); + expect(testHostComponent.dateEditComponent.monthControl.value).toEqual(5); + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(19); + + const value = testHostComponent.dateEditComponent.value; + + expect(value.calendar).toEqual('JULIAN'); + expect(value.year).toEqual(2018); + expect(value.month).toEqual(5); + expect(value.day).toEqual(19); + + const eraRadioGroup = await loader.getHarness(MatRadioGroupHarness.with({ selector: '.era' })); + expect(await (await eraRadioGroup.getCheckedRadioButton()).getValue()).toEqual('CE'); + + const yearInput = await loader.getHarness(MatInputHarness.with({ selector: '.year' })); + expect(await yearInput.getValue()).toEqual('2018'); + + const monthInput = await loader.getHarness(MatSelectHarness.with({ selector: '.month' })); + expect(await monthInput.getValueText()).toEqual('5'); + + const dayInput = await loader.getHarness(MatSelectHarness.with({ selector: '.day' })); + expect(await dayInput.getValueText()).toEqual('19'); + + // change calendar @Input + testHostComponent.calendar = 'GREGORIAN'; + + testHostFixture.detectChanges(); + + await testHostFixture.whenStable(); + + const newValue = testHostComponent.dateEditComponent.value; + + expect(newValue.calendar).toEqual('GREGORIAN'); + expect(newValue.era).toEqual('CE'); + expect(value.year).toEqual(2018); + expect(value.month).toEqual(5); + expect(value.day).toEqual(19); + + }); + + it('should reinit the days when changing the calendar', async () => { + + testHostComponent.form.controls.date.setValue(new KnoraDate('JULIAN', 'CE', 2021, 3, 31)); + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(31); + + // change to Islamic calendar + testHostComponent.calendar = 'ISLAMIC'; + + testHostFixture.detectChanges(); + + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(30); + + }); + + it('should reinit the day selection when month changes', async () => { + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.calendar).toEqual('JULIAN'); + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.yearControl.value).toEqual(2018); + expect(testHostComponent.dateEditComponent.monthControl.value).toEqual(5); + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(19); + + const dayInput = await loader.getHarness(MatSelectHarness.with({ selector: '.day' })); + + await dayInput.clickOptions(); + expect((await dayInput.getOptions()).length).toEqual(32); // 31 + null + + const monthInput = await loader.getHarness(MatSelectHarness.with({ selector: '.month' })); + + await monthInput.clickOptions(); + + // choose February + const opts = await monthInput.getOptions(); + expect(await opts[2].getText()).toEqual('2'); + await opts[2].click(); + + await testHostFixture.whenStable(); + + // last day of February 2019 is 28 + await dayInput.clickOptions(); + const opts2 = await dayInput.getOptions(); + expect(opts2.length).toEqual(29); // 28 + null + expect(await opts2[28].getText()).toEqual('28'); + + await opts2[28].click(); + + const value = testHostComponent.dateEditComponent.value; + expect(value.calendar).toEqual('JULIAN'); + expect(value.year).toEqual(2018); + expect(value.month).toEqual(2); + expect(value.day).toEqual(28); + + }); + + it('should change day selection to the latest possible day of the month when changing month selection', async () => { + + testHostComponent.calendar = 'GREGORIAN'; + + testHostFixture.detectChanges(); + + testHostComponent.form.controls.date.setValue(new KnoraDate('GREGORIAN', 'CE', 2021, 3, 31)); + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.calendar).toEqual('GREGORIAN'); + expect(testHostComponent.dateEditComponent.eraControl.value).toEqual('CE'); + expect(testHostComponent.dateEditComponent.yearControl.value).toEqual(2021); + expect(testHostComponent.dateEditComponent.monthControl.value).toEqual(3); + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(31); + + testHostComponent.dateEditComponent.monthControl.setValue(2); + + expect(testHostComponent.dateEditComponent.dayControl.value).toEqual(28); + + }); + + it('should consider a date without a year invalid', async () => { + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + testHostComponent.dateEditComponent.yearControl.setValue(null); + + expect(testHostComponent.dateEditComponent.form.valid).toBeFalse(); + + }); + +}); + +describe('DateEditComponent (no validator required)', () => { + let testHostComponent: TestHostComponentNoValueRequiredComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + BrowserAnimationsModule, + MatSelectModule + ], + declarations: [DateEditComponent, TestHostComponentNoValueRequiredComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponentNoValueRequiredComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should be valid for an empty form (null)', async () => { + + // init involves various "value changes" callbacks + await testHostFixture.whenStable(); + + expect(testHostComponent.dateEditComponent.form.valid).toBeTrue(); + + expect(testHostComponent.form.controls.date.value).toEqual(null); + + + }); + +}); + diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.ts b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.ts new file mode 100644 index 0000000000..baec452e94 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-edit/date-edit.component.ts @@ -0,0 +1,389 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/member-ordering */ +import { + Component, + DoCheck, + ElementRef, + HostBinding, + Input, + OnChanges, + OnDestroy, + OnInit, + Optional, + Self, SimpleChanges +} from '@angular/core'; +import { + CanUpdateErrorState, + CanUpdateErrorStateCtor, + ErrorStateMatcher, + mixinErrorState +} from '@angular/material/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgControl, + NgForm, Validators +} from '@angular/forms'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { KnoraDate, KnoraPeriod } from '@dasch-swiss/dsp-js'; +import { Subject, Subscription } from 'rxjs'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { FocusMonitor } from '@angular/cdk/a11y'; +import { ValueService } from 'src/app/workspace/resource/services/value.service'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +class MatInputBase { + constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl) { + } +} + +const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase = + mixinErrorState(MatInputBase); + +@Component({ + selector: 'app-date-edit', + templateUrl: './date-edit.component.html', + styleUrls: ['./date-edit.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: DateEditComponent }] +}) +export class DateEditComponent extends _MatInputMixinBase implements ControlValueAccessor, MatFormFieldControl, DoCheck, CanUpdateErrorState, OnDestroy, OnInit, OnChanges { + + static nextId = 0; + + @Input() calendar: string; + + @Input() valueRequiredValidator = true; + + form: FormGroup; + stateChanges = new Subject(); + + eraControl: FormControl; + yearControl: FormControl; + monthControl: FormControl; + dayControl: FormControl; + + months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + + days = []; + + readonly focused = false; + + readonly controlType = 'app-date-edit'; + + private _subscriptions: Subscription[] = []; + + @Input() + get value(): KnoraDate | null { + + if (!this.form.valid + || this.calendar === undefined + || this.yearControl.value === null) { // in case valueRequiredValidator is set to false, return null for an empty year + return null; + } + + let era: string; + if (this.eraControl.enabled) { + era = this.eraControl.value; + } else { + era = 'noEra'; + } + + return new KnoraDate( + this.calendar, + era, + this.yearControl.value, + this.monthControl.value ? this.monthControl.value : undefined, + this.dayControl.value ? this.dayControl.value : undefined + ); + } + + set value(date: KnoraDate | null) { + + if (date instanceof KnoraDate) { + + this._initEra(this.calendar, date.era); + + this.yearControl.setValue(date.year); + this.monthControl.setValue(date.month ? date.month : null); + this.dayControl.setValue(date.day ? date.day : null); + + } else { + // null + + this._initEra(this.calendar, 'CE'); + + this.yearControl.setValue(null); + this.monthControl.setValue(null); + this.dayControl.setValue(null); + + } + + this.stateChanges.next(); + } + + @Input() + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this._disabled ? this.form.disable() : this.form.enable(); + this.stateChanges.next(); + } + + private _disabled = false; + + @Input() + get placeholder() { + return this._placeholder; + } + + set placeholder(plh) { + this._placeholder = plh; + this.stateChanges.next(); + } + + private _placeholder: string; + + @Input() + get required() { + return this._required; + } + + set required(req) { + this._required = coerceBooleanProperty(req); + this.stateChanges.next(); + } + + private _required = false; + + @HostBinding('class.floating') + get shouldLabelFloat() { + return this.focused || !this.empty; + } + + @HostBinding() id = `app-date-edit-${DateEditComponent.nextId++}`; + + onChange = (_: any) => { + }; + + onTouched = () => { + }; + + get empty() { + return !this.yearControl && !this.monthControl && !this.dayControl; + } + + constructor(fb: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + private _fm: FocusMonitor, + private _elRef: ElementRef, + @Optional() _parentForm: NgForm, + @Optional() _parentFormGroup: FormGroupDirective, + _defaultErrorStateMatcher: ErrorStateMatcher, + private _valueService: ValueService) { + + super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + + if (this.ngControl != null) { + // setting the value accessor directly (instead of using + // the providers) to avoid running into a circular import. + this.ngControl.valueAccessor = this; + } + + this.eraControl = new FormControl(null); + + this.yearControl = new FormControl({ + value: null, + disabled: false + }); + + this.monthControl = new FormControl({ value: null, disabled: true }); + + this.dayControl = new FormControl({ value: null, disabled: true }); + + // recalculate days of month when era changes + const eraChangesSubscription = this.eraControl.valueChanges.subscribe( + () => { + if (this.yearControl.valid && this.monthControl.value) { + this._setDays(this.calendar, this.eraControl.value, this.yearControl.value, this.monthControl.value); + } + } + ); + + this._subscriptions.push(eraChangesSubscription); + + // enable/disable month selection depending on year + // enable/disable day selection depending on + const yearChangesSubscription = this.yearControl.valueChanges.subscribe( + () => { + if (this.yearControl.valid) { + this.monthControl.enable(); + } else { + this.monthControl.disable(); + } + + if (this.yearControl.valid && this.monthControl.value) { + this.dayControl.enable(); + } else { + this.dayControl.disable(); + } + } + ); + + this._subscriptions.push(yearChangesSubscription); + + // enable/disable day selection depending on month + // recalculate days when month changes + const monthChangesSubscription = this.monthControl.valueChanges.subscribe( + () => { + if (this.yearControl.valid && this.monthControl.value) { + this._setDays(this.calendar, this.eraControl.value, this.yearControl.value, this.monthControl.value); + } + + if (this.monthControl.value) { + this.dayControl.enable(); + } else { + this.dayControl.setValue(null); + this.dayControl.disable(); + } + } + ); + + this._subscriptions.push(monthChangesSubscription); + + // init form + this.form = fb.group({ + era: this.eraControl, + year: this.yearControl, + month: this.monthControl, + day: this.dayControl + }); + + } + + ngOnInit() { + if (this.valueRequiredValidator) { + this.yearControl.setValidators([Validators.required, Validators.min(1)]); + this.yearControl.updateValueAndValidity(); + } + } + + ngOnChanges(changes: SimpleChanges) { + + const calendar = this.calendar; + const era = this.eraControl.value; + const year = this.yearControl.value; + const month = this.monthControl.value; + + // async to prevent changed after checked error + resolvedPromise.then( + () => { + // reinit days on calendar change + this._setDays(calendar, era, year, month); + + // enable / disable era, but preserve active era if possible + this._initEra(calendar, era); + + this._handleInput(); + } + ); + + } + + ngDoCheck() { + if (this.ngControl) { + this.updateErrorState(); + } + } + + ngOnDestroy() { + this.stateChanges.complete(); + + this._subscriptions.forEach( + subs => { + if (subs instanceof Subscription && !subs.closed) { + subs.unsubscribe(); + } + } + ); + } + + writeValue(date: KnoraDate | null): void { + this.value = date; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + _handleInput(): void { + this.onChange(this.value); + } + + onContainerClick(event: MouseEvent): void { + } + + setDescribedByIds(ids: string[]): void { + } + + /** + * + * sets available days for a given year and month. + * + * @param calendar calendar of the given date. + * @param era era of the given date. + * @param year year of the given date. + * @param month month of the given date. + */ + private _setDays(calendar: string, era: string, year: number, month: number) { + + const yearAstro = this._valueService.convertHistoricalYearToAstronomicalYear(year, era, calendar); + + const days = this._valueService.calculateDaysInMonth(calendar, yearAstro, month); + + // empty array + this.days = []; + for (let i = 1; i <= days; i++) { + this.days.push(i); + } + + // check if selected day is still valid, otherwise set to latest possible day + if (this.dayControl.value !== null && this.dayControl.value > this.days.length) { + this.dayControl.setValue(this.days.length); + } + } + + /** + * inits the era control depending on the chosen calendar. + * + * @param calendar active calendar. + * @param era era to set. + */ + private _initEra(calendar: string, era: string) { + + if (calendar !== 'ISLAMIC') { + this.eraControl.enable(); + this.eraControl.setValue(era !== null ? era : 'CE'); + } else { + this.eraControl.setValue(null); + this.eraControl.disable(); + } + + } + +} diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.html b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.html new file mode 100644 index 0000000000..71201e719f --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.html @@ -0,0 +1,51 @@ +
+ + + is time period + + +
+
+ + + Calendar + + + {{cal}} + + + +
+
+ +
+
+ + Start + + +
+ + +
+ + End + + +
+
+
+ +
+ + Start date is required + + + End date is required + + + End date must be after start date. + +
+ +
diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.scss b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.scss new file mode 100644 index 0000000000..e2a5bd0496 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.scss @@ -0,0 +1,17 @@ +@import "../../../../../../assets/style/viewer"; + +.period-checkbox { + display: inline-block; + padding-bottom: 20px; +} + +.date-form-grid { + display: grid; + grid-template-columns: 30% 30%; + grid-template-rows: auto; + column-gap: 12px; +} + +.date-form-error { + margin-top: -16px; +} diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.spec.ts b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.spec.ts new file mode 100644 index 0000000000..93bae7d8f7 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.spec.ts @@ -0,0 +1,423 @@ +import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALUE_ACCESSOR, + NgControl, + ReactiveFormsModule +} from '@angular/forms'; +import { KnoraDate, KnoraPeriod } from '@dasch-swiss/dsp-js'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { MatFormFieldControl, MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { DateInputTextComponent } from './date-input-text.component'; +import { ErrorStateMatcher, MatOptionModule } from '@angular/material/core'; +import { Subject } from 'rxjs'; +import { MatSelectModule } from '@angular/material/select'; +import { By } from '@angular/platform-browser'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; +import { MatSelectHarness } from '@angular/material/select/testing'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class TestHostComponent implements OnInit { + + @ViewChild('dateInputText') dateInputTextComponent: DateInputTextComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + date: [new KnoraDate('JULIAN', 'CE', 2018, 5, 19)] + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class NoValueRequiredTestHostComponent implements OnInit { + + @ViewChild('dateInputText') dateInputTextComponent: DateInputTextComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + date: new FormControl(null) + }); + + } +} + +@Component({ + selector: 'app-date-edit', + template: '', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => TestDateEditComponent), + }, + { provide: MatFormFieldControl, useExisting: TestDateEditComponent } + ] +}) + +class TestDateEditComponent implements ControlValueAccessor, MatFormFieldControl { + + @Input() value; + @Input() disabled: boolean; + @Input() empty: boolean; + @Input() placeholder: string; + @Input() required: boolean; + @Input() shouldLabelFloat: boolean; + @Input() errorStateMatcher: ErrorStateMatcher; + @Input() valueRequiredValidator = true; + + @Input() calendar: string; + stateChanges = new Subject(); + + errorState = false; + focused = false; + id = 'testid'; + ngControl: NgControl | null; + onChange = (_: any) => {}; + + + writeValue(date: KnoraDate | null): void { + this.value = date; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + } + + onContainerClick(event: MouseEvent): void { + } + + setDescribedByIds(ids: string[]): void { + } + + _handleInput(): void { + this.onChange(this.value); + } + +} + +describe('DateInputTextComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatOptionModule, + MatCheckboxModule, + BrowserAnimationsModule, + ], + declarations: [DateInputTextComponent, TestDateEditComponent, TestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should initialize a date correctly', async () => { + + expect(testHostComponent.dateInputTextComponent.calendarControl.value).toEqual('JULIAN'); + + expect(testHostComponent.dateInputTextComponent.startDate.value).toEqual(new KnoraDate('JULIAN', 'CE', 2018, 5, 19)); + + expect(testHostComponent.dateInputTextComponent.isPeriodControl.value).toBeFalse(); + + expect(testHostComponent.dateInputTextComponent.endDate.value).toBeNull(); + + const hostCompDe = testHostFixture.debugElement; + const dateEditComponentDe = hostCompDe.query(By.directive(TestDateEditComponent)); + + expect((dateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('JULIAN'); + + expect((dateEditComponentDe.componentInstance as TestDateEditComponent).value).toEqual(new KnoraDate('JULIAN', 'CE', 2018, 5, 19)); + + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(true); + + const calendarSelection = await loader.getHarness(MatSelectHarness.with({ selector: '.calendar-selection' })); + expect(await calendarSelection.getValueText()).toEqual('JULIAN'); + + const periodCheckbox = await loader.getHarness(MatCheckboxHarness.with({ selector: '.period-checkbox' })); + expect(await periodCheckbox.isChecked()).toEqual(false); + + expect(testHostComponent.dateInputTextComponent.value instanceof KnoraDate).toBe(true); + + expect(testHostComponent.dateInputTextComponent.value) + .toEqual(new KnoraDate('JULIAN', 'CE', 2018, 5, 19)); + + }); + + it('should initialize a period correctly', async () => { + + testHostComponent.form.controls.date.setValue(new KnoraPeriod(new KnoraDate('JULIAN', 'CE', 2018, 5, 19), new KnoraDate('JULIAN', 'CE', 2019, 5, 19))); + + expect(testHostComponent.dateInputTextComponent.calendarControl.value).toEqual('JULIAN'); + + expect(testHostComponent.dateInputTextComponent.startDate.value).toEqual(new KnoraDate('JULIAN', 'CE', 2018, 5, 19)); + + expect(testHostComponent.dateInputTextComponent.isPeriodControl.value).toBeTrue(); + + expect(testHostComponent.dateInputTextComponent.endDate.value).toEqual(new KnoraDate('JULIAN', 'CE', 2019, 5, 19)); + + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + + const startDateEditComponentDe = hostCompDe.query(By.css('.start-date')); + + const endDateEditComponentDe = hostCompDe.query(By.css('.end-date')); + + expect((startDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('JULIAN'); + + expect((startDateEditComponentDe.componentInstance as TestDateEditComponent).value).toEqual(new KnoraDate('JULIAN', 'CE', 2018, 5, 19)); + + expect((endDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('JULIAN'); + + expect((endDateEditComponentDe.componentInstance as TestDateEditComponent).value).toEqual(new KnoraDate('JULIAN', 'CE', 2019, 5, 19)); + + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(true); + + const calendarSelection = await loader.getHarness(MatSelectHarness.with({ selector: '.calendar-selection' })); + expect(await calendarSelection.getValueText()).toEqual('JULIAN'); + + const periodCheckbox = await loader.getHarness(MatCheckboxHarness.with({ selector: '.period-checkbox' })); + expect(await periodCheckbox.isChecked()).toEqual(true); + + expect(testHostComponent.dateInputTextComponent.value instanceof KnoraPeriod).toBe(true); + + expect(testHostComponent.dateInputTextComponent.value) + .toEqual(new KnoraPeriod(new KnoraDate('JULIAN', 'CE', 2018, 5, 19), new KnoraDate('JULIAN', 'CE', 2019, 5, 19))); + + + }); + + it('should react correctly to changing the calendar for a single date', () => { + + const hostCompDe = testHostFixture.debugElement; + + const startDateEditComponentDe = hostCompDe.query(By.css('.start-date')); + + expect((startDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('JULIAN'); + + testHostComponent.dateInputTextComponent.calendarControl.setValue('ISLAMIC'); + + testHostFixture.detectChanges(); + + expect((startDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('ISLAMIC'); + + }); + + it('should react correctly to changing the calendar for a period', () => { + + testHostComponent.form.controls.date.setValue(new KnoraPeriod(new KnoraDate('JULIAN', 'CE', 2018, 5, 19), new KnoraDate('JULIAN', 'CE', 2019, 5, 19))); + + expect(testHostComponent.dateInputTextComponent.calendarControl.value).toEqual('JULIAN'); + + expect(testHostComponent.dateInputTextComponent.startDate.value).toEqual(new KnoraDate('JULIAN', 'CE', 2018, 5, 19)); + + expect(testHostComponent.dateInputTextComponent.isPeriodControl.value).toBeTrue(); + + expect(testHostComponent.dateInputTextComponent.endDate.value).toEqual(new KnoraDate('JULIAN', 'CE', 2019, 5, 19)); + + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + + const startDateEditComponentDe = hostCompDe.query(By.css('.start-date')); + + expect((startDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('JULIAN'); + + const endDateEditComponentDe = hostCompDe.query(By.css('.end-date')); + + expect((endDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('JULIAN'); + + testHostComponent.dateInputTextComponent.calendarControl.setValue('ISLAMIC'); + + testHostFixture.detectChanges(); + + expect((startDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('ISLAMIC'); + + expect((endDateEditComponentDe.componentInstance as TestDateEditComponent).calendar).toEqual('ISLAMIC'); + + }); + + it('should propagate changes made by the user for a single date', async () => { + + const hostCompDe = testHostFixture.debugElement; + + const startDateEditComponentDe = hostCompDe.query(By.css('.start-date')); + + (startDateEditComponentDe.componentInstance as TestDateEditComponent).writeValue(new KnoraDate('JULIAN', 'CE', 2019, 5, 19)); + (startDateEditComponentDe.componentInstance as TestDateEditComponent)._handleInput(); + + await testHostFixture.whenStable(); + + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(true); + + expect(testHostComponent.form.controls.date.value).toEqual(new KnoraDate('JULIAN', 'CE', 2019, 5, 19)); + }); + + it('should propagate changes made by the user for a period', async () => { + + testHostComponent.dateInputTextComponent.isPeriodControl.setValue(true); + + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + + const startDateEditComponentDe = hostCompDe.query(By.css('.start-date')); + + (startDateEditComponentDe.componentInstance as TestDateEditComponent).writeValue(new KnoraDate('JULIAN', 'CE', 2019, 5, 19)); + (startDateEditComponentDe.componentInstance as TestDateEditComponent)._handleInput(); + + const endDateEditComponentDe = hostCompDe.query(By.css('.end-date')); + + (endDateEditComponentDe.componentInstance as TestDateEditComponent).writeValue(new KnoraDate('JULIAN', 'CE', 2020, 5, 19)); + (endDateEditComponentDe.componentInstance as TestDateEditComponent)._handleInput(); + + await testHostFixture.whenStable(); + + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(true); + + expect(testHostComponent.form.controls.date.value).toEqual(new KnoraPeriod(new KnoraDate('JULIAN', 'CE', 2019, 5, 19), new KnoraDate('JULIAN', 'CE', 2020, 5, 19))); + }); + + it('should return "null" for an invalid user input (start date greater than end date)', async () => { + + testHostComponent.dateInputTextComponent.isPeriodControl.setValue(true); + + testHostComponent.dateInputTextComponent.startDate.setValue(new KnoraDate('JULIAN', 'CE', 2021, 5, 19)); + + testHostComponent.dateInputTextComponent.endDate.setValue(new KnoraDate('JULIAN', 'CE', 2020, 5, 19)); + + await testHostFixture.whenStable(); + + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(false); + + expect(testHostComponent.form.controls.date.value).toBeNull(); + }); + + it('should return "null" for an invalid user input (start date greater than end date) 2', async () => { + + testHostComponent.dateInputTextComponent.isPeriodControl.setValue(true); + + testHostComponent.dateInputTextComponent.startDate.setValue(new KnoraDate('JULIAN', 'CE', 2021, 5, 19)); + + testHostComponent.dateInputTextComponent.endDate.setValue(new KnoraDate('JULIAN', 'BCE', 2022, 5, 19)); + + await testHostFixture.whenStable(); + + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(false); + + expect(testHostComponent.form.controls.date.value).toBeNull(); + }); + + it('should initialize the date with an empty value', () => { + + testHostComponent.form.controls.date.setValue(null); + + expect(testHostComponent.dateInputTextComponent.startDate.value).toBe(null); + expect(testHostComponent.dateInputTextComponent.isPeriodControl.value).toBe(false); + expect(testHostComponent.dateInputTextComponent.endDate.value).toBe(null); + expect(testHostComponent.dateInputTextComponent.calendarControl.value).toEqual('GREGORIAN'); + + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(false); + + }); + +}); + +describe('DateInputTextComponent (no validator required)', () => { + let testHostComponent: NoValueRequiredTestHostComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatOptionModule, + MatCheckboxModule, + BrowserAnimationsModule, + ], + declarations: [DateInputTextComponent, TestDateEditComponent, NoValueRequiredTestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(NoValueRequiredTestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should receive the propagated valueRequiredValidator from the parent component', () => { + expect(testHostComponent.dateInputTextComponent.valueRequiredValidator).toBe(false); + }); + + it('should mark the form\'s validity correctly', () => { + expect(testHostComponent.dateInputTextComponent.form.valid).toBe(true); + }); + +}); diff --git a/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.ts b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.ts new file mode 100644 index 0000000000..7ce21d0a11 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input-text/date-input-text.component.ts @@ -0,0 +1,308 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { Component, DoCheck, ElementRef, HostBinding, Input, OnDestroy, OnInit, Optional, Self } from '@angular/core'; +import { + CanUpdateErrorState, + CanUpdateErrorStateCtor, + ErrorStateMatcher, + mixinErrorState +} from '@angular/material/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgControl, + NgForm, + ValidatorFn, + Validators +} from '@angular/forms'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { JDNConvertibleCalendar } from 'jdnconvertiblecalendar'; +import { Subject, Subscription } from 'rxjs'; +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { KnoraDate, KnoraPeriod } from '@dasch-swiss/dsp-js'; +import { ValueService } from '../../../services/value.service'; + +/** if a period is defined, start date must be before end date */ +export function periodStartEndValidator(isPeriod: FormControl, endDate: FormControl, valueService: ValueService): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + + if (isPeriod.value && control.value !== null && endDate.value !== null) { + // period: check if start is before end + + const jdnStartDate = valueService.createJDNCalendarDateFromKnoraDate(control.value); + const jdnEndDate = valueService.createJDNCalendarDateFromKnoraDate(endDate.value); + + const invalid = jdnStartDate.toJDNPeriod().periodEnd >= jdnEndDate.toJDNPeriod().periodStart; + + return invalid ? { 'periodStartEnd': { value: control.value } } : null; + + } + + return null; + }; +} + +class MatInputBase { + constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl) { + } +} + +const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase = + mixinErrorState(MatInputBase); + +@Component({ + selector: 'app-date-input-text', + templateUrl: './date-input-text.component.html', + styleUrls: ['./date-input-text.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: DateInputTextComponent }] +}) +export class DateInputTextComponent extends _MatInputMixinBase implements ControlValueAccessor, MatFormFieldControl, DoCheck, CanUpdateErrorState, OnInit, OnDestroy, OnInit { + + static nextId = 0; + + @Input() valueRequiredValidator = true; + + form: FormGroup; + stateChanges = new Subject(); + + isPeriodControl: FormControl; + calendarControl: FormControl; + startDate: FormControl; + endDate: FormControl; + + readonly focused = false; + + readonly controlType = 'app-date-input-text'; + + calendars = JDNConvertibleCalendar.supportedCalendars.map(cal => cal.toUpperCase()); + + private _subscriptions: Subscription[] = []; + + @Input() + get value(): KnoraDate | KnoraPeriod | null { + + if (!this.form.valid) { + return null; + } + + if (!this.isPeriodControl.value) { + return this.startDate.value; + } else { + if (this.startDate.value.calendar !== this.endDate.value.calendar) { + return null; + } + + return new KnoraPeriod(this.startDate.value, this.endDate.value); + } + } + + set value(date: KnoraDate | KnoraPeriod | null) { + + if (date instanceof KnoraDate) { + // single date + this.calendarControl.setValue(date.calendar); + this.isPeriodControl.setValue(false); + this.startDate.setValue(date); + } else if (date instanceof KnoraPeriod) { + // period + this.calendarControl.setValue(date.start.calendar); + this.isPeriodControl.setValue(true); + this.startDate.setValue(date.start); + this.endDate.setValue(date.end); + } else { + // null + this.calendarControl.setValue('GREGORIAN'); + this.isPeriodControl.setValue(false); + this.startDate.setValue(null); + this.endDate.setValue(null); + } + + this.stateChanges.next(); + } + + @Input() + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this._disabled ? this.form.disable() : this.form.enable(); + this.stateChanges.next(); + } + + private _disabled = false; + + @Input() + get placeholder() { + return this._placeholder; + } + + set placeholder(plh) { + this._placeholder = plh; + this.stateChanges.next(); + } + + private _placeholder: string; + + @Input() + get required() { + return this._required; + } + + set required(req) { + this._required = coerceBooleanProperty(req); + this.stateChanges.next(); + } + + private _required = false; + + @HostBinding('class.floating') + get shouldLabelFloat() { + return this.focused || !this.empty; + } + + @HostBinding() id = `app-date-input-text-${DateInputTextComponent.nextId++}`; + + constructor(fb: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + private _fm: FocusMonitor, + private _elRef: ElementRef, + @Optional() _parentForm: NgForm, + @Optional() _parentFormGroup: FormGroupDirective, + _defaultErrorStateMatcher: ErrorStateMatcher, + private _valueService: ValueService) { + super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + + if (this.ngControl != null) { + // setting the value accessor directly (instead of using + // the providers) to avoid running into a circular import. + this.ngControl.valueAccessor = this; + } + + this.isPeriodControl = new FormControl(false); // tODO: if period, check if start is before end + this.calendarControl = new FormControl(null); + + this.endDate = new FormControl(null); + this.startDate = new FormControl(null); + + const eraChangesSubscription = this.isPeriodControl.valueChanges.subscribe( + isPeriod => { + this.endDate.clearValidators(); + + if (isPeriod && this.valueRequiredValidator) { + // end date is required in case of a period + this.endDate.setValidators([Validators.required]); + } + + this.endDate.updateValueAndValidity(); + } + ); + + this._subscriptions.push(eraChangesSubscription); + + // tODO: find better way to detect changes + const startValueSubscription = this.startDate.valueChanges.subscribe( + () => { + // form's validity has not been updated yet, + // trigger update + this.form.updateValueAndValidity(); + this._handleInput(); + } + ); + + this._subscriptions.push(startValueSubscription); + + // tODO: find better way to detect changes + const endValueSubscription = this.endDate.valueChanges.subscribe( + () => { + // trigger period check validator set on start date control + this.startDate.updateValueAndValidity(); + // form's validity has not been updated yet, + // trigger update + this.form.updateValueAndValidity(); + this._handleInput(); + } + ); + + this._subscriptions.push(endValueSubscription); + + // init form + this.form = fb.group({ + isPeriod: this.isPeriodControl, + calendar: this.calendarControl, + startDate: this.startDate, + endDate: this.endDate + }); + + } + + onChange = (_: any) => { + }; + + onTouched = () => { + }; + + get empty() { + return !this.startDate && !this.endDate; + } + + ngOnInit(): void { + if (this.valueRequiredValidator) { + this.startDate.setValidators([Validators.required, periodStartEndValidator(this.isPeriodControl, this.endDate, this._valueService)]); + } else { + this.startDate.setValidators([periodStartEndValidator(this.isPeriodControl, this.endDate, this._valueService)]); + } + this.startDate.updateValueAndValidity(); + } + + ngDoCheck() { + if (this.ngControl) { + this.updateErrorState(); + } + } + + ngOnDestroy() { + this.stateChanges.complete(); + + this._subscriptions.forEach( + subs => { + if (subs instanceof Subscription && !subs.closed) { + subs.unsubscribe(); + } + } + ); + } + + writeValue(date: KnoraDate | KnoraPeriod | null): void { + this.value = date; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + _handleInput(): void { + this.onChange(this.value); + } + + onContainerClick(event: MouseEvent): void { + } + + setDescribedByIds(ids: string[]): void { + } + +} diff --git a/src/app/workspace/resource/values/date-value/date-input/date-input.component.html b/src/app/workspace/resource/values/date-value/date-input/date-input.component.html new file mode 100644 index 0000000000..e4da69c6dc --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input/date-input.component.html @@ -0,0 +1,50 @@ +
+ + + is time period + + +
+
+ + + Choose a start date + ({{startDateControl.value?.calendarName}}) + + + + + + +
+ +
+ + + Choose an end date + ({{endDateControl.value?.calendarName}}) + + + + + + +
+
+ +
+ + Start date is required + + + In a period, start and end dates are required and must use the same + calendar + + + In a period, start must be before end + +
+ +
diff --git a/src/app/workspace/resource/values/date-value/date-input/date-input.component.scss b/src/app/workspace/resource/values/date-value/date-input/date-input.component.scss new file mode 100644 index 0000000000..0534b75285 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input/date-input.component.scss @@ -0,0 +1,18 @@ +@import "../../../../../../assets/style/viewer"; + +.period-checkbox { + display: inline-block; + padding-bottom: 20px; +} + +.date-form-grid { + display: grid; + grid-template-columns: 49% 49%; + grid-template-rows: auto; + column-gap: 12px; +} + +.date-form-error { + margin-top: -16px; +} + diff --git a/src/app/workspace/resource/values/date-value/date-input/date-input.component.spec.ts b/src/app/workspace/resource/values/date-value/date-input/date-input.component.spec.ts new file mode 100644 index 0000000000..94c0d4f294 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input/date-input.component.spec.ts @@ -0,0 +1,341 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DateInputComponent } from './date-input.component'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { KnoraDate, KnoraPeriod } from '@dasch-swiss/dsp-js'; +import { JDNDatepickerDirective } from '../../jdn-datepicker-directive/jdndatepicker.directive'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatInputModule } from '@angular/material/input'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatJDNConvertibleCalendarDateAdapterModule } from 'jdnconvertiblecalendardateadapter'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { CalendarDate, GregorianCalendarDate, CalendarPeriod, JulianCalendarDate } from 'jdnconvertiblecalendar'; +import { By } from '@angular/platform-browser'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatInputHarness } from '@angular/material/input/testing'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class TestHostComponent implements OnInit { + + @ViewChild('dateInput') dateInputComponent: DateInputComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + date: [new KnoraDate('JULIAN', 'CE', 2018, 5, 19)] + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class NoValueRequiredTestHostComponent implements OnInit { + + @ViewChild('dateInput') dateInputComponent: DateInputComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + date: new FormControl(null) + }); + + } +} + +describe('DateInputComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule, + MatCheckboxModule, + MatJDNConvertibleCalendarDateAdapterModule, + BrowserAnimationsModule + ], + declarations: [DateInputComponent, TestHostComponent, JDNDatepickerDirective] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should initialize a date correctly', async () => { + + expect(testHostComponent.dateInputComponent.value instanceof KnoraDate).toBe(true); + expect(testHostComponent.dateInputComponent.value) + .toEqual(new KnoraDate('JULIAN', 'CE', 2018, 5, 19)); + + expect(testHostComponent.dateInputComponent.startDateControl.value) + .toEqual(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2018, 5, 19), new CalendarDate(2018, 5, 19)))); + + expect(testHostComponent.dateInputComponent.isPeriodControl.value).toBe(false); + + expect(testHostComponent.dateInputComponent.endDateControl.value).toBe(null); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(true); + + // check that MatDatepicker has been initialized correctly + const dateStartInput = await loader.getAllHarnesses(MatInputHarness); + + expect(dateStartInput.length).toEqual(1); + + const startDate = await dateStartInput[0].getValue(); + + expect(startDate).toEqual('19-05-2018'); + + const startDateReadonly = await dateStartInput[0].isReadonly(); + + expect(startDateReadonly).toBe(true); + + }); + + it('should initialize a period correctly', async () => { + + testHostComponent.form.controls.date.setValue(new KnoraPeriod(new KnoraDate('JULIAN', 'CE', 2018, 5, 19), new KnoraDate('JULIAN', 'CE', 2019, 5, 19))); + + expect(testHostComponent.dateInputComponent.value instanceof KnoraPeriod).toBe(true); + expect(testHostComponent.dateInputComponent.value) + .toEqual(new KnoraPeriod(new KnoraDate('JULIAN', 'CE', 2018, 5, 19), new KnoraDate('JULIAN', 'CE', 2019, 5, 19))); + + expect(testHostComponent.dateInputComponent.startDateControl.value) + .toEqual(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2018, 5, 19), new CalendarDate(2018, 5, 19)))); + + expect(testHostComponent.dateInputComponent.isPeriodControl.value).toBe(true); + + expect(testHostComponent.dateInputComponent.endDateControl.value) + .toEqual(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2019, 5, 19), new CalendarDate(2019, 5, 19)))); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(true); + + // check that MatDatepicker has been initialized correctly + const dateStartInput = await loader.getHarness(MatInputHarness.with({ ancestor: '.start' })); + + const startDate = await dateStartInput.getValue(); + + expect(startDate).toEqual('19-05-2018'); + + const startDateReadonly = await dateStartInput.isReadonly(); + + expect(startDateReadonly).toBe(true); + + const dateEndInput = await loader.getHarness(MatInputHarness.with({ ancestor: '.end' })); + + const endDate = await dateEndInput.getValue(); + + expect(endDate).toEqual('19-05-2019'); + + const endDateReadonly = await dateEndInput.isReadonly(); + + expect(endDateReadonly).toBe(true); + + }); + + it('should propagate changes made by the user for a single date', () => { + + testHostComponent.dateInputComponent.form.controls.dateStart.setValue(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2019, 5, 19), new CalendarDate(2019, 5, 19)))); + + testHostComponent.dateInputComponent._handleInput(); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(true); + + expect(testHostComponent.form.controls.date.value).toEqual(new KnoraDate('JULIAN', 'CE', 2019, 5, 19)); + }); + + it('should propagate changes made by the user for a period', () => { + + testHostComponent.dateInputComponent.form.controls.dateStart.setValue(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2019, 5, 19), new CalendarDate(2019, 5, 19)))); + + testHostComponent.dateInputComponent.form.controls.isPeriod.setValue(true); + + testHostComponent.dateInputComponent.form.controls.dateEnd.setValue(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2020, 5, 19), new CalendarDate(2020, 5, 19)))); + + testHostComponent.dateInputComponent._handleInput(); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(true); + + expect(testHostComponent.form.controls.date.value).toEqual(new KnoraPeriod(new KnoraDate('JULIAN', 'CE', 2019, 5, 19), new KnoraDate('JULIAN', 'CE', 2020, 5, 19))); + }); + + it('should return "null" for an invalid user input (start date greater than end date)', () => { + + testHostComponent.dateInputComponent.form.controls.dateStart.setValue(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2021, 5, 19), new CalendarDate(2021, 5, 19)))); + + testHostComponent.dateInputComponent.form.controls.isPeriod.setValue(true); + + testHostComponent.dateInputComponent.form.controls.dateEnd.setValue(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2020, 5, 19), new CalendarDate(2020, 5, 19)))); + + testHostComponent.dateInputComponent._handleInput(); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(false); + + expect(testHostComponent.dateInputComponent.value).toEqual(null); + + }); + + it('should return "null" for an invalid user input (start date and end date have different calendars)', () => { + + testHostComponent.dateInputComponent.form.controls.dateStart.setValue(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2021, 5, 19), new CalendarDate(2021, 5, 19)))); + + testHostComponent.dateInputComponent.form.controls.isPeriod.setValue(true); + + testHostComponent.dateInputComponent.form.controls.dateEnd.setValue(new GregorianCalendarDate(new CalendarPeriod(new CalendarDate(2022, 5, 19), new CalendarDate(2022, 5, 19)))); + + testHostComponent.dateInputComponent._handleInput(); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(false); + + expect(testHostComponent.dateInputComponent.value).toEqual(null); + + }); + + it('should return "null" for an invalid user input (start date is "null")', () => { + + testHostComponent.dateInputComponent.form.controls.dateStart.setValue(null); + + testHostComponent.dateInputComponent.form.controls.isPeriod.setValue(true); + + testHostComponent.dateInputComponent.form.controls.dateEnd.setValue(new JulianCalendarDate(new CalendarPeriod(new CalendarDate(2020, 5, 19), new CalendarDate(2020, 5, 19)))); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(false); + + expect(testHostComponent.dateInputComponent.value).toEqual(null); + + }); + + it('should initialize the date with an empty value', () => { + + testHostComponent.form.controls.date.setValue(null); + + expect(testHostComponent.dateInputComponent.form.controls.dateStart.value).toBe(null); + expect(testHostComponent.dateInputComponent.form.controls.isPeriod.value).toBe(false); + expect(testHostComponent.dateInputComponent.form.controls.dateEnd.value).toBe(null); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(false); + + }); + + it('should show the toggle', () => { + + testHostComponent.dateInputComponent.form.controls.isPeriod.setValue(true); + + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + const dateInputComponentDe = hostCompDe.query(By.directive(DateInputComponent)); + + const startDateToggle = dateInputComponentDe.query(By.css('.start mat-datepicker-toggle')); + + expect(startDateToggle).not.toBe(null); + + const endDateToggle = dateInputComponentDe.query(By.css('.end mat-datepicker-toggle')); + + expect(endDateToggle).not.toBe(null); + }); + + + it('should show the calendar of a date', () => { + + const hostCompDe = testHostFixture.debugElement; + const dateInputComponentDe = hostCompDe.query(By.directive(DateInputComponent)); + + const startDateCalendar = dateInputComponentDe.query(By.css('.start span.calendar')); + + expect(startDateCalendar).not.toBe(null); + + }); + + it('should mark the form\'s validity correctly', () => { + expect(testHostComponent.dateInputComponent.valueRequiredValidator).toBe(true); + expect(testHostComponent.dateInputComponent.form.valid).toBe(true); + + testHostComponent.dateInputComponent.startDateControl.setValue(null); + + testHostComponent.dateInputComponent._handleInput(); + + expect(testHostComponent.dateInputComponent.form.valid).toBe(false); + }); + +}); + +describe('NoValueRequiredTestHostComponent', () => { + let testHostComponent: NoValueRequiredTestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule, + MatCheckboxModule, + MatJDNConvertibleCalendarDateAdapterModule, + BrowserAnimationsModule + ], + declarations: [DateInputComponent, NoValueRequiredTestHostComponent, JDNDatepickerDirective] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(NoValueRequiredTestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should receive the propagated valueRequiredValidator from the parent component', () => { + expect(testHostComponent.dateInputComponent.valueRequiredValidator).toBe(false); + }); + + it('should mark the form\'s validity correctly', () => { + expect(testHostComponent.dateInputComponent.form.valid).toBe(true); + }); +}); diff --git a/src/app/workspace/resource/values/date-value/date-input/date-input.component.ts b/src/app/workspace/resource/values/date-value/date-input/date-input.component.ts new file mode 100644 index 0000000000..1ad38d6354 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-input/date-input.component.ts @@ -0,0 +1,350 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable @typescript-eslint/member-ordering */ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Component, DoCheck, ElementRef, HostBinding, Input, OnDestroy, OnInit, Optional, Self } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgControl, + NgForm, + ValidatorFn, + Validators +} from '@angular/forms'; +import { CanUpdateErrorState, CanUpdateErrorStateCtor, ErrorStateMatcher, mixinErrorState } from '@angular/material/core'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { KnoraDate, KnoraPeriod } from '@dasch-swiss/dsp-js'; +import { + CalendarDate, + CalendarPeriod, + GregorianCalendarDate, + JDNConvertibleCalendar, + JulianCalendarDate +} from 'jdnconvertiblecalendar'; +import { Subject } from 'rxjs'; +import { CalendarHeaderComponent } from '../calendar-header/calendar-header.component'; + +/** error when invalid control is dirty, touched, or submitted. */ +export class DateInputErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = form && form.submitted; + return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)); + } +} + +/** if a period is defined, start and end date must have the same calendar */ +export function sameCalendarValidator(isPeriod: FormControl, endDate: FormControl): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + + if (isPeriod.value) { + + let invalid = true; + if (control.value instanceof JDNConvertibleCalendar && endDate.value instanceof JDNConvertibleCalendar) { + invalid = control.value.calendarName !== endDate.value.calendarName; + } + + return invalid ? { 'sameCalendarRequired': { value: control.value } } : null; + } + + return null; + }; +} + +/** if a period is defined, start date must be before end date */ +export function periodStartEndValidator(isPeriod: FormControl, endDate: FormControl): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + + if (isPeriod.value) { + let invalid = true; + + if (control.value instanceof JDNConvertibleCalendar && endDate.value instanceof JDNConvertibleCalendar) { + + // check if start is before end + const startAsJdnPeriod = (control.value as JDNConvertibleCalendar).toJDNPeriod(); + const endAsJdnPeriod = (endDate.value as JDNConvertibleCalendar).toJDNPeriod(); + + // check for start after end + invalid = startAsJdnPeriod.periodStart >= endAsJdnPeriod.periodStart; + } + + return invalid ? { 'periodStartEnd': { value: control.value } } : null; + } + + return null; + }; +} + +class MatInputBase { + constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl) { + } +} + +const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase = + mixinErrorState(MatInputBase); + +@Component({ + selector: 'app-date-input', + templateUrl: './date-input.component.html', + styleUrls: ['./date-input.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: DateInputComponent }] +}) +export class DateInputComponent extends _MatInputMixinBase implements ControlValueAccessor, MatFormFieldControl, DoCheck, CanUpdateErrorState, OnDestroy, OnInit { + + static nextId = 0; + + @HostBinding() id = `app-date-input-${DateInputComponent.nextId++}`; + @Input() valueRequiredValidator = true; + + form: FormGroup; + stateChanges = new Subject(); + focused = false; + errorState = false; + controlType = 'app-date-input'; + matcher = new DateInputErrorStateMatcher(); + + calendarHeaderComponent = CalendarHeaderComponent; + startDateControl: FormControl; + endDateControl: FormControl; + isPeriodControl: FormControl; + + onChange = (_: any) => { + }; + onTouched = () => { + }; + + get empty() { + const userInput = this.form.value; + return !userInput.start && !userInput.end; + } + + @HostBinding('class.floating') + get shouldLabelFloat() { + return this.focused || !this.empty; + } + + @Input() + get required() { + return this._required; + } + + set required(req) { + this._required = coerceBooleanProperty(req); + this.stateChanges.next(); + } + + private _required = false; + + @Input() + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this._disabled ? this.form.disable() : this.form.enable(); + this.stateChanges.next(); + } + + private _disabled = false; + + @Input() + get placeholder() { + return this._placeholder; + } + + set placeholder(plh) { + this._placeholder = plh; + this.stateChanges.next(); + } + + private _placeholder: string; + + @HostBinding('attr.aria-describedby') describedBy = ''; + + setDescribedByIds(ids: string[]) { + this.describedBy = ids.join(' '); + } + + @Input() + get value(): KnoraDate | KnoraPeriod | null { + + if (!this.form.valid) { + return null; + } + + const userInput = this.form.value; + + if (!this.isPeriodControl.value) { + // single date + if (userInput.dateStart !== null) { + return new KnoraDate( + userInput.dateStart.calendarName.toUpperCase(), 'CE', userInput.dateStart.calendarStart.year, userInput.dateStart.calendarStart.month, userInput.dateStart.calendarStart.day); + } else { + return null; + } + } else { + // period + if (userInput.dateStart !== null && userInput.dateEnd !== null) { + + const start = new KnoraDate( + userInput.dateStart.calendarName.toUpperCase(), 'CE', userInput.dateStart.calendarStart.year, userInput.dateStart.calendarStart.month, userInput.dateStart.calendarStart.day); + const end = new KnoraDate( + userInput.dateEnd.calendarName.toUpperCase(), 'CE', userInput.dateEnd.calendarStart.year, userInput.dateEnd.calendarStart.month, userInput.dateEnd.calendarStart.day); + + return new KnoraPeriod(start, end); + } else { + return null; + } + } + } + + set value(date: KnoraDate | KnoraPeriod | null) { + if (date !== null) { + if (date instanceof KnoraDate) { + // single date + + this.form.setValue({ + dateStart: this.createCalendarDate(date), + dateEnd: null, + isPeriod: false + }); + + this.startDateControl.updateValueAndValidity(); + + } else { + // period + const period = date as KnoraPeriod; + + this.form.setValue({ + dateStart: this.createCalendarDate(period.start), + dateEnd: this.createCalendarDate(period.end), + isPeriod: true + }); + + this.startDateControl.updateValueAndValidity(); + + } + } else { + this.form.setValue({ dateStart: null, dateEnd: null, isPeriod: false }); + + this.startDateControl.updateValueAndValidity(); + } + this.stateChanges.next(); + } + + /** + * given a `KnoraDate`, creates a Gregorian or Julian calendar date. + * + * @param date the given KnoraDate. + */ + createCalendarDate(date: KnoraDate): GregorianCalendarDate | JulianCalendarDate { + + const calDate = new CalendarDate(date.year, date.month, date.day); + const period = new CalendarPeriod(calDate, calDate); + + // determine calendar + if (date.calendar === 'GREGORIAN') { + return new GregorianCalendarDate(period); + } else if (date.calendar === 'JULIAN') { + return new JulianCalendarDate(period); + } else { + throw new Error('Unsupported calendar: ' + date.calendar); + } + } + + @Input() errorStateMatcher: ErrorStateMatcher; + + constructor( + fb: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + private _fm: FocusMonitor, + private _elRef: ElementRef, + @Optional() _parentForm: NgForm, + @Optional() _parentFormGroup: FormGroupDirective, + _defaultErrorStateMatcher: ErrorStateMatcher) { + + super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + + this.endDateControl = new FormControl(null); + this.isPeriodControl = new FormControl(null); + this.startDateControl = new FormControl(null); + + this.form = fb.group({ + dateStart: this.startDateControl, + dateEnd: this.endDateControl, + isPeriod: this.isPeriodControl + }); + + _fm.monitor(_elRef.nativeElement, true).subscribe(origin => { + this.focused = !!origin; + this.stateChanges.next(); + }); + + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + } + + ngOnInit() { + if (this.valueRequiredValidator) { + this.startDateControl.setValidators([ + Validators.required, + sameCalendarValidator(this.isPeriodControl, this.endDateControl), + periodStartEndValidator(this.isPeriodControl, this.endDateControl) + ]); + } else { + this.startDateControl.setValidators([ + sameCalendarValidator(this.isPeriodControl, this.endDateControl), + periodStartEndValidator(this.isPeriodControl, this.endDateControl) + ]); + } + + this.startDateControl.updateValueAndValidity(); + } + + ngDoCheck() { + if (this.ngControl) { + this.updateErrorState(); + } + } + + ngOnDestroy() { + this.stateChanges.complete(); + } + + onContainerClick(event: MouseEvent) { + if ((event.target as Element).tagName.toLowerCase() !== 'input') { + this._elRef.nativeElement.querySelector('input').focus(); + } + } + + writeValue(date: KnoraDate | KnoraPeriod | null): void { + this.value = date; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + _handleInput(): void { + // trigger evaluation of validators defined for start date + this.startDateControl.updateValueAndValidity(); + this.onChange(this.value); + } + +} diff --git a/src/app/workspace/resource/values/date-value/date-value.component.html b/src/app/workspace/resource/values/date-value/date-value.component.html new file mode 100644 index 0000000000..3e04b5ccad --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-value.component.html @@ -0,0 +1,57 @@ + + + + + + Period Start: + {{valueFormControl.value?.start | knoraDate:ontologyDateFormat:displayOptions}} + + + Period End: + {{valueFormControl.value?.end | knoraDate:ontologyDateFormat:displayOptions}} + + + + + + + Date: + {{valueFormControl.value | knoraDate:ontologyDateFormat:displayOptions}} + + + + + {{commentFormControl.value}} + + + + + + + + + + + + New value must be different than the current value. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + + + diff --git a/src/app/workspace/resource/values/date-value/date-value.component.scss b/src/app/workspace/resource/values/date-value/date-value.component.scss new file mode 100644 index 0000000000..07a5bf3b3e --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-value.component.scss @@ -0,0 +1,17 @@ +@import "../../../../../assets/style/viewer"; + +:host ::ng-deep .child-value-component { + .mat-form-field-underline { + display: none; + } + .mat-form-field-infix{ + border-top: 0.2em solid transparent !important; + .mat-form-field-underline{ + display: block; + } + } +} + +.date-start, .date-end { + display: block; +} diff --git a/src/app/workspace/resource/values/date-value/date-value.component.spec.ts b/src/app/workspace/resource/values/date-value/date-value.component.spec.ts new file mode 100644 index 0000000000..6f26e8e4ea --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-value.component.spec.ts @@ -0,0 +1,652 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component, DebugElement, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule } from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatInputHarness } from '@angular/material/input/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CreateDateValue, KnoraDate, KnoraPeriod, MockResource, ReadDateValue, UpdateDateValue } from '@dasch-swiss/dsp-js'; +import { Subject } from 'rxjs'; +import { KnoraDatePipe } from 'src/app/main/pipes/formatting/knoradate.pipe'; +import { DateValueComponent } from './date-value.component'; + + +@Component({ + selector: 'app-date-input-text', + template: '', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => TestDateInputComponent), + }, + { provide: MatFormFieldControl, useExisting: TestDateInputComponent } + ] +}) +class TestDateInputComponent implements ControlValueAccessor, MatFormFieldControl { + + @Input() value; + @Input() disabled: boolean; + @Input() empty: boolean; + @Input() placeholder: string; + @Input() required: boolean; + @Input() shouldLabelFloat: boolean; + @Input() errorStateMatcher: ErrorStateMatcher; + @Input() valueRequiredValidator = true; + + stateChanges = new Subject(); + errorState = false; + focused = false; + id = 'testid'; + ngControl: NgControl | null; + onChange = (_: any) => {}; + + + writeValue(date: KnoraDate | KnoraPeriod | null): void { + this.value = date; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + } + + onContainerClick(event: MouseEvent): void { + } + + setDescribedByIds(ids: string[]): void { + } + + _handleInput(): void { + this.onChange(this.value); + } + +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: DateValueComponent; + + displayInputVal: ReadDateValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadDateValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate', ReadDateValue)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: DateValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueNoValueRequiredComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: DateValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +describe('DateValueComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + declarations: [ + DateValueComponent, + TestDateInputComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent, + TestHostCreateValueNoValueRequiredComponent, + KnoraDatePipe + ] + }) + .compileComponents(); + })); + + describe('display and edit a date value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + let valueComponentDe: DebugElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(DateValueComponent)); + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.date).toEqual(new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13)); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('13.05.2018'); + + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.dateInputComponent.value).toEqual(new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13)); + + // simulate user input + const newKnoraDate = new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13); + + testHostComponent.inputValueComponent.dateInputComponent.value = newKnoraDate; + testHostComponent.inputValueComponent.dateInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value) + .toEqual(newKnoraDate); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateDateValue).toBeTruthy(); + + expect((updatedValue as UpdateDateValue).calendar).toEqual('GREGORIAN'); + expect((updatedValue as UpdateDateValue).startYear).toEqual(2019); + expect((updatedValue as UpdateDateValue).endYear).toEqual(2019); + expect((updatedValue as UpdateDateValue).startMonth).toEqual(5); + expect((updatedValue as UpdateDateValue).endMonth).toEqual(5); + expect((updatedValue as UpdateDateValue).startDay).toEqual(13); + expect((updatedValue as UpdateDateValue).endDay).toEqual(13); + + }); + + it('should not accept a user input equivalent to the existing value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.dateInputComponent.value) + .toEqual(new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13)); + + // simulate user input (equivalent date) + const newKnoraDate = new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13); + + testHostComponent.inputValueComponent.dateInputComponent.value = newKnoraDate; + testHostComponent.inputValueComponent.dateInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value) + .toEqual(newKnoraDate); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBe(false); + + }); + + it('should validate an existing value with an added comment', async () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.displayValue.date).toEqual(new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13)); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const commentTextarea = await loader.getHarness(MatInputHarness); + + await commentTextarea.setValue('this is a comment'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateDateValue).toBeTruthy(); + + expect((updatedValue as UpdateDateValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + testHostComponent.inputValueComponent.dateInputComponent.value = null; + testHostComponent.inputValueComponent.dateInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + // simulate user input + const newKnoraDate = new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13); + + testHostComponent.inputValueComponent.dateInputComponent.value = newKnoraDate; + testHostComponent.inputValueComponent.dateInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value).toEqual(new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13)); + + expect(testHostComponent.inputValueComponent.dateInputComponent.value).toEqual(new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13)); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value).toEqual(new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13)); + + expect(testHostComponent.inputValueComponent.dateInputComponent.value).toEqual(new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13)); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', done => { + + MockResource.getTestThing().subscribe(res => { + const newDate: ReadDateValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasDate', ReadDateValue)[0]; + + newDate.id = 'updatedId'; + + newDate.date = new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13); + + testHostComponent.displayInputVal = newDate; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value).toEqual(new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13)); + + expect(valueReadModeNativeElement.innerText).toEqual('13.05.2019'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + done(); + }); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.inputValueComponent.ngOnDestroy(); + + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeTruthy(); + + }); + + it('should compare two dates', () => { + + expect(testHostComponent.inputValueComponent.sameDate( + new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13), + new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13))).toEqual(true); + + expect(testHostComponent.inputValueComponent.sameDate( + new KnoraDate('JULIAN', 'CE', 2018, 5, 13), + new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13))).toEqual(false); + + expect(testHostComponent.inputValueComponent.sameDate( + new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13), + new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13))).toEqual(false); + + }); + + it('should compare the existing version of a date to the user input', () => { + + // knoraDate('GREGORIAN', 'CE', 2018, 5, 13) + const initValue: KnoraDate | KnoraPeriod = testHostComponent.inputValueComponent.getInitValue(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13) + ) + ).toBeTruthy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13) + ) + ).toBeFalsy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, null + ) + ).toBeFalsy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, new KnoraPeriod( + new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13), + new KnoraDate('GREGORIAN', 'CE', 2019, 5, 13) + ) + ) + ).toBeFalsy(); + + }); + + it('should correctly populate an UpdateValue from a KnoraDate', () => { + + const date = new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13); + + const updateVal = new UpdateDateValue(); + + testHostComponent.inputValueComponent.populateValue(updateVal, date); + + expect(updateVal.calendar).toEqual('GREGORIAN'); + expect(updateVal.startEra).toEqual('CE'); + expect(updateVal.startDay).toEqual(13); + expect(updateVal.startMonth).toEqual(5); + expect(updateVal.startYear).toEqual(2018); + expect(updateVal.endEra).toEqual('CE'); + expect(updateVal.endDay).toEqual(13); + expect(updateVal.endMonth).toEqual(5); + expect(updateVal.endYear).toEqual(2018); + + }); + + it('should correctly populate an UpdateValue from a KnoraDate with an Islamic calendar date', () => { + + const date = new KnoraDate('ISLAMIC', 'noEra', 1441); + + const updateVal = new UpdateDateValue(); + + testHostComponent.inputValueComponent.populateValue(updateVal, date); + + expect(updateVal.calendar).toEqual('ISLAMIC'); + expect(updateVal.startEra).toBeUndefined(); + expect(updateVal.startDay).toBeUndefined(); + expect(updateVal.startMonth).toBeUndefined(); + expect(updateVal.startYear).toEqual(1441); + expect(updateVal.endEra).toBeUndefined(); + expect(updateVal.endDay).toBeUndefined(); + expect(updateVal.endMonth).toBeUndefined(); + expect(updateVal.endYear).toEqual(1441); + + }); + + it('should correctly populate an UpdateValue from a KnoraPeriod', () => { + + const dateStart = new KnoraDate('GREGORIAN', 'CE', 2018, 5, 13); + const dateEnd = new KnoraDate('GREGORIAN', 'CE', 2019, 6, 14); + + const updateVal = new UpdateDateValue(); + + testHostComponent.inputValueComponent.populateValue(updateVal, new KnoraPeriod(dateStart, dateEnd)); + + expect(updateVal.calendar).toEqual('GREGORIAN'); + expect(updateVal.startEra).toEqual('CE'); + expect(updateVal.startDay).toEqual(13); + expect(updateVal.startMonth).toEqual(5); + expect(updateVal.startYear).toEqual(2018); + expect(updateVal.endEra).toEqual('CE'); + expect(updateVal.endDay).toEqual(14); + expect(updateVal.endMonth).toEqual(6); + expect(updateVal.endYear).toEqual(2019); + + }); + + it('should correctly populate an UpdateValue from a KnoraPeriod with dates in different eras', () => { + + const dateStart = new KnoraDate('GREGORIAN', 'BCE', 2018, 5, 13); + const dateEnd = new KnoraDate('GREGORIAN', 'CE', 2019, 6, 14); + + const updateVal = new UpdateDateValue(); + + testHostComponent.inputValueComponent.populateValue(updateVal, new KnoraPeriod(dateStart, dateEnd)); + + expect(updateVal.calendar).toEqual('GREGORIAN'); + expect(updateVal.startEra).toEqual('BCE'); + expect(updateVal.startDay).toEqual(13); + expect(updateVal.startMonth).toEqual(5); + expect(updateVal.startYear).toEqual(2018); + expect(updateVal.endEra).toEqual('CE'); + expect(updateVal.endDay).toEqual(14); + expect(updateVal.endMonth).toEqual(6); + expect(updateVal.endYear).toEqual(2019); + + }); + + it('should correctly populate an UpdateValue from a KnoraPeriod with Islamic calendar dates', () => { + + const dateStart = new KnoraDate('ISLAMIC', 'noEra', 1441); + const dateEnd = new KnoraDate('ISLAMIC', 'noEra', 1442); + + const updateVal = new UpdateDateValue(); + + testHostComponent.inputValueComponent.populateValue(updateVal, new KnoraPeriod(dateStart, dateEnd)); + + expect(updateVal.calendar).toEqual('ISLAMIC'); + expect(updateVal.startEra).toBeUndefined(); + expect(updateVal.startDay).toBeUndefined(); + expect(updateVal.startMonth).toBeUndefined(); + expect(updateVal.startYear).toEqual(1441); + expect(updateVal.endEra).toBeUndefined(); + expect(updateVal.endDay).toBeUndefined(); + expect(updateVal.endMonth).toBeUndefined(); + expect(updateVal.endYear).toEqual(1442); + + }); + + }); + + describe('create a date value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let loader: HarnessLoader; + + let valueComponentDe: DebugElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(DateValueComponent)); + }); + + it('should create a value', () => { + + expect(testHostComponent.inputValueComponent.dateInputComponent.value).toEqual(null); + + // simulate user input + const newKnoraDate = new KnoraDate('JULIAN', 'CE', 2019, 5, 13); + + testHostComponent.inputValueComponent.dateInputComponent.value = newKnoraDate; + testHostComponent.inputValueComponent.dateInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateDateValue).toBeTruthy(); + + expect((newValue as CreateDateValue).calendar).toEqual('JULIAN'); + + expect((newValue as CreateDateValue).startDay).toEqual(13); + expect((newValue as CreateDateValue).endDay).toEqual(13); + expect((newValue as CreateDateValue).startMonth).toEqual(5); + expect((newValue as CreateDateValue).endMonth).toEqual(5); + expect((newValue as CreateDateValue).startYear).toEqual(2019); + expect((newValue as CreateDateValue).endYear).toEqual(2019); + + }); + + it('should reset form after cancellation', async () => { + + // simulate user input + const newKnoraDate = new KnoraDate('JULIAN', 'CE', 2019, 5, 13); + + testHostComponent.inputValueComponent.dateInputComponent.value = newKnoraDate; + testHostComponent.inputValueComponent.dateInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + const commentTextarea = await loader.getHarness(MatInputHarness); + await commentTextarea.setValue('created comment'); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.dateInputComponent.value).toEqual(null); + + const comment = await commentTextarea.getValue(); + + expect(comment).toEqual(''); + + }); + + }); + + describe('create a date value no required value', () => { + + let testHostComponent: TestHostCreateValueNoValueRequiredComponent; + let testHostFixture: ComponentFixture; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueNoValueRequiredComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + }); + + it('should not create an empty value', () => { + expect(testHostComponent.inputValueComponent.getNewValue()).toBe(false); + expect(testHostComponent.inputValueComponent.form.valid).toBe(true); + }); + + it('should propagate valueRequiredValidator to child component', () => { + expect(testHostComponent.inputValueComponent.valueRequiredValidator).toBe(false); + }); + + }); + +}); diff --git a/src/app/workspace/resource/values/date-value/date-value.component.ts b/src/app/workspace/resource/values/date-value/date-value.component.ts new file mode 100644 index 0000000000..6c15192989 --- /dev/null +++ b/src/app/workspace/resource/values/date-value/date-value.component.ts @@ -0,0 +1,199 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { + CreateDateValue, + KnoraDate, + KnoraPeriod, + ReadDateValue, + UpdateDateValue +} from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; +import { DateInputComponent } from './date-input/date-input.component'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-date-value', + templateUrl: './date-value.component.html', + styleUrls: ['./date-value.component.scss'] +}) +export class DateValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @ViewChild('dateInput') dateInputComponent: DateInputComponent; + + @Input() displayValue?: ReadDateValue; + + @Input() displayOptions?: 'era' | 'calendar' | 'all'; + + @Input() labels = false; + + @Input() ontologyDateFormat = 'dd.MM.YYYY'; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + + customValidators = []; + + matcher = new ValueErrorStateMatcher(); + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + /** + * returns true if both dates are the same. + * + * @param date1 date for comparison with date2 + * @param date2 date for comparison with date1 + */ + sameDate(date1: KnoraDate, date2: KnoraDate): boolean { + return (date1.calendar === date2.calendar && date1.year === date2.year && date1.month === date2.month && date1.day === date2.day); + } + + standardValueComparisonFunc(initValue: KnoraDate | KnoraPeriod, curValue: KnoraDate | KnoraPeriod | null): boolean { + let sameValue: boolean; + if (initValue instanceof KnoraDate && curValue instanceof KnoraDate) { + sameValue = this.sameDate(initValue, curValue); + } else if (initValue instanceof KnoraPeriod && curValue instanceof KnoraPeriod) { + sameValue = this.sameDate(initValue.start, curValue.start) && this.sameDate(initValue.end, curValue.end); + } else { + // init value and current value have different types + sameValue = false; + } + return sameValue; + } + + getInitValue(): KnoraDate | KnoraPeriod | null { + if (this.displayValue !== undefined) { + return this.displayValue.date; + } else { + return null; + } + } + + ngOnInit() { + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + /** + * given a value and a period or Date, populates the value. + * + * @param value the value to be populated. + * @param dateOrPeriod the date or period to read from. + */ + populateValue(value: UpdateDateValue | CreateDateValue, dateOrPeriod: KnoraDate | KnoraPeriod) { + + if (dateOrPeriod instanceof KnoraDate) { + + value.calendar = dateOrPeriod.calendar; + value.startEra = dateOrPeriod.era !== 'noEra' ? dateOrPeriod.era : undefined; + value.startDay = dateOrPeriod.day; + value.startMonth = dateOrPeriod.month; + value.startYear = dateOrPeriod.year; + + value.endEra = value.startEra; + value.endDay = value.startDay; + value.endMonth = value.startMonth; + value.endYear = value.startYear; + + } else if (dateOrPeriod instanceof KnoraPeriod) { + + value.calendar = dateOrPeriod.start.calendar; + + value.startEra = dateOrPeriod.start.era !== 'noEra' ? dateOrPeriod.start.era : undefined; + value.startDay = dateOrPeriod.start.day; + value.startMonth = dateOrPeriod.start.month; + value.startYear = dateOrPeriod.start.year; + + value.endEra = dateOrPeriod.end.era !== 'noEra' ? dateOrPeriod.end.era : undefined; + value.endDay = dateOrPeriod.end.day; + value.endMonth = dateOrPeriod.end.month; + value.endYear = dateOrPeriod.end.year; + + } + } + + getNewValue(): CreateDateValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newDateValue = new CreateDateValue(); + + const dateOrPeriod = this.valueFormControl.value; + + this.populateValue(newDateValue, dateOrPeriod); + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newDateValue.valueHasComment = this.commentFormControl.value; + } + + return newDateValue; + } + + getUpdatedValue(): UpdateDateValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedDateValue = new UpdateDateValue(); + + updatedDateValue.id = this.displayValue.id; + + const dateOrPeriod = this.valueFormControl.value; + + this.populateValue(updatedDateValue, dateOrPeriod); + + // add the submitted comment to updatedIntValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedDateValue.valueHasComment = this.commentFormControl.value; + } + + return updatedDateValue; + } + +} diff --git a/src/app/workspace/resource/values/decimal-value/decimal-value.component.html b/src/app/workspace/resource/values/decimal-value/decimal-value.component.html new file mode 100644 index 0000000000..27592530fb --- /dev/null +++ b/src/app/workspace/resource/values/decimal-value/decimal-value.component.html @@ -0,0 +1,36 @@ + + {{valueFormControl.value}} + {{commentFormControl.value}} + + + + + + + New value must be different than the current value. + + + New value must be a decimal value. + + + A decimal value is required. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/decimal-value/decimal-value.component.scss b/src/app/workspace/resource/values/decimal-value/decimal-value.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/workspace/resource/values/decimal-value/decimal-value.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/decimal-value/decimal-value.component.spec.ts b/src/app/workspace/resource/values/decimal-value/decimal-value.component.spec.ts new file mode 100644 index 0000000000..3a61f41cb3 --- /dev/null +++ b/src/app/workspace/resource/values/decimal-value/decimal-value.component.spec.ts @@ -0,0 +1,343 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DecimalValueComponent } from './decimal-value.component'; +import { ReadDecimalValue, MockResource, UpdateValue, UpdateDecimalValue, CreateDecimalValue } from '@dasch-swiss/dsp-js'; +import { OnInit, Component, ViewChild, DebugElement } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { $ } from 'protractor'; +import { By } from '@angular/platform-browser'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: DecimalValueComponent; + + displayInputVal: ReadDecimalValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadDecimalValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasDecimal', ReadDecimalValue)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: DecimalValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +describe('DecimalValueComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + DecimalValueComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + }) + .compileComponents(); + })); + + describe('display and edit a decimal value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + valueComponentDe = hostCompDe.query(By.directive(DecimalValueComponent)); + + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.decimal).toEqual(1.5); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('1.5'); + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('1.5'); + + valueInputNativeElement.value = '40.09'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateDecimalValue).toBeTruthy(); + + expect((updatedValue as UpdateDecimalValue).decimal).toEqual(40.09); + + }); + + it('should validate an existing value with an added comment', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('1.5'); + + commentInputNativeElement.value = 'this is a comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateDecimalValue).toBeTruthy(); + + expect((updatedValue as UpdateDecimalValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('1.5'); + + valueInputNativeElement.value = '.'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('1.5'); + + valueInputNativeElement.value = '40.09'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(valueReadModeNativeElement.innerText).toEqual('1.5'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newDecimal = new ReadDecimalValue(); + + newDecimal.decimal = 40.09; + newDecimal.id = 'updatedId'; + + testHostComponent.displayInputVal = newDecimal; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('40.09'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.inputValueComponent.ngOnDestroy(); + + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + }); + + describe('create a decimal value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(DecimalValueComponent)); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.displayValue).toEqual(undefined); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + expect(valueInputNativeElement.value).toEqual(''); + expect(commentInputNativeElement.value).toEqual(''); + }); + + it('should create a value', () => { + valueInputNativeElement.value = '40.09'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateDecimalValue).toBeTruthy(); + + expect((newValue as CreateDecimalValue).decimal).toEqual(40.09); + }); + + it('should reset form after cancellation', () => { + valueInputNativeElement.value = '40.09'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual(''); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + }); +}); diff --git a/src/app/workspace/resource/values/decimal-value/decimal-value.component.ts b/src/app/workspace/resource/values/decimal-value/decimal-value.component.ts new file mode 100644 index 0000000000..a1f238bc96 --- /dev/null +++ b/src/app/workspace/resource/values/decimal-value/decimal-value.component.ts @@ -0,0 +1,116 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; +import { CreateDecimalValue, ReadDecimalValue, UpdateDecimalValue } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { CustomRegex } from '../custom-regex'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-decimal-value', + templateUrl: './decimal-value.component.html', + styleUrls: ['./decimal-value.component.scss'] +}) +export class DecimalValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @Input() displayValue?: ReadDecimalValue; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + matcher = new ValueErrorStateMatcher(); + valueChangesSubscription: Subscription; + + customValidators = [Validators.pattern(CustomRegex.DECIMAL_REGEX)]; // only allow for decimal values + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + getInitValue(): number | null { + if (this.displayValue !== undefined) { + return this.displayValue.decimal; + } else { + return null; + } + } + + ngOnInit() { + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateDecimalValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newDecimalValue = new CreateDecimalValue(); + + newDecimalValue.decimal = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newDecimalValue.valueHasComment = this.commentFormControl.value; + } + + return newDecimalValue; + } + + getUpdatedValue(): UpdateDecimalValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedDecimalValue = new UpdateDecimalValue(); + + updatedDecimalValue.id = this.displayValue.id; + + updatedDecimalValue.decimal = this.valueFormControl.value; + + // add the submitted comment to updatedIntValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedDecimalValue.valueHasComment = this.commentFormControl.value; + } + + return updatedDecimalValue; + } + +} diff --git a/src/app/workspace/resource/values/geoname-value/geoname-value.component.html b/src/app/workspace/resource/values/geoname-value/geoname-value.component.html new file mode 100644 index 0000000000..87055fff91 --- /dev/null +++ b/src/app/workspace/resource/values/geoname-value/geoname-value.component.html @@ -0,0 +1,43 @@ + + + {{ ($geonameLabel | async)?.displayName }} + + + {{ commentFormControl.value }} + + + + + Current value: {{ ($geonameLabel | async)?.displayName }} + + + + + {{place?.displayName}} + + + + New value must be different than the current value. + + + A GeoName value is required. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/geoname-value/geoname-value.component.scss b/src/app/workspace/resource/values/geoname-value/geoname-value.component.scss new file mode 100644 index 0000000000..c57094c0f9 --- /dev/null +++ b/src/app/workspace/resource/values/geoname-value/geoname-value.component.scss @@ -0,0 +1,35 @@ +@import "../../../../../assets/style/viewer"; + +.read-mode-view .more-info, +.form-fields-container .mat-form-field .more-info { + cursor: pointer; + border: none; + padding: 0em; + margin-left: 1em; + outline: none; + background-color: transparent; + color: #000000; +} + +.form-fields-container .mat-form-field .more-info .mat-icon { + vertical-align: middle; +} + +.read-mode-view .rm-value, +.read-mode-view .more-info { + display: inline-block; + vertical-align: middle; +} + +.read-mode-view .more-info { + width: 18px; + height: 18px; +} + +.read-mode-view .more-info .mat-icon{ + font-size: 18px; +} + +.current-value { + font-size: 12px; +} diff --git a/src/app/workspace/resource/values/geoname-value/geoname-value.component.spec.ts b/src/app/workspace/resource/values/geoname-value/geoname-value.component.spec.ts new file mode 100644 index 0000000000..2acdfc267c --- /dev/null +++ b/src/app/workspace/resource/values/geoname-value/geoname-value.component.spec.ts @@ -0,0 +1,424 @@ +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CreateGeonameValue, MockResource, ReadGeonameValue, UpdateGeonameValue } from '@dasch-swiss/dsp-js'; +import { GeonameValueComponent } from './geoname-value.component'; +import { DisplayPlace, GeonameService } from '../../services/geoname.service'; +import { of } from 'rxjs'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing'; + + + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: GeonameValueComponent; + + displayInputVal: ReadGeonameValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadGeonameValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasGeoname', ReadGeonameValue)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: GeonameValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +describe('GeonameValueComponent', () => { + beforeEach(waitForAsync(() => { + + const mockGeonameService = jasmine.createSpyObj('GeonameService', ['resolveGeonameID', 'searchPlace']); + + TestBed.configureTestingModule({ + declarations: [ + GeonameValueComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule, + MatIconModule, + MatAutocompleteModule + ], + providers: [{ + provide: GeonameService, + useValue: mockGeonameService + }] + }) + .compileComponents(); + })); + + describe('display and edit a geoname value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + + let loader: HarnessLoader; + + beforeEach(() => { + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.resolveGeonameID.and.returnValue(of({ displayName: 'Basel' } as DisplayPlace)); + + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + valueComponentDe = hostCompDe.query(By.directive(GeonameValueComponent)); + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + }); + + it('should display an existing value', () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + expect(testHostComponent.inputValueComponent.displayValue.geoname).toEqual('2661604'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('Basel'); + + const anchorDebugElement = valueReadModeDebugElement.query(By.css('a')); + expect(anchorDebugElement.nativeElement).toBeDefined(); + + expect(anchorDebugElement.attributes.href).toEqual('https://www.geonames.org/2661604'); + expect(anchorDebugElement.attributes.target).toEqual('_blank'); + + expect(geonameServiceMock.resolveGeonameID).toHaveBeenCalledOnceWith('2661604'); + + }); + + it('should make an existing value editable', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + const autocomplete = await loader.getHarness(MatAutocompleteHarness); + + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + await autocomplete.enterText('Terra Lind'); + + expect(geonameServiceMock.searchPlace).toHaveBeenCalledWith('Terra Lind'); + + const options = await autocomplete.getOptions(); + + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateGeonameValue).toBeTruthy(); + + expect((updatedValue as UpdateGeonameValue).geoname).toEqual('5401678'); + + }); + + it('should not return an invalid update value', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([])); + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const autocomplete = await loader.getHarness(MatAutocompleteHarness); + + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); + + await autocomplete.enterText('invalid'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const autocomplete = await loader.getHarness(MatAutocompleteHarness); + + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); + + await autocomplete.enterText('Terra Lind'); + + const options = await autocomplete.getOptions(); + + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); + + expect(testHostComponent.inputValueComponent.valueFormControl.value.id).toEqual('5401678'); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value.id).toEqual('2661604'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.resolveGeonameID.and.returnValue(of({ displayName: 'Terra Linda High School' } as DisplayPlace)); + + const newStr = new ReadGeonameValue(); + + newStr.geoname = '5401678'; + newStr.id = 'updatedId'; + + testHostComponent.displayInputVal = newStr; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('Terra Linda High School'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(geonameServiceMock.resolveGeonameID).toHaveBeenCalledWith('5401678'); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.inputValueComponent.ngOnDestroy(); + + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + }); + + describe('create a geoname value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + let loader: HarnessLoader; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(GeonameValueComponent)); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.displayValue).toEqual(undefined); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + expect(valueInputNativeElement.value).toEqual(''); + expect(commentInputNativeElement.value).toEqual(''); + }); + + it('should create a value', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); + + const autocomplete = await loader.getHarness(MatAutocompleteHarness); + + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); + + await autocomplete.enterText('Terra Lind'); + + const options = await autocomplete.getOptions(); + + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateGeonameValue).toBeTruthy(); + + expect((newValue as CreateGeonameValue).geoname).toEqual('5401678'); + }); + + it('should reset form after cancellation', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); + + const autocomplete = await loader.getHarness(MatAutocompleteHarness); + + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); + + await autocomplete.enterText('Terra Lind'); + + const options = await autocomplete.getOptions(); + + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(await autocomplete.getValue()).toEqual(''); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + }); +}); diff --git a/src/app/workspace/resource/values/geoname-value/geoname-value.component.ts b/src/app/workspace/resource/values/geoname-value/geoname-value.component.ts new file mode 100644 index 0000000000..48bdbe6951 --- /dev/null +++ b/src/app/workspace/resource/values/geoname-value/geoname-value.component.ts @@ -0,0 +1,177 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { AbstractControl, FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { CreateGeonameValue, ReadGeonameValue, UpdateGeonameValue } from '@dasch-swiss/dsp-js'; +import { Observable, Subscription } from 'rxjs'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; +import { DisplayPlace, GeonameService, SearchPlace } from '../../services/geoname.service'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +export function geonameIdValidator(control: AbstractControl) { + // null or empty checks are out of this validator's scope + // check for a valid geoname id object + const invalid = !(control.value === null || control.value === '' || (typeof control.value === 'object' && 'id' in control.value)); + return invalid ? { invalidType: { value: control.value } } : null; +} + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-geoname-value', + templateUrl: './geoname-value.component.html', + styleUrls: ['./geoname-value.component.scss'] +}) +export class GeonameValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + @Input() displayValue?: ReadGeonameValue; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + matcher = new ValueErrorStateMatcher(); + customValidators = [geonameIdValidator]; + + $geonameLabel: Observable; + + places: SearchPlace[]; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder, private _geonameService: GeonameService) { + super(); + } + + standardValueComparisonFunc(initValue: { id: string }, curValue: { id: string } | null): boolean { + return (curValue !== null && typeof curValue === 'object' && 'id' in curValue) && initValue.id === curValue.id; + } + + getInitValue(): { id: string } | null { + + if (this.displayValue !== undefined) { + return { + id: this.displayValue.geoname + }; // todo: try to set a display name to be shown when value is updated + } else { + return null; + } + } + + /** + * used to create a value which is displayed to the user after selection from autocomplete. + * + * @param place the user selected place. + */ + displayPlaceInSearch(place: SearchPlace | null) { + if (place !== null) { + return place.displayName; + } + } + + ngOnInit() { + + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + // react to user typing places + this.valueFormControl.valueChanges.subscribe( + (searchTerm: string) => { + + // console.log(searchTerm); + // tODO: move this to a method + if ((this.mode === 'create' || this.mode === 'update') && searchTerm !== null) { + if (typeof searchTerm === 'string' && searchTerm.length >= 3) { + // console.log('searching for ' + searchTerm); + this._geonameService.searchPlace(searchTerm).subscribe( + places => this.places = places, + err => this.places = [] + ); + } else { + this.places = []; + } + } + } + ); + + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + if (this.mode === 'read') { + this.$geonameLabel = this._geonameService.resolveGeonameID(this.valueFormControl.value.id); + } + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + + // resets values and validators in form controls when input displayValue or mode changes + // at the first call of ngOnChanges, form control elements are not initialized yet + this.resetFormControl(); + + if (this.mode === 'read' && this.valueFormControl !== undefined) { + this.$geonameLabel = this._geonameService.resolveGeonameID(this.valueFormControl.value.id); + } + } + + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateGeonameValue | false { + + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newGeonameValue = new CreateGeonameValue(); + + newGeonameValue.geoname = this.valueFormControl.value.id; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newGeonameValue.valueHasComment = this.commentFormControl.value; + } + + return newGeonameValue; + + } + + getUpdatedValue(): UpdateGeonameValue | false { + + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedGeonameValue = new UpdateGeonameValue(); + + updatedGeonameValue.id = this.displayValue.id; + + updatedGeonameValue.geoname = this.valueFormControl.value.id; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedGeonameValue.valueHasComment = this.commentFormControl.value; + } + + return updatedGeonameValue; + } + +} diff --git a/src/app/workspace/resource/values/int-value/int-value.component.html b/src/app/workspace/resource/values/int-value/int-value.component.html new file mode 100644 index 0000000000..98add10ecf --- /dev/null +++ b/src/app/workspace/resource/values/int-value/int-value.component.html @@ -0,0 +1,36 @@ + + {{valueFormControl.value}} + {{commentFormControl.value}} + + + + + + + New value must be different than the current value. + + + New value must be an integer. + + + An integer value is required. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/int-value/int-value.component.scss b/src/app/workspace/resource/values/int-value/int-value.component.scss new file mode 100644 index 0000000000..de750854cb --- /dev/null +++ b/src/app/workspace/resource/values/int-value/int-value.component.scss @@ -0,0 +1,17 @@ +@import "../../../../../assets/style/viewer"; + +// Firefox: +// Hide number picker +input[type=number] { + -moz-appearance:textfield; +} + +// Show number picker on focus +input[type=number]:focus{ + -moz-appearance:number-input; +} + +input:read-only { + pointer-events: none; + -moz-appearance:textfield; + } diff --git a/src/app/workspace/resource/values/int-value/int-value.component.spec.ts b/src/app/workspace/resource/values/int-value/int-value.component.spec.ts new file mode 100644 index 0000000000..91c957632a --- /dev/null +++ b/src/app/workspace/resource/values/int-value/int-value.component.spec.ts @@ -0,0 +1,353 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IntValueComponent } from './int-value.component'; +import { ReadIntValue, MockResource, UpdateValue, UpdateIntValue, CreateIntValue } from '@dasch-swiss/dsp-js'; +import { OnInit, Component, ViewChild, DebugElement } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatInputHarness } from '@angular/material/input/testing'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: IntValueComponent; + + displayInputVal: ReadIntValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadIntValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger', ReadIntValue)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: IntValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueNoValueRequiredComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: IntValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +describe('IntValueComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + IntValueComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent, + TestHostCreateValueNoValueRequiredComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + }) + .compileComponents(); + })); + + describe('display and edit an integer value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + + let loader: HarnessLoader; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + valueComponentDe = hostCompDe.query(By.directive(IntValueComponent)); + + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.int).toEqual(1); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('1'); + + }); + + it('should make an existing value editable', async () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.value' })); + + expect(await inputElement.getValue()).toEqual('1'); + + await inputElement.setValue('20'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateIntValue).toBeTruthy(); + + expect((updatedValue as UpdateIntValue).int).toEqual(20); + + }); + + it('should validate an existing value with an added comment', async () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.value' })); + + const commentElement = await loader.getHarness(MatInputHarness.with({ selector: '.comment' })); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(await inputElement.getValue()).toEqual('1'); + + await commentElement.setValue('this is a comment'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateIntValue).toBeTruthy(); + + expect((updatedValue as UpdateIntValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should not return an invalid update value', async () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + // tODO: use MatHarness once bug with number strings is fixed + // https://github.com/angular/components/issues/18790#issuecomment-635206117 + + const valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + const valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(valueInputNativeElement.value).toEqual('1'); + + valueInputNativeElement.value = '1.5'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', async () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + const inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.value' })); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(await inputElement.getValue()).toEqual('1'); + + await inputElement.setValue('20'); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(await inputElement.getValue()).toEqual('1'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newInt = new ReadIntValue(); + + newInt.int = 20; + newInt.id = 'updatedId'; + + testHostComponent.displayInputVal = newInt; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('20'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.inputValueComponent.ngOnDestroy(); + + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + }); + + describe('create an integer value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let inputElement: MatInputHarness; + let commentElement: MatInputHarness; + + let loader: HarnessLoader; + + beforeEach(async () => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + inputElement = await loader.getHarness(MatInputHarness.with({ selector: '.value' })); + + commentElement = await loader.getHarness(MatInputHarness.with({ selector: '.comment' })); + + expect(testHostComponent.inputValueComponent.displayValue).toEqual(undefined); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + expect(await inputElement.getValue()).toEqual(''); + expect(await commentElement.getValue()).toEqual(''); + }); + + it('should create a value', async () => { + await inputElement.setValue('20'); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateIntValue).toBeTruthy(); + + expect((newValue as CreateIntValue).int).toEqual(20); + }); + + it('should reset form after cancellation', async () => { + await inputElement.setValue('20'); + + await commentElement.setValue('created comment'); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(await inputElement.getValue()).toEqual(''); + + expect(await commentElement.getValue()).toEqual(''); + + }); + }); + + describe('create value no required value', () => { + let testHostComponent: TestHostCreateValueNoValueRequiredComponent; + let testHostFixture: ComponentFixture; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueNoValueRequiredComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should not create an empty value', () => { + expect(testHostComponent.inputValueComponent.getNewValue()).toEqual(false); + }); + }); +}); diff --git a/src/app/workspace/resource/values/int-value/int-value.component.ts b/src/app/workspace/resource/values/int-value/int-value.component.ts new file mode 100644 index 0000000000..143d3574f1 --- /dev/null +++ b/src/app/workspace/resource/values/int-value/int-value.component.ts @@ -0,0 +1,118 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { CreateIntValue, ReadIntValue, UpdateIntValue } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; +import { CustomRegex } from '../custom-regex'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-int-value', + templateUrl: './int-value.component.html', + styleUrls: ['./int-value.component.scss'] +}) +export class IntValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @Input() displayValue?: ReadIntValue; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + matcher = new ValueErrorStateMatcher(); + valueChangesSubscription: Subscription; + + customValidators = [Validators.pattern(CustomRegex.INT_REGEX)]; // only allow for integer values (no fractions) + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + getInitValue(): number | null { + if (this.displayValue !== undefined) { + return this.displayValue.int; + } else { + return null; + } + } + + ngOnInit() { + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateIntValue | false { + + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newIntValue = new CreateIntValue(); + + newIntValue.int = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newIntValue.valueHasComment = this.commentFormControl.value; + } + + return newIntValue; + } + + getUpdatedValue(): UpdateIntValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedIntValue = new UpdateIntValue(); + + updatedIntValue.id = this.displayValue.id; + + updatedIntValue.int = this.valueFormControl.value; + + // add the submitted comment to updatedIntValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedIntValue.valueHasComment = this.commentFormControl.value; + } + + return updatedIntValue; + } + +} diff --git a/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.html b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.html new file mode 100644 index 0000000000..9352f2aabf --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.html @@ -0,0 +1,20 @@ +
+ + + + Start is required. + + + + + + End is required. + + + +
+ + An interval must have a start and end + +
+
diff --git a/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.scss b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.scss new file mode 100644 index 0000000000..17a6109206 --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.scss @@ -0,0 +1 @@ +@import "../../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.spec.ts b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.spec.ts new file mode 100644 index 0000000000..0a491dcfda --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.spec.ts @@ -0,0 +1,200 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Interval, IntervalInputComponent } from './interval-input.component'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { MatInputModule } from '@angular/material/input'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class TestHostComponent implements OnInit { + + @ViewChild('intervalInput') intervalInputComponent: IntervalInputComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + interval: [new Interval(1, 2)] + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class NoValueRequiredTestHostComponent implements OnInit { + + @ViewChild('intervalInput') intervalInputComponent: IntervalInputComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + interval: new FormControl(null) + }); + + } +} + +describe('InvertalInputComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let intervalInputComponentDe: DebugElement; + let startInputDebugElement: DebugElement; + let startInputNativeElement; + let endInputDebugElement: DebugElement; + let endInputNativeElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, BrowserAnimationsModule], + declarations: [IntervalInputComponent, TestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.intervalInputComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + intervalInputComponentDe = hostCompDe.query(By.directive(IntervalInputComponent)); + startInputDebugElement = intervalInputComponentDe.query(By.css('input.start')); + startInputNativeElement = startInputDebugElement.nativeElement; + endInputDebugElement = intervalInputComponentDe.query(By.css('input.end')); + endInputNativeElement = endInputDebugElement.nativeElement; + }); + + it('should initialize the interval correctly', () => { + + expect(startInputNativeElement.value).toEqual('1'); + expect(endInputNativeElement.value).toEqual('2'); + + }); + + it('should propagate changes made by the user', () => { + + startInputNativeElement.value = '3'; + startInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.form.controls.interval).toBeTruthy(); + expect(testHostComponent.form.controls.interval.value.start).toEqual(3); + expect(testHostComponent.form.controls.interval.value.end).toEqual(2); + + endInputNativeElement.value = '35'; + endInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.form.controls.interval.value).toBeTruthy(); + expect(testHostComponent.form.controls.interval.value.start).toEqual(3); + expect(testHostComponent.form.controls.interval.value.end).toEqual(35); + + }); + + it('should return "null" for an empty (invalid) user input', () => { + + startInputNativeElement.value = ''; + startInputNativeElement.dispatchEvent(new Event('input')); + + endInputNativeElement.value = ''; + endInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.form.controls.interval.value).toBe(null); + }); + + it('should initialize the interval with an empty value', () => { + + testHostComponent.form.controls.interval.setValue(null); + expect(startInputNativeElement.value).toEqual(''); + expect(endInputNativeElement.value).toEqual(''); + + }); + + it('should mark the form\'s validity correctly', () => { + expect(testHostComponent.intervalInputComponent.valueRequiredValidator).toBe(true); + expect(testHostComponent.intervalInputComponent.form.valid).toBe(true); + + testHostComponent.intervalInputComponent.startIntervalControl.setValue(null); + + testHostComponent.intervalInputComponent._handleInput(); + + expect(testHostComponent.intervalInputComponent.form.valid).toBe(false); + }); + +}); + +describe('InvertalInputComponent', () => { + let testHostComponent: NoValueRequiredTestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, BrowserAnimationsModule], + declarations: [IntervalInputComponent, NoValueRequiredTestHostComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(NoValueRequiredTestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should receive the propagated valueRequiredValidator from the parent component', () => { + expect(testHostComponent.intervalInputComponent.valueRequiredValidator).toBe(false); + }); + + it('should mark the form\'s validity correctly', () => { + expect(testHostComponent.intervalInputComponent.form.valid).toBe(true); + + testHostComponent.intervalInputComponent.startIntervalControl.setValue(1); + + testHostComponent.intervalInputComponent._handleInput(); + + expect(testHostComponent.intervalInputComponent.form.valid).toBe(false); + }); + +}); diff --git a/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.ts b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.ts new file mode 100644 index 0000000000..3fa8a34922 --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-input/interval-input.component.ts @@ -0,0 +1,236 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable @typescript-eslint/member-ordering */ +import { Component, DoCheck, ElementRef, HostBinding, Input, OnDestroy, OnInit, Optional, Self } from '@angular/core'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { AbstractControl, ControlValueAccessor, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgControl, NgForm, ValidatorFn, Validators } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { CanUpdateErrorState, CanUpdateErrorStateCtor, ErrorStateMatcher, mixinErrorState } from '@angular/material/core'; + +/** + * represents an interval consisting. + */ +export class Interval { + + /** + * @param start interval's start. + * @param end interval's end. + */ + constructor(public start: number, public end: number) { + } +} + +/** error when invalid control is dirty, touched, or submitted. */ +export class IntervalInputErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = form && form.submitted; + return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)); + } +} + +/** interval must have a start and end of the same type, either both numbers or both null */ +export function startEndSameTypeValidator(otherInterval: FormControl): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + + // valid if both start and end are null or have values + const invalid = !(control.value === null && otherInterval.value === null || control.value !== null && otherInterval.value !== null); + + return invalid ? { 'startEndSameTypeRequired': { value: control.value } } : null; + }; +} + +class MatInputBase { + constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl) { } +} +const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase = + mixinErrorState(MatInputBase); + +// https://material.angular.io/guide/creating-a-custom-form-field-control +@Component({ + selector: 'app-interval-input', + templateUrl: './interval-input.component.html', + styleUrls: ['./interval-input.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: IntervalInputComponent }] +}) +export class IntervalInputComponent extends _MatInputMixinBase implements ControlValueAccessor, MatFormFieldControl, DoCheck, CanUpdateErrorState, OnDestroy, OnInit { + static nextId = 0; + + form: FormGroup; + stateChanges = new Subject(); + @HostBinding() id = `app-interval-input-${IntervalInputComponent.nextId++}`; + focused = false; + errorState = false; + controlType = 'app-interval-input'; + matcher = new IntervalInputErrorStateMatcher(); + + startIntervalControl: FormControl; + endIntervalControl: FormControl; + + @Input() intervalStartLabel = 'start'; + @Input() intervalEndLabel = 'end'; + @Input() valueRequiredValidator = true; + + onChange = (_: any) => { }; + onTouched = () => { }; + + get empty() { + const userInput = this.form.value; + return !userInput.start && !userInput.end; + } + + @HostBinding('class.floating') + get shouldLabelFloat() { + return this.focused || !this.empty; + } + + @Input() + get required() { + return this._required; + } + + set required(req) { + this._required = coerceBooleanProperty(req); + this.stateChanges.next(); + } + + private _required = false; + + @Input() + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this._disabled ? this.form.disable() : this.form.enable(); + this.stateChanges.next(); + } + + private _disabled = false; + + @Input() + get placeholder() { + return this._placeholder; + } + + set placeholder(plh) { + this._placeholder = plh; + this.stateChanges.next(); + } + + private _placeholder: string; + + @HostBinding('attr.aria-describedby') describedBy = ''; + + setDescribedByIds(ids: string[]) { + this.describedBy = ids.join(' '); + } + + @Input() + get value(): Interval | null { + const userInput = this.form.value; + if (userInput.start !== null && userInput.end !== null) { + return new Interval(userInput.start, userInput.end); + } + return null; + } + + set value(interval: Interval | null) { + if (interval !== null) { + this.form.setValue({ start: interval.start, end: interval.end }); + } else { + this.form.setValue({ start: null, end: null }); + } + + this.startIntervalControl.updateValueAndValidity(); + this.endIntervalControl.updateValueAndValidity(); + + this.stateChanges.next(); + } + + @Input() errorStateMatcher: ErrorStateMatcher; + + constructor(fb: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + private _fm: FocusMonitor, + private _elRef: ElementRef, + @Optional() _parentForm: NgForm, + @Optional() _parentFormGroup: FormGroupDirective, + _defaultErrorStateMatcher: ErrorStateMatcher) { + + super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + + this.startIntervalControl = new FormControl(null); + this.endIntervalControl = new FormControl(null); + + this.form = fb.group({ + start: this.startIntervalControl, + end: this.endIntervalControl + }); + + _fm.monitor(_elRef.nativeElement, true).subscribe(origin => { + this.focused = !!origin; + this.stateChanges.next(); + }); + + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + } + + ngOnInit() { + if (this.valueRequiredValidator) { + this.startIntervalControl.setValidators([Validators.required, startEndSameTypeValidator(this.endIntervalControl)]); + this.endIntervalControl.setValidators([Validators.required, startEndSameTypeValidator(this.startIntervalControl)]); + } else { + this.startIntervalControl.setValidators(startEndSameTypeValidator(this.endIntervalControl)); + this.endIntervalControl.setValidators(startEndSameTypeValidator(this.startIntervalControl)); + } + + this.startIntervalControl.updateValueAndValidity(); + this.endIntervalControl.updateValueAndValidity(); + } + + ngDoCheck() { + if (this.ngControl) { + this.updateErrorState(); + } + } + + ngOnDestroy() { + this.stateChanges.complete(); + } + + onContainerClick(event: MouseEvent) { + if ((event.target as Element).tagName.toLowerCase() !== 'input') { + this._elRef.nativeElement.querySelector('input').focus(); + } + } + + writeValue(interval: Interval | null): void { + this.value = interval; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + _handleInput(): void { + this.startIntervalControl.updateValueAndValidity(); + this.endIntervalControl.updateValueAndValidity(); + this.onChange(this.value); + } + +} diff --git a/src/app/workspace/resource/values/interval-value/interval-value.component.html b/src/app/workspace/resource/values/interval-value/interval-value.component.html new file mode 100644 index 0000000000..262cd6b5ea --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-value.component.html @@ -0,0 +1,31 @@ + + Start: {{valueFormControl.value?.start}} + End: {{valueFormControl.value?.end}} + {{commentFormControl.value}} + + + + + + + New value must be different than the current value. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/interval-value/interval-value.component.scss b/src/app/workspace/resource/values/interval-value/interval-value.component.scss new file mode 100644 index 0000000000..b7be839b15 --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-value.component.scss @@ -0,0 +1,17 @@ +@import "../../../../../assets/style/viewer"; + +:host ::ng-deep .child-value-component { + .mat-form-field-underline { + display: none; + } + .mat-form-field-infix{ + border-top: 0.2em solid transparent !important; + .mat-form-field-underline{ + display: block; + } + } +} + +.interval-start, .interval-end { + display: block; +} diff --git a/src/app/workspace/resource/values/interval-value/interval-value.component.spec.ts b/src/app/workspace/resource/values/interval-value/interval-value.component.spec.ts new file mode 100644 index 0000000000..d6301fc61c --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-value.component.spec.ts @@ -0,0 +1,509 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IntervalValueComponent } from './interval-value.component'; +import { Component, DebugElement, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { CreateIntervalValue, MockResource, ReadIntervalValue, UpdateIntervalValue } from '@dasch-swiss/dsp-js'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatInputModule } from '@angular/material/input'; +import { Interval } from './interval-input/interval-input.component'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { Subject } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { ErrorStateMatcher } from '@angular/material/core'; + +@Component({ + selector: 'app-interval-input', + template: '', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => TestIntervalInputComponent), + }, + { provide: MatFormFieldControl, useExisting: TestIntervalInputComponent } + ] +}) +class TestIntervalInputComponent implements ControlValueAccessor, MatFormFieldControl { + + @Input() value; + @Input() disabled: boolean; + @Input() empty: boolean; + @Input() placeholder: string; + @Input() required: boolean; + @Input() shouldLabelFloat: boolean; + @Input() errorStateMatcher: ErrorStateMatcher; + @Input() valueRequiredValidator = true; + + stateChanges = new Subject(); + errorState = false; + focused = false; + id = 'testid'; + ngControl: NgControl | null; + onChange = (_: any) => { + }; + + writeValue(interval: Interval | null): void { + this.value = interval; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + } + + onContainerClick(event: MouseEvent): void { + } + + setDescribedByIds(ids: string[]): void { + } + + _handleInput(): void { + this.onChange(this.value); + } + +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: IntervalValueComponent; + + displayInputVal: ReadIntervalValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadIntervalValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasInterval', ReadIntervalValue)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: IntervalValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueNoValueRequiredComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: IntervalValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + } +} + + +describe('IntervalValueComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + IntervalValueComponent, + TestHostDisplayValueComponent, + TestIntervalInputComponent, + TestHostCreateValueComponent, + TestHostCreateValueNoValueRequiredComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + }) + .compileComponents(); + })); + + describe('display and edit an interval value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + let intervalStartReadModeDebugElement: DebugElement; + let intervalEndReadModeDebugElement: DebugElement; + let intervalStartReadModeNativeElement; + let intervalEndReadModeNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(IntervalValueComponent)); + intervalStartReadModeDebugElement = valueComponentDe.query(By.css('.rm-value.interval-start')); + intervalStartReadModeNativeElement = intervalStartReadModeDebugElement.nativeElement; + + intervalEndReadModeDebugElement = valueComponentDe.query(By.css('.rm-value.interval-end')); + intervalEndReadModeNativeElement = intervalEndReadModeDebugElement.nativeElement; + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.start).toEqual(0); + + expect(testHostComponent.inputValueComponent.displayValue.end).toEqual(216000); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(intervalStartReadModeNativeElement.innerText).toEqual('Start: 0'); + + expect(intervalEndReadModeNativeElement.innerText).toEqual('End: 216000'); + + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.intervalInputComponent.value.start).toEqual(0); + + expect(testHostComponent.inputValueComponent.intervalInputComponent.value.end).toEqual(216000); + + // simulate user input + const newInterval = { + start: 100, + end: 200 + }; + + testHostComponent.inputValueComponent.intervalInputComponent.value = newInterval; + testHostComponent.inputValueComponent.intervalInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateIntervalValue).toBeTruthy(); + + expect((updatedValue as UpdateIntervalValue).start).toEqual(100); + expect((updatedValue as UpdateIntervalValue).end).toEqual(200); + + }); + + it('should compare the existing version of an interval to the user input', () => { + + // interval 0, 216000 + const initValue: Interval = testHostComponent.inputValueComponent.getInitValue(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, new Interval(0, 216000) + ) + ).toBeTruthy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, new Interval(1, 216000) + ) + ).toBeFalsy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, new Interval(2, 21) + ) + ).toBeFalsy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, new Interval(0, 21600) + ) + ).toBeFalsy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, null + ) + ).toBeFalsy(); + + + + }); + + it('should validate an existing value with an added comment', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.displayValue.start).toEqual(0); + + expect(testHostComponent.inputValueComponent.displayValue.end).toEqual(216000); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + commentInputNativeElement.value = 'this is a comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateIntervalValue).toBeTruthy(); + + expect((updatedValue as UpdateIntervalValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + testHostComponent.inputValueComponent.intervalInputComponent.value = null; + testHostComponent.inputValueComponent.intervalInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + // simulate user input + const newInterval = { + start: 100, + end: 200 + }; + + testHostComponent.inputValueComponent.intervalInputComponent.value = newInterval; + testHostComponent.inputValueComponent.intervalInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value.start).toEqual(100); + + expect(testHostComponent.inputValueComponent.valueFormControl.value.end).toEqual(200); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.intervalInputComponent.value.start).toEqual(0); + + expect(testHostComponent.inputValueComponent.intervalInputComponent.value.end).toEqual(216000); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newInterval = new ReadIntervalValue(); + + newInterval.start = 300; + newInterval.end = 500; + newInterval.id = 'updatedId'; + + testHostComponent.displayInputVal = newInterval; + + testHostFixture.detectChanges(); + + expect(intervalStartReadModeNativeElement.innerText).toEqual('Start: 300'); + + expect(intervalEndReadModeNativeElement.innerText).toEqual('End: 500'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.inputValueComponent.ngOnDestroy(); + + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + + }); + + describe('create an interval value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(IntervalValueComponent)); + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + }); + + it('should create a value', () => { + + expect(testHostComponent.inputValueComponent.intervalInputComponent.value).toEqual(null); + + // simulate user input + const newInterval = { + start: 100, + end: 200 + }; + + testHostComponent.inputValueComponent.intervalInputComponent.value = newInterval; + testHostComponent.inputValueComponent.intervalInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateIntervalValue).toBeTruthy(); + + expect((newValue as CreateIntervalValue).start).toEqual(100); + expect((newValue as CreateIntervalValue).end).toEqual(200); + }); + + it('should reset form after cancellation', () => { + // simulate user input + const newInterval = { + start: 100, + end: 200 + }; + + testHostComponent.inputValueComponent.intervalInputComponent.value = newInterval; + testHostComponent.inputValueComponent.intervalInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.intervalInputComponent.value).toEqual(null); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + + }); + + describe('create an interval value no value required', () => { + + let testHostComponent: TestHostCreateValueNoValueRequiredComponent; + let testHostFixture: ComponentFixture; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueNoValueRequiredComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should not create an empty value', () => { + expect(testHostComponent.inputValueComponent.getNewValue()).toBe(false); + expect(testHostComponent.inputValueComponent.form.valid).toBe(true); + }); + + it('should propagate valueRequiredValidator to child component', () => { + expect(testHostComponent.inputValueComponent.valueRequiredValidator).toBe(false); + }); + + }); +}); diff --git a/src/app/workspace/resource/values/interval-value/interval-value.component.ts b/src/app/workspace/resource/values/interval-value/interval-value.component.ts new file mode 100644 index 0000000000..c7c05a5bbe --- /dev/null +++ b/src/app/workspace/resource/values/interval-value/interval-value.component.ts @@ -0,0 +1,131 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; +import { CreateIntervalValue, ReadIntervalValue, UpdateIntervalValue } from '@dasch-swiss/dsp-js'; +import { + FormBuilder, + FormControl, + FormGroup +} from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { Interval, IntervalInputComponent } from './interval-input/interval-input.component'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-interval-value', + templateUrl: './interval-value.component.html', + styleUrls: ['./interval-value.component.scss'] +}) +export class IntervalValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @ViewChild('intervalInput') intervalInputComponent: IntervalInputComponent; + + @Input() displayValue?: ReadIntervalValue; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + + customValidators = []; + + matcher = new ValueErrorStateMatcher(); + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + standardValueComparisonFunc(initValue: Interval, curValue: Interval | null): boolean { + return (curValue instanceof Interval) && initValue.start === curValue.start && initValue.end === curValue.end; + } + + getInitValue(): Interval | null { + if (this.displayValue !== undefined) { + return new Interval(this.displayValue.start, this.displayValue.end); + } else { + return null; + } + } + + ngOnInit() { + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateIntervalValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newIntervalValue = new CreateIntervalValue(); + + newIntervalValue.start = this.valueFormControl.value.start; + newIntervalValue.end = this.valueFormControl.value.end; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newIntervalValue.valueHasComment = this.commentFormControl.value; + } + + return newIntervalValue; + } + + getUpdatedValue(): UpdateIntervalValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedIntervalValue = new UpdateIntervalValue(); + + updatedIntervalValue.id = this.displayValue.id; + + updatedIntervalValue.start = this.valueFormControl.value.start; + updatedIntervalValue.end = this.valueFormControl.value.end; + + // add the submitted comment to updatedIntervalValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedIntervalValue.valueHasComment = this.commentFormControl.value; + } + + return updatedIntervalValue; + } + +} diff --git a/src/app/workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive.spec.ts b/src/app/workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive.spec.ts new file mode 100644 index 0000000000..234cf73ba2 --- /dev/null +++ b/src/app/workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive.spec.ts @@ -0,0 +1,116 @@ +import { JDNDatepickerDirective } from './jdndatepicker.directive'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ACTIVE_CALENDAR } from 'jdnconvertiblecalendardateadapter'; +import { DateAdapter } from '@angular/material/core'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild(JDNDatepickerDirective) jdnDir; + + activeCalendar: string; + + ngOnInit() { + this.activeCalendar = 'Gregorian'; + } +} + + +describe('JDNDatepickerDirective', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let testBehaviourSubject; + let setNextCalSpy; + let setCompleteSpy; + + let testBehaviourSubjSpy; + + beforeEach(waitForAsync(() => { + + testBehaviourSubject = jasmine.createSpyObj('ACTIVE_CALENDAR', ['next', 'complete']); + + setNextCalSpy = testBehaviourSubject.next.and.stub(); + setCompleteSpy = testBehaviourSubject.complete.and.stub(); + + // overrides the injection token defined in JDNDatepickerDirective's metadat + TestBed.overrideProvider(ACTIVE_CALENDAR, { useValue: testBehaviourSubject }); + TestBed.overrideProvider(DateAdapter, { useValue: {} }); + + TestBed.configureTestingModule({ + declarations: [ + JDNDatepickerDirective, + TestHostComponent + ], + providers: [ + { + provide: DateAdapter, useValue: {} + }, + { + provide: ACTIVE_CALENDAR, useValue: testBehaviourSubject + } + ], + imports: [ + BrowserAnimationsModule + ], + }) + .compileComponents(); + + testBehaviourSubjSpy = TestBed.inject(ACTIVE_CALENDAR); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.jdnDir).toBeTruthy(); + }); + + it('should create an instance', () => { + expect(testBehaviourSubjSpy.next).toHaveBeenCalledTimes(1); + expect(testBehaviourSubjSpy.next).toHaveBeenCalledWith('Gregorian'); + }); + + it('should update the calendar when the input changes', () => { + testHostComponent.activeCalendar = 'Julian'; + testHostFixture.detectChanges(); + + expect(testBehaviourSubjSpy.next).toHaveBeenCalledTimes(2); + + expect(testBehaviourSubjSpy.next.calls.all()[0].args).toEqual(['Gregorian']); + expect(testBehaviourSubjSpy.next.calls.all()[1].args).toEqual(['Julian']); + + }); + + it('should set the calendar to Gregorian when called with null', () => { + testHostComponent.activeCalendar = null; + testHostFixture.detectChanges(); + + expect(testBehaviourSubjSpy.next).toHaveBeenCalledTimes(2); + + expect(testBehaviourSubjSpy.next.calls.all()[0].args).toEqual(['Gregorian']); + expect(testBehaviourSubjSpy.next.calls.all()[1].args).toEqual(['Gregorian']); + + }); + + it('should complete the BehaviourSubject when destroyed', () => { + + expect(setCompleteSpy).toHaveBeenCalledTimes(0); + + testHostComponent.jdnDir.ngOnDestroy(); + + expect(setCompleteSpy).toHaveBeenCalledTimes(1); + + }); + +}); diff --git a/src/app/workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive.ts b/src/app/workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive.ts new file mode 100644 index 0000000000..c93e8b0a29 --- /dev/null +++ b/src/app/workspace/resource/values/jdn-datepicker-directive/jdndatepicker.directive.ts @@ -0,0 +1,48 @@ +import { Directive, Inject, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; +import { JDNConvertibleCalendar } from 'jdnconvertiblecalendar'; +import { ACTIVE_CALENDAR, JDNConvertibleCalendarDateAdapter } from 'jdnconvertiblecalendardateadapter'; +import { BehaviorSubject } from 'rxjs'; + +export function makeCalendarToken() { + return new BehaviorSubject('Gregorian'); +} + +@Directive({ + selector: 'app-jdn-datepicker', + providers: [ + { provide: DateAdapter, useClass: JDNConvertibleCalendarDateAdapter, deps: [MAT_DATE_LOCALE, ACTIVE_CALENDAR] }, + { provide: ACTIVE_CALENDAR, useFactory: makeCalendarToken } + ] +}) +export class JDNDatepickerDirective implements OnChanges, OnDestroy { + + private _activeCalendar: 'Gregorian' | 'Julian' | 'Islamic'; + + @Input() + set activeCalendar(value: 'Gregorian' | 'Julian' | 'Islamic' | null) { + if (value !== null && value !== undefined) { + this._activeCalendar = value; + } else { + this._activeCalendar = 'Gregorian'; + } + } + + get activeCalendar() { + return this._activeCalendar; + } + + constructor( + @Inject(ACTIVE_CALENDAR) private _activeCalendarToken, + private _adapter: DateAdapter) { + } + + ngOnChanges(changes: SimpleChanges): void { + this._activeCalendarToken.next(this.activeCalendar); + } + + ngOnDestroy(): void { + this._activeCalendarToken.complete(); + } + +} diff --git a/src/app/workspace/resource/values/link-value/link-value.component.html b/src/app/workspace/resource/values/link-value/link-value.component.html new file mode 100644 index 0000000000..2511ef23ce --- /dev/null +++ b/src/app/workspace/resource/values/link-value/link-value.component.html @@ -0,0 +1,39 @@ + + + {{valueFormControl.value?.label}} + + {{commentFormControl.value}} + + + + + + + + {{res?.label}} + + + + New value must be different than the current value. + + + New value must be a valid resource type. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/link-value/link-value.component.scss b/src/app/workspace/resource/values/link-value/link-value.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/workspace/resource/values/link-value/link-value.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/link-value/link-value.component.spec.ts b/src/app/workspace/resource/values/link-value/link-value.component.spec.ts new file mode 100644 index 0000000000..6e4a103f68 --- /dev/null +++ b/src/app/workspace/resource/values/link-value/link-value.component.spec.ts @@ -0,0 +1,643 @@ +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + CreateLinkValue, + MockResource, + ReadLinkValue, + ReadResource, + ReadResourceSequence, + SearchEndpointV2, + UpdateLinkValue +} from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { LinkValueComponent } from './link-value.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: LinkValueComponent; + + displayInputVal: ReadLinkValue; + parentResource: ReadResource; + propIri: string; + mode: 'read' | 'update' | 'create' | 'search'; + linkValueClicked: ReadLinkValue; + linkValueHovered: ReadLinkValue; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadLinkValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue', ReadLinkValue)[0]; + + this.displayInputVal = inputVal; + this.propIri = this.displayInputVal.property; + this.parentResource = res; + this.mode = 'read'; + }); + + } + + refResClicked(readLinkValue: ReadLinkValue) { + this.linkValueClicked = readLinkValue; + } + + refResHovered(readLinkValue: ReadLinkValue) { + this.linkValueHovered = readLinkValue; + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: LinkValueComponent; + parentResource: ReadResource; + propIri: string; + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + this.propIri = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue'; + this.parentResource = res; + this.mode = 'create'; + }); + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueNoValueRequiredComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: LinkValueComponent; + parentResource: ReadResource; + propIri: string; + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + MockResource.getTestThing().subscribe(res => { + this.propIri = 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThingValue'; + this.parentResource = res; + this.mode = 'create'; + }); + } +} + +describe('LinkValueComponent', () => { + + beforeEach(waitForAsync(() => { + const valuesSpyObj = { + v2: { + search: jasmine.createSpyObj('search', ['doSearchByLabel']), + } + }; + TestBed.configureTestingModule({ + declarations: [ + LinkValueComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent, + TestHostCreateValueNoValueRequiredComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + MatAutocompleteModule, + BrowserAnimationsModule + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: valuesSpyObj + } + ] + }) + .compileComponents(); + })); + + describe('display and edit a link value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(LinkValueComponent)); + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + }); + + it('should display an existing value', fakeAsync(() => { + + expect(testHostComponent.inputValueComponent.displayValue.linkedResourceIri).toEqual('http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ'); + expect(testHostComponent.inputValueComponent.displayValue.linkedResource.label).toEqual('Sierra'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(testHostComponent.inputValueComponent.valueFormControl.value instanceof ReadResource).toBe(true); + expect(testHostComponent.inputValueComponent.valueFormControl.value.label).toEqual('Sierra'); + + // setValue has to be called, otherwise the native input field does not get the label via the displayWith function + const res = testHostComponent.inputValueComponent.valueFormControl.value; + testHostComponent.inputValueComponent.valueFormControl.setValue(res); + + // https://github.com/angular/components/blob/29e74eb9431ba01d951ee33df554f465609b59fa/src/material/autocomplete/autocomplete.spec.ts#L2577-L2580 + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('Sierra'); + + const anchorDebugElement = valueReadModeDebugElement.query(By.css('a')); + expect(anchorDebugElement.nativeElement).toBeDefined(); + + })); + + it('should make a link value editable', fakeAsync(() => { + + testHostComponent.mode = 'update'; + testHostFixture.detectChanges(); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const update = new ReadResource(); + update.id = 'newId'; + update.label = 'new target'; + + testHostComponent.inputValueComponent.valueFormControl.setValue(update); + + // https://github.com/angular/components/blob/29e74eb9431ba01d951ee33df554f465609b59fa/src/material/autocomplete/autocomplete.spec.ts#L2577-L2580 + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(valueInputNativeElement.value).toEqual('new target'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateLinkValue).toBeTruthy(); + + expect((updatedValue as UpdateLinkValue).linkedResourceIri).toEqual('newId'); + + })); + + it('should compare the existing version of a link to the user input', () => { + + // sierra, http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ + const initValue: ReadResource = testHostComponent.inputValueComponent.getInitValue(); + + const readRes1 = new ReadResource(); + readRes1.id = 'http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ'; + readRes1.label = 'Sierra'; + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, readRes1 + ) + ).toBeTruthy(); + + const readRes2 = new ReadResource(); + readRes2.id = 'newId'; + readRes2.label = 'new target'; + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, readRes2 + ) + ).toBeFalsy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, null + ) + ).toBeFalsy(); + + expect( + testHostComponent.inputValueComponent.standardValueComparisonFunc( + initValue, 'searchlabel' + ) + ).toBeFalsy(); + + }); + + it('should search for resources by their label', () => { + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + (valuesSpy.v2.search as jasmine.SpyObj).doSearchByLabel.and.callFake( + () => { + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + return of(new ReadResourceSequence([res])); + } + ); + + // simulate user searching for label 'thing' + testHostComponent.inputValueComponent.valueFormControl.setValue('thing'); + + expect(valuesSpy.v2.search.doSearchByLabel).toHaveBeenCalledWith('thing', 0, { limitToResourceClass: 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing' }); + expect(testHostComponent.inputValueComponent.resources.length).toEqual(1); + expect(testHostComponent.inputValueComponent.resources[0].id).toEqual('http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'); + }); + + it('should not return an invalid update value (string)', () => { + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valuesSpy.v2.search as jasmine.SpyObj).doSearchByLabel.and.callFake( + () => { + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + return of(new ReadResourceSequence([res])); + } + ); + + testHostComponent.mode = 'update'; + testHostFixture.detectChanges(); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + testHostComponent.inputValueComponent.valueFormControl.setValue('my string'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should not return an invalid update value (no value)', () => { + + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valuesSpy.v2.search as jasmine.SpyObj).doSearchByLabel.and.callFake( + () => { + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + return of(new ReadResourceSequence([res])); + } + ); + + testHostComponent.mode = 'update'; + testHostFixture.detectChanges(); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + testHostComponent.inputValueComponent.valueFormControl.setValue(null); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should validate an existing value with an added comment', () => { + + testHostComponent.mode = 'update'; + testHostFixture.detectChanges(); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + commentInputNativeElement.value = 'this is a comment'; + commentInputNativeElement.dispatchEvent(new Event('input')); + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + expect(updatedValue instanceof UpdateLinkValue).toBeTruthy(); + expect((updatedValue as UpdateLinkValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should restore the initially displayed value', fakeAsync(() => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + // simulate user input + const update = new ReadResource(); + update.id = 'newId'; + update.label = 'new target'; + + testHostComponent.inputValueComponent.valueFormControl.setValue(update); + + // https://github.com/angular/components/blob/29e74eb9431ba01d951ee33df554f465609b59fa/src/material/autocomplete/autocomplete.spec.ts#L2577-L2580 + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(valueInputNativeElement.value).toEqual('new target'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(valueInputNativeElement.value).toEqual('Sierra'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + })); + + it('should set a new display value', fakeAsync(() => { + + // setValue has to be called, otherwise the native input field does not get the label via the displayWith function + const res = testHostComponent.inputValueComponent.valueFormControl.value; + testHostComponent.inputValueComponent.valueFormControl.setValue(res); + + // https://github.com/angular/components/blob/29e74eb9431ba01d951ee33df554f465609b59fa/src/material/autocomplete/autocomplete.spec.ts#L2577-L2580 + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value.label).toEqual('Sierra'); + + const linkedRes = new ReadResource(); + linkedRes.id = 'newId'; + linkedRes.label = 'new target'; + + const newLink = new ReadLinkValue(); + newLink.id = 'updatedId'; + newLink.linkedResourceIri = 'newId'; + newLink.linkedResource = linkedRes; + + testHostComponent.displayInputVal = newLink; + + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('new target'); + + })); + + it('should emit the displayValue when the value is clicked on', () => { + + expect(testHostComponent.linkValueClicked).toBeUndefined(); + + valueReadModeNativeElement.click(); + + expect(testHostComponent.linkValueClicked).toEqual(testHostComponent.displayInputVal); + }); + + it('should emit the displayValue when the value is hovered', () => { + + expect(testHostComponent.linkValueHovered).toBeUndefined(); + + valueReadModeNativeElement.dispatchEvent( + new MouseEvent('mouseover', { + view: window, + bubbles: true, + cancelable: true + }) + ); + + expect(testHostComponent.linkValueHovered).toEqual(testHostComponent.displayInputVal); + }); + + }); + + describe('create a new link value', () => { + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(LinkValueComponent)); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + }); + + it('should search a new value', () => { + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valuesSpy.v2.search as jasmine.SpyObj).doSearchByLabel.and.callFake( + () => { + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + return of(new ReadResourceSequence([res])); + } + ); + + testHostComponent.inputValueComponent.searchByLabel('thing'); + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + expect(valuesSpy.v2.search.doSearchByLabel).toHaveBeenCalledWith('thing', 0, { limitToResourceClass: 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing' }); + expect(testHostComponent.inputValueComponent.resources.length).toEqual(1); + }); + + it('should create a value', () => { + + // simulate user input + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + testHostComponent.inputValueComponent.valueFormControl.setValue(res); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value instanceof ReadResource).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + expect(newValue instanceof CreateLinkValue).toBeTruthy(); + expect((newValue as CreateLinkValue).linkedResourceIri).toEqual('http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'); + }); + + it('should only create a new value if input is a resource', () => { + // simulate user input + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valuesSpy.v2.search as jasmine.SpyObj).doSearchByLabel.and.callFake( + () => { + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + return of(new ReadResourceSequence([res])); + } + ); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const label = 'thing'; + testHostComponent.inputValueComponent.valueFormControl.setValue(label); + + expect(testHostComponent.inputValueComponent.valueFormControl.value instanceof ReadResource).toBeFalsy(); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateLinkValue).toBeFalsy(); + }); + + it('should reset form after cancellation', fakeAsync(() => { + + // simulate user input + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + testHostComponent.inputValueComponent.valueFormControl.setValue(res); + + // https://github.com/angular/components/blob/29e74eb9431ba01d951ee33df554f465609b59fa/src/material/autocomplete/autocomplete.spec.ts#L2577-L2580 + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(valueInputNativeElement.value).toEqual('hidden thing'); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + testHostFixture.detectChanges(); + tick(); + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value).toEqual(null); + + expect(valueInputNativeElement.value).toEqual(''); + expect(commentInputNativeElement.value).toEqual(''); + + })); + }); + + describe('create a new link value no value required', () => { + let testHostComponent: TestHostCreateValueNoValueRequiredComponent; + let testHostFixture: ComponentFixture; + + beforeEach(() => { + + testHostFixture = TestBed.createComponent(TestHostCreateValueNoValueRequiredComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create a value', () => { + + // simulate user input + const res = new ReadResource(); + res.id = 'http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'; + res.label = 'hidden thing'; + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.valueFormControl.setValue(res); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value instanceof ReadResource).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + expect(newValue instanceof CreateLinkValue).toBeTruthy(); + expect((newValue as CreateLinkValue).linkedResourceIri).toEqual('http://rdfh.ch/0001/IwMDbs0KQsaxSRUTl2cAIQ'); + }); + }); +}); diff --git a/src/app/workspace/resource/values/link-value/link-value.component.ts b/src/app/workspace/resource/values/link-value/link-value.component.ts new file mode 100644 index 0000000000..f9b6ae7963 --- /dev/null +++ b/src/app/workspace/resource/values/link-value/link-value.component.ts @@ -0,0 +1,206 @@ +import { + Component, + EventEmitter, + Inject, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { AbstractControl, FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { + CreateLinkValue, + KnoraApiConnection, + ReadLinkValue, + ReadResource, + ReadResourceSequence, + UpdateLinkValue +} from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +export function resourceValidator(control: AbstractControl) { + const invalid = !(control.value === null || control.value === '' || control.value instanceof ReadResource); + return invalid ? { invalidType: { value: control.value } } : null; +} + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-link-value', + templateUrl: './link-value.component.html', + styleUrls: ['./link-value.component.scss'] +}) + +export class LinkValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + @Input() displayValue?: ReadLinkValue; + @Input() parentResource: ReadResource; + @Input() propIri: string; + + @Output() referredResourceClicked: EventEmitter = new EventEmitter(); + + @Output() referredResourceHovered: EventEmitter = new EventEmitter(); + + resources: ReadResource[] = []; + restrictToResourceClass: string; + valueFormControl: FormControl; + commentFormControl: FormControl; + form: FormGroup; + + valueChangesSubscription: Subscription; + labelChangesSubscription: Subscription; + // label cannot contain logical operations of lucene index + customValidators = [resourceValidator]; + + constructor( + @Inject(FormBuilder) private _fb: FormBuilder, + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection) { + super(); + } + + /** + * displays a selected resource using its label. + * + * @param resource the resource to be displayed (or no selection yet). + */ + displayResource(resource: ReadResource | null): string { + // null is the initial value (no selection yet) + if (resource instanceof ReadResource) { + return resource.label; + } + } + + /** + * search for resources whose labels contain the given search term, restricting to to the given properties object constraint. + * this is to be used for update and new linked resources + * + * @param searchTerm label to be searched + */ + searchByLabel(searchTerm: string) { + // at least 3 characters are required + if (typeof searchTerm === 'string' && searchTerm.length >= 3) { + this._dspApiConnection.v2.search.doSearchByLabel( + searchTerm, 0, { limitToResourceClass: this.restrictToResourceClass }).subscribe( + (response: ReadResourceSequence) => { + this.resources = response.resources; + }); + } else { + this.resources = []; + } + } + + // show the label of the linked resource + getInitValue(): ReadResource | null { + if (this.displayValue !== undefined) { + return this.displayValue.linkedResource; + } else { + return null; + } + } + + standardValueComparisonFunc(initValue: ReadResource, curValue: ReadResource | string | null): boolean { + return (curValue instanceof ReadResource) && initValue.id === curValue.id; + } + + ngOnInit() { + const linkType = this.parentResource.getLinkPropertyIriFromLinkValuePropertyIri(this.propIri); + this.restrictToResourceClass = this.parentResource.entityInfo.properties[linkType].objectType; + + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.labelChangesSubscription = this.valueFormControl.valueChanges.subscribe(data => { + this.searchByLabel(data); + }); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + if (this.labelChangesSubscription !== undefined) { + this.labelChangesSubscription.unsubscribe(); + } + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateLinkValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + const newLinkValue = new CreateLinkValue(); + newLinkValue.linkedResourceIri = this.valueFormControl.value.id; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newLinkValue.valueHasComment = this.commentFormControl.value; + } + + return newLinkValue; + } + + getUpdatedValue(): UpdateLinkValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedLinkValue = new UpdateLinkValue(); + + updatedLinkValue.id = this.displayValue.id; + + updatedLinkValue.linkedResourceIri = this.valueFormControl.value.id; + + // add the submitted comment to updatedLinkValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedLinkValue.valueHasComment = this.commentFormControl.value; + } + + return updatedLinkValue; + } + + /** + * emits the displayValue on click. + */ + refResClicked() { + this.referredResourceClicked.emit(this.displayValue); + } + + /** + * emits the displayValue on hover. + */ + refResHovered() { + this.referredResourceHovered.emit(this.displayValue); + } +} diff --git a/src/app/workspace/resource/values/list-value/list-value.component.html b/src/app/workspace/resource/values/list-value/list-value.component.html new file mode 100644 index 0000000000..26dde2a617 --- /dev/null +++ b/src/app/workspace/resource/values/list-value/list-value.component.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + New value must be different than the current value. + + + This value already exists for this property. Duplicate values are not allowed. + + + + {{valueFormControl.value}} + {{commentFormControl.value}} + + + + + + + + + + + diff --git a/src/app/workspace/resource/values/list-value/list-value.component.scss b/src/app/workspace/resource/values/list-value/list-value.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/workspace/resource/values/list-value/list-value.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/list-value/list-value.component.spec.ts b/src/app/workspace/resource/values/list-value/list-value.component.spec.ts new file mode 100644 index 0000000000..413a21975a --- /dev/null +++ b/src/app/workspace/resource/values/list-value/list-value.component.spec.ts @@ -0,0 +1,291 @@ +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + CreateListValue, + ListNodeV2, + ListsEndpointV2, + MockResource, + ReadListValue, + ResourcePropertyDefinition, + UpdateListValue +} from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { ListValueComponent } from './list-value.component'; +import { SublistValueComponent } from './subList-value/sublist-value.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: ListValueComponent; + + displayInputVal: ReadListValue; + propertyDef: ResourcePropertyDefinition; + + mode: 'read' | 'update' | 'create' | 'search'; + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadListValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasListItem', ReadListValue)[0]; + this.displayInputVal = inputVal; + this.mode = 'read'; + }); + this.propertyDef = new ResourcePropertyDefinition(); + this.propertyDef.guiAttributes.push('hlist='); + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: ListValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + propertyDef: ResourcePropertyDefinition; + + ngOnInit() { + this.mode = 'create'; + this.propertyDef = new ResourcePropertyDefinition(); + this.propertyDef.guiAttributes.push('hlist='); + } +} + +describe('ListValueComponent', () => { + + beforeEach(waitForAsync(() => { + const valuesSpyObj = { + v2: { + values: jasmine.createSpyObj('values', ['updateValue', 'getValue', 'setValue']), + list: jasmine.createSpyObj('list', ['getList']) + } + }; + TestBed.configureTestingModule({ + declarations: [ + ListValueComponent, + SublistValueComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + MatMenuModule, + MatSnackBarModule, + BrowserAnimationsModule + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: valuesSpyObj + } + ] + }) + .compileComponents(); + })); + + describe('display and edit a list value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(ListValueComponent)); + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.listNode).toMatch('http://rdfh.ch/lists/0001/treeList01'); + expect(testHostComponent.inputValueComponent.displayValue.listNodeLabel).toMatch('Tree list node 01'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('Tree list node 01'); + + }); + + it('should make list value editable as button', () => { + const valuesSpy = TestBed.inject(DspApiConnectionToken); + (valuesSpy.v2.list as jasmine.SpyObj).getList.and.callFake( + (rootNodeIri: string) => { + const res = new ListNodeV2(); + res.id = 'http://rdfh.ch/lists/0001/treeList'; + res.label = 'Listenwurzel'; + res.isRootNode = true; + return of(res); + } + ); + testHostComponent.mode = 'update'; + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(valuesSpy.v2.list.getList).toHaveBeenCalledTimes(1); + expect(valuesSpy.v2.list.getList).toHaveBeenCalledWith('http://rdfh.ch/lists/0001/treeList'); + expect(testHostComponent.inputValueComponent.listRootNode.children.length).toEqual(1); + + const openListButtonDe = valueComponentDe.query(By.css('button')); + + expect(openListButtonDe.nativeElement.textContent.trim()).toBe('Tree list node 01'); + + expect(testHostComponent.inputValueComponent.selectedNode.label).toBe('Tree list node 01'); + + const openListButtonEle: HTMLElement = openListButtonDe.nativeElement; + openListButtonEle.click(); + testHostFixture.detectChanges(); + + testHostComponent.inputValueComponent.menuTrigger.openMenu(); + }); + + it('should validate an existing value with an added comment', () => { + const valuesSpy = TestBed.inject(DspApiConnectionToken); + (valuesSpy.v2.list as jasmine.SpyObj).getList.and.callFake( + (rootNodeIri) => { + const res = new ListNodeV2(); + res.id = 'http://rdfh.ch/lists/0001/treeList'; + res.label = 'Listenwurzel'; + res.isRootNode = true; + return of(res); + } + ); + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + commentInputNativeElement.value = 'this is a comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateListValue).toBeTruthy(); + + expect((updatedValue as UpdateListValue).valueHasComment).toEqual('this is a comment'); + + }); + }); + describe('create a list value', () => { + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + beforeEach(() => { + const valuesSpy = TestBed.inject(DspApiConnectionToken); + + (valuesSpy.v2.list as jasmine.SpyObj).getList.and.callFake( + (rootNodeIri: string) => { + const res = new ListNodeV2(); + res.id = 'http://rdfh.ch/lists/0001/treeList'; + res.label = 'Listenwurzel'; + res.isRootNode = true; + return of(res); + } + ); + + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostComponent.mode = 'create'; + + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + expect(valuesSpy.v2.list.getList).toHaveBeenCalledTimes(1); + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(ListValueComponent)); + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + + }); + it('should create a value', () => { + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + testHostComponent.inputValueComponent.valueFormControl.setValue('http://rdfh.ch/lists/0001/treeList01'); + testHostFixture.detectChanges(); + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateListValue).toBeTruthy(); + + expect((newValue as CreateListValue).listNode).toEqual('http://rdfh.ch/lists/0001/treeList01'); + }); + + it('should reset form after cancellation', () => { + // simulate user input + const newList = 'http://rdfh.ch/lists/0001/treeList01'; + + testHostComponent.inputValueComponent.valueFormControl.setValue(newList); + + testHostFixture.detectChanges(); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value).toEqual(null); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + }); +}); diff --git a/src/app/workspace/resource/values/list-value/list-value.component.ts b/src/app/workspace/resource/values/list-value/list-value.component.ts new file mode 100644 index 0000000000..8fdd5c9888 --- /dev/null +++ b/src/app/workspace/resource/values/list-value/list-value.component.ts @@ -0,0 +1,167 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { + ApiResponseError, + CreateListValue, + KnoraApiConnection, + ListNodeV2, + ReadListValue, + ResourcePropertyDefinition, + UpdateListValue +} from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; +import { NotificationService } from 'src/app/main/services/notification.service'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-list-value', + templateUrl: './list-value.component.html', + styleUrls: ['./list-value.component.scss'] +}) +export class ListValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @Input() displayValue?: ReadListValue; + @Input() propertyDef: ResourcePropertyDefinition; + @ViewChild(MatMenuTrigger) menuTrigger: MatMenuTrigger; + + valueFormControl: FormControl; + commentFormControl: FormControl; + listRootNode: ListNodeV2; + // active node + selectedNode: ListNodeV2; + + form: FormGroup; + + valueChangesSubscription: Subscription; + + customValidators = []; + + constructor( + @Inject(FormBuilder) private _fb: FormBuilder, + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _notification: NotificationService + ) { + super(); + } + + getInitValue(): string | null { + if (this.displayValue !== undefined) { + return this.displayValue.listNode; + } else { + return null; + } + } + // override the resetFormControl() from the base component to deal with appending root nodes. + resetFormControl(): void { + super.resetFormControl(); + if (this.mode === 'update') { + this.selectedNode = new ListNodeV2(); + this.selectedNode.label = this.displayValue.listNodeLabel; + } else { + this.selectedNode = null; + } + if (this.valueFormControl !== undefined) { + if (this.mode !== 'read') { + this.listRootNode = new ListNodeV2(); + const rootNodeIris = this.propertyDef.guiAttributes; + for (const rootNodeIri of rootNodeIris) { + // get rid of the "hlist" + const trimmedRootNodeIRI = rootNodeIri.substr(7, rootNodeIri.length - (1 + 7)); + this._dspApiConnection.v2.list.getList(trimmedRootNodeIRI).subscribe( + (response2: ListNodeV2) => { + this.listRootNode.children.push(response2); + }, (error: ApiResponseError) => { + this._notification.openSnackBar(error); + }); + } + } else { + this.valueFormControl.setValue(this.displayValue.listNodeLabel); + } + } + } + + ngOnInit() { + + this.valueFormControl = new FormControl(null); + this.commentFormControl = new FormControl(null); + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateListValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newListValue = new CreateListValue(); + + + newListValue.listNode = this.valueFormControl.value; + + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newListValue.valueHasComment = this.commentFormControl.value; + } + + return newListValue; + } + + getUpdatedValue(): UpdateListValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedListValue = new UpdateListValue(); + + updatedListValue.id = this.displayValue.id; + if (this.selectedNode) { + updatedListValue.listNode = this.selectedNode.id; + } else { + updatedListValue.listNode = this.displayValue.listNode; + } + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedListValue.valueHasComment = this.commentFormControl.value; + } + + return updatedListValue; + } + + getSelectedNode(item: ListNodeV2) { + this.menuTrigger.closeMenu(); + this.valueFormControl.markAsDirty(); + this.selectedNode = item; + this.valueFormControl.setValue(item.id); + } + +} diff --git a/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.html b/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.html new file mode 100644 index 0000000000..6f3892d5e9 --- /dev/null +++ b/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.scss b/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.spec.ts b/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.spec.ts new file mode 100644 index 0000000000..ad2a775283 --- /dev/null +++ b/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.spec.ts @@ -0,0 +1,166 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { SublistValueComponent } from './sublist-value.component'; + +export class ListNodeV2 { + + readonly children: ListNodeV2[]; + + readonly isRootNode: boolean; + + constructor(readonly id: string, readonly label: string, readonly position?: number, readonly hasRootNode?: string) { + + // if hasRootNode is not given, this node is the root node. + this.isRootNode = (hasRootNode === undefined); + + this.children = []; + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + selector: 'app-host-component', + template: ` + + + + + + + + + + + + + + + + + + + ` +}) +class TestHostComponent implements OnInit { + @ViewChild(MatMenuTrigger) menuTrigger: MatMenuTrigger; + + @ViewChild('childMenu', { static: true }) public childMenu: MatMenuTrigger; + + testList; + + selectedNode: ListNodeV2; + + children: ListNodeV2[]; + + constructor() { + } + + getSelectedNode(item: ListNodeV2) { + this.menuTrigger.closeMenu(); + this.selectedNode = item; + } + + ngOnInit() { + + const testList = new ListNodeV2( + 'http://rdfh.ch/lists/0001/treeList', + 'tree list' + ); + + const testListChild1 = new ListNodeV2( + 'http://rdfh.ch/lists/0001/treeList/01', + 'tree list 01', + 1, + 'http://rdfh.ch/lists/0001/treeList' + ); + + const testListChild2 = new ListNodeV2( + 'http://rdfh.ch/lists/0001/treeList/02', + 'tree list 02', + 2, + 'http://rdfh.ch/lists/0001/treeList' + ); + + testListChild1.children.push(testListChild2); + + testList.children.push(testListChild1); + + this.testList = testList; + } +} + +describe('SublistValueComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + SublistValueComponent, + TestHostComponent + ], + imports: [ + MatMenuModule, + BrowserAnimationsModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create', () => { + // access the test host component's child + expect(testHostComponent.childMenu).toBeTruthy(); + }); + + it('should open the menu for child nodes', () => { + + const ele: DebugElement = testHostFixture.debugElement; + + ele.nativeElement.click(); + + testHostFixture.detectChanges(); + + const openListButtonDe = ele.query(By.css('button')); + + const openListButtonEle: HTMLElement = openListButtonDe.nativeElement; + + openListButtonEle.click(); + + testHostFixture.detectChanges(); + + const listNodeEle = ele.query(By.css('.mat-menu-content button')); + + // select root node + listNodeEle.nativeElement.click(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.selectedNode.id).toEqual('http://rdfh.ch/lists/0001/treeList'); + + }); +}); diff --git a/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.ts b/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.ts new file mode 100644 index 0000000000..6c540ee2d1 --- /dev/null +++ b/src/app/workspace/resource/values/list-value/subList-value/sublist-value.component.ts @@ -0,0 +1,23 @@ +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { MatMenu } from '@angular/material/menu'; +import { ListNodeV2 } from '@dasch-swiss/dsp-js'; + +@Component({ + selector: 'app-sublist-value', + templateUrl: './sublist-value.component.html', + styleUrls: ['./sublist-value.component.scss'] +}) +export class SublistValueComponent { + + @Input() children: ListNodeV2[]; + + @Output() selectedNode: EventEmitter = new EventEmitter(); + + @ViewChild('childMenu', { static: true }) public childMenu: MatMenu; + constructor() { + } + setValue(item: ListNodeV2) { + this.selectedNode.emit(item); + } + +} diff --git a/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.html b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.html new file mode 100644 index 0000000000..490d96a8d2 --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.html @@ -0,0 +1,4 @@ +
+
+ {{comment}} +
diff --git a/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.scss b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.scss new file mode 100644 index 0000000000..17a6109206 --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.scss @@ -0,0 +1 @@ +@import "../../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.spec.ts b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.spec.ts new file mode 100644 index 0000000000..a6063d9680 --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.spec.ts @@ -0,0 +1,147 @@ +import { Component, OnInit, Pipe, PipeTransform, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ReadTextValueAsHtml } from '@dasch-swiss/dsp-js'; +import { TextValueAsHtmlComponent } from './text-value-as-html.component'; + +/** + * mocked linkify pipe from main/pipes. + */ +@Pipe({ name: 'appLinkify' }) +class MockPipe implements PipeTransform { + transform(value: string): string { + // do stuff here, if you want + return value; + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TextValueAsHtmlComponent; + + displayInputVal: ReadTextValueAsHtml; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'read'; + } +} + +describe('TextValueAsHtmlComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + MockPipe, + TestHostDisplayValueComponent, + TextValueAsHtmlComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + providers: [] + }) + .compileComponents(); + })); + + describe('display text value with markup', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let hostCompDe; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + + hostCompDe = testHostFixture.debugElement; + + }); + + it('should display an existing value', () => { + + const inputVal: ReadTextValueAsHtml = new ReadTextValueAsHtml(); + + inputVal.hasPermissions = 'CR knora-admin:Creator|M knora-admin:ProjectMember|V knora-admin:KnownUser|RV knora-admin:UnknownUser'; + inputVal.userHasPermission = 'CR'; + inputVal.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + inputVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/TEST_ID'; + inputVal.html = + '

This is a very simple HTML document with a link

'; + + testHostComponent.displayInputVal = inputVal; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const valueComponentDe = hostCompDe.query(By.directive(TextValueAsHtmlComponent)); + + const valueParagraph = valueComponentDe.query(By.css('div.value')); + const valueParagraphNativeElement = valueParagraph.nativeElement; + + expect(testHostComponent.inputValueComponent.displayValue.html) + .toEqual('

This is a very simple HTML document with a link

'); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueParagraphNativeElement.innerHTML) + .toEqual('

This is a very simple HTML document with a link

'); + + const commentSpan = valueComponentDe.query(By.css('span.comment')); + + expect(commentSpan).toBe(null); + + }); + + it('should display an existing value with a comment', () => { + + const inputVal: ReadTextValueAsHtml = new ReadTextValueAsHtml(); + + inputVal.type = 'http://api.knora.org/ontology/knora-api/v2#TextValue'; + inputVal.id = 'http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw/values/TEST_ID'; + inputVal.html = + '

This is a very simple HTML document with a link and a comment

'; + inputVal.valueHasComment = 'very interesting'; + + testHostComponent.displayInputVal = inputVal; + + testHostFixture.detectChanges(); + + const valueComponentDe = hostCompDe.query(By.directive(TextValueAsHtmlComponent)); + + const valueParagraph = valueComponentDe.query(By.css('div.value')); + const valueParagraphNativeElement = valueParagraph.nativeElement; + + const commentSpan = valueComponentDe.query(By.css('span.comment')); + const commentSpanNativeElement = commentSpan.nativeElement; + + expect(testHostComponent.inputValueComponent.displayValue.html) + .toEqual('

This is a very simple HTML document with a link and a comment

'); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueParagraphNativeElement.innerHTML) + .toEqual('

This is a very simple HTML document with a link and a comment

'); + + expect(commentSpanNativeElement.innerText).toEqual('very interesting'); + + }); + }); +}); diff --git a/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.ts b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.ts new file mode 100644 index 0000000000..93e177ee1e --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-html/text-value-as-html.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit, Inject, Input, ElementRef } from '@angular/core'; +import { ReadTextValueAsHtml } from '@dasch-swiss/dsp-js'; +import { FormControl, FormGroup, FormBuilder } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +@Component({ + selector: 'app-text-value-as-html', + templateUrl: './text-value-as-html.component.html', + styleUrls: ['./text-value-as-html.component.scss'] +}) +export class TextValueAsHtmlComponent extends BaseValueDirective implements OnInit { + + @Input() displayValue?: ReadTextValueAsHtml; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + + customValidators = []; + + commentLabel = 'Comment'; + htmlFromKnora: string; + comment: string; + + constructor() { + super(); + } + + ngOnInit() { + this.htmlFromKnora = this.getInitValue(); + this.comment = this.getInitComment(); + } + + getInitValue() { + if (this.displayValue !== undefined) { + return this.displayValue.html; + } else { + return null; + } + } + + // readonly + getNewValue(): false { + return false; + } + + // readonly + getUpdatedValue(): false { + return false; + } + +} diff --git a/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.html b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.html new file mode 100644 index 0000000000..efb8c7100e --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.html @@ -0,0 +1,33 @@ + + + {{commentFormControl.value}} + + + + + + + New value must be different than the current value. + + + A text value is required. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.scss b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.scss new file mode 100644 index 0000000000..17a6109206 --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.scss @@ -0,0 +1 @@ +@import "../../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.spec.ts b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.spec.ts new file mode 100644 index 0000000000..3cc8df7b97 --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.spec.ts @@ -0,0 +1,581 @@ +import { Component, DebugElement, OnInit, Pipe, PipeTransform, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CreateTextValueAsString, MockResource, ReadTextValueAsString, UpdateTextValueAsString } from '@dasch-swiss/dsp-js'; +import { TextValueAsStringComponent } from './text-value-as-string.component'; + +/** + * mocked linkify pipe from main/pipes. + */ +@Pipe({ name: 'appLinkify' }) +class MockPipe implements PipeTransform { + transform(value: string): string { + // do stuff here, if you want + return value; + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TextValueAsStringComponent; + + displayInputVal: ReadTextValueAsString; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadTextValueAsString = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasText', ReadTextValueAsString)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueCommentComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TextValueAsStringComponent; + + displayInputVal: ReadTextValueAsString; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadTextValueAsString = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasText', ReadTextValueAsString)[0]; + + inputVal.valueHasComment = 'this is a comment'; + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TextValueAsStringComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +describe('TextValueAsStringComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + MockPipe, + TestHostDisplayValueComponent, + TestHostDisplayValueCommentComponent, + TextValueAsStringComponent, + TestHostCreateValueComponent], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + providers: [] + }) + .compileComponents(); + })); + + describe('display and edit a text value without markup', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + valueComponentDe = hostCompDe.query(By.directive(TextValueAsStringComponent)); + + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.text).toEqual('test'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('test'); + + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('test'); + + valueInputNativeElement.value = 'updated text'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateTextValueAsString).toBeTruthy(); + + expect((updatedValue as UpdateTextValueAsString).text).toEqual('updated text'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('test'); + + valueInputNativeElement.value = ''; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('test'); + + valueInputNativeElement.value = 'updated text'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(valueInputNativeElement.value).toEqual('test'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newStr = new ReadTextValueAsString(); + + newStr.text = 'my updated text'; + newStr.id = 'updatedId'; + + testHostComponent.displayInputVal = newStr; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('my updated text'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + + }); + + describe('display and edit a text value and comment without markup', () => { + + let testHostComponent: TestHostDisplayValueCommentComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + + let commentReadModeDebugElement: DebugElement; + let commentReadModeNativeElement; + + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueCommentComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(TextValueAsStringComponent)); + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.text).toEqual('test'); + + expect(testHostComponent.inputValueComponent.displayValue.valueHasComment).toEqual('this is a comment'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(valueReadModeNativeElement.innerText).toEqual('test'); + + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('test'); + + expect(commentInputNativeElement.value).toEqual('this is a comment'); + + valueInputNativeElement.value = 'updated text'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + commentInputNativeElement.value = 'this is an updated comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateTextValueAsString).toBeTruthy(); + + expect((updatedValue as UpdateTextValueAsString).text).toEqual('updated text'); + + expect((updatedValue as UpdateTextValueAsString).valueHasComment).toEqual('this is an updated comment'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('test'); + + expect(commentInputNativeElement.value).toEqual('this is a comment'); + + valueInputNativeElement.value = ''; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + commentInputNativeElement.value = 'this is an updated comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('test'); + + valueInputNativeElement.value = 'updated text'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + commentInputNativeElement.value = 'this is an updated comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(valueInputNativeElement.value).toEqual('test'); + + expect(commentInputNativeElement.value).toEqual('this is a comment'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newStr = new ReadTextValueAsString(); + + newStr.text = 'my updated text'; + newStr.valueHasComment = 'my updated comment'; + newStr.id = 'updatedId'; + + testHostComponent.displayInputVal = newStr; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('my updated text'); + + expect(testHostComponent.inputValueComponent.displayValue.valueHasComment).toEqual('my updated comment'); + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + testHostComponent.mode = 'read'; + testHostComponent.inputValueComponent.shouldShowComment = true; + + testHostFixture.detectChanges(); + + commentReadModeDebugElement = valueComponentDe.query(By.css('.rm-comment')); + + commentReadModeNativeElement = commentReadModeDebugElement.nativeElement; + + expect(commentReadModeNativeElement.innerText).toEqual('my updated comment'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + }); + + describe('create a text value without markup', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(TextValueAsStringComponent)); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.displayValue).toEqual(undefined); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + expect(valueInputNativeElement.value).toEqual(''); + expect(commentInputNativeElement.value).toEqual(''); + }); + + it('should create a value', () => { + valueInputNativeElement.value = 'created text'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateTextValueAsString).toBeTruthy(); + + expect((newValue as CreateTextValueAsString).text).toEqual('created text'); + }); + + it('should reset form after cancellation', () => { + valueInputNativeElement.value = 'created text'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual(''); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + + // *** begin testing comments *** + + // value: yes comment:yes + it('should allow a comment if a value exists', () => { + valueInputNativeElement.value = 'test'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + commentInputNativeElement.value = 'comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + }); + + // value: yes comment:no + it('should allow no comment if a value exists', () => { + valueInputNativeElement.value = 'test'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + }); + + // value: no comment:yes + it('should not allow a comment if a value does not exist', () => { + commentInputNativeElement.value = 'comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + }); + + // *** end testing comments *** + }); + +}); diff --git a/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.ts b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.ts new file mode 100644 index 0000000000..8653b73fe0 --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-string/text-value-as-string.component.ts @@ -0,0 +1,120 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { ValueErrorStateMatcher } from '../../value-error-state-matcher'; +import { CreateTextValueAsString, ReadTextValueAsString, UpdateTextValueAsString } from '@dasch-swiss/dsp-js'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-text-value-as-string', + templateUrl: './text-value-as-string.component.html', + styleUrls: ['./text-value-as-string.component.scss'] +}) +export class TextValueAsStringComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @Input() displayValue?: ReadTextValueAsString; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + matcher = new ValueErrorStateMatcher(); + customValidators = []; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + getInitValue(): string | null { + + if (this.displayValue !== undefined) { + return this.displayValue.text; + } else { + return null; + } + } + + ngOnInit() { + + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + + // resets values and validators in form controls when input displayValue or mode changes + // at the first call of ngOnChanges, form control elements are not initialized yet + this.resetFormControl(); + } + + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateTextValueAsString | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newTextValue = new CreateTextValueAsString(); + + newTextValue.text = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newTextValue.valueHasComment = this.commentFormControl.value; + } + + return newTextValue; + + } + + getUpdatedValue(): UpdateTextValueAsString | false { + + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedTextValue = new UpdateTextValueAsString(); + + updatedTextValue.id = this.displayValue.id; + + updatedTextValue.text = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedTextValue.valueHasComment = this.commentFormControl.value; + } + + return updatedTextValue; + } + +} diff --git a/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.html b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.html new file mode 100644 index 0000000000..006c8a08ea --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.html @@ -0,0 +1,36 @@ + +
+
+ + {{displayValue?.xml}} + + {{commentFormControl.value}} +
+ +
+ + + + + + +
+ + No class was provided for CKEditor. + +
+ diff --git a/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.scss b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.scss new file mode 100644 index 0000000000..feacfb747a --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.scss @@ -0,0 +1,5 @@ +.rm-value { + ::ng-deep p { + margin: 0; + } +} diff --git a/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.spec.ts b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.spec.ts new file mode 100644 index 0000000000..51c3ca5900 --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.spec.ts @@ -0,0 +1,546 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { + Component, + DebugElement, + Directive, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Constants, CreateTextValueAsXml, MockResource, ReadTextValueAsXml, UpdateTextValueAsXml } from '@dasch-swiss/dsp-js'; +import { TextValueAsXMLComponent } from './text-value-as-xml.component'; + +/** + * test host component to simulate parent component. + */ +@Component({ + selector: 'ckeditor', + template: '', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => TestCKEditorComponent), + } + ] +}) +class TestCKEditorComponent implements ControlValueAccessor { + + @Input() config; + + @Input() editor; + + value; + + constructor() { } + + onChange = (_: any) => { + }; + + writeValue(obj: any) { + this.value = obj; + } + + registerOnChange(fn: any) { + this.onChange = fn; + } + + registerOnTouched(fn: any) { + } + + _handleInput(): void { + this.onChange(this.value); + } + +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TextValueAsXMLComponent; + + displayInputVal: ReadTextValueAsXml; + + mode: 'read' | 'update' | 'create' | 'search'; + + refResClicked: string; + + refResHovered: string; + + ngOnInit() { + + MockResource.getTestThing().subscribe( + res => { + + this.displayInputVal = res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasRichtext', ReadTextValueAsXml)[0]; + + this.mode = 'read'; + } + ); + + } + + standoffLinkClicked(refResIri: string) { + this.refResClicked = refResIri; + } + + standoffLinkHovered(refResIri: string) { + this.refResHovered = refResIri; + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TextValueAsXMLComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +@Directive({ + selector: '[appHtmlLink]' +}) +export class TestTextValueHtmlLinkDirective { + + @Output() internalLinkClicked = new EventEmitter(); + + @Output() internalLinkHovered = new EventEmitter(); + +} + +describe('TextValueAsXMLComponent', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + TextValueAsXMLComponent, + TestHostDisplayValueComponent, + TestCKEditorComponent, + TestHostCreateValueComponent, + TestTextValueHtmlLinkDirective + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ] + }) + .compileComponents(); + })); + + describe('display and edit a text value with xml markup', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + + let ckeditorDe: DebugElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + valueComponentDe = hostCompDe.query(By.directive(TextValueAsXMLComponent)); + + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + // reset before each it + ckeditorDe = undefined; + + }); + + it('should display an existing value for the standard mapping as formatted text', () => { + + expect(testHostComponent.inputValueComponent.displayValue.xml).toEqual('\n

test with markup

'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerHTML).toEqual('\n

test with markup

'); + + }); + + it('should display an existing value for the standard mapping as formatted text and react to clicking on a standoff link', () => { + + expect(testHostComponent.inputValueComponent.displayValue.xml).toEqual('\n

test with markup

'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerHTML).toEqual('\n

test with markup

'); + + expect(testHostComponent.refResClicked).toBeUndefined(); + + const debugElement = valueComponentDe.query(By.directive(TestTextValueHtmlLinkDirective)); + + // https://stackoverflow.com/questions/50611721/how-to-access-property-of-directive-in-a-test-host-in-angular-5/51716105 + const linkDirective = debugElement.injector.get(TestTextValueHtmlLinkDirective); + + // simulate click event on a standoff link + linkDirective.internalLinkClicked.emit('testIri'); + + expect(testHostComponent.refResClicked).toEqual('testIri'); + + }); + + it('should display an existing value for the standard mapping as formatted text and react to hovering on a standoff link', () => { + + expect(testHostComponent.inputValueComponent.displayValue.xml).toEqual('\n

test with markup

'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerHTML).toEqual('\n

test with markup

'); + + expect(testHostComponent.refResHovered).toBeUndefined(); + + const debugElement = valueComponentDe.query(By.directive(TestTextValueHtmlLinkDirective)); + + // https://stackoverflow.com/questions/50611721/how-to-access-property-of-directive-in-a-test-host-in-angular-5/51716105 + const linkDirective = debugElement.injector.get(TestTextValueHtmlLinkDirective); + + // simulate click event on a standoff link + linkDirective.internalLinkHovered.emit('testIri'); + + expect(testHostComponent.refResHovered).toEqual('testIri'); + + }); + + it('should display an existing value for a custom mapping as XML source code', () => { + + const newXml = new ReadTextValueAsXml(); + + newXml.xml = '

my updated text

'; + newXml.mapping = 'http://rdfh.ch/standoff/mappings/customMapping'; + + newXml.id = 'id'; + + testHostComponent.displayInputVal = newXml; + + testHostFixture.detectChanges(); + + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + + expect(valueReadModeNativeElement.innerText).toEqual( + '

my updated text

'); + + // custom mappings are not supported by this component + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.valueFormControl.disabled).toBeFalsy(); + + expect(ckeditorDe.componentInstance.value).toEqual('\n

test with markup

'); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '\n

test with a lot of markup

'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateTextValueAsXml).toBeTruthy(); + + expect((updatedValue as UpdateTextValueAsXml).xml).toEqual('\n' + + '

test with a lot of markup

'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(ckeditorDe.componentInstance.value).toEqual('\n

test with markup

'); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = ''; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(ckeditorDe.componentInstance.value).toEqual('\n

test with markup

'); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '

updated text

'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(ckeditorDe.componentInstance.value).toEqual('\n

test with markup

'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newXml = new ReadTextValueAsXml(); + + newXml.xml = '

my updated text

'; + newXml.mapping = Constants.StandardMapping; + + newXml.id = 'updatedId'; + + testHostComponent.displayInputVal = newXml; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerHTML).toEqual('

my updated text

'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + + it('convert markup received from CKEditor: -> ', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '

test with a lot of markup

'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect((testHostComponent.inputValueComponent.getUpdatedValue() as UpdateTextValueAsXml).xml) + .toEqual('

test with a lot of markup

'); + + }); + + it('convert markup received from CKEditor:
->
', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '

test with horizontal line


'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect((testHostComponent.inputValueComponent.getUpdatedValue() as UpdateTextValueAsXml).xml) + .toEqual('

test with horizontal line


'); + + }); + + it('convert markup received from CKEditor:
->
', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '

test with soft break

'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect((testHostComponent.inputValueComponent.getUpdatedValue() as UpdateTextValueAsXml).xml) + .toEqual('

test with soft break

'); + + }); + + it('convert markup received from CKEditor: -> ', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '

test with struck word

'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect((testHostComponent.inputValueComponent.getUpdatedValue() as UpdateTextValueAsXml).xml) + .toEqual('

test with struck word

'); + + }); + + it('remove markup received from CKEditor:
', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + ckeditorDe = valueComponentDe.query(By.directive(TestCKEditorComponent)); + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '

testtest
testtest

'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect((testHostComponent.inputValueComponent.getUpdatedValue() as UpdateTextValueAsXml).xml) + .toEqual('

testtest
testtest

'); + + }); + + }); + + describe('create a text value with markup', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let ckeditorDe: DebugElement; + + let valueComponentDe: DebugElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + const hostCompDe = testHostFixture.debugElement; + + ckeditorDe = hostCompDe.query(By.directive(TestCKEditorComponent)); + + valueComponentDe = hostCompDe.query(By.directive(TextValueAsXMLComponent)); + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + }); + + it('should create a value', () => { + + // simulate input in ckeditor + ckeditorDe.componentInstance.value = '

created text

'; + ckeditorDe.componentInstance._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + expect(testHostComponent.inputValueComponent.valueFormControl.disabled).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateTextValueAsXml).toBeTruthy(); + + expect((newValue as CreateTextValueAsXml).xml).toEqual('

created text

'); + expect((newValue as CreateTextValueAsXml).mapping).toEqual(Constants.StandardMapping); + }); + + it('should reset form after cancellation', () => { + ckeditorDe.componentInstance.value = '

created text

'; + ckeditorDe.componentInstance._handleInput(); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(ckeditorDe.componentInstance.value).toEqual(null); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + + }); + +}); diff --git a/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.ts b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.ts new file mode 100644 index 0000000000..8517dd8e1e --- /dev/null +++ b/src/app/workspace/resource/values/text-value/text-value-as-xml/text-value-as-xml.component.ts @@ -0,0 +1,256 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + Component, + EventEmitter, + Inject, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { Constants, CreateTextValueAsXml, ReadTextValueAsXml, UpdateTextValueAsXml } from '@dasch-swiss/dsp-js'; +import * as Editor from 'ckeditor5-custom-build'; +import { Subscription } from 'rxjs'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; +import { ValueErrorStateMatcher } from '../../value-error-state-matcher'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-text-value-as-xml', + templateUrl: './text-value-as-xml.component.html', + styleUrls: ['./text-value-as-xml.component.scss'] +}) +export class TextValueAsXMLComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + @Input() displayValue?: ReadTextValueAsXml; + + @Output() internalLinkClicked: EventEmitter = new EventEmitter(); + + @Output() internalLinkHovered: EventEmitter = new EventEmitter(); + + readonly standardMapping = Constants.StandardMapping; // todo: define this somewhere else + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + matcher = new ValueErrorStateMatcher(); + customValidators = []; + + // https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/frameworks/angular.html + editor: Editor; + editorConfig; + + // xML conversion + xmlTransform = { + '
': '
', + '': '', + '': '', + '': '', + '': '', + '
': '', + '
': '', + '
': '
' + }; + + // tODO: get this from config via AppInitService + readonly resourceBasePath = 'http://rdfh.ch/'; + + constructor(@Inject(FormBuilder) private fb: FormBuilder) { + super(); + } + + standardValueComparisonFunc(initValue: any, curValue: any): boolean { + const initValueTrimmed = typeof initValue === 'string' ? initValue.trim() : initValue; + const curValueTrimmed = typeof curValue === 'string' ? curValue.trim() : curValue; + + return initValueTrimmed === this._handleXML(curValueTrimmed, false, false); + } + + getInitValue(): string | null { + + // check for standard mapping + if (this.displayValue !== undefined && this.displayValue.mapping === this.standardMapping) { + return this._handleXML(this.displayValue.xml, true); + } else { + return null; + } + } + + ngOnInit() { + + this.editor = Editor; + + this.editorConfig = { + entities: false, + link: { + addTargetToExternalLinks: false, + decorators: { + isInternal: { + // label: 'internal link to a Knora resource', + mode: 'automatic', // automatic requires callback -> but the callback is async and the user could save the text before the check ... + callback: url => /* console.log(url, url.startsWith( 'http://rdfh.ch/' ));*/ + !!url && url.startsWith(this.resourceBasePath) + , + attributes: { + class: Constants.SalsahLink + } + } + } + }, + toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', + 'numberedList', 'blockQuote', 'underline', 'strikethrough', 'subscript', 'superscript', 'horizontalline', 'insertTable', 'code', 'codeBlock', 'removeformat', 'redo', 'undo'], + heading: { + options: [ + { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' }, + { model: 'heading1', view: 'h1', title: 'Heading 1' }, + { model: 'heading2', view: 'h2', title: 'Heading 2' }, + { model: 'heading3', view: 'h3', title: 'Heading 3' }, + { model: 'heading4', view: 'h4', title: 'Heading 4' }, + { model: 'heading5', view: 'h5', title: 'Heading 5' }, + { model: 'heading6', view: 'h6', title: 'Heading 6' }, + { model: 'formatted', view: 'pre', title: 'Formatted' }, + { model: 'cite', view: 'cite', title: 'Cited' } + + ] + }, + codeBlock: { + languages: [ + { language: 'plaintext', label: 'Plain text', class: '' } + ] + } + }; + + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this.fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + + } + + ngOnChanges(changes: SimpleChanges): void { + + // resets values and validators in form controls when input displayValue or mode changes + // at the first call of ngOnChanges, form control elements are not initialized yet + this.resetFormControl(); + + } + + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateTextValueAsXml | false { + + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newTextValue = new CreateTextValueAsXml(); + + newTextValue.xml = this._handleXML(this.valueFormControl.value, false); + newTextValue.mapping = this.standardMapping; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newTextValue.valueHasComment = this.commentFormControl.value; + } + + return newTextValue; + + } + + getUpdatedValue(): UpdateTextValueAsXml | false { + + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedTextValue = new UpdateTextValueAsXml(); + + updatedTextValue.id = this.displayValue.id; + + updatedTextValue.xml = this._handleXML(this.valueFormControl.value, false); + updatedTextValue.mapping = this.standardMapping; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedTextValue.valueHasComment = this.commentFormControl.value; + } + + return updatedTextValue; + } + + /** + * converts XML to HTML suitable for CKEditor and vice versa. + * + * @param xml xml to be processed. + * @param fromKnora true if xml is received from Knora. + * @param addXMLDocType whether to add the doctype to the XML. + */ + private _handleXML(xml: string, fromKnora: boolean, addXMLDocType = true) { + + const doctype = ''; + const textTag = 'text'; + const openingTextTag = `<${textTag}>`; + const closingTextTag = ``; + + // check if xml is a string + if (typeof xml !== 'string') { + return xml; + } + + if (fromKnora) { + // cKEditor accepts tags from version 4 + // see 4 to 5 migration, see https://ckeditor.com/docs/ckeditor5/latest/builds/guides/migrate.html + return xml.replace(doctype, '') + .replace(openingTextTag, '') + .replace(closingTextTag, ''); + } else { + + // replace   entity + xml = xml.replace(/ /g, String.fromCharCode(160)); + + // get XML transform config + const keys = Object.keys(this.xmlTransform); + for (const key of keys) { + // replace tags defined in config + xml = xml.replace(new RegExp(key, 'g'), this.xmlTransform[key]); + } + + if (addXMLDocType) { + return doctype + openingTextTag + xml + closingTextTag; + } else { + return xml; + } + } + + } +} diff --git a/src/app/workspace/resource/values/time-value/time-input/time-input.component.html b/src/app/workspace/resource/values/time-value/time-input/time-input.component.html new file mode 100644 index 0000000000..0f39b54372 --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-input/time-input.component.html @@ -0,0 +1,24 @@ +
+ + + + + + + + + + + A time value in precision HH:MM is required. + + + Time should be given in precision HH:MM. + + +
+ + A time value must have a date and time. + +
+
diff --git a/src/app/workspace/resource/values/time-value/time-input/time-input.component.scss b/src/app/workspace/resource/values/time-value/time-input/time-input.component.scss new file mode 100644 index 0000000000..17a6109206 --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-input/time-input.component.scss @@ -0,0 +1 @@ +@import "../../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/time-value/time-input/time-input.component.spec.ts b/src/app/workspace/resource/values/time-value/time-input/time-input.component.spec.ts new file mode 100644 index 0000000000..c496075436 --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-input/time-input.component.spec.ts @@ -0,0 +1,222 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TimeInputComponent, DateTime } from './time-input.component'; +import { Component, OnInit, ViewChild, DebugElement } from '@angular/core'; +import { FormGroup, FormBuilder, ReactiveFormsModule, FormControl } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { GregorianCalendarDate, CalendarPeriod, CalendarDate } from 'jdnconvertiblecalendar'; +import { MatJDNConvertibleCalendarDateAdapterModule } from 'jdnconvertiblecalendardateadapter'; +import { JDNDatepickerDirective } from '../../jdn-datepicker-directive/jdndatepicker.directive'; + + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class TestHostComponent implements OnInit { + + @ViewChild('timeInput') timeInputComponent: TimeInputComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + time: '2019-08-06T12:00:00Z' + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` +
+ + + +
` +}) +class NoValueRequiredTestHostComponent implements OnInit { + + @ViewChild('timeInput') timeInputComponent: TimeInputComponent; + + form: FormGroup; + + constructor(private _fb: FormBuilder) { + } + + ngOnInit(): void { + + this.form = this._fb.group({ + time: new FormControl(null) + }); + + } +} + +describe('TimeInputComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + let datetimeInputComponentDe: DebugElement; + let dateInputDebugElement: DebugElement; + let dateInputNativeElement; + let timeInputDebugElement: DebugElement; + let timeInputNativeElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule, + MatJDNConvertibleCalendarDateAdapterModule, + BrowserAnimationsModule], + declarations: [TimeInputComponent, TestHostComponent, JDNDatepickerDirective] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.timeInputComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + datetimeInputComponentDe = hostCompDe.query(By.directive(TimeInputComponent)); + + dateInputDebugElement = datetimeInputComponentDe.query(By.css('input.date')); + dateInputNativeElement = dateInputDebugElement.nativeElement; + + timeInputDebugElement = datetimeInputComponentDe.query(By.css('input.time')); + timeInputNativeElement = timeInputDebugElement.nativeElement; + }); + + it('should initialize the date correctly', () => { + expect(dateInputNativeElement.value).toEqual('06-08-2019'); + + expect(timeInputNativeElement.value).toEqual('14:00'); + }); + + it('should propagate changes made by the user', () => { + testHostComponent.form.controls.time.setValue('1993-10-10T11:00:00Z'); + + dateInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.form.controls.time).toBeTruthy(); + expect(testHostComponent.form.controls.time.value).toEqual('1993-10-10T11:00:00Z'); + + timeInputNativeElement.value = '17:00'; + + timeInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.form.controls.time).toBeTruthy(); + expect(testHostComponent.form.controls.time.value).toEqual('1993-10-10T16:00:00Z'); + }); + + it('should return a timestamp from userInputToTimestamp()', () => { + const calendarDate = new CalendarDate(1993, 10, 10); + const gcd = new GregorianCalendarDate(new CalendarPeriod(calendarDate, calendarDate)); + const userInput = new DateTime(gcd, '12:00'); + + const timestamp = testHostComponent.timeInputComponent.userInputToTimestamp(userInput); + + expect(timestamp).toEqual('1993-10-10T11:00:00Z'); + }); + + it('should return a DateTime from convertTimestampToDateTime()', () => { + const timestamp = '1993-10-10T11:00:00Z'; + + const dateTime = testHostComponent.timeInputComponent.convertTimestampToDateTime(timestamp); + + expect(dateTime.date.toCalendarPeriod().periodStart.year).toEqual(1993); + expect(dateTime.date.toCalendarPeriod().periodStart.month).toEqual(10); + expect(dateTime.date.toCalendarPeriod().periodStart.day).toEqual(10); + + expect(dateTime.time).toEqual('12:00'); + + }); + + it('should mark the form\'s validity correctly', () => { + expect(testHostComponent.timeInputComponent.valueRequiredValidator).toBe(true); + expect(testHostComponent.timeInputComponent.form.valid).toBe(true); + + testHostComponent.timeInputComponent.timeFormControl.setValue(null); + + testHostComponent.timeInputComponent._handleInput(); + + expect(testHostComponent.timeInputComponent.form.valid).toBe(false); + + testHostComponent.timeInputComponent.timeFormControl.setValue(''); + + testHostComponent.timeInputComponent._handleInput(); + + expect(testHostComponent.timeInputComponent.form.valid).toBe(false); + }); +}); + +describe('TimeInputComponent no value required', () => { + let testHostComponent: NoValueRequiredTestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule, + MatJDNConvertibleCalendarDateAdapterModule, + BrowserAnimationsModule], + declarations: [TimeInputComponent, NoValueRequiredTestHostComponent, JDNDatepickerDirective] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(NoValueRequiredTestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should receive the propagated valueRequiredValidator from the parent component', () => { + expect(testHostComponent.timeInputComponent.valueRequiredValidator).toBe(false); + }); + + it('should mark the form\'s validity correctly', () => { + expect(testHostComponent.timeInputComponent.form.valid).toBe(true); + + testHostComponent.timeInputComponent.timeFormControl.setValue('2019-08-06T12:00:00Z'); + + testHostComponent.timeInputComponent._handleInput(); + + expect(testHostComponent.timeInputComponent.form.valid).toBe(false); + }); +}); diff --git a/src/app/workspace/resource/values/time-value/time-input/time-input.component.ts b/src/app/workspace/resource/values/time-value/time-input/time-input.component.ts new file mode 100644 index 0000000000..e72752ccd4 --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-input/time-input.component.ts @@ -0,0 +1,280 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable @typescript-eslint/member-ordering */ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DatePipe } from '@angular/common'; +import { ValueErrorStateMatcher } from '../../value-error-state-matcher'; +import { Component, DoCheck, ElementRef, HostBinding, Input, OnDestroy, OnInit, Optional, Self } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgControl, + NgForm, + ValidatorFn, + Validators +} from '@angular/forms'; +import { CanUpdateErrorState, CanUpdateErrorStateCtor, ErrorStateMatcher, mixinErrorState } from '@angular/material/core'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { CalendarDate, CalendarPeriod, GregorianCalendarDate } from 'jdnconvertiblecalendar'; +import { Subject } from 'rxjs'; +import { CustomRegex } from '../../custom-regex'; + +/** a valid time value must have both a date and a time, or both inputs must be null */ +export function dateTimeValidator(otherControl: FormControl): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + + // valid if both date and time are null or have values, excluding empty strings + const invalid = !(control.value === null && otherControl.value === null || + ((control.value !== null && control.value !== '') && (otherControl.value !== null && otherControl.value !== ''))); + + return invalid ? { 'validDateTimeRequired': { value: control.value } } : null; + }; +} + +class MatInputBase { + constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl) { } +} +const _MatInputMixinBase: CanUpdateErrorStateCtor & typeof MatInputBase = mixinErrorState(MatInputBase); + +export class DateTime { + /** + * @param date DateTime's date. + * @param time DateTime's time. + */ + constructor(public date: GregorianCalendarDate, public time: string) { + } +} + +@Component({ + selector: 'app-time-input', + templateUrl: './time-input.component.html', + styleUrls: ['./time-input.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: TimeInputComponent }] +}) +export class TimeInputComponent extends _MatInputMixinBase implements ControlValueAccessor, MatFormFieldControl, DoCheck, CanUpdateErrorState, OnDestroy, OnInit { + + static nextId = 0; + + form: FormGroup; + stateChanges = new Subject(); + @HostBinding() id = `app-time-input-${TimeInputComponent.nextId++}`; + focused = false; + errorState = false; + controlType = 'app-time-input'; + matcher = new ValueErrorStateMatcher(); + onChange = (_: any) => { }; + onTouched = () => { }; + + @Input() dateLabel = 'Date'; + @Input() timeLabel = 'Time'; + @Input() valueRequiredValidator = true; + + dateFormControl: FormControl; + timeFormControl: FormControl; + + datePipe = new DatePipe('en-US'); + + get empty() { + const userInput = this.form.value; + return !userInput.date && !userInput.time; + } + + @HostBinding('class.floating') + get shouldLabelFloat() { + return this.focused || !this.empty; + } + + @Input() + get required() { + return this._required; + } + + set required(req) { + this._required = coerceBooleanProperty(req); + this.stateChanges.next(); + } + + private _required = false; + + @Input() + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this._disabled ? this.form.disable() : this.form.enable(); + this.stateChanges.next(); + } + + private _disabled = false; + + @Input() + get placeholder() { + return this._placeholder; + } + + set placeholder(plh) { + this._placeholder = plh; + this.stateChanges.next(); + } + + private _placeholder: string; + + @HostBinding('attr.aria-describedby') describedBy = ''; + + setDescribedByIds(ids: string[]) { + this.describedBy = ids.join(' '); + } + + @Input() + get value(): string | null { + if (this.form.valid) { + try { + const userInput = new DateTime(this.form.value.date, this.form.value.time); + return this.userInputToTimestamp(userInput); + } catch { + return null; + } + } + return null; + } + + set value(timestamp: string | null) { + if (timestamp !== null) { + try { + const dateTime = this.convertTimestampToDateTime(timestamp); + this.form.setValue({ date: dateTime.date, time: dateTime.time }); + } catch { + this.form.setValue({ date: null, time: null }); + } + } else { + this.form.setValue({ date: null, time: null }); + } + + this.dateFormControl.updateValueAndValidity(); + this.timeFormControl.updateValueAndValidity(); + + this.stateChanges.next(); + } + + @Input() errorStateMatcher: ErrorStateMatcher; + + constructor(fb: FormBuilder, + @Optional() @Self() public ngControl: NgControl, + private _fm: FocusMonitor, + private _elRef: ElementRef, + @Optional() _parentForm: NgForm, + @Optional() _parentFormGroup: FormGroupDirective, + _defaultErrorStateMatcher: ErrorStateMatcher) { + + super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + + this.dateFormControl = new FormControl(null); + + this.timeFormControl = new FormControl(null); + + this.form = fb.group({ + date: this.dateFormControl, + time: this.timeFormControl + }); + + _fm.monitor(_elRef.nativeElement, true).subscribe(origin => { + this.focused = !!origin; + this.stateChanges.next(); + }); + + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + } + + ngOnInit() { + if (this.valueRequiredValidator) { + this.dateFormControl.setValidators([Validators.required, dateTimeValidator(this.timeFormControl)]); + this.timeFormControl.setValidators([Validators.required, dateTimeValidator(this.dateFormControl), Validators.pattern(CustomRegex.TIME_REGEX)]); + } else { + this.dateFormControl.setValidators(dateTimeValidator(this.timeFormControl)); + this.timeFormControl.setValidators([dateTimeValidator(this.dateFormControl), Validators.pattern(CustomRegex.TIME_REGEX)]); + } + + this.dateFormControl.updateValueAndValidity(); + this.timeFormControl.updateValueAndValidity(); + } + + ngDoCheck() { + if (this.ngControl) { + this.updateErrorState(); + } + } + + ngOnDestroy() { + this.stateChanges.complete(); + } + + onContainerClick(event: MouseEvent) { + if ((event.target as Element).tagName.toLowerCase() !== 'input') { + this._elRef.nativeElement.querySelector('input').focus(); + } + } + + writeValue(datetime: string | null): void { + this.value = datetime; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + _handleInput(): void { + this.dateFormControl.updateValueAndValidity(); + this.timeFormControl.updateValueAndValidity(); + this.onChange(this.value); + } + + // return converted Date obj as a string without the milliseconds + userInputToTimestamp(userInput: DateTime): string { + const splitTime = userInput.time.split(':'); + + // in a Javascript Date, the month is 0-based so we need to subtract 1 + const updateDate = new Date(userInput.date.toCalendarPeriod().periodStart.year, + (userInput.date.toCalendarPeriod().periodStart.month - 1), + userInput.date.toCalendarPeriod().periodStart.day, + Number(splitTime[0]), + Number(splitTime[1]) + ); + + return updateDate.toISOString().split('.')[0] + 'Z'; + } + + // converts and returns a unix timestamp string as an array consisting of a GregorianCalendarDate and a string + convertTimestampToDateTime(timestamp: string): DateTime { + const calendarDate = new CalendarDate(Number(this.datePipe.transform(timestamp, 'y')), + Number(this.datePipe.transform(timestamp, 'M')), + Number(this.datePipe.transform(timestamp, 'd'))); + + const date = new GregorianCalendarDate(new CalendarPeriod(calendarDate, calendarDate)); + + const time = this.datePipe.transform(timestamp, 'HH:mm'); + + const dateTime = new DateTime(date, time); + + return dateTime; + } + +} diff --git a/src/app/workspace/resource/values/time-value/time-value.component.html b/src/app/workspace/resource/values/time-value/time-value.component.html new file mode 100644 index 0000000000..f9704adaa0 --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-value.component.html @@ -0,0 +1,31 @@ + + Date: {{valueFormControl.value | date}} + Time: {{valueFormControl.value | date:"HH:mm"}} + {{commentFormControl.value}} + + + + + + + New value must be different than the current value. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/time-value/time-value.component.scss b/src/app/workspace/resource/values/time-value/time-value.component.scss new file mode 100644 index 0000000000..f396a0dc34 --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-value.component.scss @@ -0,0 +1,17 @@ +@import "../../../../../assets/style/viewer"; + +:host ::ng-deep .child-value-component { + .mat-form-field-underline { + display: none; + } + .mat-form-field-infix{ + border-top: 0.2em solid transparent !important; + .mat-form-field-underline{ + display: block; + } + } +} + +.date, .time { + display: block; +} diff --git a/src/app/workspace/resource/values/time-value/time-value.component.spec.ts b/src/app/workspace/resource/values/time-value/time-value.component.spec.ts new file mode 100644 index 0000000000..b6ec346ee5 --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-value.component.spec.ts @@ -0,0 +1,431 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TimeValueComponent } from './time-value.component'; +import { Component, DebugElement, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { CreateTimeValue, MockResource, ReadTimeValue, UpdateTimeValue, KnoraDate } from '@dasch-swiss/dsp-js'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { Subject } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { ErrorStateMatcher } from '@angular/material/core'; + +@Component({ + selector: 'app-time-input', + template: '', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => TestTimeInputComponent), + }, + { provide: MatFormFieldControl, useExisting: TestTimeInputComponent } + ] +}) +class TestTimeInputComponent implements ControlValueAccessor, MatFormFieldControl { + + @Input() value; + @Input() disabled: boolean; + @Input() empty: boolean; + @Input() placeholder: string; + @Input() required: boolean; + @Input() shouldLabelFloat: boolean; + @Input() errorStateMatcher: ErrorStateMatcher; + @Input() valueRequiredValidator = true; + + stateChanges = new Subject(); + errorState = false; + focused = false; + id = 'testid'; + ngControl: NgControl | null; + + onChange = (_: any) => { + }; + + writeValue(dateTime: string | null): void { + this.value = dateTime; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + } + + onContainerClick(event: MouseEvent): void { + } + + setDescribedByIds(ids: string[]): void { + } + + _handleInput(): void { + this.onChange(this.value); + } + +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TimeValueComponent; + + displayInputVal: ReadTimeValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadTimeValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasTimeStamp', ReadTimeValue)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TimeValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueNoValueRequiredComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: TimeValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + } +} + +describe('TimeValueComponent', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + TimeValueComponent, + TestHostDisplayValueComponent, + TestTimeInputComponent, + TestHostCreateValueComponent, + TestHostCreateValueNoValueRequiredComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + }) + .compileComponents(); + })); + + describe('display and edit a time value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + let dateReadModeDebugElement: DebugElement; + let dateReadModeNativeElement; + + let timeReadModeDebugElement: DebugElement; + let timeReadModeNativeElement; + + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(TimeValueComponent)); + dateReadModeDebugElement = valueComponentDe.query(By.css('.rm-value.date')); + dateReadModeNativeElement = dateReadModeDebugElement.nativeElement; + + timeReadModeDebugElement = valueComponentDe.query(By.css('.rm-value.time')); + timeReadModeNativeElement = timeReadModeDebugElement.nativeElement; + + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.time).toEqual('2019-08-30T10:45:20.173572Z'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(dateReadModeNativeElement.innerText).toEqual('Date: Aug 30, 2019'); + expect(timeReadModeNativeElement.innerText).toEqual('Time: 12:45'); + + }); + + it('should make an existing value editable', () => { + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.timeInputComponent.value).toEqual('2019-08-30T10:45:20.173572Z'); + + testHostComponent.inputValueComponent.timeInputComponent.value = '2019-06-30T00:00:00Z'; + + testHostComponent.inputValueComponent.timeInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.valueFormControl.value).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateTimeValue).toBeTruthy(); + + expect((updatedValue as UpdateTimeValue).time).toEqual('2019-06-30T00:00:00Z'); + + }); + + it('should validate an existing value with an added comment', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.timeInputComponent.value).toEqual('2019-08-30T10:45:20.173572Z'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + commentInputNativeElement.value = 'this is a comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateTimeValue).toBeTruthy(); + + expect((updatedValue as UpdateTimeValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + testHostComponent.inputValueComponent.timeInputComponent.value = ''; + testHostComponent.inputValueComponent.timeInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.timeInputComponent.value).toEqual('2019-08-30T10:45:20.173572Z'); + + testHostComponent.inputValueComponent.timeInputComponent.value = '2019-06-30T00:00:00Z'; + testHostComponent.inputValueComponent.timeInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.timeInputComponent.value).toEqual('2019-06-30T00:00:00Z'); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.timeInputComponent.value).toEqual('2019-08-30T10:45:20.173572Z'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newTime = new ReadTimeValue(); + + newTime.time = '2019-07-04T00:00:00.000Z'; + newTime.id = 'updatedId'; + + testHostComponent.displayInputVal = newTime; + + testHostFixture.detectChanges(); + + expect(dateReadModeNativeElement.innerText).toEqual('Date: Jul 4, 2019'); + expect(timeReadModeNativeElement.innerText).toEqual('Time: 02:00'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.inputValueComponent.ngOnDestroy(); + + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + }); + + describe('create a time value', () => { + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + + let valueComponentDe: DebugElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(TimeValueComponent)); + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + }); + + it('should create a value', () => { + + expect(testHostComponent.inputValueComponent.timeInputComponent.value).toEqual(null); + + testHostComponent.inputValueComponent.timeInputComponent.value = '2019-01-01T11:00:00.000Z'; + testHostComponent.inputValueComponent.timeInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateTimeValue).toBeTruthy(); + + expect((newValue as CreateTimeValue).time).toEqual('2019-01-01T11:00:00.000Z'); + }); + + it('should reset form after cancellation', () => { + + testHostComponent.inputValueComponent.timeInputComponent.value = '2019-06-30T00:00:00Z'; + testHostComponent.inputValueComponent.timeInputComponent._handleInput(); + + testHostFixture.detectChanges(); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(testHostComponent.inputValueComponent.timeInputComponent.value).toEqual(null); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + }); + + describe('create a time value no value required', () => { + let testHostComponent: TestHostCreateValueNoValueRequiredComponent; + let testHostFixture: ComponentFixture; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueNoValueRequiredComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should not create an empty value', () => { + expect(testHostComponent.inputValueComponent.getNewValue()).toBe(false); + expect(testHostComponent.inputValueComponent.form.valid).toBe(true); + }); + + it('should propagate valueRequiredValidator to child component', () => { + expect(testHostComponent.inputValueComponent.valueRequiredValidator).toBe(false); + }); + + }); + +}); diff --git a/src/app/workspace/resource/values/time-value/time-value.component.ts b/src/app/workspace/resource/values/time-value/time-value.component.ts new file mode 100644 index 0000000000..90cdf0e10a --- /dev/null +++ b/src/app/workspace/resource/values/time-value/time-value.component.ts @@ -0,0 +1,120 @@ +import { Component, OnInit, OnChanges, OnDestroy, ViewChild, Input, Inject, SimpleChanges } from '@angular/core'; +import { TimeInputComponent } from './time-input/time-input.component'; +import { ReadTimeValue, CreateTimeValue, UpdateTimeValue } from '@dasch-swiss/dsp-js'; +import { FormControl, FormGroup, FormBuilder } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-time-value', + templateUrl: './time-value.component.html', + styleUrls: ['./time-value.component.scss'] +}) +export class TimeValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @ViewChild('timeInput') timeInputComponent: TimeInputComponent; + + @Input() displayValue?: ReadTimeValue; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + + valueChangesSubscription: Subscription; + + customValidators = []; + + matcher = new ValueErrorStateMatcher(); + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + getInitValue(): string | null { + if (this.displayValue !== undefined) { + return this.displayValue.time; + } else { + return null; + } + } + + ngOnInit() { + // initialize form control elements + this.valueFormControl = new FormControl(null); + + this.commentFormControl = new FormControl(null); + + // subscribe to any change on the comment and recheck validity + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl, + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + // unsubscribe when the object is destroyed to prevent memory leaks + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateTimeValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newTimeValue = new CreateTimeValue(); + + newTimeValue.time = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newTimeValue.valueHasComment = this.commentFormControl.value; + } + + return newTimeValue; + } + + getUpdatedValue(): UpdateTimeValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + + const updatedTimeValue = new UpdateTimeValue(); + + updatedTimeValue.id = this.displayValue.id; + updatedTimeValue.time = this.valueFormControl.value; + + // add the submitted comment to updatedTimeValue only if user has added a comment + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedTimeValue.valueHasComment = this.commentFormControl.value; + } + + return updatedTimeValue; + } + +} diff --git a/src/app/workspace/resource/values/uri-value/uri-value.component.html b/src/app/workspace/resource/values/uri-value/uri-value.component.html new file mode 100644 index 0000000000..18f464676a --- /dev/null +++ b/src/app/workspace/resource/values/uri-value/uri-value.component.html @@ -0,0 +1,38 @@ + + + {{ label ? label : valueFormControl.value}} + + {{commentFormControl.value}} + + + + + + + New value must be different than the current value. + + + New value must be a valid URI. + + + A URI value is required. + + + This value already exists for this property. Duplicate values are not allowed. + + + + + + + diff --git a/src/app/workspace/resource/values/uri-value/uri-value.component.scss b/src/app/workspace/resource/values/uri-value/uri-value.component.scss new file mode 100644 index 0000000000..44169e9db7 --- /dev/null +++ b/src/app/workspace/resource/values/uri-value/uri-value.component.scss @@ -0,0 +1 @@ +@import "../../../../../assets/style/viewer"; diff --git a/src/app/workspace/resource/values/uri-value/uri-value.component.spec.ts b/src/app/workspace/resource/values/uri-value/uri-value.component.spec.ts new file mode 100644 index 0000000000..7392d84c47 --- /dev/null +++ b/src/app/workspace/resource/values/uri-value/uri-value.component.spec.ts @@ -0,0 +1,371 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UriValueComponent } from './uri-value.component'; +import { ReadUriValue, MockResource, UpdateValue, UpdateUriValue, CreateUriValue } from '@dasch-swiss/dsp-js'; +import { OnInit, Component, ViewChild, DebugElement } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; + + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostDisplayValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: UriValueComponent; + + displayInputVal: ReadUriValue; + + mode: 'read' | 'update' | 'create' | 'search'; + + label: string; + + ngOnInit() { + + MockResource.getTestThing().subscribe(res => { + const inputVal: ReadUriValue = + res.getValuesAs('http://0.0.0.0:3333/ontology/0001/anything/v2#hasUri', ReadUriValue)[0]; + + this.displayInputVal = inputVal; + + this.mode = 'read'; + }); + + } +} + +/** + * test host component to simulate parent component. + */ +@Component({ + template: ` + ` +}) +class TestHostCreateValueComponent implements OnInit { + + @ViewChild('inputVal') inputValueComponent: UriValueComponent; + + mode: 'read' | 'update' | 'create' | 'search'; + + ngOnInit() { + + this.mode = 'create'; + + } +} + +describe('UriValueComponent', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + UriValueComponent, + TestHostDisplayValueComponent, + TestHostCreateValueComponent + ], + imports: [ + ReactiveFormsModule, + MatInputModule, + BrowserAnimationsModule + ], + }) + .compileComponents(); + })); + + describe('display and edit a Uri value', () => { + let testHostComponent: TestHostDisplayValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let valueReadModeDebugElement: DebugElement; + let valueReadModeNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + valueComponentDe = hostCompDe.query(By.directive(UriValueComponent)); + + valueReadModeDebugElement = valueComponentDe.query(By.css('.rm-value')); + valueReadModeNativeElement = valueReadModeDebugElement.nativeElement; + }); + + it('should display an existing value', () => { + + expect(testHostComponent.inputValueComponent.displayValue.uri).toEqual('http://www.google.ch'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('http://www.google.ch'); + + const anchorDebugElement = valueReadModeDebugElement.query(By.css('a')); + expect(anchorDebugElement.nativeElement).toBeDefined(); + + expect(anchorDebugElement.attributes['href']).toEqual('http://www.google.ch'); + expect(anchorDebugElement.attributes['target']).toEqual('_blank'); + + }); + + it('should display an existing value with a label', () => { + + testHostComponent.label = 'testlabel'; + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.displayValue.uri).toEqual('http://www.google.ch'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('read'); + + expect(valueReadModeNativeElement.innerText).toEqual('testlabel'); + + const anchorDebugElement = valueReadModeDebugElement.query(By.css('a')); + expect(anchorDebugElement.nativeElement).toBeDefined(); + + expect(anchorDebugElement.attributes['href']).toEqual('http://www.google.ch'); + expect(anchorDebugElement.attributes['target']).toEqual('_blank'); + + }); + + it('should make an existing value editable', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('http://www.google.ch'); + + valueInputNativeElement.value = 'http://www.reddit.com'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateUriValue).toBeTruthy(); + + expect((updatedValue as UpdateUriValue).uri).toEqual('http://www.reddit.com'); + + }); + + it('should validate an existing value with an added comment', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('http://www.google.ch'); + + commentInputNativeElement.value = 'this is a comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue instanceof UpdateUriValue).toBeTruthy(); + + expect((updatedValue as UpdateUriValue).valueHasComment).toEqual('this is a comment'); + + }); + + it('should not return an invalid update value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('http://www.google.ch'); + + valueInputNativeElement.value = 'http://www.google.'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + const updatedValue = testHostComponent.inputValueComponent.getUpdatedValue(); + + expect(updatedValue).toBeFalsy(); + + }); + + it('should restore the initially displayed value', () => { + + testHostComponent.mode = 'update'; + + testHostFixture.detectChanges(); + + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.mode).toEqual('update'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual('http://www.google.ch'); + + valueInputNativeElement.value = 'http://www.reddit.com'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(valueInputNativeElement.value).toEqual('http://www.google.ch'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + }); + + it('should set a new display value', () => { + + const newUri = new ReadUriValue(); + + newUri.uri = 'http://www.reddit.com'; + newUri.id = 'updatedId'; + + testHostComponent.displayInputVal = newUri; + + testHostFixture.detectChanges(); + + expect(valueReadModeNativeElement.innerText).toEqual('http://www.reddit.com'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + }); + + it('should unsubscribe when destroyed', () => { + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeFalsy(); + + testHostComponent.inputValueComponent.ngOnDestroy(); + + expect(testHostComponent.inputValueComponent.valueChangesSubscription.closed).toBeTruthy(); + }); + }); + + describe('create a URI value', () => { + + let testHostComponent: TestHostCreateValueComponent; + let testHostFixture: ComponentFixture; + let valueComponentDe: DebugElement; + let valueInputDebugElement: DebugElement; + let valueInputNativeElement; + let commentInputDebugElement: DebugElement; + let commentInputNativeElement; + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + expect(testHostComponent.inputValueComponent).toBeTruthy(); + + const hostCompDe = testHostFixture.debugElement; + + valueComponentDe = hostCompDe.query(By.directive(UriValueComponent)); + valueInputDebugElement = valueComponentDe.query(By.css('input.value')); + valueInputNativeElement = valueInputDebugElement.nativeElement; + + commentInputDebugElement = valueComponentDe.query(By.css('textarea.comment')); + commentInputNativeElement = commentInputDebugElement.nativeElement; + + expect(testHostComponent.inputValueComponent.displayValue).toEqual(undefined); + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + expect(valueInputNativeElement.value).toEqual(''); + expect(commentInputNativeElement.value).toEqual(''); + }); + + it('should create a value', () => { + valueInputNativeElement.value = 'http://www.reddit.com'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + const newValue = testHostComponent.inputValueComponent.getNewValue(); + + expect(newValue instanceof CreateUriValue).toBeTruthy(); + + expect((newValue as CreateUriValue).uri).toEqual('http://www.reddit.com'); + }); + + it('should reset form after cancellation', () => { + valueInputNativeElement.value = 'http://www.reddit.com'; + + valueInputNativeElement.dispatchEvent(new Event('input')); + + commentInputNativeElement.value = 'created comment'; + + commentInputNativeElement.dispatchEvent(new Event('input')); + + testHostFixture.detectChanges(); + + expect(testHostComponent.inputValueComponent.mode).toEqual('create'); + + expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); + + testHostComponent.inputValueComponent.resetFormControl(); + + expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); + + expect(valueInputNativeElement.value).toEqual(''); + + expect(commentInputNativeElement.value).toEqual(''); + + }); + }); +}); diff --git a/src/app/workspace/resource/values/uri-value/uri-value.component.ts b/src/app/workspace/resource/values/uri-value/uri-value.component.ts new file mode 100644 index 0000000000..4b0b5f2e85 --- /dev/null +++ b/src/app/workspace/resource/values/uri-value/uri-value.component.ts @@ -0,0 +1,112 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { ValueErrorStateMatcher } from '../value-error-state-matcher'; +import { CreateUriValue, ReadUriValue, UpdateUriValue } from '@dasch-swiss/dsp-js'; +import { Subscription } from 'rxjs'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { CustomRegex } from '../custom-regex'; +import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; + +// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror +const resolvedPromise = Promise.resolve(null); + +@Component({ + selector: 'app-uri-value', + templateUrl: './uri-value.component.html', + styleUrls: ['./uri-value.component.scss'] +}) +export class UriValueComponent extends BaseValueDirective implements OnInit, OnChanges, OnDestroy { + + @Input() displayValue?: ReadUriValue; + @Input() label?: string; + + valueFormControl: FormControl; + commentFormControl: FormControl; + + form: FormGroup; + matcher = new ValueErrorStateMatcher(); + valueChangesSubscription: Subscription; + + customValidators = [Validators.pattern(CustomRegex.URI_REGEX)]; + + constructor(@Inject(FormBuilder) private _fb: FormBuilder) { + super(); + } + + getInitValue(): string | null { + if (this.displayValue !== undefined) { + return this.displayValue.uri; + } else { + return null; + } + } + + ngOnInit() { + this.valueFormControl = new FormControl(null); + this.commentFormControl = new FormControl(null); + + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( + data => { + this.valueFormControl.updateValueAndValidity(); + } + ); + + this.form = this._fb.group({ + value: this.valueFormControl, + comment: this.commentFormControl + }); + + this.resetFormControl(); + + resolvedPromise.then(() => { + // add form to the parent form group + this.addToParentFormGroup(this.formName, this.form); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.resetFormControl(); + } + + ngOnDestroy(): void { + this.unsubscribeFromValueChanges(); + + resolvedPromise.then(() => { + // remove form from the parent form group + this.removeFromParentFormGroup(this.formName); + }); + } + + getNewValue(): CreateUriValue | false { + if (this.mode !== 'create' || !this.form.valid || this.isEmptyVal()) { + return false; + } + + const newUriValue = new CreateUriValue(); + + newUriValue.uri = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + newUriValue.valueHasComment = this.commentFormControl.value; + } + + return newUriValue; + } + + getUpdatedValue(): UpdateUriValue | false { + if (this.mode !== 'update' || !this.form.valid) { + return false; + } + const updatedUriValue = new UpdateUriValue(); + + updatedUriValue.id = this.displayValue.id; + + updatedUriValue.uri = this.valueFormControl.value; + + if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { + updatedUriValue.valueHasComment = this.commentFormControl.value; + } + + return updatedUriValue; + } + +} diff --git a/src/app/workspace/resource/values/value-error-state-matcher.ts b/src/app/workspace/resource/values/value-error-state-matcher.ts new file mode 100644 index 0000000000..5da033039d --- /dev/null +++ b/src/app/workspace/resource/values/value-error-state-matcher.ts @@ -0,0 +1,9 @@ +import { FormControl, FormGroupDirective, NgForm } from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; + +export class ValueErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = form && form.submitted; + return (control && control.invalid && (control.dirty || control.touched || isSubmitted)); + } +} diff --git a/src/app/workspace/results/list-view/list-view.component.html b/src/app/workspace/results/list-view/list-view.component.html new file mode 100644 index 0000000000..d2a0c6b140 --- /dev/null +++ b/src/app/workspace/results/list-view/list-view.component.html @@ -0,0 +1,70 @@ + +
+ + Display as  + + + + + + + + + +
+ + +
+ + + +
+
+ + + + + + +
+
+ + +
+

Your search - {{search.query}} - did not match any documents.

+

Suggestions:

+
    +
  • Make sure that you are logged in and you have all the needed permissions.
  • +
  • Make sure that all words are spelled correctly.
  • +
  • Try different keywords.
  • +
  • Try more general keywords.
  • +
  • Try fewer keywords.
  • +
+
+ +
+ + + diff --git a/src/app/workspace/results/list-view/list-view.component.scss b/src/app/workspace/results/list-view/list-view.component.scss new file mode 100644 index 0000000000..b983823d8b --- /dev/null +++ b/src/app/workspace/results/list-view/list-view.component.scss @@ -0,0 +1,45 @@ +@import "../../../../assets/style/config"; + +.list-view-header, +.list-view-footer { + display: flex; + box-sizing: border-box; + flex-direction: row; + align-items: center; + white-space: nowrap; + padding: 0 16px; + height: 58px; + width: 100%; + z-index: 1; + background: $bright; + + mat-paginator { + background: none; + } +} + +.list-view-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + position: sticky !important; + top: 0; + + .switch-view-label { + margin-right: 16px; + } +} + +button.active { + background-color: $black-12-opacity; +} + +.no-results { + margin: 64px; +} + +.link:hover { + background-color: $black-12-opacity; +} + +::ng-deep .selected-resource { + background-color: $black-20-opacity; +} diff --git a/src/app/workspace/results/list-view/list-view.component.spec.ts b/src/app/workspace/results/list-view/list-view.component.spec.ts new file mode 100644 index 0000000000..f836fb4cb8 --- /dev/null +++ b/src/app/workspace/results/list-view/list-view.component.spec.ts @@ -0,0 +1,230 @@ +import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { CountQueryResponse, IFulltextSearchParams, MockResource, ReadResourceSequence, SearchEndpointV2 } from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { AdvancedSearchParams, AdvancedSearchParamsService } from '../../../search/services/advanced-search-params.service'; +import { ListViewComponent, SearchParams } from './list-view.component'; + +/** + * test component to simulate child component, here resource-list. + */ +@Component({ + selector: 'app-resource-list', + template: '' +}) +class TestResourceListComponent { + + @Input() resources: ReadResourceSequence; + + @Input() selectedResourceIdx: number; + + @Input() withMultipleSelection?: boolean = false; + +} + +/** + * test component to simulate child component, here resource-grid. + */ +@Component({ + selector: 'app-resource-grid', + template: '' +}) +class TestResourceGridComponent { + + @Input() resources: ReadResourceSequence; + +} + +/** + * test component to simulate child component, here progress-indicator from action module. + */ +@Component({ + selector: 'app-progress-indicator', + template: '' +}) +class TestProgressIndicatorComponent { + +} + +/** + * test parent component to simulate integration of list-view component. + */ +@Component({ + template: ` + + ` +}) +class TestParentComponent implements OnInit { + + @ViewChild('listViewFulltext') listViewFulltext: ListViewComponent; + @ViewChild('listViewGravsearch') listViewGravsearch: ListViewComponent; + + fulltext: SearchParams; + gravsearch: SearchParams; + + resIri: string; + + ngOnInit() { + + this.fulltext = { + query: 'fake query', + mode: 'fulltext', + filter: { + limitToProject: 'http://rdfh.ch/projects/0803' + } + }; + + this.gravsearch = { + query: 'fake query', + mode: 'gravsearch' + }; + } + + openResource(id: string) { + this.resIri = id; + } + +} + +describe('ListViewComponent', () => { + + let testHostComponent: TestParentComponent; + let testHostFixture: ComponentFixture; + + let searchParamsServiceSpy: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + + const searchSpyObj = { + v2: { + search: jasmine.createSpyObj('search', ['doFulltextSearch', 'doFulltextSearchCountQuery', 'doExtendedSearch', 'doExtendedSearchCountQuery']) + } + }; + + const searchParamsSpyObj = jasmine.createSpyObj('SearchParamsService', ['getSearchParams']); + + TestBed.configureTestingModule({ + declarations: [ + ListViewComponent, + TestParentComponent, + TestProgressIndicatorComponent, + TestResourceGridComponent, + TestResourceListComponent + ], + imports: [ + MatButtonModule, + MatIconModule, + MatPaginatorModule, + MatSnackBarModule + ], + providers: [ + { + provide: DspApiConnectionToken, + useValue: searchSpyObj + }, + { + provide: AdvancedSearchParamsService, + useValue: searchParamsSpyObj + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + + searchParamsServiceSpy = TestBed.inject(AdvancedSearchParamsService) as jasmine.SpyObj; + + const generateFakeQuery = (offset: number) => 'fake query OFFSET ' + offset; + + searchParamsServiceSpy.getSearchParams.and.callFake((): AdvancedSearchParams => new AdvancedSearchParams(generateFakeQuery)); + + const searchSpy = TestBed.inject(DspApiConnectionToken); + + (searchSpy.v2.search as jasmine.SpyObj).doFulltextSearchCountQuery.and.callFake( + () => { + const num = new CountQueryResponse(); + num.numberOfResults = 5; + return of(num); + } + ); + + (searchSpy.v2.search as jasmine.SpyObj).doFulltextSearch.and.callFake( + (searchTerm: string, offset?: number, params?: IFulltextSearchParams) => { + + let resources: ReadResourceSequence; + // mock list of resourcses to simulate full-text search response + MockResource.getTestThings(5).subscribe(res => { + resources = res; + }); + if (resources.resources.length) { + return of(resources); + } + } + ); + + (searchSpy.v2.search as jasmine.SpyObj).doExtendedSearchCountQuery.and.callFake( + () => { + const num = new CountQueryResponse(); + num.numberOfResults = 5; + return of(num); + } + ); + + (searchSpy.v2.search as jasmine.SpyObj).doExtendedSearch.and.callFake( + (searchTerm: string) => { + + let resources: ReadResourceSequence; + // mock list of resourcses to simulate full-text search response + MockResource.getTestThings(5).subscribe(res => { + resources = res; + }); + if (resources.resources.length) { + return of(resources); + } + } + ); + + testHostFixture = TestBed.createComponent(TestParentComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should do fulltext search', () => { + + const searchSpy = TestBed.inject(DspApiConnectionToken); + + // do fulltext search count query + expect(searchSpy.v2.search.doFulltextSearchCountQuery).toHaveBeenCalledWith('fake query', 0, { limitToProject: 'http://rdfh.ch/projects/0803' }); + + // do fulltext search + expect(searchSpy.v2.search.doFulltextSearch).toHaveBeenCalledWith('fake query', 0, { limitToProject: 'http://rdfh.ch/projects/0803' }); + expect(testHostComponent.listViewFulltext.resources.resources.length).toBe(5); + + }); + + it('should do advanced search', () => { + + const searchSpy = TestBed.inject(DspApiConnectionToken); + + // do advanced search count query + expect(searchSpy.v2.search.doExtendedSearchCountQuery).toHaveBeenCalledWith('fake query'); + + // generate gravesearch query + expect(searchParamsServiceSpy.getSearchParams).toHaveBeenCalled(); + + // do advanced search + expect(searchSpy.v2.search.doExtendedSearch).toHaveBeenCalledWith('fake query OFFSET 0'); + expect(testHostComponent.listViewGravsearch.resources.resources.length).toBe(5); + + }); + + +}); diff --git a/src/app/workspace/results/list-view/list-view.component.ts b/src/app/workspace/results/list-view/list-view.component.ts new file mode 100644 index 0000000000..691a93abe6 --- /dev/null +++ b/src/app/workspace/results/list-view/list-view.component.ts @@ -0,0 +1,259 @@ +import { Component, EventEmitter, Inject, Input, OnChanges, Output } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { ApiResponseError, CountQueryResponse, IFulltextSearchParams, KnoraApiConnection, ReadResourceSequence } from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { NotificationService } from 'src/app/main/services/notification.service'; +import { AdvancedSearchParamsService } from '../../../search/services/advanced-search-params.service'; + +/** + * query: search query. It can be gravserch query or fulltext string query. + * The query value is expected to have at least length of 3 characters. + * + * mode: search mode "fulltext" OR "gravsearch" + * + * filter: Optional fulltext search parameter with following (optional) properties: + * - limitToResourceClass: string; Iri of resource class the fulltext search is restricted to, if any. + * - limitToProject: string; Iri of the project the fulltext search is restricted to, if any. + * - limitToStandoffClass: string; Iri of standoff class the fulltext search is restricted to, if any. + */ +export interface SearchParams { + query: string; + mode: 'fulltext' | 'gravsearch'; + filter?: IFulltextSearchParams; +} + +export interface ShortResInfo { + id: string; + label: string; +} + +/* return the selected resources in below format + * + * count: total number of resources selected + * selectedIds: list of selected resource's ids + */ +export interface FilteredResources { + count: number; + resListIndex: number[]; + resInfo: ShortResInfo[]; + selectionType: 'multiple' | 'single'; +} + +/* return the checkbox value + * + * checked: checkbox value + * resIndex: resource index from the list + */ +export interface CheckboxUpdate { + checked: boolean; + resIndex: number; + resId: string; + resLabel: string; + isCheckbox: boolean; +} + +@Component({ + selector: 'app-list-view', + templateUrl: './list-view.component.html', + styleUrls: ['./list-view.component.scss'] +}) +export class ListViewComponent implements OnChanges { + + @Input() search: SearchParams; + + @Input() view?: 'list' | 'grid' = 'list'; // todo: will be expanded with 'table' as soon as resource-table component is done + + @Input() displayViewSwitch?: boolean = true; + + /** + * set to true if multiple resources can be selected for comparison + */ + @Input() withMultipleSelection?: boolean = false; + + /** + * emits the selected resources 1-n + */ + @Output() selectedResources: EventEmitter = new EventEmitter(); + + /** + * @deprecated Use selectedResources instead + * + * Click on checkbox will emit the resource info + * + * @param {EventEmitter} resourcesSelected + */ + @Output() multipleResourcesSelected?: EventEmitter = new EventEmitter(); + + /** + * @deprecated Use selectedResources instead + * + * Click on an item will emit the resource iri + * + * @param {EventEmitter} singleResourceSelected + */ + @Output() singleResourceSelected?: EventEmitter = new EventEmitter(); + + /** + * @deprecated Use selectedResources instead. + * Click on an item will emit the resource iri + */ + @Output() resourceSelected: EventEmitter = new EventEmitter(); + + resources: ReadResourceSequence; + + selectedResourceIdx: number[] = []; + + resetCheckBoxes = false; + + // matPaginator Output + pageEvent: PageEvent; + + // number of all results + numberOfAllResults: number; + + // progress status + loading = true; + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _advancedSearchParamsService: AdvancedSearchParamsService, + private _notification: NotificationService + ) { } + + ngOnChanges(): void { + // reset + this.pageEvent = new PageEvent(); + this.pageEvent.pageIndex = 0; + this.resources = undefined; + + this._doSearch(); + } + + /** + * + * @param view 'list' | ' grid'; TODO: will be expanded with 'table' as soon as resource-table component is done + */ + toggleView(view: 'list' | 'grid') { + this.view = view; + } + + // the child component send the selected resources to the parent of this component directly; + // but when this component is intialized, it should select the first item in the list and + // emit this selected resource to the parent. + emitSelectedResources(res?: FilteredResources) { + + if (!res || res.count === 0) { + // no resource is selected: In case of an error or no search results + this.selectedResources.emit({ count: 0, resListIndex: [], resInfo: [], selectionType: 'single' }); + } else if (res.count > 0) { + this.selectedResourceIdx = res.resListIndex; + this.selectedResources.emit(res); + this.resourceSelected.emit(res.resInfo[0].id); + } + + + } + + goToPage(page: PageEvent) { + this.pageEvent = page; + this._doSearch(); + } + + + /** + * do the search and send the resources to the child components + * like resource-list, resource-grid or resource-table + */ + private _doSearch() { + + this.loading = true; + + if (this.search.mode === 'fulltext') { + // search mode: fulltext + if (this.pageEvent.pageIndex === 0) { + // perform count query + this._dspApiConnection.v2.search.doFulltextSearchCountQuery(this.search.query, this.pageEvent.pageIndex, this.search.filter).subscribe( + (count: CountQueryResponse) => { + this.numberOfAllResults = count.numberOfResults; + + if (this.numberOfAllResults === 0) { + this.emitSelectedResources(); + this.resources = undefined; + this.loading = false; + } + }, + (countError: ApiResponseError) => { + this._notification.openSnackBar(countError); + } + ); + } + + // perform full text search + this._dspApiConnection.v2.search.doFulltextSearch(this.search.query, this.pageEvent.pageIndex, this.search.filter).subscribe( + (response: ReadResourceSequence) => { + // if the response does not contain any resources even the search count is greater than 0, + // it means that the user does not have the permissions to see anything: emit an empty result + if (response.resources.length === 0) { + this.emitSelectedResources(); + } + this.resources = response; + this.loading = false; + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + this.resources = undefined; + this.loading = false; + } + ); + + } else if (this.search.mode === 'gravsearch') { + + // search mode: gravsearch + if (this.pageEvent.pageIndex === 0) { + // perform count query + this._dspApiConnection.v2.search.doExtendedSearchCountQuery(this.search.query).subscribe( + (count: CountQueryResponse) => { + this.numberOfAllResults = count.numberOfResults; + + if (this.numberOfAllResults === 0) { + this.emitSelectedResources(); + this.resources = undefined; + this.loading = false; + } + }, + (countError: ApiResponseError) => { + this._notification.openSnackBar(countError); + } + ); + } + + // perform advanced search + const gravsearch = this._advancedSearchParamsService.getSearchParams().generateGravsearch(this.pageEvent.pageIndex); + + if (typeof gravsearch === 'string') { + this._dspApiConnection.v2.search.doExtendedSearch(gravsearch).subscribe( + (response: ReadResourceSequence) => { + // if the response does not contain any resources even the search count is greater than 0, + // it means that the user does not have the permissions to see anything: emit an empty result + if (response.resources.length === 0) { + this.emitSelectedResources(); + } + this.resources = response; + this.loading = false; + }, + (error: ApiResponseError) => { + this._notification.openSnackBar(error); + this.resources = undefined; + this.loading = false; + } + ); + } else { + console.error('The gravsearch query is not set correctly'); + this.resources = undefined; + this.loading = false; + } + + } + + } +} diff --git a/src/app/workspace/results/list-view/list-view.service.spec.ts b/src/app/workspace/results/list-view/list-view.service.spec.ts new file mode 100644 index 0000000000..d8ee57ed22 --- /dev/null +++ b/src/app/workspace/results/list-view/list-view.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed } from '@angular/core/testing'; +import { ListViewService } from './list-view.service'; + +describe('ListViewService', () => { + let service: ListViewService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ListViewService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/workspace/results/list-view/list-view.service.ts b/src/app/workspace/results/list-view/list-view.service.ts new file mode 100644 index 0000000000..9f4c118820 --- /dev/null +++ b/src/app/workspace/results/list-view/list-view.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { CheckboxUpdate, FilteredResources, ShortResInfo } from './list-view.component'; + +@Injectable({ + providedIn: 'root' +}) +export class ListViewService { + + // for keeping track of multiple selection + selectedResourcesCount = 0; + selectedResourcesList: ShortResInfo[] = []; + selectedResourceIdxMultiple = []; + + constructor() { } + + viewResource(status: CheckboxUpdate, withMultipleSelection: boolean, selectedResourceIdx: number [], resChecks: MatCheckbox[]): FilteredResources { + + if (selectedResourceIdx.length === 1 && this.selectedResourcesCount === 0) { + // reset the selected resources count and list + // this.selectedResourcesCount = 0; + this.selectedResourcesList = []; + this.selectedResourceIdxMultiple = []; + } + + + const resInfo: ShortResInfo = { + id: status.resId, + label: status.resLabel + }; + + // when multiple selection and checkbox is used to select more + // than one resources + if (withMultipleSelection && status.isCheckbox) { + + if (status.checked) { + if (selectedResourceIdx.indexOf(status.resIndex) <= 0) { + // add resource in to the selected resources list + this.selectedResourcesList.push(resInfo); + + // increase the count of selected resources + this.selectedResourcesCount += 1; + + // add resource list index to apply selected class style + this.selectedResourceIdxMultiple.push(status.resIndex); + } + } else { + // remove resource from the selected resources list + let index = this.selectedResourcesList.findIndex(d => d.id === status.resId); + this.selectedResourcesList.splice(index, 1); + + // decrease the count of selected resources + this.selectedResourcesCount -= 1; + + // remove resource list index from the selected index list + index = this.selectedResourceIdxMultiple.findIndex(d => d === status.resIndex); + this.selectedResourceIdxMultiple.splice(index, 1); + } + // selectedResourceIdx = selectedResourceIdxMultiple; + return { count: this.selectedResourcesCount, resListIndex: this.selectedResourceIdxMultiple, resInfo: this.selectedResourcesList, selectionType: 'multiple' }; + + } else { + // else condition when single resource is clicked for viewing + + // unselect checkboxes if any + if (resChecks) { + + resChecks.forEach(function (ckb) { + if (ckb.checked) { + ckb.checked = false; + } + }); + } + + // reset all the variables for multiple selection + this.selectedResourceIdxMultiple = []; + this.selectedResourcesCount = 0; + this.selectedResourcesList = []; + + // add resource list index to apply selected class style + // selectedResourceIdx = [status.resListIndex]; + return { count: 1, resListIndex: [status.resIndex], resInfo: [resInfo], selectionType: 'single' }; + } + } +} diff --git a/src/app/workspace/results/list-view/resource-grid/resource-grid.component.html b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.html new file mode 100644 index 0000000000..0f3371b0aa --- /dev/null +++ b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.html @@ -0,0 +1,38 @@ + +
+ +
diff --git a/src/app/workspace/results/list-view/resource-grid/resource-grid.component.scss b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.scss new file mode 100644 index 0000000000..9513764f1a --- /dev/null +++ b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.scss @@ -0,0 +1,79 @@ +@import "../../../../../assets/style/viewer"; + +.resource-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: auto; + grid-auto-rows: 1fr; + gap: 8px 8px; + justify-items: stretch; + align-items: stretch; + align-content: space-evenly; + padding: 8px 0; +} + +.grid-card { + height: calc(100% - 32px); +} + +.res-class-header-text { + width: 90%; + float: left; +} + +.res-class-header-actions { + width: 10%; + float: right; +} + +.res-class-label, +.res-prop-label { + color: rgba(0, 0, 0, 0.54); +} + +.res-class-label { + font-size: 14px !important; +} + +.res-class-value { + font-weight: bold !important; + font-size: 16px !important; + line-height: 1.5; +} + +.res-class-label, +.res-class-value { + margin: 4px 0 !important; // to overwrite it with .mat-card-title margin +} + +.res-prop-label { + font-style: italic; +} + +// responsive for tablet device +@media (max-width: map-get($grid-breakpoints, tablet)) and (min-width: map-get($grid-breakpoints, phone)) { + .resource-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: auto; + grid-auto-rows: 1fr; + gap: 4px 4px; + justify-items: stretch; + align-items: stretch; + align-content: space-evenly; + } +} + +// responsive for phone device +@media (max-width: map-get($grid-breakpoints, phone)) { + .resource-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: auto; + grid-auto-rows: 1fr; + gap: 4px 4px; + justify-items: stretch; + align-items: stretch; + align-content: space-evenly; + } +} diff --git a/src/app/workspace/results/list-view/resource-grid/resource-grid.component.spec.ts b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.spec.ts new file mode 100644 index 0000000000..f9a89c2df6 --- /dev/null +++ b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.spec.ts @@ -0,0 +1,96 @@ +import { Component, OnInit, Pipe, PipeTransform, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MockResource, ReadResourceSequence } from '@dasch-swiss/dsp-js'; +import { ResourceGridComponent } from './resource-grid.component'; +import { FilteredResources } from '../list-view.component'; + +/** + * mocked truncate pipe from action module. + */ +@Pipe({ name: 'appTruncate' }) +class MockPipe implements PipeTransform { + transform(value: string, limit?: number, trail?: string): string { + // do stuff here, if you want + return value; + } +} + +/** + * test parent component to simulate integration of resource-grid component. + */ +@Component({ + template: ` + ` +}) +class TestParentComponent implements OnInit { + + @ViewChild('resGrid') resourceGridComponent: ResourceGridComponent; + + resources: ReadResourceSequence; + + selectedResourceIdx = [0]; + + selectedResources: FilteredResources; + + ngOnInit() { + + MockResource.getTestThings(5).subscribe(res => { + this.resources = res; + }); + } + + emitSelectedResources(resInfo: FilteredResources) { + this.selectedResources = resInfo; + } + +} + +describe('ResourceGridComponent', () => { + let testHostComponent: TestParentComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + MockPipe, + ResourceGridComponent, + TestParentComponent + ], + imports: [ + MatCardModule, + MatCheckboxModule + ], + providers: [] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestParentComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('expect 5 resources', () => { + expect(testHostComponent.resources).toBeTruthy(); + expect(testHostComponent.resources.resources.length).toBe(5); + }); + + it('should open first resource', () => { + // trigger the click + const nativeElement = testHostFixture.nativeElement; + const item = nativeElement.querySelector('div.link'); + item.dispatchEvent(new Event('click')); + + spyOn(testHostComponent, 'emitSelectedResources').call({ count: 1, resListIndex: [0], resIds: ['http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'], selectionType: 'single' }); + expect(testHostComponent.emitSelectedResources).toHaveBeenCalled(); + expect(testHostComponent.emitSelectedResources).toHaveBeenCalledTimes(1); + + // expect(testHostComponent.resIri).toEqual('http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'); + }); + +}); diff --git a/src/app/workspace/results/list-view/resource-grid/resource-grid.component.ts b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.ts new file mode 100644 index 0000000000..1e93403334 --- /dev/null +++ b/src/app/workspace/results/list-view/resource-grid/resource-grid.component.ts @@ -0,0 +1,58 @@ +import { Component, EventEmitter, Input, Output, ViewChildren } from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { ReadResourceSequence } from '@dasch-swiss/dsp-js'; +import { CheckboxUpdate, FilteredResources } from '../list-view.component'; +import { ListViewService } from '../list-view.service'; + +@Component({ + selector: 'app-resource-grid', + templateUrl: './resource-grid.component.html', + styleUrls: ['./resource-grid.component.scss'] +}) +export class ResourceGridComponent { + + /** + * list of all resource checkboxes. This list is used to + * unselect all checkboxes when single selection to view + * resource is used + */ + @ViewChildren('gridCkbox') resChecks: MatCheckbox[]; + + /** + * list of resources of type ReadResourceSequence + * + * @param {ReadResourceSequence} resources + */ + @Input() resources: ReadResourceSequence; + + /** + * list of all selected resources indices + */ + @Input() selectedResourceIdx: number[]; + + /** + * set to true if multiple resources can be selected for comparison + */ + @Input() withMultipleSelection?: boolean = false; + + /** + * click on checkbox will emit the resource info + * + * @param {EventEmitter} resourcesSelected + */ + @Output() resourcesSelected?: EventEmitter = new EventEmitter(); + + constructor( + private _listView: ListViewService + ) { } + + selectResource(status: CheckboxUpdate) { + const selection: FilteredResources = this._listView.viewResource(status, this.withMultipleSelection, this.selectedResourceIdx, this.resChecks); + + this.selectedResourceIdx = selection.resListIndex; + + this.resourcesSelected.emit(selection); + + } + +} diff --git a/src/app/workspace/results/list-view/resource-list/resource-list.component.html b/src/app/workspace/results/list-view/resource-list/resource-list.component.html new file mode 100644 index 0000000000..1e4653f1e2 --- /dev/null +++ b/src/app/workspace/results/list-view/resource-list/resource-list.component.html @@ -0,0 +1,35 @@ + + + + diff --git a/src/app/workspace/results/list-view/resource-list/resource-list.component.scss b/src/app/workspace/results/list-view/resource-list/resource-list.component.scss new file mode 100644 index 0000000000..b0f1988e58 --- /dev/null +++ b/src/app/workspace/results/list-view/resource-list/resource-list.component.scss @@ -0,0 +1,49 @@ +@import "../../../../../assets/style/viewer"; + +.resource-list { + .mat-list-item { + height: auto; + min-height: 40px; + padding: 8px 0; + + &.border-bottom { + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + .mat-line { + white-space: normal !important; + } + } +} + +.res-checkbox { + margin: 0 15px; +} + +.res-prop-value { + text-align: justify; +} + +.res-class-label, +.res-prop-label { + color: rgba(0, 0, 0, 0.54); +} + +.res-class-label, +.res-class-value { + margin: 4px 0; +} + +.res-class-label { + font-size: 14px !important; +} + +.res-class-value { + font-weight: bold !important; + font-size: 16px !important; + line-height: 1.5; +} + +.res-prop-label { + font-style: italic; +} diff --git a/src/app/workspace/results/list-view/resource-list/resource-list.component.spec.ts b/src/app/workspace/results/list-view/resource-list/resource-list.component.spec.ts new file mode 100644 index 0000000000..c7911b601a --- /dev/null +++ b/src/app/workspace/results/list-view/resource-list/resource-list.component.spec.ts @@ -0,0 +1,99 @@ +import { Component, OnInit, Pipe, PipeTransform, ViewChild } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatLineModule } from '@angular/material/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { MockResource, ReadResourceSequence } from '@dasch-swiss/dsp-js'; +import { ResourceListComponent } from './resource-list.component'; +import { FilteredResources } from '../list-view.component'; + +/** + * mocked truncate pipe from action module. + */ +@Pipe({ name: 'appTruncate' }) +class MockPipe implements PipeTransform { + transform(value: string, limit?: number, trail?: string): string { + // do stuff here, if you want + return value; + } +} + +/** + * test parent component to simulate integration of resource-list component. + */ +@Component({ + template: ` + ` +}) +class TestParentComponent implements OnInit { + + @ViewChild('resList') resourceListComponent: ResourceListComponent; + + resources: ReadResourceSequence; + + selectedResourceIdx = [0]; + + selectedResources: FilteredResources; + + ngOnInit() { + + MockResource.getTestThings(5).subscribe(res => { + this.resources = res; + }); + } + + emitSelectedResources(resInfo: FilteredResources) { + this.selectedResources = resInfo; + } + +} + +describe('ResourceListComponent', () => { + let testHostComponent: TestParentComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + MockPipe, + ResourceListComponent, + TestParentComponent + ], + imports: [ + MatCheckboxModule, + MatIconModule, + MatLineModule, + MatListModule + ], + providers: [] + }) + .compileComponents(); + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestParentComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('expect 5 resources', () => { + expect(testHostComponent.resources).toBeTruthy(); + expect(testHostComponent.resources.resources.length).toBe(5); + }); + + it('should open first resource', () => { + // trigger the click + const nativeElement = testHostFixture.nativeElement; + const item = nativeElement.querySelector('div.link'); + item.dispatchEvent(new Event('click')); + + spyOn(testHostComponent, 'emitSelectedResources').call({ count: 1, resListIndex: [0], resIds: ['http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw'], selectionType: 'single' }); + expect(testHostComponent.emitSelectedResources).toHaveBeenCalled(); + expect(testHostComponent.emitSelectedResources).toHaveBeenCalledTimes(1); + + }); + +}); diff --git a/src/app/workspace/results/list-view/resource-list/resource-list.component.ts b/src/app/workspace/results/list-view/resource-list/resource-list.component.ts new file mode 100644 index 0000000000..d31b8d4f12 --- /dev/null +++ b/src/app/workspace/results/list-view/resource-list/resource-list.component.ts @@ -0,0 +1,63 @@ +import { Component, EventEmitter, Input, OnInit, Output, ViewChildren } from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { ReadResourceSequence } from '@dasch-swiss/dsp-js'; +import { CheckboxUpdate, FilteredResources } from '../list-view.component'; +import { ListViewService } from '../list-view.service'; + +@Component({ + selector: 'app-resource-list', + templateUrl: './resource-list.component.html', + styleUrls: ['./resource-list.component.scss'] +}) +export class ResourceListComponent implements OnInit { + + /** + * list of all resource checkboxes. This list is used to + * unselect all checkboxes when single selection to view + * resource is used + */ + @ViewChildren('ckbox') resChecks: MatCheckbox[]; + + /** + * list of resources of type ReadResourceSequence + * + * @param {ReadResourceSequence} resources + */ + @Input() resources: ReadResourceSequence; + + /** + * list of all selected resources indices + */ + @Input() selectedResourceIdx: number[]; + + /** + * set to true if multiple resources can be selected for comparison + */ + @Input() withMultipleSelection?: boolean = false; + + /** + * click on checkbox will emit the resource info + * + * @param {EventEmitter} resourcesSelected + */ + @Output() resourcesSelected?: EventEmitter = new EventEmitter(); + + constructor( + private _listView: ListViewService + ) { } + + ngOnInit() { + // select the first item in the list + this.selectResource({ checked: true, resIndex: 0, resId: this.resources.resources[0].id, resLabel: this.resources.resources[0].label, isCheckbox: false }); + } + + selectResource(status: CheckboxUpdate) { + const selection: FilteredResources = this._listView.viewResource(status, this.withMultipleSelection, this.selectedResourceIdx, this.resChecks); + + this.selectedResourceIdx = selection.resListIndex; + + this.resourcesSelected.emit(selection); + + } + +} diff --git a/src/app/workspace/results/results.component.html b/src/app/workspace/results/results.component.html index d81152f757..792585252d 100644 --- a/src/app/workspace/results/results.component.html +++ b/src/app/workspace/results/results.component.html @@ -16,9 +16,9 @@ - - + diff --git a/src/assets/style/_config.scss b/src/assets/style/_config.scss index b083042c83..70b7efddd3 100644 --- a/src/assets/style/_config.scss +++ b/src/assets/style/_config.scss @@ -23,3 +23,16 @@ $header-height: 72px; $sub-header-height: 64px; $panel-height: 40px; $tab-bar-margin: 36px; + +// color variables +$bright: rgba(249, 249, 249, 1); +$grey: rgba(184, 184, 184, 1); +$dark: rgba(41, 41, 41, 1); +$black: rgb(11, 11, 11); +$black-12-opacity: rgba($black, 0.12); +$black-14-opacity: rgba($black, 0.14); +$black-20-opacity: rgba($black, 0.2); +$black-60-opacity: rgba($black, 0.6); + +// general border color +$border-color: rgb(235, 235, 235);